Trouve-KA — moteur de recherche Québec-first (crawler, index, ranking, API, web)
Moteur complet : crawler distribué poli (robots.txt, SSRF, pièges), classification Québec (page + domaine), indexation immédiate OpenSearch (cherchable en ~2 s), ranking BM25 bilingue + function_score Québec-first, galerie d'images, web Next.js au design Groupe KA, déployé sur m2m32 + satellites via www.trouve-ka.com. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 151 changed files with +20,001 and −0
added
.env.example
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +# Trouve-KA — variables d'environnement (exemple, sans secrets) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +# --- Bases de données --- | |
| 6 | +DATABASE_URL=postgresql://trouveka:trouveka@localhost:5432/trouveka | |
| 7 | +REDIS_URL=redis://localhost:6379/0 | |
| 8 | +SEARCH_URL=http://localhost:9200 | |
| 9 | + | |
| 10 | +# --- Crawler --- | |
| 11 | +CRAWLER_USER_AGENT="Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)" | |
| 12 | +CRAWLER_CONTACT_URL=https://www.trouve-ka.com/trouveka-bot | |
| 13 | +MAX_GLOBAL_CONCURRENCY=24 | |
| 14 | +MAX_PER_HOST_CONCURRENCY=2 | |
| 15 | +DEFAULT_HOST_DELAY=2.0 | |
| 16 | +MAX_RESPONSE_BYTES=3000000 | |
| 17 | +MAX_REDIRECTS=5 | |
| 18 | +FETCH_TIMEOUT=20 | |
| 19 | +MAX_CRAWL_DEPTH=8 | |
| 20 | +MAX_LINKS_PER_PAGE=300 | |
| 21 | +MAX_URLS_PER_DOMAIN=5000 | |
| 22 | + | |
| 23 | +# --- API --- | |
| 24 | +API_HOST=0.0.0.0 | |
| 25 | +API_PORT=8080 | |
| 26 | +ADMIN_TOKEN=change-me-admin-token | |
| 27 | + | |
| 28 | +# --- Web --- | |
| 29 | +PUBLIC_URL=https://www.trouve-ka.com | |
| 30 | +NGROK_DOMAIN=www.trouve-ka.com | |
| 31 | +API_URL=http://localhost:8080 | |
| 32 | + | |
| 33 | +# --- Index --- | |
| 34 | +SEARCH_INDEX=trouveka-docs | |
added
.gitignore
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +node_modules/ | |
| 2 | +.next/ | |
| 3 | +dist/ | |
| 4 | +__pycache__/ | |
| 5 | +*.pyc | |
| 6 | +.venv/ | |
| 7 | +.env | |
| 8 | +.env.local | |
| 9 | +*.egg-info/ | |
| 10 | +.pytest_cache/ | |
| 11 | +.ruff_cache/ | |
| 12 | +data/ | |
| 13 | +.DS_Store | |
added
CLAUDE.md
+363 −0
@@ -0,0 +1,363 @@ | ||
| 1 | +# CLAUDE.md — Trouve-KA | |
| 2 | + | |
| 3 | +**Projet :** Trouve-KA — *Cherche le Québec.* | |
| 4 | +**Mission :** Moteur de recherche web indépendant, Québec-first, avec son propre crawler, index, ranking, API et application web publique. | |
| 5 | + | |
| 6 | +Trouve-KA n'est **pas** un métamoteur. Aucune dépendance à Google, Bing ou Brave pour les résultats primaires. L'objectif : un index structuré et continuellement mis à jour du web québécois. | |
| 7 | + | |
| 8 | +``` | |
| 9 | +Crawler → Frontier → Fetcher → Parser → Classification Québec | |
| 10 | +→ Déduplication → Indexer → Index → Ranking → API → Web App | |
| 11 | +``` | |
| 12 | + | |
| 13 | +Le crawler fait partie du produit. Tout vit dans un seul monorepo. | |
| 14 | + | |
| 15 | +--- | |
| 16 | + | |
| 17 | +## 0. Règles non négociables | |
| 18 | + | |
| 19 | +### 0.1 Header obligatoire dans chaque fichier de code | |
| 20 | + | |
| 21 | +**Chaque fichier source** (TS, JS, Python, SQL, config exécutable, scripts) doit commencer par un header d'auteur : | |
| 22 | + | |
| 23 | +```ts | |
| 24 | +/** | |
| 25 | + * Trouve-KA — <nom du module> | |
| 26 | + * Author: Simon-Pierre Boucher | |
| 27 | + * Contact: contact@spboucher.ai | |
| 28 | + */ | |
| 29 | +``` | |
| 30 | + | |
| 31 | +```python | |
| 32 | +# Trouve-KA — <nom du module> | |
| 33 | +# Author: Simon-Pierre Boucher | |
| 34 | +# Contact: contact@spboucher.ai | |
| 35 | +``` | |
| 36 | + | |
| 37 | +Adapter la syntaxe de commentaire au langage. Aucune exception. Un lint/check CI doit vérifier la présence du header. | |
| 38 | + | |
| 39 | +### 0.2 Cible de déploiement | |
| 40 | + | |
| 41 | +- **Node de déploiement : `m2m32`** (32 Go RAM). Tout le stack (Postgres, Redis, backend de recherche, workers, API, web) doit tourner confortablement sur cette machine. | |
| 42 | +- **Exposition publique via ngrok : `www.trouve-ka.com`.** Configurer ngrok avec le domaine réservé; l'app web et l'API doivent fonctionner correctement derrière le tunnel (URLs absolues, cookies, CORS, headers `X-Forwarded-*`). | |
| 43 | +- Docker Compose est l'outil de déploiement. Pas de Kubernetes. | |
| 44 | +- Fournir un script/target `deploy:m2m32` et documenter la procédure complète (compose up + tunnel ngrok) dans le README. | |
| 45 | +- Dimensionner les défauts (concurrence, tailles de heap du moteur de recherche, connexions Postgres) pour 32 Go, avec configuration par variables d'environnement. | |
| 46 | + | |
| 47 | +### 0.3 Principe cardinal | |
| 48 | + | |
| 49 | +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Enrichis en asynchrone.** | |
| 50 | + | |
| 51 | +Le moteur doit être utilisable **dès que le crawl démarre**. Jamais de cycle « crawler tout → construire l'index → lancer la recherche ». Chaque page traitée avec succès devient cherchable en secondes. | |
| 52 | + | |
| 53 | +``` | |
| 54 | +12:00:00 démarrage crawler | |
| 55 | +12:00:04 première page fetchée | |
| 56 | +12:00:06 score Québec calculé | |
| 57 | +12:00:07 document indexé | |
| 58 | +12:00:08 cherchable par l'utilisateur | |
| 59 | +``` | |
| 60 | + | |
| 61 | +Aucune interdiction plus importante que celle-ci : **ne jamais bloquer l'indexation sur l'enrichissement** (embeddings, entités, scoring avancé = asynchrone, mise à jour du document après coup). | |
| 62 | + | |
| 63 | +--- | |
| 64 | + | |
| 65 | +## 1. Philosophie produit | |
| 66 | + | |
| 67 | +Trouve-KA est un vrai moteur, pas : un annuaire, une liste curée, un wrapper ChatGPT, un frontend Google CSE, un dataset statique. | |
| 68 | + | |
| 69 | +Il doit de façon autonome : découvrir des sites → les crawler → comprendre le contenu → juger la pertinence québécoise → indexer → ranker → exposer via recherche → découvrir davantage → rafraîchir en continu. | |
| 70 | + | |
| 71 | +**Interdictions absolues (§ fake) :** pas de données de crawl factices, pas de compteurs simulés présentés comme réels, pas de résultats hard-codés, pas d'API de recherche placeholder. Les fixtures sont réservées aux tests. Le dev tourne sur le vrai crawler et le vrai index local. | |
| 72 | + | |
| 73 | +--- | |
| 74 | + | |
| 75 | +## 2. Architecture du dépôt | |
| 76 | + | |
| 77 | +Monorepo : | |
| 78 | + | |
| 79 | +``` | |
| 80 | +trouve-ka/ | |
| 81 | +├── apps/ | |
| 82 | +│ ├── web/ # moteur public | |
| 83 | +│ ├── api/ # search + APIs internes | |
| 84 | +│ └── admin/ # dashboard crawl/index | |
| 85 | +├── services/ | |
| 86 | +│ ├── crawler/ frontier/ parser/ classifier/ | |
| 87 | +│ ├── indexer/ ranking/ scheduler/ enrichment/ | |
| 88 | +├── packages/ | |
| 89 | +│ ├── database/ shared/ config/ logging/ | |
| 90 | +│ ├── queue/ types/ search-core/ | |
| 91 | +├── infrastructure/ | |
| 92 | +│ ├── docker/ migrations/ monitoring/ deployment/ # inclut config ngrok + m2m32 | |
| 93 | +├── scripts/ | |
| 94 | +│ ├── bootstrap-seeds/ start-crawler/ rebuild-index/ health-check/ | |
| 95 | +├── docs/ | |
| 96 | +├── CLAUDE.md README.md docker-compose.yml | |
| 97 | +``` | |
| 98 | + | |
| 99 | +Claude peut améliorer cette structure sur justification technique solide. | |
| 100 | + | |
| 101 | +--- | |
| 102 | + | |
| 103 | +## 3. Stack technologique | |
| 104 | + | |
| 105 | +| Couche | Choix | | |
| 106 | +|---|---| | |
| 107 | +| Frontend | Next.js, TypeScript, React, Tailwind, shadcn/ui | | |
| 108 | +| API | FastAPI (Python) ou backend TypeScript si ça réduit la complexité; le crawler peut rester Python même si l'API est TS | | |
| 109 | +| BD relationnelle | PostgreSQL (domaines, URLs, état de crawl, métadonnées, entités, scheduler, bookkeeping) | | |
| 110 | +| Queue | Redis + vraie file de tâches/streams (évaluer avant de choisir) | | |
| 111 | +| Recherche | À sélectionner parmi : OpenSearch, Elasticsearch, Typesense, Meilisearch, Vespa, Tantivy, Quickwit | | |
| 112 | + | |
| 113 | +Critères de sélection du backend de recherche : qualité BM25, indexation incrémentale, facettes, tolérance aux typos, ranking custom, recherche hybride, performance, scaling horizontal, **complexité opérationnelle sur un seul node m2m32**. Pour le MVP : qualité de recherche + indexation incrémentale + opérations simples. Ne pas choisir le plus facile par défaut. | |
| 114 | + | |
| 115 | +--- | |
| 116 | + | |
| 117 | +## 4. Qualité de recherche progressive | |
| 118 | + | |
| 119 | +Chaque document s'enrichit par étapes; **la disponibilité en recherche ne dépend jamais des étapes suivantes.** | |
| 120 | + | |
| 121 | +- **Étape 1 (immédiat) :** URL, titre, description, corps, headings, domaine, langue, timestamp, score Québec → index BM25. | |
| 122 | +- **Étape 2 (async) :** embedding, classification thématique, organisations, signaux de localisation, entité canonique. | |
| 123 | +- **Étape 3 (async) :** autorité de domaine, score de graphe de liens / PageRank-like, fraîcheur, qualité, spam, intention commerciale, pertinence locale. | |
| 124 | + | |
| 125 | +--- | |
| 126 | + | |
| 127 | +## 5. Crawler | |
| 128 | + | |
| 129 | +Un vrai crawler : frontier d'URLs, scheduling par domaine, robots.txt, canonicalisation, redirections, retries, cache HTTP, compression, gestion des content-types, budgets de crawl, concurrence par hôte, rate limiting, files de priorité, prévention des doublons, historique, hash de contenu, détection de changements. | |
| 130 | + | |
| 131 | +### 5.1 Identité | |
| 132 | +User-agent identifiable : `Mozilla-compatible / TrouveKABot`. Page publique `/trouveka-bot` : quoi, pourquoi, UA, contact (**contact@spboucher.ai**), comment bloquer, respect de robots.txt. **Pas de stealth, pas de rotation de proxys par défaut.** IPs stables et comportement poli. | |
| 133 | + | |
| 134 | +### 5.2 robots.txt et politesse | |
| 135 | +Parsing conforme aux standards, cache des règles, respect de `noindex` / `nofollow` / `canonical` / `X-Robots-Tag`. Politesse par origine : 1–2 requêtes concurrentes max par hôte, budgets indépendants, politiques configurables. Un gros débit global ne doit jamais agresser un site individuel. | |
| 136 | + | |
| 137 | +### 5.3 Frontier | |
| 138 | +Chaque URL : `url, domain, priority, depth, source_url, discovered_at, last_crawled_at, next_crawl_at, status`. Priorités évolutives selon : pertinence Québec, autorité, source de découverte, profondeur, importance du domaine, fraîcheur, historique de changement, succès, duplication. | |
| 139 | + | |
| 140 | +Fonction de priorité conceptuelle : | |
| 141 | +`P = w_q·Q + w_a·A + w_f·F + w_l·L + w_n·N − w_d·D − w_s·S` | |
| 142 | +(Québec, Autorité, Fraîcheur, Liens, Nouveauté, Doublon, Spam). Poids calibrés par mesures, jamais figés. | |
| 143 | + | |
| 144 | +### 5.4 Canonicalisation d'URL | |
| 145 | +Extrême prudence. Normaliser : fragments, ports par défaut, slashs, paramètres de tracking/UTM, ordre des paramètres quand approprié, http/https, www, trailing slash, tags canonical. **Ne jamais fusionner deux ressources distinctes par accident.** | |
| 146 | + | |
| 147 | +### 5.5 Recrawl adaptatif | |
| 148 | +Homepage de nouvelles → minutes; article → heures puis décroissant; page gouvernementale → quotidien; site statique → mensuel; archive inchangée → rarement. Fréquence de changement mesurée empiriquement : page inchangée → intervalle ↑; page volatile → intervalle ↓. | |
| 149 | + | |
| 150 | +### 5.6 Détection de changement et doublons | |
| 151 | +Stocker `content_hash`, etag, last-modified, `last_changed_at`. Réindexer seulement si changement. Doublons : exacts (hash) d'abord, puis SimHash/MinHash/shingling si justifié. Gérer miroirs, versions imprimables, doublons de paramètres, syndication. Conserver la provenance. | |
| 152 | + | |
| 153 | +### 5.7 Sécurité du crawl (SSRF) | |
| 154 | +Bloquer : localhost, 127.0.0.0/8, plages privées IPv4/IPv6, endpoints de métadonnées cloud, `file://`, schémas dangereux. Revalider DNS/IP. Chaque réponse a des limites : taille max, redirections max, timeout, temps de parse max, liens extraits max, profondeur max. | |
| 155 | + | |
| 156 | +### 5.8 Pièges de crawl | |
| 157 | +Détecter : calendriers infinis, session IDs, explosions de navigation à facettes, pagination infinie, paramètres aléatoires, boucles. Limites par pattern/domaine. | |
| 158 | + | |
| 159 | +### 5.9 Erreurs | |
| 160 | +Codes structurés : DNS, timeout, TLS, 4xx, 5xx, robots refusé, parse échoué, contenu non supporté, trop gros, doublon, spam, non pertinent Québec. L'échec est normal; il est traqué. | |
| 161 | + | |
| 162 | +### 5.10 Rendu navigateur | |
| 163 | +**Jamais** de Chromium par page. Défaut : fetch HTTP. Le navigateur est un fallback spécialisé par domaine — essentiel pour l'échelle et pour tenir sur m2m32. | |
| 164 | + | |
| 165 | +--- | |
| 166 | + | |
| 167 | +## 6. Extraction de contenu | |
| 168 | + | |
| 169 | +Extraire : titre, meta description, corps principal, headings, données structurées (JSON-LD, schema.org, microdata, OpenGraph), URL canonique, langue, liens + anchors, dates de publication/modification, auteur, indices d'organisation et d'adresse. | |
| 170 | + | |
| 171 | +Retirer : navigation, menus, bannières cookies, footers répétitifs, scripts, styles, pub. | |
| 172 | + | |
| 173 | +Types au départ : HTML, texte, PDF. Architecture extensible (DOCX/XLSX/PPTX, RSS/Atom) sans que l'extraction coûteuse bloque le crawl HTML. | |
| 174 | + | |
| 175 | +Traiter chaque page crawlée comme **non fiable** : sanitizer HTML, URLs, métadonnées; ne jamais faire confiance aux MIME types distants. | |
| 176 | + | |
| 177 | +--- | |
| 178 | + | |
| 179 | +## 7. Détection Québec | |
| 180 | + | |
| 181 | +L'innovation clé. Un `.ca` seul ne suffit pas. Calculer `quebec_score ∈ [0,1]` avec **deux scores distincts** : | |
| 182 | + | |
| 183 | +- `domain_quebec_score` | |
| 184 | +- `page_quebec_score` (un article du NYT sur Montréal peut être pertinent sans que le domaine le soit) | |
| 185 | + | |
| 186 | +Signaux : toponymes (Québec, Montréal, Gatineau, Sherbrooke, Trois-Rivières, Saguenay, Laval, Longueuil…), adresses postales QC, province dans les adresses structurées, indicatifs téléphoniques (signal faible), organisations québécoises connues (entreprises, municipalités, universités, médias, gouvernement), langue (le français augmente la probabilité sans la prouver), graphe de domaines (un domaine massivement lié par des domaines québécois gagne du signal), métadonnées structurées, pages contact/footer. | |
| 187 | + | |
| 188 | +--- | |
| 189 | + | |
| 190 | +## 8. Découverte | |
| 191 | + | |
| 192 | +**Qualité des seeds > quantité.** Démarrer avec des nœuds fortement connectés : gouvernement du Québec, municipalités, universités, cégeps, grands médias, annuaires d'affaires, associations professionnelles, chambres de commerce, tourisme régional, grandes entreprises. | |
| 193 | + | |
| 194 | +Boucle : crawl → extraction des liens sortants → scoring Québec des domaines cibles → ajout au frontier → répéter. Détecter automatiquement sitemaps (`/sitemap.xml`, entrées robots.txt, index de sitemaps) et flux RSS/Atom, sans confiance aveugle. | |
| 195 | + | |
| 196 | +Maintenir une base de domaines : `domain, first_seen, last_crawled, page_count, quebec_score, language_distribution, robots_status, authority_score, inlinks, outlinks, content_change_rate…` | |
| 197 | + | |
| 198 | +Construire le graphe de liens dès le crawl (Page→Page, Domain→Domain, Org→Domain…) — d'abord pour le ranking et la découverte, plus tard pour l'écosystème KA. | |
| 199 | + | |
| 200 | +--- | |
| 201 | + | |
| 202 | +## 9. Index et ranking | |
| 203 | + | |
| 204 | +Document indexé (cible) : `id, url, canonical_url, domain, title, description, body, headings, language, page_quebec_score, domain_quebec_score, locations, organizations, people, categories, published_at, crawled_at, authority_score, freshness_score, quality_score, spam_score, embedding`. | |
| 205 | + | |
| 206 | +**BM25 d'abord.** Puis fonction de ranking dédiée : | |
| 207 | + | |
| 208 | +`Score(d,q) = w_b·BM25 + w_s·Sémantique + w_q·Québec + w_a·Autorité + w_f·Fraîcheur + w_l·Localité + w_u·Qualité − w_p·Spam` | |
| 209 | + | |
| 210 | +Le ranking est un package/service indépendant, évolutif, aux poids non figés. | |
| 211 | + | |
| 212 | +**Québec-first :** pour `meilleur programme thermopompe`, Hydro-Québec, un programme gouvernemental ou une entreprise CVC québécoise doivent battre un article international générique, à pertinence égale. | |
| 213 | + | |
| 214 | +**Bilingue dès le départ :** comprendre `thermopompe` ↔ `heat pump`. Pas de traduction à l'ingestion; plus tard : embeddings multilingues, expansion de requête, dictionnaires de synonymes. | |
| 215 | + | |
| 216 | +**Pipeline de requête :** normalisation → détection de langue → correction → intention → extraction d'entités/lieux → retrieval lexical → retrieval sémantique → fusion → reranking. **Chaque composant avancé est optionnel; BM25 fonctionne même si tout le reste tombe.** | |
| 217 | + | |
| 218 | +**Localisation :** `plombier Gatineau`, `subvention Sherbrooke` boostent les documents géographiquement pertinents sur preuve géographique explicite, pas juste du keyword matching. | |
| 219 | + | |
| 220 | +--- | |
| 221 | + | |
| 222 | +## 10. Interface | |
| 223 | + | |
| 224 | +**Homepage = la boîte de recherche.** Rien d'autre d'important. | |
| 225 | + | |
| 226 | +``` | |
| 227 | + Trouve-KA | |
| 228 | + [ Rechercher... ] | |
| 229 | + Chercher | |
| 230 | + Cherche le Québec. | |
| 231 | +``` | |
| 232 | + | |
| 233 | +Résultats : favicon, titre, URL/breadcrumb, snippet, badges optionnels (Québec, Gouvernement, Entreprise…). Sensation : rapide, propre, premium, minimal, fiable. Filtres MVP sobres : Tout / Actualités / Gouvernement / Français / English / Québec seulement. | |
| 234 | + | |
| 235 | +**Compteur d'index visible** (« 18 432 pages indexées » le jour 1, c'est parfait — la croissance fait partie du produit) + page `/status` : pages/domaines indexés, débit horaire, état du crawler et de l'index. | |
| 236 | + | |
| 237 | +**Dashboard admin** : longueur des files, débits fetch/parse/index, distribution HTTP, domaines actifs, blocages robots, retries, latences, stockage, taux de doublons, taux d'acceptation Québec, flux live du crawl. | |
| 238 | + | |
| 239 | +Mobile impeccable, accessibilité complète (navigation clavier, HTML sémantique, contrastes, focus states). Design : minimal, moderne, québécois sans clichés — **pas** de fleurs de lys partout, pas de hero marketing, pas de gradients IA. | |
| 240 | + | |
| 241 | +--- | |
| 242 | + | |
| 243 | +## 11. APIs | |
| 244 | + | |
| 245 | +**Recherche :** `GET /api/search?q=&page=&limit=&language=&location=&category=&freshness=` → résultats structurés (`title, url, display_url, snippet, domain, score`, `took_ms`, `total`). Ne pas exposer le scoring interne brut en prod. | |
| 246 | + | |
| 247 | +**Contrôle du crawler (protégé, jamais public sans auth) :** pause/reprise, ajout de seeds, recrawl URL/domaine, inspection du frontier, priorités, blocage de domaine. | |
| 248 | + | |
| 249 | +**Soumission d'URL** (feature simple) : « Soumettre un site québécois » → frontier. Soumission ≠ inclusion; le crawler valide. | |
| 250 | + | |
| 251 | +--- | |
| 252 | + | |
| 253 | +## 12. Coûts, LLM et embeddings | |
| 254 | + | |
| 255 | +Le crawl doit être **économique** : bande passante, compute, stockage — pas d'API LLM ni d'API de scraping commerciale par page. Le cœur (fetch, parse, canonicalisation, langue, hash, doublons, BM25) est déterministe. Les LLM enrichissent des pages **sélectionnées** en asynchrone (catégorisation, entités complexes, classification géographique ambiguë). Embeddings : toujours en arrière-plan, jamais bloquants. | |
| 256 | + | |
| 257 | +**Stockage brut :** métadonnées → Postgres; contenu cherchable → index; HTML brut compressé optionnel → object storage. Pas de blobs HTML géants dans Postgres. | |
| 258 | + | |
| 259 | +Analytics de recherche : métriques agrégées et respectueuses de la vie privée (requête, latence, position cliquée, zéro-résultat, langue). **Les requêtes zéro-résultat sont de l'or** : les stocker pour piloter le crawl par la demande. Pas de profils utilisateurs invasifs. | |
| 260 | + | |
| 261 | +--- | |
| 262 | + | |
| 263 | +## 13. Observabilité et dégradation | |
| 264 | + | |
| 265 | +Logs structurés avec `crawl_id, url_id, domain_id, worker_id, job_id`. Métriques compatibles Prometheus/Grafana/OpenTelemetry (choix pragmatique). | |
| 266 | + | |
| 267 | +**Dégradation gracieuse obligatoire :** embeddings en panne → lexical continue; enrichissement en retard → nouvelles pages cherchables quand même; un worker crash → frontier continue; frontend redémarre → crawler continue. | |
| 268 | + | |
| 269 | +Workers scalables indépendamment (`crawler-worker × N`, `parser-worker × N`, `indexer-worker × N`) — mais **pas de complexité distribuée prématurée** : Postgres + Redis + backend de recherche + quelques workers suffisent au départ. | |
| 270 | + | |
| 271 | +--- | |
| 272 | + | |
| 273 | +## 14. Provenance, droit d'auteur, avenir | |
| 274 | + | |
| 275 | +- Toujours préserver : URL originale, canonique, timestamp de crawl, domaine source. Trouve-KA renvoie vers les éditeurs originaux — c'est un index, pas un remplacement de contenu. | |
| 276 | +- Snippets raisonnables, attribution, liens sortants. Stockage brut, indexation, cache et affichage conçus séparément. | |
| 277 | +- Futur (non-MVP mais à ne pas bloquer architecturalement) : webmaster tools, verticales (news, entreprises, gouvernement, immobilier…), historique de versions des pages, réponses IA basées **uniquement** sur l'index avec citations, écosystème KA (Person-KA, Service-KA, Entreprise-KA…) alimenté par l'extraction d'entités asynchrone. Pas de collecte de données personnelles invasives. | |
| 278 | + | |
| 279 | +--- | |
| 280 | + | |
| 281 | +## 15. Configuration et DX | |
| 282 | + | |
| 283 | +Variables d'env validées : `DATABASE_URL, REDIS_URL, SEARCH_URL, CRAWLER_USER_AGENT, CRAWLER_CONTACT_URL, MAX_GLOBAL_CONCURRENCY, DEFAULT_HOST_DELAY, MAX_RESPONSE_BYTES, NGROK_DOMAIN=www.trouve-ka.com, PUBLIC_URL=https://www.trouve-ka.com`. Fournir `.env.example` sans secrets. | |
| 284 | + | |
| 285 | +Expérience développeur cible : | |
| 286 | + | |
| 287 | +``` | |
| 288 | +git clone … && cd trouve-ka | |
| 289 | +cp .env.example .env | |
| 290 | +docker compose up -d | |
| 291 | +pnpm install && pnpm dev | |
| 292 | +pnpm crawl:seed | |
| 293 | +# → des résultats apparaissent en quelques instants sur localhost:3000 | |
| 294 | +``` | |
| 295 | + | |
| 296 | +Déploiement m2m32 : même stack via compose + tunnel ngrok vers www.trouve-ka.com, scripté et documenté. | |
| 297 | + | |
| 298 | +--- | |
| 299 | + | |
| 300 | +## 16. Tests et évaluation | |
| 301 | + | |
| 302 | +Tests unitaires sur la logique critique : normalisation d'URL, robots, scoring Québec, canonical, doublons, scheduling, prévention SSRF, extraction HTML, API de recherche. | |
| 303 | + | |
| 304 | +Tests d'intégration sur un **web de fixtures local** (plombier québécois, université montréalaise, entreprise ontarienne, doublon, redirection, chemin bloqué par robots, article FR, article EN québécois) : fixture → crawler → parser → index → résultat de recherche. | |
| 305 | + | |
| 306 | +Dataset d'évaluation du ranking (« université québec », « plombier gatineau », « subvention thermopompe »…) avec domaines attendus. Les expériences de ranking doivent être mesurables. Scripts de benchmark : fetches/s, parses/s, docs indexés/s, latence de recherche, lag de queue, RAM/CPU/disque — l'optimisation est fondée sur des preuves, pas des intuitions. | |
| 307 | + | |
| 308 | +**Cibles :** recherche p50 < 100 ms, p95 < 300 ms à l'échelle MVP; page HTML normale cherchable en secondes; UI quasi instantanée. | |
| 309 | + | |
| 310 | +--- | |
| 311 | + | |
| 312 | +## 17. Ordre d'implémentation | |
| 313 | + | |
| 314 | +1. **Fondation** — monorepo, BD, queue, backend de recherche, web, API, crawler basique. | |
| 315 | +2. **Boucle complète** — seed → fetch → parse → index → search. **Ne pas continuer tant que ça ne marche pas de bout en bout.** | |
| 316 | +3. **Découverte continue** — liens sortants, frontier, domaines, scheduler, robots. | |
| 317 | +4. **Intelligence Québec** — scores, langue, localisation, scoring de domaines. | |
| 318 | +5. **Qualité de recherche** — BM25 custom, snippets, autorité, fraîcheur, doublons, compréhension de requête. | |
| 319 | +6. **Échelle** — plus de workers, meilleures queues, recrawl, monitoring. | |
| 320 | +7. **Couche sémantique** — embeddings, hybride, reranking, entités. | |
| 321 | +8. **Écosystème KA.** | |
| 322 | + | |
| 323 | +--- | |
| 324 | + | |
| 325 | +## 18. Milestone critique (non négociable) | |
| 326 | + | |
| 327 | +La première implémentation n'est réussie que si Claude démontre : | |
| 328 | + | |
| 329 | +1. démarrage du stack; | |
| 330 | +2. seed de sites québécois; | |
| 331 | +3. le crawler découvre des URLs; | |
| 332 | +4. télécharge des pages; | |
| 333 | +5. le parser extrait du texte utile; | |
| 334 | +6. les pages reçoivent un score Québec; | |
| 335 | +7. insertion **immédiate** dans l'index; | |
| 336 | +8. l'app web publique cherche ces pages; | |
| 337 | +9. les nouvelles pages deviennent cherchables **sans rien redémarrer**; | |
| 338 | +10. le frontier continue de découvrir des sites. | |
| 339 | + | |
| 340 | +**Succès Jour 1 :** même avec 10 000 pages, l'architecture se comporte exactement comme elle le fera avec 10 000 000+. Le crawler étend sa connaissance du web québécois pendant que le moteur est déjà opérationnel sur tout ce qui a été découvert. | |
| 341 | + | |
| 342 | +--- | |
| 343 | + | |
| 344 | +## 19. Méthode de travail de Claude | |
| 345 | + | |
| 346 | +Avant tout code substantiel : analyser cette spec → inspecter le dépôt existant → identifier le réutilisable → rechercher les décisions techniques incertaines → planifier → choisir sur preuves → documenter les décisions d'architecture → **implémenter verticalement** (du logiciel qui marche, pas de l'architecture spéculative). | |
| 347 | + | |
| 348 | +**Autonomie :** décisions raisonnables sans demander en permanence. Plusieurs options valables → analyser, choisir la meilleure, documenter, avancer. Ne bloquer que si c'est réellement bloquant. | |
| 349 | + | |
| 350 | +**Standard de qualité :** code typé, documenté, modulaire, observable, testable, sécurisé, efficace, orienté production. Pas de fichiers géants ni de couplage profond. Schémas migrables (concepts propres : `domains, urls, crawl_attempts, documents, document_versions, links, frontier_items, robots_rules, entities, index_jobs`). Et bien sûr : **header auteur dans chaque fichier** (§0.1). | |
| 351 | + | |
| 352 | +**Documentation :** README excellent (quoi, architecture, quick start, crawl, indexation incrémentale, recherche, layout, env, dev, tests, déploiement m2m32 + ngrok) et docs d'architecture avec diagrammes Mermaid. | |
| 353 | + | |
| 354 | +--- | |
| 355 | + | |
| 356 | +## 20. Vision | |
| 357 | + | |
| 358 | +L'objectif n'est pas de battre Google sur « Taylor Swift » ou « iPhone ». C'est de devenir extraordinairement bon sur : entreprises, institutions, gouvernement, municipalités, services, personnes, événements, produits, immobilier, documents, nouvelles et savoir local **du Québec**. | |
| 359 | + | |
| 360 | +Chaque page crawlée doit accroître la connaissance propre de Trouve-KA — jamais générer une requête vers l'API de quelqu'un d'autre. Chaque crawl améliore la couverture, le graphe de liens, la classification Québec, l'autorité, la fraîcheur, les priorités futures. **La valeur du dataset se compose dans le temps.** | |
| 361 | + | |
| 362 | +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.** | |
| 363 | +> Le web ne « finit » jamais. Trouve-KA non plus. | |
added
M2M32brouve-ka/.env.example
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +# Trouve-KA — variables d'environnement (exemple, sans secrets) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +# --- Bases de données --- | |
| 6 | +DATABASE_URL=postgresql://trouveka:trouveka@localhost:5432/trouveka | |
| 7 | +REDIS_URL=redis://localhost:6379/0 | |
| 8 | +SEARCH_URL=http://localhost:9200 | |
| 9 | + | |
| 10 | +# --- Crawler --- | |
| 11 | +CRAWLER_USER_AGENT="Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)" | |
| 12 | +CRAWLER_CONTACT_URL=https://www.trouve-ka.com/trouveka-bot | |
| 13 | +MAX_GLOBAL_CONCURRENCY=24 | |
| 14 | +MAX_PER_HOST_CONCURRENCY=2 | |
| 15 | +DEFAULT_HOST_DELAY=2.0 | |
| 16 | +MAX_RESPONSE_BYTES=3000000 | |
| 17 | +MAX_REDIRECTS=5 | |
| 18 | +FETCH_TIMEOUT=20 | |
| 19 | +MAX_CRAWL_DEPTH=8 | |
| 20 | +MAX_LINKS_PER_PAGE=300 | |
| 21 | +MAX_URLS_PER_DOMAIN=5000 | |
| 22 | + | |
| 23 | +# --- API --- | |
| 24 | +API_HOST=0.0.0.0 | |
| 25 | +API_PORT=8080 | |
| 26 | +ADMIN_TOKEN=change-me-admin-token | |
| 27 | + | |
| 28 | +# --- Web --- | |
| 29 | +PUBLIC_URL=https://www.trouve-ka.com | |
| 30 | +NGROK_DOMAIN=www.trouve-ka.com | |
| 31 | +API_URL=http://localhost:8080 | |
| 32 | + | |
| 33 | +# --- Index --- | |
| 34 | +SEARCH_INDEX=trouveka-docs | |
added
M2M32brouve-ka/.gitignore
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +node_modules/ | |
| 2 | +.next/ | |
| 3 | +dist/ | |
| 4 | +__pycache__/ | |
| 5 | +*.pyc | |
| 6 | +.venv/ | |
| 7 | +.env | |
| 8 | +.env.local | |
| 9 | +*.egg-info/ | |
| 10 | +.pytest_cache/ | |
| 11 | +.ruff_cache/ | |
| 12 | +data/ | |
| 13 | +.DS_Store | |
added
M2M32brouve-ka/CLAUDE.md
+363 −0
@@ -0,0 +1,363 @@ | ||
| 1 | +# CLAUDE.md — Trouve-KA | |
| 2 | + | |
| 3 | +**Projet :** Trouve-KA — *Cherche le Québec.* | |
| 4 | +**Mission :** Moteur de recherche web indépendant, Québec-first, avec son propre crawler, index, ranking, API et application web publique. | |
| 5 | + | |
| 6 | +Trouve-KA n'est **pas** un métamoteur. Aucune dépendance à Google, Bing ou Brave pour les résultats primaires. L'objectif : un index structuré et continuellement mis à jour du web québécois. | |
| 7 | + | |
| 8 | +``` | |
| 9 | +Crawler → Frontier → Fetcher → Parser → Classification Québec | |
| 10 | +→ Déduplication → Indexer → Index → Ranking → API → Web App | |
| 11 | +``` | |
| 12 | + | |
| 13 | +Le crawler fait partie du produit. Tout vit dans un seul monorepo. | |
| 14 | + | |
| 15 | +--- | |
| 16 | + | |
| 17 | +## 0. Règles non négociables | |
| 18 | + | |
| 19 | +### 0.1 Header obligatoire dans chaque fichier de code | |
| 20 | + | |
| 21 | +**Chaque fichier source** (TS, JS, Python, SQL, config exécutable, scripts) doit commencer par un header d'auteur : | |
| 22 | + | |
| 23 | +```ts | |
| 24 | +/** | |
| 25 | + * Trouve-KA — <nom du module> | |
| 26 | + * Author: Simon-Pierre Boucher | |
| 27 | + * Contact: contact@spboucher.ai | |
| 28 | + */ | |
| 29 | +``` | |
| 30 | + | |
| 31 | +```python | |
| 32 | +# Trouve-KA — <nom du module> | |
| 33 | +# Author: Simon-Pierre Boucher | |
| 34 | +# Contact: contact@spboucher.ai | |
| 35 | +``` | |
| 36 | + | |
| 37 | +Adapter la syntaxe de commentaire au langage. Aucune exception. Un lint/check CI doit vérifier la présence du header. | |
| 38 | + | |
| 39 | +### 0.2 Cible de déploiement | |
| 40 | + | |
| 41 | +- **Node de déploiement : `m2m32`** (32 Go RAM). Tout le stack (Postgres, Redis, backend de recherche, workers, API, web) doit tourner confortablement sur cette machine. | |
| 42 | +- **Exposition publique via ngrok : `www.trouve-ka.com`.** Configurer ngrok avec le domaine réservé; l'app web et l'API doivent fonctionner correctement derrière le tunnel (URLs absolues, cookies, CORS, headers `X-Forwarded-*`). | |
| 43 | +- Docker Compose est l'outil de déploiement. Pas de Kubernetes. | |
| 44 | +- Fournir un script/target `deploy:m2m32` et documenter la procédure complète (compose up + tunnel ngrok) dans le README. | |
| 45 | +- Dimensionner les défauts (concurrence, tailles de heap du moteur de recherche, connexions Postgres) pour 32 Go, avec configuration par variables d'environnement. | |
| 46 | + | |
| 47 | +### 0.3 Principe cardinal | |
| 48 | + | |
| 49 | +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Enrichis en asynchrone.** | |
| 50 | + | |
| 51 | +Le moteur doit être utilisable **dès que le crawl démarre**. Jamais de cycle « crawler tout → construire l'index → lancer la recherche ». Chaque page traitée avec succès devient cherchable en secondes. | |
| 52 | + | |
| 53 | +``` | |
| 54 | +12:00:00 démarrage crawler | |
| 55 | +12:00:04 première page fetchée | |
| 56 | +12:00:06 score Québec calculé | |
| 57 | +12:00:07 document indexé | |
| 58 | +12:00:08 cherchable par l'utilisateur | |
| 59 | +``` | |
| 60 | + | |
| 61 | +Aucune interdiction plus importante que celle-ci : **ne jamais bloquer l'indexation sur l'enrichissement** (embeddings, entités, scoring avancé = asynchrone, mise à jour du document après coup). | |
| 62 | + | |
| 63 | +--- | |
| 64 | + | |
| 65 | +## 1. Philosophie produit | |
| 66 | + | |
| 67 | +Trouve-KA est un vrai moteur, pas : un annuaire, une liste curée, un wrapper ChatGPT, un frontend Google CSE, un dataset statique. | |
| 68 | + | |
| 69 | +Il doit de façon autonome : découvrir des sites → les crawler → comprendre le contenu → juger la pertinence québécoise → indexer → ranker → exposer via recherche → découvrir davantage → rafraîchir en continu. | |
| 70 | + | |
| 71 | +**Interdictions absolues (§ fake) :** pas de données de crawl factices, pas de compteurs simulés présentés comme réels, pas de résultats hard-codés, pas d'API de recherche placeholder. Les fixtures sont réservées aux tests. Le dev tourne sur le vrai crawler et le vrai index local. | |
| 72 | + | |
| 73 | +--- | |
| 74 | + | |
| 75 | +## 2. Architecture du dépôt | |
| 76 | + | |
| 77 | +Monorepo : | |
| 78 | + | |
| 79 | +``` | |
| 80 | +trouve-ka/ | |
| 81 | +├── apps/ | |
| 82 | +│ ├── web/ # moteur public | |
| 83 | +│ ├── api/ # search + APIs internes | |
| 84 | +│ └── admin/ # dashboard crawl/index | |
| 85 | +├── services/ | |
| 86 | +│ ├── crawler/ frontier/ parser/ classifier/ | |
| 87 | +│ ├── indexer/ ranking/ scheduler/ enrichment/ | |
| 88 | +├── packages/ | |
| 89 | +│ ├── database/ shared/ config/ logging/ | |
| 90 | +│ ├── queue/ types/ search-core/ | |
| 91 | +├── infrastructure/ | |
| 92 | +│ ├── docker/ migrations/ monitoring/ deployment/ # inclut config ngrok + m2m32 | |
| 93 | +├── scripts/ | |
| 94 | +│ ├── bootstrap-seeds/ start-crawler/ rebuild-index/ health-check/ | |
| 95 | +├── docs/ | |
| 96 | +├── CLAUDE.md README.md docker-compose.yml | |
| 97 | +``` | |
| 98 | + | |
| 99 | +Claude peut améliorer cette structure sur justification technique solide. | |
| 100 | + | |
| 101 | +--- | |
| 102 | + | |
| 103 | +## 3. Stack technologique | |
| 104 | + | |
| 105 | +| Couche | Choix | | |
| 106 | +|---|---| | |
| 107 | +| Frontend | Next.js, TypeScript, React, Tailwind, shadcn/ui | | |
| 108 | +| API | FastAPI (Python) ou backend TypeScript si ça réduit la complexité; le crawler peut rester Python même si l'API est TS | | |
| 109 | +| BD relationnelle | PostgreSQL (domaines, URLs, état de crawl, métadonnées, entités, scheduler, bookkeeping) | | |
| 110 | +| Queue | Redis + vraie file de tâches/streams (évaluer avant de choisir) | | |
| 111 | +| Recherche | À sélectionner parmi : OpenSearch, Elasticsearch, Typesense, Meilisearch, Vespa, Tantivy, Quickwit | | |
| 112 | + | |
| 113 | +Critères de sélection du backend de recherche : qualité BM25, indexation incrémentale, facettes, tolérance aux typos, ranking custom, recherche hybride, performance, scaling horizontal, **complexité opérationnelle sur un seul node m2m32**. Pour le MVP : qualité de recherche + indexation incrémentale + opérations simples. Ne pas choisir le plus facile par défaut. | |
| 114 | + | |
| 115 | +--- | |
| 116 | + | |
| 117 | +## 4. Qualité de recherche progressive | |
| 118 | + | |
| 119 | +Chaque document s'enrichit par étapes; **la disponibilité en recherche ne dépend jamais des étapes suivantes.** | |
| 120 | + | |
| 121 | +- **Étape 1 (immédiat) :** URL, titre, description, corps, headings, domaine, langue, timestamp, score Québec → index BM25. | |
| 122 | +- **Étape 2 (async) :** embedding, classification thématique, organisations, signaux de localisation, entité canonique. | |
| 123 | +- **Étape 3 (async) :** autorité de domaine, score de graphe de liens / PageRank-like, fraîcheur, qualité, spam, intention commerciale, pertinence locale. | |
| 124 | + | |
| 125 | +--- | |
| 126 | + | |
| 127 | +## 5. Crawler | |
| 128 | + | |
| 129 | +Un vrai crawler : frontier d'URLs, scheduling par domaine, robots.txt, canonicalisation, redirections, retries, cache HTTP, compression, gestion des content-types, budgets de crawl, concurrence par hôte, rate limiting, files de priorité, prévention des doublons, historique, hash de contenu, détection de changements. | |
| 130 | + | |
| 131 | +### 5.1 Identité | |
| 132 | +User-agent identifiable : `Mozilla-compatible / TrouveKABot`. Page publique `/trouveka-bot` : quoi, pourquoi, UA, contact (**contact@spboucher.ai**), comment bloquer, respect de robots.txt. **Pas de stealth, pas de rotation de proxys par défaut.** IPs stables et comportement poli. | |
| 133 | + | |
| 134 | +### 5.2 robots.txt et politesse | |
| 135 | +Parsing conforme aux standards, cache des règles, respect de `noindex` / `nofollow` / `canonical` / `X-Robots-Tag`. Politesse par origine : 1–2 requêtes concurrentes max par hôte, budgets indépendants, politiques configurables. Un gros débit global ne doit jamais agresser un site individuel. | |
| 136 | + | |
| 137 | +### 5.3 Frontier | |
| 138 | +Chaque URL : `url, domain, priority, depth, source_url, discovered_at, last_crawled_at, next_crawl_at, status`. Priorités évolutives selon : pertinence Québec, autorité, source de découverte, profondeur, importance du domaine, fraîcheur, historique de changement, succès, duplication. | |
| 139 | + | |
| 140 | +Fonction de priorité conceptuelle : | |
| 141 | +`P = w_q·Q + w_a·A + w_f·F + w_l·L + w_n·N − w_d·D − w_s·S` | |
| 142 | +(Québec, Autorité, Fraîcheur, Liens, Nouveauté, Doublon, Spam). Poids calibrés par mesures, jamais figés. | |
| 143 | + | |
| 144 | +### 5.4 Canonicalisation d'URL | |
| 145 | +Extrême prudence. Normaliser : fragments, ports par défaut, slashs, paramètres de tracking/UTM, ordre des paramètres quand approprié, http/https, www, trailing slash, tags canonical. **Ne jamais fusionner deux ressources distinctes par accident.** | |
| 146 | + | |
| 147 | +### 5.5 Recrawl adaptatif | |
| 148 | +Homepage de nouvelles → minutes; article → heures puis décroissant; page gouvernementale → quotidien; site statique → mensuel; archive inchangée → rarement. Fréquence de changement mesurée empiriquement : page inchangée → intervalle ↑; page volatile → intervalle ↓. | |
| 149 | + | |
| 150 | +### 5.6 Détection de changement et doublons | |
| 151 | +Stocker `content_hash`, etag, last-modified, `last_changed_at`. Réindexer seulement si changement. Doublons : exacts (hash) d'abord, puis SimHash/MinHash/shingling si justifié. Gérer miroirs, versions imprimables, doublons de paramètres, syndication. Conserver la provenance. | |
| 152 | + | |
| 153 | +### 5.7 Sécurité du crawl (SSRF) | |
| 154 | +Bloquer : localhost, 127.0.0.0/8, plages privées IPv4/IPv6, endpoints de métadonnées cloud, `file://`, schémas dangereux. Revalider DNS/IP. Chaque réponse a des limites : taille max, redirections max, timeout, temps de parse max, liens extraits max, profondeur max. | |
| 155 | + | |
| 156 | +### 5.8 Pièges de crawl | |
| 157 | +Détecter : calendriers infinis, session IDs, explosions de navigation à facettes, pagination infinie, paramètres aléatoires, boucles. Limites par pattern/domaine. | |
| 158 | + | |
| 159 | +### 5.9 Erreurs | |
| 160 | +Codes structurés : DNS, timeout, TLS, 4xx, 5xx, robots refusé, parse échoué, contenu non supporté, trop gros, doublon, spam, non pertinent Québec. L'échec est normal; il est traqué. | |
| 161 | + | |
| 162 | +### 5.10 Rendu navigateur | |
| 163 | +**Jamais** de Chromium par page. Défaut : fetch HTTP. Le navigateur est un fallback spécialisé par domaine — essentiel pour l'échelle et pour tenir sur m2m32. | |
| 164 | + | |
| 165 | +--- | |
| 166 | + | |
| 167 | +## 6. Extraction de contenu | |
| 168 | + | |
| 169 | +Extraire : titre, meta description, corps principal, headings, données structurées (JSON-LD, schema.org, microdata, OpenGraph), URL canonique, langue, liens + anchors, dates de publication/modification, auteur, indices d'organisation et d'adresse. | |
| 170 | + | |
| 171 | +Retirer : navigation, menus, bannières cookies, footers répétitifs, scripts, styles, pub. | |
| 172 | + | |
| 173 | +Types au départ : HTML, texte, PDF. Architecture extensible (DOCX/XLSX/PPTX, RSS/Atom) sans que l'extraction coûteuse bloque le crawl HTML. | |
| 174 | + | |
| 175 | +Traiter chaque page crawlée comme **non fiable** : sanitizer HTML, URLs, métadonnées; ne jamais faire confiance aux MIME types distants. | |
| 176 | + | |
| 177 | +--- | |
| 178 | + | |
| 179 | +## 7. Détection Québec | |
| 180 | + | |
| 181 | +L'innovation clé. Un `.ca` seul ne suffit pas. Calculer `quebec_score ∈ [0,1]` avec **deux scores distincts** : | |
| 182 | + | |
| 183 | +- `domain_quebec_score` | |
| 184 | +- `page_quebec_score` (un article du NYT sur Montréal peut être pertinent sans que le domaine le soit) | |
| 185 | + | |
| 186 | +Signaux : toponymes (Québec, Montréal, Gatineau, Sherbrooke, Trois-Rivières, Saguenay, Laval, Longueuil…), adresses postales QC, province dans les adresses structurées, indicatifs téléphoniques (signal faible), organisations québécoises connues (entreprises, municipalités, universités, médias, gouvernement), langue (le français augmente la probabilité sans la prouver), graphe de domaines (un domaine massivement lié par des domaines québécois gagne du signal), métadonnées structurées, pages contact/footer. | |
| 187 | + | |
| 188 | +--- | |
| 189 | + | |
| 190 | +## 8. Découverte | |
| 191 | + | |
| 192 | +**Qualité des seeds > quantité.** Démarrer avec des nœuds fortement connectés : gouvernement du Québec, municipalités, universités, cégeps, grands médias, annuaires d'affaires, associations professionnelles, chambres de commerce, tourisme régional, grandes entreprises. | |
| 193 | + | |
| 194 | +Boucle : crawl → extraction des liens sortants → scoring Québec des domaines cibles → ajout au frontier → répéter. Détecter automatiquement sitemaps (`/sitemap.xml`, entrées robots.txt, index de sitemaps) et flux RSS/Atom, sans confiance aveugle. | |
| 195 | + | |
| 196 | +Maintenir une base de domaines : `domain, first_seen, last_crawled, page_count, quebec_score, language_distribution, robots_status, authority_score, inlinks, outlinks, content_change_rate…` | |
| 197 | + | |
| 198 | +Construire le graphe de liens dès le crawl (Page→Page, Domain→Domain, Org→Domain…) — d'abord pour le ranking et la découverte, plus tard pour l'écosystème KA. | |
| 199 | + | |
| 200 | +--- | |
| 201 | + | |
| 202 | +## 9. Index et ranking | |
| 203 | + | |
| 204 | +Document indexé (cible) : `id, url, canonical_url, domain, title, description, body, headings, language, page_quebec_score, domain_quebec_score, locations, organizations, people, categories, published_at, crawled_at, authority_score, freshness_score, quality_score, spam_score, embedding`. | |
| 205 | + | |
| 206 | +**BM25 d'abord.** Puis fonction de ranking dédiée : | |
| 207 | + | |
| 208 | +`Score(d,q) = w_b·BM25 + w_s·Sémantique + w_q·Québec + w_a·Autorité + w_f·Fraîcheur + w_l·Localité + w_u·Qualité − w_p·Spam` | |
| 209 | + | |
| 210 | +Le ranking est un package/service indépendant, évolutif, aux poids non figés. | |
| 211 | + | |
| 212 | +**Québec-first :** pour `meilleur programme thermopompe`, Hydro-Québec, un programme gouvernemental ou une entreprise CVC québécoise doivent battre un article international générique, à pertinence égale. | |
| 213 | + | |
| 214 | +**Bilingue dès le départ :** comprendre `thermopompe` ↔ `heat pump`. Pas de traduction à l'ingestion; plus tard : embeddings multilingues, expansion de requête, dictionnaires de synonymes. | |
| 215 | + | |
| 216 | +**Pipeline de requête :** normalisation → détection de langue → correction → intention → extraction d'entités/lieux → retrieval lexical → retrieval sémantique → fusion → reranking. **Chaque composant avancé est optionnel; BM25 fonctionne même si tout le reste tombe.** | |
| 217 | + | |
| 218 | +**Localisation :** `plombier Gatineau`, `subvention Sherbrooke` boostent les documents géographiquement pertinents sur preuve géographique explicite, pas juste du keyword matching. | |
| 219 | + | |
| 220 | +--- | |
| 221 | + | |
| 222 | +## 10. Interface | |
| 223 | + | |
| 224 | +**Homepage = la boîte de recherche.** Rien d'autre d'important. | |
| 225 | + | |
| 226 | +``` | |
| 227 | + Trouve-KA | |
| 228 | + [ Rechercher... ] | |
| 229 | + Chercher | |
| 230 | + Cherche le Québec. | |
| 231 | +``` | |
| 232 | + | |
| 233 | +Résultats : favicon, titre, URL/breadcrumb, snippet, badges optionnels (Québec, Gouvernement, Entreprise…). Sensation : rapide, propre, premium, minimal, fiable. Filtres MVP sobres : Tout / Actualités / Gouvernement / Français / English / Québec seulement. | |
| 234 | + | |
| 235 | +**Compteur d'index visible** (« 18 432 pages indexées » le jour 1, c'est parfait — la croissance fait partie du produit) + page `/status` : pages/domaines indexés, débit horaire, état du crawler et de l'index. | |
| 236 | + | |
| 237 | +**Dashboard admin** : longueur des files, débits fetch/parse/index, distribution HTTP, domaines actifs, blocages robots, retries, latences, stockage, taux de doublons, taux d'acceptation Québec, flux live du crawl. | |
| 238 | + | |
| 239 | +Mobile impeccable, accessibilité complète (navigation clavier, HTML sémantique, contrastes, focus states). Design : minimal, moderne, québécois sans clichés — **pas** de fleurs de lys partout, pas de hero marketing, pas de gradients IA. | |
| 240 | + | |
| 241 | +--- | |
| 242 | + | |
| 243 | +## 11. APIs | |
| 244 | + | |
| 245 | +**Recherche :** `GET /api/search?q=&page=&limit=&language=&location=&category=&freshness=` → résultats structurés (`title, url, display_url, snippet, domain, score`, `took_ms`, `total`). Ne pas exposer le scoring interne brut en prod. | |
| 246 | + | |
| 247 | +**Contrôle du crawler (protégé, jamais public sans auth) :** pause/reprise, ajout de seeds, recrawl URL/domaine, inspection du frontier, priorités, blocage de domaine. | |
| 248 | + | |
| 249 | +**Soumission d'URL** (feature simple) : « Soumettre un site québécois » → frontier. Soumission ≠ inclusion; le crawler valide. | |
| 250 | + | |
| 251 | +--- | |
| 252 | + | |
| 253 | +## 12. Coûts, LLM et embeddings | |
| 254 | + | |
| 255 | +Le crawl doit être **économique** : bande passante, compute, stockage — pas d'API LLM ni d'API de scraping commerciale par page. Le cœur (fetch, parse, canonicalisation, langue, hash, doublons, BM25) est déterministe. Les LLM enrichissent des pages **sélectionnées** en asynchrone (catégorisation, entités complexes, classification géographique ambiguë). Embeddings : toujours en arrière-plan, jamais bloquants. | |
| 256 | + | |
| 257 | +**Stockage brut :** métadonnées → Postgres; contenu cherchable → index; HTML brut compressé optionnel → object storage. Pas de blobs HTML géants dans Postgres. | |
| 258 | + | |
| 259 | +Analytics de recherche : métriques agrégées et respectueuses de la vie privée (requête, latence, position cliquée, zéro-résultat, langue). **Les requêtes zéro-résultat sont de l'or** : les stocker pour piloter le crawl par la demande. Pas de profils utilisateurs invasifs. | |
| 260 | + | |
| 261 | +--- | |
| 262 | + | |
| 263 | +## 13. Observabilité et dégradation | |
| 264 | + | |
| 265 | +Logs structurés avec `crawl_id, url_id, domain_id, worker_id, job_id`. Métriques compatibles Prometheus/Grafana/OpenTelemetry (choix pragmatique). | |
| 266 | + | |
| 267 | +**Dégradation gracieuse obligatoire :** embeddings en panne → lexical continue; enrichissement en retard → nouvelles pages cherchables quand même; un worker crash → frontier continue; frontend redémarre → crawler continue. | |
| 268 | + | |
| 269 | +Workers scalables indépendamment (`crawler-worker × N`, `parser-worker × N`, `indexer-worker × N`) — mais **pas de complexité distribuée prématurée** : Postgres + Redis + backend de recherche + quelques workers suffisent au départ. | |
| 270 | + | |
| 271 | +--- | |
| 272 | + | |
| 273 | +## 14. Provenance, droit d'auteur, avenir | |
| 274 | + | |
| 275 | +- Toujours préserver : URL originale, canonique, timestamp de crawl, domaine source. Trouve-KA renvoie vers les éditeurs originaux — c'est un index, pas un remplacement de contenu. | |
| 276 | +- Snippets raisonnables, attribution, liens sortants. Stockage brut, indexation, cache et affichage conçus séparément. | |
| 277 | +- Futur (non-MVP mais à ne pas bloquer architecturalement) : webmaster tools, verticales (news, entreprises, gouvernement, immobilier…), historique de versions des pages, réponses IA basées **uniquement** sur l'index avec citations, écosystème KA (Person-KA, Service-KA, Entreprise-KA…) alimenté par l'extraction d'entités asynchrone. Pas de collecte de données personnelles invasives. | |
| 278 | + | |
| 279 | +--- | |
| 280 | + | |
| 281 | +## 15. Configuration et DX | |
| 282 | + | |
| 283 | +Variables d'env validées : `DATABASE_URL, REDIS_URL, SEARCH_URL, CRAWLER_USER_AGENT, CRAWLER_CONTACT_URL, MAX_GLOBAL_CONCURRENCY, DEFAULT_HOST_DELAY, MAX_RESPONSE_BYTES, NGROK_DOMAIN=www.trouve-ka.com, PUBLIC_URL=https://www.trouve-ka.com`. Fournir `.env.example` sans secrets. | |
| 284 | + | |
| 285 | +Expérience développeur cible : | |
| 286 | + | |
| 287 | +``` | |
| 288 | +git clone … && cd trouve-ka | |
| 289 | +cp .env.example .env | |
| 290 | +docker compose up -d | |
| 291 | +pnpm install && pnpm dev | |
| 292 | +pnpm crawl:seed | |
| 293 | +# → des résultats apparaissent en quelques instants sur localhost:3000 | |
| 294 | +``` | |
| 295 | + | |
| 296 | +Déploiement m2m32 : même stack via compose + tunnel ngrok vers www.trouve-ka.com, scripté et documenté. | |
| 297 | + | |
| 298 | +--- | |
| 299 | + | |
| 300 | +## 16. Tests et évaluation | |
| 301 | + | |
| 302 | +Tests unitaires sur la logique critique : normalisation d'URL, robots, scoring Québec, canonical, doublons, scheduling, prévention SSRF, extraction HTML, API de recherche. | |
| 303 | + | |
| 304 | +Tests d'intégration sur un **web de fixtures local** (plombier québécois, université montréalaise, entreprise ontarienne, doublon, redirection, chemin bloqué par robots, article FR, article EN québécois) : fixture → crawler → parser → index → résultat de recherche. | |
| 305 | + | |
| 306 | +Dataset d'évaluation du ranking (« université québec », « plombier gatineau », « subvention thermopompe »…) avec domaines attendus. Les expériences de ranking doivent être mesurables. Scripts de benchmark : fetches/s, parses/s, docs indexés/s, latence de recherche, lag de queue, RAM/CPU/disque — l'optimisation est fondée sur des preuves, pas des intuitions. | |
| 307 | + | |
| 308 | +**Cibles :** recherche p50 < 100 ms, p95 < 300 ms à l'échelle MVP; page HTML normale cherchable en secondes; UI quasi instantanée. | |
| 309 | + | |
| 310 | +--- | |
| 311 | + | |
| 312 | +## 17. Ordre d'implémentation | |
| 313 | + | |
| 314 | +1. **Fondation** — monorepo, BD, queue, backend de recherche, web, API, crawler basique. | |
| 315 | +2. **Boucle complète** — seed → fetch → parse → index → search. **Ne pas continuer tant que ça ne marche pas de bout en bout.** | |
| 316 | +3. **Découverte continue** — liens sortants, frontier, domaines, scheduler, robots. | |
| 317 | +4. **Intelligence Québec** — scores, langue, localisation, scoring de domaines. | |
| 318 | +5. **Qualité de recherche** — BM25 custom, snippets, autorité, fraîcheur, doublons, compréhension de requête. | |
| 319 | +6. **Échelle** — plus de workers, meilleures queues, recrawl, monitoring. | |
| 320 | +7. **Couche sémantique** — embeddings, hybride, reranking, entités. | |
| 321 | +8. **Écosystème KA.** | |
| 322 | + | |
| 323 | +--- | |
| 324 | + | |
| 325 | +## 18. Milestone critique (non négociable) | |
| 326 | + | |
| 327 | +La première implémentation n'est réussie que si Claude démontre : | |
| 328 | + | |
| 329 | +1. démarrage du stack; | |
| 330 | +2. seed de sites québécois; | |
| 331 | +3. le crawler découvre des URLs; | |
| 332 | +4. télécharge des pages; | |
| 333 | +5. le parser extrait du texte utile; | |
| 334 | +6. les pages reçoivent un score Québec; | |
| 335 | +7. insertion **immédiate** dans l'index; | |
| 336 | +8. l'app web publique cherche ces pages; | |
| 337 | +9. les nouvelles pages deviennent cherchables **sans rien redémarrer**; | |
| 338 | +10. le frontier continue de découvrir des sites. | |
| 339 | + | |
| 340 | +**Succès Jour 1 :** même avec 10 000 pages, l'architecture se comporte exactement comme elle le fera avec 10 000 000+. Le crawler étend sa connaissance du web québécois pendant que le moteur est déjà opérationnel sur tout ce qui a été découvert. | |
| 341 | + | |
| 342 | +--- | |
| 343 | + | |
| 344 | +## 19. Méthode de travail de Claude | |
| 345 | + | |
| 346 | +Avant tout code substantiel : analyser cette spec → inspecter le dépôt existant → identifier le réutilisable → rechercher les décisions techniques incertaines → planifier → choisir sur preuves → documenter les décisions d'architecture → **implémenter verticalement** (du logiciel qui marche, pas de l'architecture spéculative). | |
| 347 | + | |
| 348 | +**Autonomie :** décisions raisonnables sans demander en permanence. Plusieurs options valables → analyser, choisir la meilleure, documenter, avancer. Ne bloquer que si c'est réellement bloquant. | |
| 349 | + | |
| 350 | +**Standard de qualité :** code typé, documenté, modulaire, observable, testable, sécurisé, efficace, orienté production. Pas de fichiers géants ni de couplage profond. Schémas migrables (concepts propres : `domains, urls, crawl_attempts, documents, document_versions, links, frontier_items, robots_rules, entities, index_jobs`). Et bien sûr : **header auteur dans chaque fichier** (§0.1). | |
| 351 | + | |
| 352 | +**Documentation :** README excellent (quoi, architecture, quick start, crawl, indexation incrémentale, recherche, layout, env, dev, tests, déploiement m2m32 + ngrok) et docs d'architecture avec diagrammes Mermaid. | |
| 353 | + | |
| 354 | +--- | |
| 355 | + | |
| 356 | +## 20. Vision | |
| 357 | + | |
| 358 | +L'objectif n'est pas de battre Google sur « Taylor Swift » ou « iPhone ». C'est de devenir extraordinairement bon sur : entreprises, institutions, gouvernement, municipalités, services, personnes, événements, produits, immobilier, documents, nouvelles et savoir local **du Québec**. | |
| 359 | + | |
| 360 | +Chaque page crawlée doit accroître la connaissance propre de Trouve-KA — jamais générer une requête vers l'API de quelqu'un d'autre. Chaque crawl améliore la couverture, le graphe de liens, la classification Québec, l'autorité, la fraîcheur, les priorités futures. **La valeur du dataset se compose dans le temps.** | |
| 361 | + | |
| 362 | +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.** | |
| 363 | +> Le web ne « finit » jamais. Trouve-KA non plus. | |
added
M2M32brouve-ka/README.md
+166 −0
@@ -0,0 +1,166 @@ | ||
| 1 | +# Trouve-KA — Cherche le Québec. | |
| 2 | + | |
| 3 | +**Moteur de recherche web indépendant, Québec-first.** Son propre crawler, son propre | |
| 4 | +index, son propre ranking, son API et son application web publique. Pas un métamoteur : | |
| 5 | +aucune dépendance à Google, Bing ou Brave pour les résultats. | |
| 6 | + | |
| 7 | +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.** | |
| 8 | + | |
| 9 | +En production : **https://www.trouve-ka.com** (node m2m32 du cluster MacLustr, tunnel ngrok). | |
| 10 | + | |
| 11 | +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 12 | + | |
| 13 | +--- | |
| 14 | + | |
| 15 | +## Ce que c'est | |
| 16 | + | |
| 17 | +``` | |
| 18 | +Crawler → Frontier → Fetcher → Parser → Classification Québec | |
| 19 | +→ Déduplication → Indexer → Index → Ranking → API → Web App | |
| 20 | +``` | |
| 21 | + | |
| 22 | +Le crawler découvre le web québécois à partir de seeds à forte autorité (gouvernement, | |
| 23 | +municipalités, universités, médias), suit les liens sortants, juge la pertinence | |
| 24 | +québécoise de chaque page (`page_quebec_score` **et** `domain_quebec_score`), | |
| 25 | +et indexe **immédiatement** : une page fetchée est cherchable en ~2-4 secondes, | |
| 26 | +pendant que le frontier continue de grandir. L'enrichissement (autorité, entités, | |
| 27 | +embeddings) arrive après, en asynchrone, sans jamais bloquer. | |
| 28 | + | |
| 29 | +Détails : [docs/architecture.md](docs/architecture.md) (diagrammes Mermaid) et | |
| 30 | +[docs/decisions.md](docs/decisions.md) (pourquoi OpenSearch, pourquoi Postgres | |
| 31 | +comme frontier, etc.). | |
| 32 | + | |
| 33 | +## Stack | |
| 34 | + | |
| 35 | +| Couche | Choix | | |
| 36 | +|---|---| | |
| 37 | +| Web | Next.js 15, TypeScript, React 19, Tailwind (composants style shadcn/ui) | | |
| 38 | +| API | FastAPI (Python 3.12) | | |
| 39 | +| Pipeline | Python 3.12 async — httpx, selectolax, Protego | | |
| 40 | +| BD relationnelle | PostgreSQL 16 (frontier, domaines, documents, graphe de liens, analytics) | | |
| 41 | +| Coordination | Redis 7 (politesse par hôte, pause, Redis Streams pour l'enrichissement) | | |
| 42 | +| Recherche | OpenSearch 2.17 (BM25 FR/EN, synonymes bilingues, function_score Québec-first) | | |
| 43 | +| Déploiement | Docker Compose sur m2m32 + ngrok (www.trouve-ka.com) | | |
| 44 | + | |
| 45 | +## Layout du monorepo | |
| 46 | + | |
| 47 | +``` | |
| 48 | +apps/web # moteur public + dashboard /admin (Next.js) | |
| 49 | +apps/api # FastAPI (trouveka.api) | |
| 50 | +services/ # crawler, frontier, parser, classifier, indexer, ranking, scheduler, enrichment | |
| 51 | +packages/ # config, database, logging, queue, search-core, shared, types | |
| 52 | +infrastructure/ # docker/, migrations/, monitoring/, deployment/ (m2m32 + ngrok) | |
| 53 | +scripts/ # bootstrap-seeds/, start-crawler/, health-check/, eval/, check-headers.py | |
| 54 | +tests/ # unitaires : canonicalisation, SSRF, robots, scoring Québec, fetcher, ranking | |
| 55 | +``` | |
| 56 | + | |
| 57 | +Le backend Python est un seul package namespace `trouveka.*` mappé sur ce layout | |
| 58 | +(voir `pyproject.toml`). | |
| 59 | + | |
| 60 | +## Démarrage rapide (dev) | |
| 61 | + | |
| 62 | +```bash | |
| 63 | +git clone … && cd trouve-ka | |
| 64 | +cp .env.example .env | |
| 65 | +docker compose up -d postgres redis opensearch # infra | |
| 66 | +uv venv --python 3.12 .venv && uv pip install -e ".[dev]" --python .venv/bin/python | |
| 67 | +pnpm install | |
| 68 | + | |
| 69 | +pnpm crawl:seed # migrations + 60+ seeds québécoises | |
| 70 | +bash scripts/start-crawler/start.sh & # le crawl démarre | |
| 71 | +.venv/bin/uvicorn trouveka.api.main:app --port 8080 & | |
| 72 | +pnpm dev # → http://localhost:3000 | |
| 73 | +# → des résultats apparaissent en quelques secondes | |
| 74 | +``` | |
| 75 | + | |
| 76 | +Ports occupés sur la machine? Surcharger dans `.env` : `PG_PORT`, `REDIS_PORT`, | |
| 77 | +`SEARCH_PORT`, `API_PORT` (et les URLs correspondantes). | |
| 78 | + | |
| 79 | +Stack complet en containers : `docker compose up -d --build` (le service `migrate` | |
| 80 | +applique les migrations, `crawler-worker` se scale avec | |
| 81 | +`docker compose up -d --scale crawler-worker=3`). | |
| 82 | + | |
| 83 | +## Comment ça marche | |
| 84 | + | |
| 85 | +### Crawl et politesse | |
| 86 | +- **Identité assumée** : UA `Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)`, | |
| 87 | + page publique [/trouveka-bot](https://www.trouve-ka.com/trouveka-bot), pas de stealth. | |
| 88 | +- **robots.txt** parsé avec Protego, cache 24 h en base; `noindex`/`nofollow`/`X-Robots-Tag` respectés. | |
| 89 | +- **Politesse par origine** : verrou Redis par hôte (défaut 2 s entre requêtes, `Crawl-delay` respecté), | |
| 90 | + quel que soit le nombre de workers. | |
| 91 | +- **Sécurité** : garde SSRF (IP privées/loopback/métadonnées cloud bloquées, revalidée à chaque | |
| 92 | + redirection), limites par réponse (3 Mo, 5 redirections, timeout 20 s), détection de pièges | |
| 93 | + (session IDs, calendriers infinis, facettes explosives, pagination sans fin). | |
| 94 | + | |
| 95 | +### Indexation incrémentale | |
| 96 | +Le worker exécute fetch→parse→score→index **inline** : `refresh_interval: 1s` côté | |
| 97 | +OpenSearch → cherchable en secondes. Détection de changement par hash de contenu + | |
| 98 | +ETag/If-Modified-Since; recrawl adaptatif (inchangé → intervalle ×2, volatil → ÷2). | |
| 99 | + | |
| 100 | +### Détection Québec | |
| 101 | +Deux scores distincts (`page` et `domaine`) calculés à partir de : TLD (.qc.ca, .quebec), | |
| 102 | +gazetteer de toponymes (pondération réduite pour les ambigus type Laval/Hull), codes | |
| 103 | +postaux G/H/J, indicatifs (418/514/438/…), organisations connues (Hydro-Québec, RAMQ, | |
| 104 | +UQAM…), mentions structurées de la province (JSON-LD), langue française (indice, pas preuve). | |
| 105 | +Déterministe et gratuit — aucun LLM dans le chemin chaud (§12). | |
| 106 | + | |
| 107 | +### Ranking | |
| 108 | +`function_score` OpenSearch : BM25 bilingue (analyzers FR + EN, synonymes | |
| 109 | +thermopompe↔heat pump à la recherche) + scores Québec + autorité de domaine | |
| 110 | +(inlinks pondérés) + fraîcheur + boost de localité (« plombier Gatineau » → documents | |
| 111 | +avec preuve géographique `locations`). Chaque composant est optionnel; BM25 tient seul. | |
| 112 | + | |
| 113 | +Évaluation mesurable : `python3 scripts/eval/run-eval.py --api http://localhost:8080` | |
| 114 | +(dataset dans `scripts/eval/ranking-eval.yaml`). | |
| 115 | + | |
| 116 | +## API | |
| 117 | + | |
| 118 | +- `GET /api/search?q=&page=&limit=&language=&category=&quebec_only=&freshness=` | |
| 119 | +- `GET /api/status` — compteurs publics (pages, domaines, débit, état du crawler) | |
| 120 | +- `POST /api/submit {"url": …}` — soumettre un site québécois (soumission ≠ inclusion) | |
| 121 | +- `GET|POST /api/admin/*` — protégé par header `X-Admin-Token` : overview, flux live, | |
| 122 | + pause/reprise, seeds, recrawl, blocage de domaine, inspection du frontier | |
| 123 | + | |
| 124 | +## Tests | |
| 125 | + | |
| 126 | +```bash | |
| 127 | +.venv/bin/python -m pytest tests/ # 54 tests : URLs, SSRF, pièges, Québec, frontier, parser, fetcher, ranking | |
| 128 | +python3 scripts/check-headers.py # header auteur obligatoire dans chaque fichier source (CI) | |
| 129 | +bash scripts/health-check/check.sh # santé du stack | |
| 130 | +``` | |
| 131 | + | |
| 132 | +## Déploiement m2m32 + ngrok (www.trouve-ka.com) | |
| 133 | + | |
| 134 | +Prérequis sur m2m32 : colima + docker + docker-compose (brew), ngrok authentifié, | |
| 135 | +domaine `www.trouve-ka.com` réservé dans le compte ngrok. | |
| 136 | + | |
| 137 | +```bash | |
| 138 | +pnpm deploy:m2m32 # = bash infrastructure/deployment/deploy-m2m32.sh | |
| 139 | +``` | |
| 140 | + | |
| 141 | +Le script : rsync du monorepo → `docker compose up -d --build` → migrations + seeds | |
| 142 | +(idempotent) → tunnel `ngrok http --url=www.trouve-ka.com 3000` → health-check. | |
| 143 | +Le web proxifie `/api/*` vers l'API interne : un seul port exposé, pas d'URL absolues | |
| 144 | +côté client, cookies/CORS sans surprise derrière le tunnel. | |
| 145 | + | |
| 146 | +Opérations courantes sur le node : | |
| 147 | + | |
| 148 | +```bash | |
| 149 | +ssh M2M32 'cd trouve-ka && docker compose logs -f crawler-worker' # crawl en direct | |
| 150 | +ssh M2M32 'cd trouve-ka && docker compose up -d --scale crawler-worker=3' | |
| 151 | +``` | |
| 152 | + | |
| 153 | +## Observabilité | |
| 154 | + | |
| 155 | +Logs JSON structurés par service, métriques réelles via `/api/status` et | |
| 156 | +`/api/admin/overview`, dashboard `/admin` (files, débits, distribution HTTP, latences | |
| 157 | +p50/p95, flux live). Voir [infrastructure/monitoring/README.md](infrastructure/monitoring/README.md). | |
| 158 | + | |
| 159 | +## Règles du dépôt | |
| 160 | + | |
| 161 | +- **Header auteur obligatoire** dans chaque fichier source (§0.1) — vérifié par | |
| 162 | + `scripts/check-headers.py`. | |
| 163 | +- **Aucune donnée factice** : pas de compteurs simulés, pas de résultats hard-codés; | |
| 164 | + les fixtures vivent dans `tests/` uniquement. | |
| 165 | +- Provenance préservée : URL originale, canonique, timestamp de crawl, domaine source. | |
| 166 | + Trouve-KA renvoie vers les éditeurs originaux. | |
added
M2M32brouve-ka/apps/api/__init__.py
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +# Trouve-KA — application API | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""API publique (recherche, statut, soumission) et API de contrôle du crawler (protégée).""" | |
added
M2M32brouve-ka/apps/api/main.py
+297 −0
@@ -0,0 +1,297 @@ | ||
| 1 | +# Trouve-KA — API FastAPI | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""API de Trouve-KA. | |
| 6 | + | |
| 7 | +Publique : /api/search, /api/status, /api/submit, /api/health | |
| 8 | +Protégée (X-Admin-Token) : /api/admin/* — contrôle du crawler, jamais public sans auth. | |
| 9 | + | |
| 10 | +Dégradation gracieuse : si OpenSearch tombe, /api/status répond quand même; | |
| 11 | +si Postgres tombe, la recherche répond quand même (analytics sautées). | |
| 12 | +""" | |
| 13 | + | |
| 14 | +import html | |
| 15 | +import re | |
| 16 | +import time | |
| 17 | +from contextlib import asynccontextmanager | |
| 18 | +from typing import Annotated | |
| 19 | + | |
| 20 | +from fastapi import Depends, FastAPI, Header, HTTPException, Query | |
| 21 | +from fastapi.middleware.cors import CORSMiddleware | |
| 22 | +from pydantic import BaseModel | |
| 23 | + | |
| 24 | +from trouveka.config import get_settings | |
| 25 | +from trouveka.database import Database | |
| 26 | +from trouveka.logging import get_logger | |
| 27 | +from trouveka.queue import Coordination | |
| 28 | +from trouveka.ranking import build_search_body | |
| 29 | +from trouveka.search_core import SearchCore | |
| 30 | +from trouveka.shared import canonicalize_url, display_url, extract_domain, is_http_url | |
| 31 | + | |
| 32 | +log = get_logger("api") | |
| 33 | +settings = get_settings() | |
| 34 | + | |
| 35 | +db = Database(settings.database_url, pool_min=settings.pg_pool_min, pool_max=settings.pg_pool_max) | |
| 36 | +coord = Coordination(settings.redis_url) | |
| 37 | +search = SearchCore(settings.search_url, settings.search_index) | |
| 38 | + | |
| 39 | + | |
| 40 | +@asynccontextmanager | |
| 41 | +async def lifespan(_app: FastAPI): | |
| 42 | + await db.connect() | |
| 43 | + await search.ensure_index() | |
| 44 | + yield | |
| 45 | + await search.close() | |
| 46 | + await coord.close() | |
| 47 | + await db.close() | |
| 48 | + | |
| 49 | + | |
| 50 | +app = FastAPI(title="Trouve-KA API", version="0.1.0", lifespan=lifespan) | |
| 51 | + | |
| 52 | +# Derrière ngrok (www.trouve-ka.com), le web app proxifie /api : CORS permissif inutile | |
| 53 | +# en prod, mais pratique en dev local (web sur :3000, API sur :8080). | |
| 54 | +app.add_middleware( | |
| 55 | + CORSMiddleware, | |
| 56 | + allow_origins=["http://localhost:3000", settings.public_url], | |
| 57 | + allow_methods=["GET", "POST"], | |
| 58 | + allow_headers=["*", "X-Admin-Token"], | |
| 59 | +) | |
| 60 | + | |
| 61 | + | |
| 62 | +def require_admin(x_admin_token: Annotated[str | None, Header()] = None) -> None: | |
| 63 | + if not x_admin_token or x_admin_token != settings.admin_token: | |
| 64 | + raise HTTPException(status_code=401, detail="Jeton admin invalide") | |
| 65 | + | |
| 66 | + | |
| 67 | +_TAG_RE = re.compile(r"<(?!/?em>)[^>]*>") | |
| 68 | + | |
| 69 | + | |
| 70 | +def _safe_snippet(fragments: list[str]) -> str: | |
| 71 | + """Ne laisse passer que <em>/</em> (highlight); tout le reste est échappé par OpenSearch.""" | |
| 72 | + return _TAG_RE.sub("", " … ".join(fragments))[:400] | |
| 73 | + | |
| 74 | + | |
| 75 | +BADGE_LABELS = {"government": "Gouvernement", "news": "Actualités", "education": "Éducation"} | |
| 76 | + | |
| 77 | + | |
| 78 | +# ---------------------------------------------------------------------- publique | |
| 79 | + | |
| 80 | +@app.get("/api/health") | |
| 81 | +async def health(): | |
| 82 | + return {"ok": True, "search_ok": await search.ping()} | |
| 83 | + | |
| 84 | + | |
| 85 | +@app.get("/api/search") | |
| 86 | +async def api_search( | |
| 87 | + q: str = Query(..., min_length=1, max_length=200), | |
| 88 | + page: int = Query(1, ge=1, le=100), | |
| 89 | + limit: int = Query(10, ge=1, le=50), | |
| 90 | + language: str | None = Query(None, pattern="^(fr|en)$"), | |
| 91 | + location: str | None = None, | |
| 92 | + category: str | None = Query(None, max_length=40), | |
| 93 | + quebec_only: bool = False, | |
| 94 | + freshness: str | None = Query(None, pattern="^(day|week|month|year)$"), | |
| 95 | +): | |
| 96 | + started = time.monotonic() | |
| 97 | + query_text = f"{q} {location}" if location else q | |
| 98 | + body, analysis = build_search_body( | |
| 99 | + query_text, page=page, limit=limit, language=language, | |
| 100 | + category=category, quebec_only=quebec_only, freshness=freshness, | |
| 101 | + ) | |
| 102 | + try: | |
| 103 | + res = await search.search(body) | |
| 104 | + except Exception: | |
| 105 | + log.exception("recherche échouée", extra={"ctx": {"q": q}}) | |
| 106 | + raise HTTPException(status_code=503, detail="Le moteur de recherche est temporairement indisponible") | |
| 107 | + | |
| 108 | + took_ms = int((time.monotonic() - started) * 1000) | |
| 109 | + results = [] | |
| 110 | + for hit in res["hits"]["hits"]: | |
| 111 | + src = hit["_source"] | |
| 112 | + highlight = hit.get("highlight", {}) | |
| 113 | + fragments = highlight.get("body") or highlight.get("description") or [] | |
| 114 | + snippet = _safe_snippet(fragments) if fragments else html.escape(src.get("description") or "")[:400] | |
| 115 | + badges = [BADGE_LABELS[c] for c in src.get("categories", []) if c in BADGE_LABELS] | |
| 116 | + if src.get("page_quebec_score", 0) >= 0.45 or src.get("domain_quebec_score", 0) >= 0.6: | |
| 117 | + badges.insert(0, "Québec") | |
| 118 | + results.append({ | |
| 119 | + "title": src.get("title") or src["url"], | |
| 120 | + "url": src["url"], | |
| 121 | + "display_url": display_url(src["url"]), | |
| 122 | + "snippet": snippet, | |
| 123 | + "domain": src["domain"], | |
| 124 | + "language": src.get("language"), | |
| 125 | + "quebec_score": src.get("page_quebec_score", 0), | |
| 126 | + "badges": badges, | |
| 127 | + "published_at": src.get("published_at"), | |
| 128 | + }) | |
| 129 | + | |
| 130 | + total = res["hits"]["total"]["value"] | |
| 131 | + # Analytics agrégées, respectueuses de la vie privée — jamais bloquantes | |
| 132 | + try: | |
| 133 | + await db.record_search_query(q, analysis["language"], total, took_ms) | |
| 134 | + except Exception: | |
| 135 | + log.exception("analytics de recherche sautées") | |
| 136 | + | |
| 137 | + return {"query": q, "total": total, "took_ms": took_ms, "page": page, "limit": limit, "results": results} | |
| 138 | + | |
| 139 | + | |
| 140 | +@app.get("/api/status") | |
| 141 | +async def api_status(): | |
| 142 | + snapshot: dict = {} | |
| 143 | + try: | |
| 144 | + snapshot = await db.status_snapshot() | |
| 145 | + except Exception: | |
| 146 | + log.exception("statut PG indisponible") | |
| 147 | + search_ok = await search.ping() | |
| 148 | + try: | |
| 149 | + paused = await coord.is_paused() | |
| 150 | + except Exception: | |
| 151 | + paused = False | |
| 152 | + return { | |
| 153 | + "pages_indexed": snapshot.get("pages_indexed", 0), | |
| 154 | + "domains_count": snapshot.get("domains_count", 0), | |
| 155 | + "indexed_last_hour": snapshot.get("indexed_last_hour", 0), | |
| 156 | + "fetched_last_hour": snapshot.get("fetched_last_hour", 0), | |
| 157 | + "errors_last_hour": snapshot.get("errors_last_hour", 0), | |
| 158 | + "frontier_pending": snapshot.get("frontier_pending", 0), | |
| 159 | + "frontier_in_progress": snapshot.get("frontier_in_progress", 0), | |
| 160 | + "crawler_state": "paused" if paused else "running", | |
| 161 | + "search_ok": search_ok, | |
| 162 | + } | |
| 163 | + | |
| 164 | + | |
| 165 | +@app.get("/api/live") | |
| 166 | +async def api_live(): | |
| 167 | + """Dernière page visitée par TrouveKABot — alimente le flux temps réel du footer. | |
| 168 | + | |
| 169 | + Public mais volontairement minimal : domaine + URL + horodatage, rien d'interne. | |
| 170 | + """ | |
| 171 | + try: | |
| 172 | + events = await db.recent_events(1) | |
| 173 | + except Exception: | |
| 174 | + return {"event": None} | |
| 175 | + if not events: | |
| 176 | + return {"event": None} | |
| 177 | + e = events[0] | |
| 178 | + return { | |
| 179 | + "event": { | |
| 180 | + "at": e["at"], | |
| 181 | + "url": e["url"], | |
| 182 | + "domain": extract_domain(e["url"]), | |
| 183 | + "outcome": e["outcome"], | |
| 184 | + } | |
| 185 | + } | |
| 186 | + | |
| 187 | + | |
| 188 | +class SubmitBody(BaseModel): | |
| 189 | + url: str | |
| 190 | + | |
| 191 | + | |
| 192 | +@app.post("/api/submit") | |
| 193 | +async def api_submit(payload: SubmitBody): | |
| 194 | + """Soumettre un site québécois. Soumission ≠ inclusion : le crawler valide.""" | |
| 195 | + if not is_http_url(payload.url): | |
| 196 | + raise HTTPException(status_code=422, detail="URL invalide (http/https seulement)") | |
| 197 | + url = canonicalize_url(payload.url) | |
| 198 | + domain = extract_domain(url) if url else None | |
| 199 | + if not url or not domain: | |
| 200 | + raise HTTPException(status_code=422, detail="URL invalide") | |
| 201 | + await db.add_submission(url) | |
| 202 | + await db.enqueue_url(url, domain, priority=0.7, depth=0) | |
| 203 | + return { | |
| 204 | + "accepted": True, | |
| 205 | + "message": "Merci! Le site sera visité par TrouveKABot. La soumission ne garantit pas l'inclusion.", | |
| 206 | + } | |
| 207 | + | |
| 208 | + | |
| 209 | +# ---------------------------------------------------------------------- admin | |
| 210 | + | |
| 211 | +class SeedsBody(BaseModel): | |
| 212 | + urls: list[str] | |
| 213 | + | |
| 214 | + | |
| 215 | +class RecrawlBody(BaseModel): | |
| 216 | + url: str | None = None | |
| 217 | + domain: str | None = None | |
| 218 | + | |
| 219 | + | |
| 220 | +class DomainBody(BaseModel): | |
| 221 | + domain: str | |
| 222 | + | |
| 223 | + | |
| 224 | +@app.get("/api/admin/overview", dependencies=[Depends(require_admin)]) | |
| 225 | +async def admin_overview(): | |
| 226 | + overview = await db.admin_overview() | |
| 227 | + overview["paused"] = await coord.is_paused() | |
| 228 | + try: | |
| 229 | + overview["enrich_backlog"] = await coord.enrich_backlog() | |
| 230 | + except Exception: | |
| 231 | + overview["enrich_backlog"] = None | |
| 232 | + return overview | |
| 233 | + | |
| 234 | + | |
| 235 | +@app.get("/api/admin/recent", dependencies=[Depends(require_admin)]) | |
| 236 | +async def admin_recent(limit: int = Query(50, ge=1, le=200)): | |
| 237 | + return {"events": await db.recent_events(limit)} | |
| 238 | + | |
| 239 | + | |
| 240 | +@app.post("/api/admin/pause", dependencies=[Depends(require_admin)]) | |
| 241 | +async def admin_pause(): | |
| 242 | + await coord.pause_crawler() | |
| 243 | + return {"paused": True} | |
| 244 | + | |
| 245 | + | |
| 246 | +@app.post("/api/admin/resume", dependencies=[Depends(require_admin)]) | |
| 247 | +async def admin_resume(): | |
| 248 | + await coord.resume_crawler() | |
| 249 | + return {"paused": False} | |
| 250 | + | |
| 251 | + | |
| 252 | +@app.post("/api/admin/seeds", dependencies=[Depends(require_admin)]) | |
| 253 | +async def admin_seeds(payload: SeedsBody): | |
| 254 | + added = 0 | |
| 255 | + for raw in payload.urls[:500]: | |
| 256 | + url = canonicalize_url(raw.strip()) | |
| 257 | + domain = extract_domain(url) if url else None | |
| 258 | + if url and domain: | |
| 259 | + if await db.enqueue_url(url, domain, priority=1.0, depth=0, is_seed=True): | |
| 260 | + added += 1 | |
| 261 | + return {"added": added} | |
| 262 | + | |
| 263 | + | |
| 264 | +@app.post("/api/admin/recrawl", dependencies=[Depends(require_admin)]) | |
| 265 | +async def admin_recrawl(payload: RecrawlBody): | |
| 266 | + if payload.url: | |
| 267 | + url = canonicalize_url(payload.url) | |
| 268 | + ok = await db.requeue_url(url) if url else False | |
| 269 | + return {"requeued": 1 if ok else 0} | |
| 270 | + if payload.domain: | |
| 271 | + return {"requeued": await db.requeue_domain(payload.domain.lower())} | |
| 272 | + raise HTTPException(status_code=422, detail="url ou domain requis") | |
| 273 | + | |
| 274 | + | |
| 275 | +@app.post("/api/admin/domains/block", dependencies=[Depends(require_admin)]) | |
| 276 | +async def admin_block_domain(payload: DomainBody): | |
| 277 | + await db.block_domain(payload.domain.lower()) | |
| 278 | + return {"blocked": payload.domain.lower()} | |
| 279 | + | |
| 280 | + | |
| 281 | +@app.get("/api/admin/frontier", dependencies=[Depends(require_admin)]) | |
| 282 | +async def admin_frontier( | |
| 283 | + domain: str | None = None, | |
| 284 | + status: str | None = Query(None, pattern="^(pending|in_progress|done|failed|blocked)$"), | |
| 285 | + limit: int = Query(100, ge=1, le=500), | |
| 286 | +): | |
| 287 | + return {"items": await db.frontier_inspect(domain, status, limit)} | |
| 288 | + | |
| 289 | + | |
| 290 | +def main() -> None: | |
| 291 | + import uvicorn | |
| 292 | + | |
| 293 | + uvicorn.run("trouveka.api.main:app", host=settings.api_host, port=settings.api_port, workers=1) | |
| 294 | + | |
| 295 | + | |
| 296 | +if __name__ == "__main__": | |
| 297 | + main() | |
added
M2M32brouve-ka/docker-compose.yml
+149 −0
@@ -0,0 +1,149 @@ | ||
| 1 | +# Trouve-KA — stack Docker Compose (dev local et déploiement m2m32) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | +# | |
| 5 | +# Dimensionné pour m2m32 (32 Go RAM) : OpenSearch 2 Go de heap, Postgres léger, | |
| 6 | +# workers scalables via `docker compose up -d --scale crawler-worker=3`. | |
| 7 | + | |
| 8 | +services: | |
| 9 | + postgres: | |
| 10 | + image: postgres:16-alpine | |
| 11 | + environment: | |
| 12 | + POSTGRES_USER: trouveka | |
| 13 | + POSTGRES_PASSWORD: trouveka | |
| 14 | + POSTGRES_DB: trouveka | |
| 15 | + ports: | |
| 16 | + - "${PG_PORT:-5432}:5432" | |
| 17 | + volumes: | |
| 18 | + - pgdata:/var/lib/postgresql/data | |
| 19 | + healthcheck: | |
| 20 | + test: ["CMD-SHELL", "pg_isready -U trouveka"] | |
| 21 | + interval: 5s | |
| 22 | + timeout: 3s | |
| 23 | + retries: 20 | |
| 24 | + | |
| 25 | + redis: | |
| 26 | + image: redis:7-alpine | |
| 27 | + ports: | |
| 28 | + - "${REDIS_PORT:-6379}:6379" | |
| 29 | + volumes: | |
| 30 | + - redisdata:/data | |
| 31 | + command: ["redis-server", "--appendonly", "yes"] | |
| 32 | + healthcheck: | |
| 33 | + test: ["CMD", "redis-cli", "ping"] | |
| 34 | + interval: 5s | |
| 35 | + timeout: 3s | |
| 36 | + retries: 20 | |
| 37 | + | |
| 38 | + opensearch: | |
| 39 | + image: opensearchproject/opensearch:2.17.1 | |
| 40 | + environment: | |
| 41 | + discovery.type: single-node | |
| 42 | + plugins.security.disabled: "true" | |
| 43 | + OPENSEARCH_JAVA_OPTS: "-Xms2g -Xmx2g" | |
| 44 | + OPENSEARCH_INITIAL_ADMIN_PASSWORD: "TrouveKA!Local1" | |
| 45 | + bootstrap.memory_lock: "true" | |
| 46 | + ulimits: | |
| 47 | + memlock: | |
| 48 | + soft: -1 | |
| 49 | + hard: -1 | |
| 50 | + ports: | |
| 51 | + - "${SEARCH_PORT:-9200}:9200" | |
| 52 | + volumes: | |
| 53 | + - osdata:/usr/share/opensearch/data | |
| 54 | + healthcheck: | |
| 55 | + test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"] | |
| 56 | + interval: 10s | |
| 57 | + timeout: 5s | |
| 58 | + retries: 30 | |
| 59 | + | |
| 60 | + api: | |
| 61 | + build: | |
| 62 | + context: . | |
| 63 | + dockerfile: infrastructure/docker/backend.Dockerfile | |
| 64 | + command: ["uvicorn", "trouveka.api.main:app", "--host", "0.0.0.0", "--port", "8080"] | |
| 65 | + environment: &backend_env | |
| 66 | + DATABASE_URL: postgresql://trouveka:trouveka@postgres:5432/trouveka | |
| 67 | + REDIS_URL: redis://redis:6379/0 | |
| 68 | + SEARCH_URL: http://opensearch:9200 | |
| 69 | + ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me-admin-token} | |
| 70 | + PUBLIC_URL: ${PUBLIC_URL:-https://www.trouve-ka.com} | |
| 71 | + MAX_GLOBAL_CONCURRENCY: ${MAX_GLOBAL_CONCURRENCY:-24} | |
| 72 | + DEFAULT_HOST_DELAY: ${DEFAULT_HOST_DELAY:-2.0} | |
| 73 | + MAX_RESPONSE_BYTES: ${MAX_RESPONSE_BYTES:-3000000} | |
| 74 | + ports: | |
| 75 | + - "8080:8080" | |
| 76 | + depends_on: | |
| 77 | + postgres: | |
| 78 | + condition: service_healthy | |
| 79 | + redis: | |
| 80 | + condition: service_healthy | |
| 81 | + opensearch: | |
| 82 | + condition: service_healthy | |
| 83 | + restart: unless-stopped | |
| 84 | + | |
| 85 | + migrate: | |
| 86 | + build: | |
| 87 | + context: . | |
| 88 | + dockerfile: infrastructure/docker/backend.Dockerfile | |
| 89 | + command: ["python", "-c", "import asyncio; from trouveka.database import run_migrations; from trouveka.config import get_settings; print(asyncio.run(run_migrations(get_settings().database_url)))"] | |
| 90 | + environment: *backend_env | |
| 91 | + depends_on: | |
| 92 | + postgres: | |
| 93 | + condition: service_healthy | |
| 94 | + restart: "no" | |
| 95 | + | |
| 96 | + crawler-worker: | |
| 97 | + build: | |
| 98 | + context: . | |
| 99 | + dockerfile: infrastructure/docker/backend.Dockerfile | |
| 100 | + command: ["python", "-m", "trouveka.crawler.worker"] | |
| 101 | + environment: *backend_env | |
| 102 | + depends_on: | |
| 103 | + migrate: | |
| 104 | + condition: service_completed_successfully | |
| 105 | + opensearch: | |
| 106 | + condition: service_healthy | |
| 107 | + redis: | |
| 108 | + condition: service_healthy | |
| 109 | + restart: unless-stopped | |
| 110 | + | |
| 111 | + enrichment-worker: | |
| 112 | + build: | |
| 113 | + context: . | |
| 114 | + dockerfile: infrastructure/docker/backend.Dockerfile | |
| 115 | + command: ["python", "-m", "trouveka.enrichment.worker"] | |
| 116 | + environment: *backend_env | |
| 117 | + depends_on: | |
| 118 | + migrate: | |
| 119 | + condition: service_completed_successfully | |
| 120 | + restart: unless-stopped | |
| 121 | + | |
| 122 | + scheduler: | |
| 123 | + build: | |
| 124 | + context: . | |
| 125 | + dockerfile: infrastructure/docker/backend.Dockerfile | |
| 126 | + command: ["python", "-m", "trouveka.scheduler.loop"] | |
| 127 | + environment: *backend_env | |
| 128 | + depends_on: | |
| 129 | + migrate: | |
| 130 | + condition: service_completed_successfully | |
| 131 | + restart: unless-stopped | |
| 132 | + | |
| 133 | + web: | |
| 134 | + build: | |
| 135 | + context: . | |
| 136 | + dockerfile: infrastructure/docker/web.Dockerfile | |
| 137 | + environment: | |
| 138 | + API_URL: http://api:8080 | |
| 139 | + PORT: "3000" | |
| 140 | + ports: | |
| 141 | + - "3000:3000" | |
| 142 | + depends_on: | |
| 143 | + - api | |
| 144 | + restart: unless-stopped | |
| 145 | + | |
| 146 | +volumes: | |
| 147 | + pgdata: | |
| 148 | + redisdata: | |
| 149 | + osdata: | |
added
M2M32brouve-ka/docs/architecture.md
+106 −0
@@ -0,0 +1,106 @@ | ||
| 1 | +# Trouve-KA — Architecture | |
| 2 | + | |
| 3 | +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +## Vue d'ensemble | |
| 6 | + | |
| 7 | +```mermaid | |
| 8 | +flowchart LR | |
| 9 | + subgraph Découverte | |
| 10 | + SEEDS[Seeds §8] --> FRONTIER | |
| 11 | + SUBMIT[Soumissions /soumettre] --> FRONTIER | |
| 12 | + LINKS[Liens sortants] --> FRONTIER | |
| 13 | + end | |
| 14 | + FRONTIER[(Frontier<br/>Postgres)] -->|claim SKIP LOCKED| CW[crawler-worker × N] | |
| 15 | + CW -->|politesse SET NX PX| REDIS[(Redis)] | |
| 16 | + CW --> ROBOTS[robots.txt cache] | |
| 17 | + CW --> FETCH[Fetcher HTTP<br/>garde SSRF] | |
| 18 | + FETCH --> PARSE[Parser selectolax] | |
| 19 | + PARSE --> QC[Classification Québec<br/>page_quebec_score] | |
| 20 | + QC --> DEDUP[Hash contenu / doublons] | |
| 21 | + DEDUP -->|indexation IMMÉDIATE| OS[(OpenSearch<br/>trouveka-docs)] | |
| 22 | + DEDUP --> PG[(Postgres<br/>documents, domaines, liens)] | |
| 23 | + CW -->|jamais bloquant| STREAM[Redis Stream<br/>trouveka:enrich] | |
| 24 | + STREAM --> EW[enrichment-worker] | |
| 25 | + EW -->|update partiel| OS | |
| 26 | + SCHED[scheduler] -->|items abandonnés,<br/>autorité de domaine| PG | |
| 27 | + OS --> API[API FastAPI] | |
| 28 | + PG --> API | |
| 29 | + API --> WEB[Next.js web<br/>+ /admin] | |
| 30 | + WEB --> NGROK[ngrok<br/>www.trouve-ka.com] | |
| 31 | +``` | |
| 32 | + | |
| 33 | +## Principe cardinal (§0.3) | |
| 34 | + | |
| 35 | +```mermaid | |
| 36 | +sequenceDiagram | |
| 37 | + participant F as Frontier | |
| 38 | + participant W as crawler-worker | |
| 39 | + participant O as OpenSearch | |
| 40 | + participant U as Utilisateur | |
| 41 | + F->>W: claim URL (t+0s) | |
| 42 | + W->>W: fetch + parse + score Québec (t+2s) | |
| 43 | + W->>O: index_document (t+3s) | |
| 44 | + Note over O: refresh_interval 1s | |
| 45 | + U->>O: recherche (t+4s) — la page est déjà cherchable | |
| 46 | + W--)W: enrichissement async (étapes 2-3, plus tard) | |
| 47 | +``` | |
| 48 | + | |
| 49 | +Jamais de cycle « crawler tout → indexer → chercher ». L'enrichissement met à jour | |
| 50 | +des documents **déjà cherchables** (update partiel), il ne conditionne rien. | |
| 51 | + | |
| 52 | +## Cycle de vie d'une URL | |
| 53 | + | |
| 54 | +```mermaid | |
| 55 | +stateDiagram-v2 | |
| 56 | + [*] --> pending: découverte (seed, lien, soumission) | |
| 57 | + pending --> in_progress: claim (priorité DESC, SKIP LOCKED) | |
| 58 | + in_progress --> pending: politesse (trop tôt pour cet hôte) | |
| 59 | + in_progress --> pending: succès → next_crawl_at adaptatif | |
| 60 | + in_progress --> pending: erreur transitoire (retry backoff ≤3) | |
| 61 | + in_progress --> done: robots refusé / redirection / doublon | |
| 62 | + in_progress --> failed: erreur permanente ou retries épuisés | |
| 63 | + in_progress --> blocked: domaine bloqué | |
| 64 | + pending --> in_progress: recrawl (fréquence mesurée §5.5) | |
| 65 | +``` | |
| 66 | + | |
| 67 | +## Scores (§4, §7, §9) | |
| 68 | + | |
| 69 | +| Étape | Quand | Champs | | |
| 70 | +|---|---|---| | |
| 71 | +| 1 — immédiat | pipeline inline | titre, corps, headings, langue, `page_quebec_score`, `locations`, catégories grossières | | |
| 72 | +| 2 — async | enrichment-worker | `domain_quebec_score` à jour, `authority_score` propagé (plus tard : embeddings, entités) | | |
| 73 | +| 3 — async | scheduler | autorité de domaine depuis `domain_links` (inlinks pondérés Québec) | | |
| 74 | + | |
| 75 | +Ranking à la requête : `function_score = BM25 (multi_match FR/EN + synonymes) + | |
| 76 | +w·page_quebec + w·domain_quebec + w·autorité + gauss(published_at) + boost localité` | |
| 77 | +(désactivable composant par composant — BM25 reste seul debout si tout tombe, §9). | |
| 78 | + | |
| 79 | +## Dégradation gracieuse (§13) | |
| 80 | + | |
| 81 | +- OpenSearch en panne → l'API répond 503 sur /search, /status reste up; le crawler continue d'alimenter Postgres? Non : l'indexation échoue → l'item est relâché en retry; le frontier survit. | |
| 82 | +- Redis en panne → politesse locale impossible : le worker s'arrête proprement; Postgres intact. | |
| 83 | +- Un worker crash → `scheduler.reset_stale_items` relance ses URLs après 30 min. | |
| 84 | +- Enrichissement en retard → aucune conséquence sur la recherche (backlog visible dans /admin). | |
| 85 | + | |
| 86 | +## Déploiement m2m32 (§0.2) | |
| 87 | + | |
| 88 | +```mermaid | |
| 89 | +flowchart LR | |
| 90 | + NG[ngrok www.trouve-ka.com] --> WEB3000[web :3000] | |
| 91 | + WEB3000 -->|rewrite /api/*| API8080[api :8080] | |
| 92 | + subgraph m2m32[Docker Compose sur m2m32 — 32 Go] | |
| 93 | + WEB3000 | |
| 94 | + API8080 | |
| 95 | + PG5432[postgres] | |
| 96 | + RD[redis] | |
| 97 | + OS9200[opensearch 2 Go heap] | |
| 98 | + CWX[crawler-worker × N] | |
| 99 | + EN[enrichment-worker] | |
| 100 | + SC[scheduler] | |
| 101 | + end | |
| 102 | +``` | |
| 103 | + | |
| 104 | +Un seul port exposé publiquement (3000 via ngrok). Budgets mémoire : OpenSearch 2 Go | |
| 105 | +de heap (~3 Go RSS), Postgres < 1 Go, workers Python ~100-200 Mo chacun, web ~150 Mo — | |
| 106 | +large marge sur 32 Go, scalable par `docker compose up -d --scale crawler-worker=3`. | |
added
M2M32brouve-ka/docs/decisions.md
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +# Trouve-KA — Décisions d'architecture | |
| 2 | + | |
| 3 | +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +## D1 — Backend de recherche : OpenSearch 2.x | |
| 6 | + | |
| 7 | +**Candidats évalués :** OpenSearch, Elasticsearch, Typesense, Meilisearch, Vespa, Tantivy, Quickwit (CLAUDE.md §3). | |
| 8 | + | |
| 9 | +**Choix : OpenSearch 2.17**, parce que : | |
| 10 | +- **BM25 réel et paramétrable** par champ (multi_match, boosts), critère n°1 du MVP. | |
| 11 | +- **Indexation incrémentale** near-real-time : `refresh_interval: 1s` → une page indexée est cherchable en ~1 s (principe cardinal §0.3). | |
| 12 | +- **`function_score`** : la fonction de ranking Québec-first custom (§9) s'exprime nativement, poids ajustables sans réindexation. | |
| 13 | +- **Analyzers FR/EN** + `synonym_graph` search-time (bilinguisme sans traduction à l'ingestion). | |
| 14 | +- **Highlighting** natif pour les snippets, **facettes** pour les filtres, **k-NN natif** pour l'hybride sémantique futur (§17.7) sans changer de moteur. | |
| 15 | +- Licence Apache 2.0, image ARM64, tourne en 2 Go de heap sur un seul node m2m32. | |
| 16 | + | |
| 17 | +**Rejetés :** Meilisearch/Typesense (ranking custom trop rigide pour `Score(d,q)` pondéré; c'était le choix « facile » interdit par la spec), Tantivy (librairie Rust, il faudrait construire le serveur), Quickwit (orienté logs append-only, pas de mise à jour de documents), Vespa (excellent mais complexité opérationnelle démesurée pour un node unique), Elasticsearch (équivalent fonctionnel d'OpenSearch, licence moins permissive). | |
| 18 | + | |
| 19 | +## D2 — Backend Python unique (FastAPI + pipeline) | |
| 20 | + | |
| 21 | +Crawler, parser, classifier, indexer, ranking, scheduler, enrichment et API partagent un seul langage (Python 3.12) et un seul packaging (`pyproject.toml` racine, namespace `trouveka.*` mappé sur `packages/` et `services/`). Réduit la complexité (§3 : « le crawler peut rester Python même si l'API est TS » — ici tout le backend est Python, seul le web est TS). | |
| 22 | + | |
| 23 | +## D3 — Frontier dans Postgres, coordination dans Redis | |
| 24 | + | |
| 25 | +- **Frontier = Postgres** : état relationnel (priorités, retries, scheduling), claims multi-workers via `FOR UPDATE SKIP LOCKED` — N workers sans coordinateur. | |
| 26 | +- **Redis** : politesse par hôte (`SET NX PX`, un fetch par hôte par fenêtre, tous workers confondus), drapeau pause, **Redis Streams** pour l'enrichissement asynchrone (consumer groups, ack explicite). | |
| 27 | +- Pas de Celery/RQ : la file d'enrichissement est un stream nu, observable (`XLEN`), sans dépendance lourde. | |
| 28 | + | |
| 29 | +## D4 — Pipeline inline (fetch→parse→score→index) dans le crawler-worker | |
| 30 | + | |
| 31 | +Le chemin rapide est exécuté inline par le worker : c'est la garantie la plus simple du « cherchable en secondes ». La scalabilité passe par le nombre de workers (`--scale crawler-worker=N`), pas par une séparation prématurée fetch/parse/index (§13 : pas de complexité distribuée prématurée). Les étapes 2-3 (§4) passent par le stream d'enrichissement et ne bloquent jamais. | |
| 32 | + | |
| 33 | +## D5 — apps/admin fusionné dans apps/web (route /admin) | |
| 34 | + | |
| 35 | +Un seul runtime Next.js sur m2m32 au lieu de deux (~150 Mo RSS économisés), même design system, même proxy API. La séparation reste possible plus tard (le dashboard est un groupe de composants isolés sous `components/admin/`). Justification prévue par §2 (« Claude peut améliorer cette structure sur justification technique solide »). | |
| 36 | + | |
| 37 | +## D6 — Graphe de liens au niveau domaine d'abord | |
| 38 | + | |
| 39 | +`domain_links` agrégé (from, to, count) plutôt que page→page : suffisant pour l'autorité de domaine et la découverte au MVP, volume contrôlé. Le graphe page→page viendra quand le ranking l'exigera (§8). | |
| 40 | + | |
| 41 | +## D7 — Réseau : le web proxifie /api | |
| 42 | + | |
| 43 | +Next.js rewrite `/api/*` → API interne. Derrière ngrok (www.trouve-ka.com), un seul port exposé (3000), pas d'URL absolues côté client, pas de CORS en prod. | |
added
M2M32brouve-ka/infrastructure/deployment/deploy-m2m32.sh
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Trouve-KA — déploiement sur le node m2m32 (cluster MacLustr) + tunnel ngrok | |
| 3 | +# Author: Simon-Pierre Boucher | |
| 4 | +# Contact: contact@spboucher.ai | |
| 5 | +# | |
| 6 | +# Prérequis sur m2m32 : Docker (ou colima) + docker compose, ngrok authentifié. | |
| 7 | +# Le domaine www.trouve-ka.com doit être réservé dans le compte ngrok. | |
| 8 | +set -euo pipefail | |
| 9 | + | |
| 10 | +NODE="${NODE:-M2M32}" | |
| 11 | +REMOTE_DIR="~/trouve-ka" | |
| 12 | +NGROK_DOMAIN="${NGROK_DOMAIN:-www.trouve-ka.com}" | |
| 13 | + | |
| 14 | +cd "$(dirname "$0")/../.." | |
| 15 | + | |
| 16 | +echo "→ Synchronisation du code vers $NODE:$REMOTE_DIR" | |
| 17 | +rsync -az --delete \ | |
| 18 | + --exclude node_modules --exclude .next --exclude .venv --exclude __pycache__ \ | |
| 19 | + --exclude .git --exclude data --exclude '*.egg-info' \ | |
| 20 | + ./ "$NODE:$REMOTE_DIR/" | |
| 21 | + | |
| 22 | +echo "→ Build et démarrage du stack sur $NODE" | |
| 23 | +ssh "$NODE" "cd $REMOTE_DIR && docker compose up -d --build" | |
| 24 | + | |
| 25 | +echo "→ Chargement des seeds (idempotent)" | |
| 26 | +ssh "$NODE" "cd $REMOTE_DIR && docker compose run --rm migrate && \ | |
| 27 | + docker compose run --rm --entrypoint python crawler-worker -m trouveka.crawler.seed scripts/bootstrap-seeds/seeds.txt" | |
| 28 | + | |
| 29 | +echo "→ Tunnel ngrok ($NGROK_DOMAIN → :3000)" | |
| 30 | +ssh "$NODE" "pgrep -f 'ngrok http.*$NGROK_DOMAIN' > /dev/null || \ | |
| 31 | + nohup ngrok http --url=$NGROK_DOMAIN 3000 > ~/trouve-ka-ngrok.log 2>&1 &" | |
| 32 | + | |
| 33 | +echo "→ Vérification de santé" | |
| 34 | +sleep 5 | |
| 35 | +ssh "$NODE" "cd $REMOTE_DIR && bash scripts/health-check/check.sh" || true | |
| 36 | + | |
| 37 | +echo "✓ Déploiement terminé : https://$NGROK_DOMAIN" | |
| 38 | +echo " N'oublie pas de mettre à jour ~/Desktop/cluster-skill/cluster-deployments.json" | |
added
M2M32brouve-ka/infrastructure/docker/backend.Dockerfile
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +# Trouve-KA — image backend Python (API, crawler, enrichissement, scheduler) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +FROM python:3.12-slim | |
| 6 | + | |
| 7 | +WORKDIR /app | |
| 8 | + | |
| 9 | +COPY pyproject.toml ./ | |
| 10 | +COPY packages ./packages | |
| 11 | +COPY services ./services | |
| 12 | +COPY apps/api ./apps/api | |
| 13 | +COPY infrastructure/migrations ./infrastructure/migrations | |
| 14 | +COPY scripts ./scripts | |
| 15 | + | |
| 16 | +RUN pip install --no-cache-dir . | |
| 17 | + | |
| 18 | +ENV PYTHONUNBUFFERED=1 | |
| 19 | + | |
| 20 | +CMD ["uvicorn", "trouveka.api.main:app", "--host", "0.0.0.0", "--port", "8080"] | |
added
M2M32brouve-ka/infrastructure/docker/web.Dockerfile
+26 −0
@@ -0,0 +1,26 @@ | ||
| 1 | +# Trouve-KA — image web Next.js | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +FROM node:22-alpine AS builder | |
| 6 | +WORKDIR /repo | |
| 7 | +RUN corepack enable | |
| 8 | +COPY package.json pnpm-workspace.yaml ./ | |
| 9 | +COPY apps/web/package.json apps/web/ | |
| 10 | +# sharp et unrs-resolver ont des scripts de build napi légitimes requis par Next | |
| 11 | +RUN pnpm install --frozen-lockfile=false --dangerously-allow-all-builds | |
| 12 | +COPY apps/web apps/web | |
| 13 | +# Les rewrites Next.js sont compilés au build : l'URL interne de l'API doit être fixée ici | |
| 14 | +ARG API_URL=http://api:8080 | |
| 15 | +ENV API_URL=${API_URL} | |
| 16 | +RUN pnpm --filter @trouveka/web build | |
| 17 | + | |
| 18 | +FROM node:22-alpine | |
| 19 | +WORKDIR /repo | |
| 20 | +RUN corepack enable | |
| 21 | +ENV NODE_ENV=production | |
| 22 | +COPY --from=builder /repo/package.json /repo/pnpm-workspace.yaml ./ | |
| 23 | +COPY --from=builder /repo/node_modules ./node_modules | |
| 24 | +COPY --from=builder /repo/apps/web ./apps/web | |
| 25 | +EXPOSE 3000 | |
| 26 | +CMD ["pnpm", "--filter", "@trouveka/web", "start"] | |
added
M2M32brouve-ka/infrastructure/migrations/001_init.sql
+152 −0
@@ -0,0 +1,152 @@ | ||
| 1 | +-- Trouve-KA — migration 001 : schéma initial | |
| 2 | +-- Author: Simon-Pierre Boucher | |
| 3 | +-- Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +BEGIN; | |
| 6 | + | |
| 7 | +CREATE TABLE IF NOT EXISTS schema_migrations ( | |
| 8 | + version INTEGER PRIMARY KEY, | |
| 9 | + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 10 | +); | |
| 11 | + | |
| 12 | +-- --------------------------------------------------------------------------- | |
| 13 | +-- Domaines connus du web québécois (et non québécois, pour mémoire) | |
| 14 | +-- --------------------------------------------------------------------------- | |
| 15 | +CREATE TABLE IF NOT EXISTS domains ( | |
| 16 | + id BIGSERIAL PRIMARY KEY, | |
| 17 | + domain TEXT NOT NULL UNIQUE, | |
| 18 | + first_seen TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 19 | + last_crawled_at TIMESTAMPTZ, | |
| 20 | + robots_fetched_at TIMESTAMPTZ, | |
| 21 | + robots_body TEXT, | |
| 22 | + robots_status TEXT, -- ok | not_found | error | forbidden | |
| 23 | + crawl_delay_ms INTEGER, -- délai imposé par robots.txt (Crawl-delay) | |
| 24 | + quebec_score REAL NOT NULL DEFAULT 0, | |
| 25 | + authority_score REAL NOT NULL DEFAULT 0, | |
| 26 | + page_count INTEGER NOT NULL DEFAULT 0, | |
| 27 | + inlink_domains INTEGER NOT NULL DEFAULT 0, | |
| 28 | + outlink_domains INTEGER NOT NULL DEFAULT 0, | |
| 29 | + language_stats JSONB NOT NULL DEFAULT '{}'::jsonb, | |
| 30 | + content_change_rate REAL, | |
| 31 | + blocked BOOLEAN NOT NULL DEFAULT FALSE, | |
| 32 | + is_seed BOOLEAN NOT NULL DEFAULT FALSE | |
| 33 | +); | |
| 34 | +CREATE INDEX IF NOT EXISTS idx_domains_quebec ON domains (quebec_score DESC); | |
| 35 | + | |
| 36 | +-- --------------------------------------------------------------------------- | |
| 37 | +-- URLs canoniques connues | |
| 38 | +-- --------------------------------------------------------------------------- | |
| 39 | +CREATE TABLE IF NOT EXISTS urls ( | |
| 40 | + id BIGSERIAL PRIMARY KEY, | |
| 41 | + url TEXT NOT NULL UNIQUE, | |
| 42 | + canonical_url TEXT, | |
| 43 | + domain_id BIGINT NOT NULL REFERENCES domains(id), | |
| 44 | + first_seen TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 45 | +); | |
| 46 | +CREATE INDEX IF NOT EXISTS idx_urls_domain ON urls (domain_id); | |
| 47 | + | |
| 48 | +-- --------------------------------------------------------------------------- | |
| 49 | +-- Frontier : la file d'URLs à crawler, avec priorités et scheduling | |
| 50 | +-- --------------------------------------------------------------------------- | |
| 51 | +CREATE TABLE IF NOT EXISTS frontier_items ( | |
| 52 | + id BIGSERIAL PRIMARY KEY, | |
| 53 | + url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id), | |
| 54 | + priority REAL NOT NULL DEFAULT 0.5, | |
| 55 | + depth INTEGER NOT NULL DEFAULT 0, | |
| 56 | + source_url_id BIGINT REFERENCES urls(id), | |
| 57 | + discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 58 | + last_crawled_at TIMESTAMPTZ, | |
| 59 | + next_crawl_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 60 | + status TEXT NOT NULL DEFAULT 'pending', -- pending|in_progress|done|failed|blocked | |
| 61 | + retries INTEGER NOT NULL DEFAULT 0, | |
| 62 | + error_code TEXT, | |
| 63 | + locked_by TEXT, | |
| 64 | + locked_at TIMESTAMPTZ | |
| 65 | +); | |
| 66 | +CREATE INDEX IF NOT EXISTS idx_frontier_ready | |
| 67 | + ON frontier_items (next_crawl_at, priority DESC) | |
| 68 | + WHERE status = 'pending'; | |
| 69 | +CREATE INDEX IF NOT EXISTS idx_frontier_status ON frontier_items (status); | |
| 70 | + | |
| 71 | +-- --------------------------------------------------------------------------- | |
| 72 | +-- Historique des tentatives de crawl (l'échec est normal; il est traqué) | |
| 73 | +-- --------------------------------------------------------------------------- | |
| 74 | +CREATE TABLE IF NOT EXISTS crawl_attempts ( | |
| 75 | + id BIGSERIAL PRIMARY KEY, | |
| 76 | + url_id BIGINT NOT NULL REFERENCES urls(id), | |
| 77 | + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 78 | + status_code INTEGER, | |
| 79 | + error_code TEXT, -- dns|timeout|tls|http_4xx|http_5xx|robots_denied|parse_failed| | |
| 80 | + -- unsupported_content|too_large|duplicate|spam|not_quebec|ssrf_blocked|ok | |
| 81 | + outcome TEXT NOT NULL, -- indexed|duplicate|error|not_quebec|robots_blocked|redirect|unchanged | |
| 82 | + content_hash TEXT, | |
| 83 | + bytes INTEGER, | |
| 84 | + duration_ms INTEGER, | |
| 85 | + redirect_url TEXT, | |
| 86 | + title TEXT, | |
| 87 | + quebec_score REAL | |
| 88 | +); | |
| 89 | +CREATE INDEX IF NOT EXISTS idx_attempts_time ON crawl_attempts (fetched_at DESC); | |
| 90 | +CREATE INDEX IF NOT EXISTS idx_attempts_url ON crawl_attempts (url_id); | |
| 91 | + | |
| 92 | +-- --------------------------------------------------------------------------- | |
| 93 | +-- Documents indexés (métadonnées; le contenu cherchable vit dans l'index) | |
| 94 | +-- --------------------------------------------------------------------------- | |
| 95 | +CREATE TABLE IF NOT EXISTS documents ( | |
| 96 | + id BIGSERIAL PRIMARY KEY, | |
| 97 | + url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id), | |
| 98 | + content_hash TEXT NOT NULL, | |
| 99 | + etag TEXT, | |
| 100 | + last_modified TEXT, | |
| 101 | + title TEXT, | |
| 102 | + description TEXT, | |
| 103 | + language TEXT, | |
| 104 | + page_quebec_score REAL NOT NULL DEFAULT 0, | |
| 105 | + published_at TIMESTAMPTZ, | |
| 106 | + last_changed_at TIMESTAMPTZ, | |
| 107 | + first_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 108 | + last_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 109 | + enrichment_stage INTEGER NOT NULL DEFAULT 1 | |
| 110 | +); | |
| 111 | +CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents (content_hash); | |
| 112 | + | |
| 113 | +-- --------------------------------------------------------------------------- | |
| 114 | +-- Graphe de liens agrégé au niveau domaine (page→page viendra plus tard) | |
| 115 | +-- --------------------------------------------------------------------------- | |
| 116 | +CREATE TABLE IF NOT EXISTS domain_links ( | |
| 117 | + from_domain_id BIGINT NOT NULL REFERENCES domains(id), | |
| 118 | + to_domain_id BIGINT NOT NULL REFERENCES domains(id), | |
| 119 | + link_count INTEGER NOT NULL DEFAULT 1, | |
| 120 | + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 121 | + PRIMARY KEY (from_domain_id, to_domain_id) | |
| 122 | +); | |
| 123 | +CREATE INDEX IF NOT EXISTS idx_domain_links_to ON domain_links (to_domain_id); | |
| 124 | + | |
| 125 | +-- --------------------------------------------------------------------------- | |
| 126 | +-- Analytics de recherche agrégées et respectueuses de la vie privée | |
| 127 | +-- (les requêtes zéro-résultat sont de l'or : elles pilotent le crawl) | |
| 128 | +-- --------------------------------------------------------------------------- | |
| 129 | +CREATE TABLE IF NOT EXISTS search_queries ( | |
| 130 | + id BIGSERIAL PRIMARY KEY, | |
| 131 | + query TEXT NOT NULL, | |
| 132 | + language TEXT, | |
| 133 | + results_total INTEGER NOT NULL, | |
| 134 | + took_ms INTEGER NOT NULL, | |
| 135 | + zero_result BOOLEAN NOT NULL DEFAULT FALSE, | |
| 136 | + created_at TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 137 | +); | |
| 138 | +CREATE INDEX IF NOT EXISTS idx_queries_zero ON search_queries (created_at) WHERE zero_result; | |
| 139 | + | |
| 140 | +-- --------------------------------------------------------------------------- | |
| 141 | +-- Soumissions publiques d'URL (soumission ≠ inclusion) | |
| 142 | +-- --------------------------------------------------------------------------- | |
| 143 | +CREATE TABLE IF NOT EXISTS submissions ( | |
| 144 | + id BIGSERIAL PRIMARY KEY, | |
| 145 | + url TEXT NOT NULL, | |
| 146 | + submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 147 | + status TEXT NOT NULL DEFAULT 'queued' -- queued|accepted|rejected | |
| 148 | +); | |
| 149 | + | |
| 150 | +INSERT INTO schema_migrations (version) VALUES (1) ON CONFLICT DO NOTHING; | |
| 151 | + | |
| 152 | +COMMIT; | |
added
M2M32brouve-ka/infrastructure/migrations/002_frontier_priority_index.sql
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +-- Trouve-KA — migration 002 : index de claim par priorité | |
| 2 | +-- Author: Simon-Pierre Boucher | |
| 3 | +-- Contact: contact@spboucher.ai | |
| 4 | +-- | |
| 5 | +-- Le claim ordonne par priority DESC; l'index 001 (next_crawl_at, priority) | |
| 6 | +-- force un tri de toutes les lignes dues à chaque claim. Cet index sert | |
| 7 | +-- l'ORDER BY directement (la plupart des lignes pending sont dues). | |
| 8 | + | |
| 9 | +BEGIN; | |
| 10 | + | |
| 11 | +CREATE INDEX IF NOT EXISTS idx_frontier_priority | |
| 12 | + ON frontier_items (priority DESC, next_crawl_at) | |
| 13 | + WHERE status = 'pending'; | |
| 14 | + | |
| 15 | +INSERT INTO schema_migrations (version) VALUES (2) ON CONFLICT DO NOTHING; | |
| 16 | + | |
| 17 | +COMMIT; | |
added
M2M32brouve-ka/infrastructure/monitoring/README.md
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +# Trouve-KA — Observabilité | |
| 2 | + | |
| 3 | +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +## En place (MVP) | |
| 6 | + | |
| 7 | +- **Logs structurés JSON** (une ligne par événement) sur stdout de chaque service, | |
| 8 | + avec contexte `worker_id, url_id, domain_id, url, outcome, quebec_score, ms` | |
| 9 | + (`packages/logging`). Consultables via `docker compose logs -f crawler-worker`. | |
| 10 | +- **Métriques opérationnelles** exposées par l'API : | |
| 11 | + - `GET /api/status` (public) : pages indexées, domaines, débits horaires, état du crawler. | |
| 12 | + - `GET /api/admin/overview` (protégé) : files par statut, débits, distribution HTTP, | |
| 13 | + latences fetch p50/p95, top domaines, erreurs récentes, backlog d'enrichissement. | |
| 14 | + - Toutes calculées sur les vraies tables (`crawl_attempts`, `frontier_items`) — | |
| 15 | + aucun compteur simulé (§ fake). | |
| 16 | +- **Dashboard admin** : `/admin` sur le web (flux live du crawl, contrôles pause/seeds/blocage). | |
| 17 | + | |
| 18 | +## Prochaine étape (post-MVP) | |
| 19 | + | |
| 20 | +Exporter ces mêmes agrégats au format Prometheus (`/metrics`) et brancher | |
| 21 | +Grafana — les requêtes SQL de `Database.admin_overview()` sont la source de vérité, | |
| 22 | +l'export est un simple reformatage. Choix pragmatique conforme au CLAUDE.md §13. | |
added
M2M32brouve-ka/package.json
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +{ | |
| 2 | + "name": "trouve-ka", | |
| 3 | + "private": true, | |
| 4 | + "version": "0.1.0", | |
| 5 | + "packageManager": "pnpm@11.1.2", | |
| 6 | + "author": "Simon-Pierre Boucher <contact@spboucher.ai>", | |
| 7 | + "description": "Trouve-KA — Cherche le Québec. Moteur de recherche web indépendant, Québec-first.", | |
| 8 | + "pnpm": { | |
| 9 | + "onlyBuiltDependencies": ["sharp", "unrs-resolver"] | |
| 10 | + }, | |
| 11 | + "scripts": { | |
| 12 | + "dev": "pnpm --filter @trouveka/web dev", | |
| 13 | + "build": "pnpm --filter @trouveka/web build", | |
| 14 | + "start": "pnpm --filter @trouveka/web start", | |
| 15 | + "crawl:seed": "bash scripts/bootstrap-seeds/seed.sh", | |
| 16 | + "check:headers": "python3 scripts/check-headers.py", | |
| 17 | + "deploy:m2m32": "bash infrastructure/deployment/deploy-m2m32.sh" | |
| 18 | + } | |
| 19 | +} | |
added
M2M32brouve-ka/packages/config/__init__.py
+67 −0
@@ -0,0 +1,67 @@ | ||
| 1 | +# Trouve-KA — configuration validée par variables d'environnement | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Configuration centrale de Trouve-KA. | |
| 6 | + | |
| 7 | +Toutes les valeurs sont surchargeables par variable d'environnement et | |
| 8 | +dimensionnées par défaut pour le node de déploiement m2m32 (32 Go RAM). | |
| 9 | +""" | |
| 10 | + | |
| 11 | +from functools import lru_cache | |
| 12 | + | |
| 13 | +from pydantic_settings import BaseSettings, SettingsConfigDict | |
| 14 | + | |
| 15 | + | |
| 16 | +class Settings(BaseSettings): | |
| 17 | + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") | |
| 18 | + | |
| 19 | + # Bases de données | |
| 20 | + database_url: str = "postgresql://trouveka:trouveka@localhost:5432/trouveka" | |
| 21 | + redis_url: str = "redis://localhost:6379/0" | |
| 22 | + search_url: str = "http://localhost:9200" | |
| 23 | + search_index: str = "trouveka-docs" | |
| 24 | + | |
| 25 | + # Crawler — identité et politesse | |
| 26 | + crawler_user_agent: str = ( | |
| 27 | + "Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)" | |
| 28 | + ) | |
| 29 | + crawler_contact_url: str = "https://www.trouve-ka.com/trouveka-bot" | |
| 30 | + max_global_concurrency: int = 24 | |
| 31 | + max_per_host_concurrency: int = 2 | |
| 32 | + default_host_delay: float = 2.0 # secondes entre deux requêtes vers un même hôte | |
| 33 | + | |
| 34 | + # Crawler — limites de sécurité (chaque réponse a des limites) | |
| 35 | + max_response_bytes: int = 3_000_000 | |
| 36 | + max_redirects: int = 5 | |
| 37 | + fetch_timeout: float = 20.0 | |
| 38 | + max_crawl_depth: int = 8 | |
| 39 | + max_links_per_page: int = 300 | |
| 40 | + max_urls_per_domain: int = 5_000 | |
| 41 | + max_query_params: int = 8 | |
| 42 | + max_path_segments: int = 12 | |
| 43 | + | |
| 44 | + # Recrawl adaptatif (bornes, en heures) | |
| 45 | + min_recrawl_hours: float = 1.0 | |
| 46 | + max_recrawl_hours: float = 24 * 30.0 | |
| 47 | + default_recrawl_hours: float = 24.0 | |
| 48 | + | |
| 49 | + # Indexation | |
| 50 | + min_quebec_score_to_index: float = 0.15 | |
| 51 | + min_body_length: int = 80 | |
| 52 | + | |
| 53 | + # API | |
| 54 | + api_host: str = "0.0.0.0" | |
| 55 | + api_port: int = 8080 | |
| 56 | + admin_token: str = "change-me-admin-token" | |
| 57 | + public_url: str = "https://www.trouve-ka.com" | |
| 58 | + ngrok_domain: str = "www.trouve-ka.com" | |
| 59 | + | |
| 60 | + # Postgres pool (dimensionné pour 32 Go / plusieurs workers) | |
| 61 | + pg_pool_min: int = 2 | |
| 62 | + pg_pool_max: int = 10 | |
| 63 | + | |
| 64 | + | |
| 65 | +@lru_cache | |
| 66 | +def get_settings() -> Settings: | |
| 67 | + return Settings() | |
added
M2M32brouve-ka/packages/database/__init__.py
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +# Trouve-KA — accès PostgreSQL (pool asyncpg + dépôts) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Couche d'accès PostgreSQL : pool asyncpg, migrations, dépôts frontier/domaines/documents.""" | |
| 6 | + | |
| 7 | +from .db import Database | |
| 8 | +from .migrate import run_migrations | |
| 9 | + | |
| 10 | +__all__ = ["Database", "run_migrations"] | |
added
M2M32brouve-ka/packages/database/db.py
+522 −0
@@ -0,0 +1,522 @@ | ||
| 1 | +# Trouve-KA — dépôts PostgreSQL | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Dépôts PostgreSQL de Trouve-KA. | |
| 6 | + | |
| 7 | +Le frontier vit dans Postgres : les workers réclament des lots d'URLs via | |
| 8 | +FOR UPDATE SKIP LOCKED, ce qui permet N workers concurrents sans coordination | |
| 9 | +externe (CLAUDE.md §13 : pas de complexité distribuée prématurée). | |
| 10 | +""" | |
| 11 | + | |
| 12 | +from datetime import UTC, datetime, timedelta | |
| 13 | +from typing import Any | |
| 14 | + | |
| 15 | +import asyncpg | |
| 16 | + | |
| 17 | + | |
| 18 | +def _dsn(url: str) -> str: | |
| 19 | + # asyncpg accepte postgresql:// mais pas postgresql+asyncpg:// | |
| 20 | + return url.replace("postgresql+asyncpg://", "postgresql://") | |
| 21 | + | |
| 22 | + | |
| 23 | +class Database: | |
| 24 | + def __init__(self, database_url: str, *, pool_min: int = 2, pool_max: int = 10): | |
| 25 | + self._url = _dsn(database_url) | |
| 26 | + self._pool_min = pool_min | |
| 27 | + self._pool_max = pool_max | |
| 28 | + self.pool: asyncpg.Pool | None = None | |
| 29 | + | |
| 30 | + async def connect(self) -> None: | |
| 31 | + self.pool = await asyncpg.create_pool( | |
| 32 | + self._url, min_size=self._pool_min, max_size=self._pool_max, command_timeout=30 | |
| 33 | + ) | |
| 34 | + | |
| 35 | + async def close(self) -> None: | |
| 36 | + if self.pool: | |
| 37 | + await self.pool.close() | |
| 38 | + self.pool = None | |
| 39 | + | |
| 40 | + # ------------------------------------------------------------------ domaines | |
| 41 | + | |
| 42 | + async def upsert_domain(self, domain: str, *, is_seed: bool = False) -> int: | |
| 43 | + row = await self.pool.fetchrow( | |
| 44 | + """ | |
| 45 | + INSERT INTO domains (domain, is_seed) VALUES ($1, $2) | |
| 46 | + ON CONFLICT (domain) DO UPDATE SET is_seed = domains.is_seed OR EXCLUDED.is_seed | |
| 47 | + RETURNING id | |
| 48 | + """, | |
| 49 | + domain, | |
| 50 | + is_seed, | |
| 51 | + ) | |
| 52 | + return row["id"] | |
| 53 | + | |
| 54 | + async def get_domain(self, domain: str) -> asyncpg.Record | None: | |
| 55 | + return await self.pool.fetchrow("SELECT * FROM domains WHERE domain = $1", domain) | |
| 56 | + | |
| 57 | + async def get_domain_by_id(self, domain_id: int) -> asyncpg.Record | None: | |
| 58 | + return await self.pool.fetchrow("SELECT * FROM domains WHERE id = $1", domain_id) | |
| 59 | + | |
| 60 | + async def save_robots( | |
| 61 | + self, domain_id: int, body: str | None, status: str, crawl_delay_ms: int | None | |
| 62 | + ) -> None: | |
| 63 | + await self.pool.execute( | |
| 64 | + """ | |
| 65 | + UPDATE domains SET robots_body = $2, robots_status = $3, | |
| 66 | + robots_fetched_at = now(), crawl_delay_ms = $4 | |
| 67 | + WHERE id = $1 | |
| 68 | + """, | |
| 69 | + domain_id, | |
| 70 | + body, | |
| 71 | + status, | |
| 72 | + crawl_delay_ms, | |
| 73 | + ) | |
| 74 | + | |
| 75 | + async def block_domain(self, domain: str) -> None: | |
| 76 | + await self.pool.execute( | |
| 77 | + "UPDATE domains SET blocked = TRUE WHERE domain = $1", domain | |
| 78 | + ) | |
| 79 | + await self.pool.execute( | |
| 80 | + """ | |
| 81 | + UPDATE frontier_items SET status = 'blocked' | |
| 82 | + WHERE status IN ('pending', 'in_progress') | |
| 83 | + AND url_id IN (SELECT u.id FROM urls u JOIN domains d ON d.id = u.domain_id | |
| 84 | + WHERE d.domain = $1) | |
| 85 | + """, | |
| 86 | + domain, | |
| 87 | + ) | |
| 88 | + | |
| 89 | + async def update_domain_after_page( | |
| 90 | + self, domain_id: int, page_quebec_score: float, language: str | None | |
| 91 | + ) -> None: | |
| 92 | + """Met à jour le score Québec du domaine (moyenne mobile) et ses stats de langue.""" | |
| 93 | + lang = language or "unknown" | |
| 94 | + await self.pool.execute( | |
| 95 | + """ | |
| 96 | + UPDATE domains SET | |
| 97 | + page_count = page_count + 1, | |
| 98 | + last_crawled_at = now(), | |
| 99 | + quebec_score = (quebec_score * page_count + $2) / (page_count + 1), | |
| 100 | + language_stats = jsonb_set( | |
| 101 | + language_stats, ARRAY[$3], | |
| 102 | + (COALESCE(language_stats->>$3, '0')::int + 1)::text::jsonb) | |
| 103 | + WHERE id = $1 | |
| 104 | + """, | |
| 105 | + domain_id, | |
| 106 | + page_quebec_score, | |
| 107 | + lang, | |
| 108 | + ) | |
| 109 | + | |
| 110 | + async def record_domain_links(self, from_domain_id: int, to_domain_counts: dict[int, int]) -> None: | |
| 111 | + if not to_domain_counts: | |
| 112 | + return | |
| 113 | + await self.pool.executemany( | |
| 114 | + """ | |
| 115 | + INSERT INTO domain_links (from_domain_id, to_domain_id, link_count) | |
| 116 | + VALUES ($1, $2, $3) | |
| 117 | + ON CONFLICT (from_domain_id, to_domain_id) | |
| 118 | + DO UPDATE SET link_count = domain_links.link_count + EXCLUDED.link_count, | |
| 119 | + updated_at = now() | |
| 120 | + """, | |
| 121 | + [(from_domain_id, to_id, n) for to_id, n in to_domain_counts.items() if to_id != from_domain_id], | |
| 122 | + ) | |
| 123 | + | |
| 124 | + # ------------------------------------------------------------------ frontier | |
| 125 | + | |
| 126 | + async def enqueue_url( | |
| 127 | + self, | |
| 128 | + url: str, | |
| 129 | + domain: str, | |
| 130 | + *, | |
| 131 | + priority: float = 0.5, | |
| 132 | + depth: int = 0, | |
| 133 | + source_url_id: int | None = None, | |
| 134 | + is_seed: bool = False, | |
| 135 | + max_urls_per_domain: int = 5000, | |
| 136 | + ) -> int | None: | |
| 137 | + """Ajoute une URL au frontier si inconnue. Retourne url_id si ajoutée, None sinon.""" | |
| 138 | + async with self.pool.acquire() as conn: | |
| 139 | + async with conn.transaction(): | |
| 140 | + domain_id = await conn.fetchval( | |
| 141 | + """ | |
| 142 | + INSERT INTO domains (domain, is_seed) VALUES ($1, $2) | |
| 143 | + ON CONFLICT (domain) DO UPDATE SET is_seed = domains.is_seed OR EXCLUDED.is_seed | |
| 144 | + RETURNING id | |
| 145 | + """, | |
| 146 | + domain, | |
| 147 | + is_seed, | |
| 148 | + ) | |
| 149 | + dom = await conn.fetchrow( | |
| 150 | + "SELECT blocked, page_count FROM domains WHERE id = $1", domain_id | |
| 151 | + ) | |
| 152 | + if dom["blocked"]: | |
| 153 | + return None | |
| 154 | + if not is_seed and dom["page_count"] >= max_urls_per_domain: | |
| 155 | + return None | |
| 156 | + url_id = await conn.fetchval( | |
| 157 | + """ | |
| 158 | + INSERT INTO urls (url, domain_id) VALUES ($1, $2) | |
| 159 | + ON CONFLICT (url) DO NOTHING RETURNING id | |
| 160 | + """, | |
| 161 | + url, | |
| 162 | + domain_id, | |
| 163 | + ) | |
| 164 | + if url_id is None: | |
| 165 | + return None # URL déjà connue : pas de doublon dans le frontier | |
| 166 | + await conn.execute( | |
| 167 | + """ | |
| 168 | + INSERT INTO frontier_items (url_id, priority, depth, source_url_id) | |
| 169 | + VALUES ($1, $2, $3, $4) ON CONFLICT (url_id) DO NOTHING | |
| 170 | + """, | |
| 171 | + url_id, | |
| 172 | + priority, | |
| 173 | + depth, | |
| 174 | + source_url_id, | |
| 175 | + ) | |
| 176 | + return url_id | |
| 177 | + | |
| 178 | + async def claim_batch(self, worker_id: str, limit: int = 20) -> list[asyncpg.Record]: | |
| 179 | + """Réclame un lot d'URLs prêtes à crawler (SKIP LOCKED, multi-workers sûr).""" | |
| 180 | + return await self.pool.fetch( | |
| 181 | + """ | |
| 182 | + WITH claimed AS ( | |
| 183 | + SELECT f.id FROM frontier_items f | |
| 184 | + WHERE f.status = 'pending' AND f.next_crawl_at <= now() | |
| 185 | + ORDER BY f.priority DESC, f.next_crawl_at | |
| 186 | + LIMIT $2 | |
| 187 | + FOR UPDATE SKIP LOCKED | |
| 188 | + ) | |
| 189 | + UPDATE frontier_items f SET status = 'in_progress', locked_by = $1, locked_at = now() | |
| 190 | + FROM claimed | |
| 191 | + WHERE f.id = claimed.id | |
| 192 | + RETURNING f.id, f.url_id, f.priority, f.depth, f.retries, | |
| 193 | + f.last_crawled_at, f.next_crawl_at, | |
| 194 | + (SELECT url FROM urls WHERE id = f.url_id) AS url, | |
| 195 | + (SELECT domain_id FROM urls WHERE id = f.url_id) AS domain_id | |
| 196 | + """, | |
| 197 | + worker_id, | |
| 198 | + limit, | |
| 199 | + ) | |
| 200 | + | |
| 201 | + async def release_item( | |
| 202 | + self, | |
| 203 | + url_id: int, | |
| 204 | + *, | |
| 205 | + status: str, | |
| 206 | + error_code: str | None = None, | |
| 207 | + next_crawl_at: datetime | None = None, | |
| 208 | + priority: float | None = None, | |
| 209 | + increment_retries: bool = False, | |
| 210 | + ) -> None: | |
| 211 | + await self.pool.execute( | |
| 212 | + """ | |
| 213 | + UPDATE frontier_items SET | |
| 214 | + status = $2, | |
| 215 | + error_code = $3, | |
| 216 | + last_crawled_at = now(), | |
| 217 | + next_crawl_at = COALESCE($4, next_crawl_at), | |
| 218 | + priority = COALESCE($5, priority), | |
| 219 | + retries = retries + CASE WHEN $6 THEN 1 ELSE 0 END, | |
| 220 | + locked_by = NULL, locked_at = NULL | |
| 221 | + WHERE url_id = $1 | |
| 222 | + """, | |
| 223 | + url_id, | |
| 224 | + status, | |
| 225 | + error_code, | |
| 226 | + next_crawl_at, | |
| 227 | + priority, | |
| 228 | + increment_retries, | |
| 229 | + ) | |
| 230 | + | |
| 231 | + async def reset_stale_items(self, older_than_minutes: int = 30) -> int: | |
| 232 | + """Relance les items in_progress abandonnés (worker mort). Retourne le nombre.""" | |
| 233 | + result = await self.pool.execute( | |
| 234 | + """ | |
| 235 | + UPDATE frontier_items SET status = 'pending', locked_by = NULL, locked_at = NULL | |
| 236 | + WHERE status = 'in_progress' AND locked_at < now() - ($1 || ' minutes')::interval | |
| 237 | + """, | |
| 238 | + str(older_than_minutes), | |
| 239 | + ) | |
| 240 | + return int(result.split()[-1]) | |
| 241 | + | |
| 242 | + async def requeue_url(self, url: str) -> bool: | |
| 243 | + result = await self.pool.execute( | |
| 244 | + """ | |
| 245 | + UPDATE frontier_items SET status = 'pending', next_crawl_at = now(), retries = 0 | |
| 246 | + WHERE url_id = (SELECT id FROM urls WHERE url = $1) | |
| 247 | + """, | |
| 248 | + url, | |
| 249 | + ) | |
| 250 | + return result.endswith("1") | |
| 251 | + | |
| 252 | + async def requeue_domain(self, domain: str) -> int: | |
| 253 | + result = await self.pool.execute( | |
| 254 | + """ | |
| 255 | + UPDATE frontier_items SET status = 'pending', next_crawl_at = now() | |
| 256 | + WHERE status IN ('done', 'failed') | |
| 257 | + AND url_id IN (SELECT u.id FROM urls u JOIN domains d ON d.id = u.domain_id | |
| 258 | + WHERE d.domain = $1) | |
| 259 | + """, | |
| 260 | + domain, | |
| 261 | + ) | |
| 262 | + return int(result.split()[-1]) | |
| 263 | + | |
| 264 | + # ------------------------------------------------------------------ crawl / documents | |
| 265 | + | |
| 266 | + async def record_attempt( | |
| 267 | + self, | |
| 268 | + url_id: int, | |
| 269 | + *, | |
| 270 | + status_code: int | None, | |
| 271 | + error_code: str, | |
| 272 | + outcome: str, | |
| 273 | + content_hash: str | None = None, | |
| 274 | + num_bytes: int | None = None, | |
| 275 | + duration_ms: int | None = None, | |
| 276 | + redirect_url: str | None = None, | |
| 277 | + title: str | None = None, | |
| 278 | + quebec_score: float | None = None, | |
| 279 | + ) -> None: | |
| 280 | + await self.pool.execute( | |
| 281 | + """ | |
| 282 | + INSERT INTO crawl_attempts | |
| 283 | + (url_id, status_code, error_code, outcome, content_hash, bytes, | |
| 284 | + duration_ms, redirect_url, title, quebec_score) | |
| 285 | + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) | |
| 286 | + """, | |
| 287 | + url_id, | |
| 288 | + status_code, | |
| 289 | + error_code, | |
| 290 | + outcome, | |
| 291 | + content_hash, | |
| 292 | + num_bytes, | |
| 293 | + duration_ms, | |
| 294 | + redirect_url, | |
| 295 | + title, | |
| 296 | + quebec_score, | |
| 297 | + ) | |
| 298 | + | |
| 299 | + async def get_document(self, url_id: int) -> asyncpg.Record | None: | |
| 300 | + return await self.pool.fetchrow("SELECT * FROM documents WHERE url_id = $1", url_id) | |
| 301 | + | |
| 302 | + async def find_duplicate(self, chash: str, url_id: int) -> asyncpg.Record | None: | |
| 303 | + """Doublon exact : même hash de contenu sur une autre URL.""" | |
| 304 | + return await self.pool.fetchrow( | |
| 305 | + "SELECT * FROM documents WHERE content_hash = $1 AND url_id != $2 LIMIT 1", | |
| 306 | + chash, | |
| 307 | + url_id, | |
| 308 | + ) | |
| 309 | + | |
| 310 | + async def upsert_document( | |
| 311 | + self, | |
| 312 | + url_id: int, | |
| 313 | + *, | |
| 314 | + content_hash: str, | |
| 315 | + etag: str | None, | |
| 316 | + last_modified: str | None, | |
| 317 | + title: str, | |
| 318 | + description: str, | |
| 319 | + language: str | None, | |
| 320 | + page_quebec_score: float, | |
| 321 | + published_at: datetime | None, | |
| 322 | + changed: bool, | |
| 323 | + ) -> int: | |
| 324 | + row = await self.pool.fetchrow( | |
| 325 | + """ | |
| 326 | + INSERT INTO documents | |
| 327 | + (url_id, content_hash, etag, last_modified, title, description, language, | |
| 328 | + page_quebec_score, published_at, last_changed_at) | |
| 329 | + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) | |
| 330 | + ON CONFLICT (url_id) DO UPDATE SET | |
| 331 | + content_hash = EXCLUDED.content_hash, | |
| 332 | + etag = EXCLUDED.etag, | |
| 333 | + last_modified = EXCLUDED.last_modified, | |
| 334 | + title = EXCLUDED.title, | |
| 335 | + description = EXCLUDED.description, | |
| 336 | + language = EXCLUDED.language, | |
| 337 | + page_quebec_score = EXCLUDED.page_quebec_score, | |
| 338 | + published_at = EXCLUDED.published_at, | |
| 339 | + last_indexed_at = now(), | |
| 340 | + last_changed_at = CASE WHEN $10 THEN now() ELSE documents.last_changed_at END | |
| 341 | + RETURNING id | |
| 342 | + """, | |
| 343 | + url_id, | |
| 344 | + content_hash, | |
| 345 | + etag, | |
| 346 | + last_modified, | |
| 347 | + title, | |
| 348 | + description, | |
| 349 | + language, | |
| 350 | + page_quebec_score, | |
| 351 | + published_at, | |
| 352 | + changed, | |
| 353 | + ) | |
| 354 | + return row["id"] | |
| 355 | + | |
| 356 | + async def set_canonical(self, url_id: int, canonical_url: str) -> None: | |
| 357 | + await self.pool.execute( | |
| 358 | + "UPDATE urls SET canonical_url = $2 WHERE id = $1", url_id, canonical_url | |
| 359 | + ) | |
| 360 | + | |
| 361 | + # ------------------------------------------------------------------ analytics / statut | |
| 362 | + | |
| 363 | + async def record_search_query( | |
| 364 | + self, query: str, language: str | None, total: int, took_ms: int | |
| 365 | + ) -> None: | |
| 366 | + await self.pool.execute( | |
| 367 | + """ | |
| 368 | + INSERT INTO search_queries (query, language, results_total, took_ms, zero_result) | |
| 369 | + VALUES ($1, $2, $3, $4, $3 = 0) | |
| 370 | + """, | |
| 371 | + query[:500], | |
| 372 | + language, | |
| 373 | + total, | |
| 374 | + took_ms, | |
| 375 | + ) | |
| 376 | + | |
| 377 | + async def add_submission(self, url: str) -> None: | |
| 378 | + await self.pool.execute("INSERT INTO submissions (url) VALUES ($1)", url) | |
| 379 | + | |
| 380 | + async def status_snapshot(self) -> dict[str, Any]: | |
| 381 | + since = datetime.now(UTC) - timedelta(hours=1) | |
| 382 | + frontier = { | |
| 383 | + r["status"]: r["n"] | |
| 384 | + for r in await self.pool.fetch( | |
| 385 | + "SELECT status, count(*)::int AS n FROM frontier_items GROUP BY status" | |
| 386 | + ) | |
| 387 | + } | |
| 388 | + row = await self.pool.fetchrow( | |
| 389 | + """ | |
| 390 | + SELECT | |
| 391 | + (SELECT count(*)::int FROM documents) AS docs, | |
| 392 | + (SELECT count(*)::int FROM domains WHERE page_count > 0) AS domains, | |
| 393 | + (SELECT count(*)::int FROM documents WHERE last_indexed_at >= $1) AS indexed_1h, | |
| 394 | + (SELECT count(*)::int FROM crawl_attempts WHERE fetched_at >= $1) AS fetched_1h, | |
| 395 | + (SELECT count(*)::int FROM crawl_attempts | |
| 396 | + WHERE fetched_at >= $1 AND outcome = 'error') AS errors_1h | |
| 397 | + """, | |
| 398 | + since, | |
| 399 | + ) | |
| 400 | + return { | |
| 401 | + "pages_indexed": row["docs"], | |
| 402 | + "domains_count": row["domains"], | |
| 403 | + "indexed_last_hour": row["indexed_1h"], | |
| 404 | + "fetched_last_hour": row["fetched_1h"], | |
| 405 | + "errors_last_hour": row["errors_1h"], | |
| 406 | + "frontier_pending": frontier.get("pending", 0), | |
| 407 | + "frontier_in_progress": frontier.get("in_progress", 0), | |
| 408 | + "frontier": frontier, | |
| 409 | + } | |
| 410 | + | |
| 411 | + async def admin_overview(self) -> dict[str, Any]: | |
| 412 | + since = datetime.now(UTC) - timedelta(hours=1) | |
| 413 | + base = await self.status_snapshot() | |
| 414 | + http_status = { | |
| 415 | + str(r["status_code"]): r["n"] | |
| 416 | + for r in await self.pool.fetch( | |
| 417 | + """ | |
| 418 | + SELECT status_code, count(*)::int AS n FROM crawl_attempts | |
| 419 | + WHERE fetched_at >= $1 AND status_code IS NOT NULL | |
| 420 | + GROUP BY status_code ORDER BY n DESC | |
| 421 | + """, | |
| 422 | + since, | |
| 423 | + ) | |
| 424 | + } | |
| 425 | + outcomes = { | |
| 426 | + r["outcome"]: r["n"] | |
| 427 | + for r in await self.pool.fetch( | |
| 428 | + "SELECT outcome, count(*)::int AS n FROM crawl_attempts WHERE fetched_at >= $1 GROUP BY outcome", | |
| 429 | + since, | |
| 430 | + ) | |
| 431 | + } | |
| 432 | + top_domains = [ | |
| 433 | + dict(r) | |
| 434 | + for r in await self.pool.fetch( | |
| 435 | + """ | |
| 436 | + SELECT domain, page_count AS pages, round(quebec_score::numeric, 3)::float AS quebec_score | |
| 437 | + FROM domains WHERE page_count > 0 | |
| 438 | + ORDER BY page_count DESC LIMIT 20 | |
| 439 | + """ | |
| 440 | + ) | |
| 441 | + ] | |
| 442 | + recent_errors = [ | |
| 443 | + {"url": r["url"], "error_code": r["error_code"], "at": r["fetched_at"].isoformat()} | |
| 444 | + for r in await self.pool.fetch( | |
| 445 | + """ | |
| 446 | + SELECT u.url, a.error_code, a.fetched_at | |
| 447 | + FROM crawl_attempts a JOIN urls u ON u.id = a.url_id | |
| 448 | + WHERE a.outcome = 'error' ORDER BY a.fetched_at DESC LIMIT 20 | |
| 449 | + """ | |
| 450 | + ) | |
| 451 | + ] | |
| 452 | + latency = await self.pool.fetchrow( | |
| 453 | + """ | |
| 454 | + SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms) AS p50, | |
| 455 | + percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95 | |
| 456 | + FROM crawl_attempts WHERE fetched_at >= $1 AND duration_ms IS NOT NULL | |
| 457 | + """, | |
| 458 | + since, | |
| 459 | + ) | |
| 460 | + return { | |
| 461 | + "frontier": { | |
| 462 | + "pending": base["frontier"].get("pending", 0), | |
| 463 | + "in_progress": base["frontier"].get("in_progress", 0), | |
| 464 | + "done": base["frontier"].get("done", 0), | |
| 465 | + "failed": base["frontier"].get("failed", 0), | |
| 466 | + "blocked": base["frontier"].get("blocked", 0), | |
| 467 | + }, | |
| 468 | + "rates": { | |
| 469 | + "fetched_1h": base["fetched_last_hour"], | |
| 470 | + "indexed_1h": outcomes.get("indexed", 0), | |
| 471 | + "errors_1h": outcomes.get("error", 0), | |
| 472 | + "robots_blocked_1h": outcomes.get("robots_blocked", 0), | |
| 473 | + "duplicates_1h": outcomes.get("duplicate", 0), | |
| 474 | + "parsed_1h": outcomes.get("indexed", 0) + outcomes.get("not_quebec", 0), | |
| 475 | + }, | |
| 476 | + "http_status": http_status, | |
| 477 | + "top_domains": top_domains, | |
| 478 | + "recent_errors": recent_errors, | |
| 479 | + "latency": { | |
| 480 | + "fetch_p50_ms": round(latency["p50"]) if latency and latency["p50"] else None, | |
| 481 | + "fetch_p95_ms": round(latency["p95"]) if latency and latency["p95"] else None, | |
| 482 | + }, | |
| 483 | + } | |
| 484 | + | |
| 485 | + async def recent_events(self, limit: int = 50) -> list[dict[str, Any]]: | |
| 486 | + rows = await self.pool.fetch( | |
| 487 | + """ | |
| 488 | + SELECT a.fetched_at, u.url, a.status_code, a.outcome, a.quebec_score, a.title | |
| 489 | + FROM crawl_attempts a JOIN urls u ON u.id = a.url_id | |
| 490 | + ORDER BY a.fetched_at DESC LIMIT $1 | |
| 491 | + """, | |
| 492 | + min(limit, 200), | |
| 493 | + ) | |
| 494 | + return [ | |
| 495 | + { | |
| 496 | + "at": r["fetched_at"].isoformat(), | |
| 497 | + "url": r["url"], | |
| 498 | + "status": r["status_code"], | |
| 499 | + "outcome": r["outcome"], | |
| 500 | + "quebec_score": r["quebec_score"], | |
| 501 | + "title": r["title"], | |
| 502 | + } | |
| 503 | + for r in rows | |
| 504 | + ] | |
| 505 | + | |
| 506 | + async def frontier_inspect( | |
| 507 | + self, domain: str | None = None, status: str | None = None, limit: int = 100 | |
| 508 | + ) -> list[dict[str, Any]]: | |
| 509 | + rows = await self.pool.fetch( | |
| 510 | + """ | |
| 511 | + SELECT u.url, f.priority, f.depth, f.status, f.retries, f.next_crawl_at, f.error_code | |
| 512 | + FROM frontier_items f JOIN urls u ON u.id = f.url_id | |
| 513 | + JOIN domains d ON d.id = u.domain_id | |
| 514 | + WHERE ($1::text IS NULL OR d.domain = $1) | |
| 515 | + AND ($2::text IS NULL OR f.status = $2) | |
| 516 | + ORDER BY f.priority DESC LIMIT $3 | |
| 517 | + """, | |
| 518 | + domain, | |
| 519 | + status, | |
| 520 | + min(limit, 500), | |
| 521 | + ) | |
| 522 | + return [dict(r) | {"next_crawl_at": r["next_crawl_at"].isoformat()} for r in rows] | |
added
M2M32brouve-ka/packages/database/migrate.py
+46 −0
@@ -0,0 +1,46 @@ | ||
| 1 | +# Trouve-KA — exécution des migrations SQL | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Applique les migrations SQL de infrastructure/migrations dans l'ordre.""" | |
| 6 | + | |
| 7 | +import os | |
| 8 | +import pathlib | |
| 9 | +import re | |
| 10 | + | |
| 11 | +import asyncpg | |
| 12 | + | |
| 13 | + | |
| 14 | +def _find_migrations_dir() -> pathlib.Path: | |
| 15 | + """Résout le dossier de migrations : env, cwd (container /app), ou racine du dépôt (editable).""" | |
| 16 | + candidates = [ | |
| 17 | + os.environ.get("TROUVEKA_MIGRATIONS_DIR"), | |
| 18 | + pathlib.Path.cwd() / "infrastructure" / "migrations", | |
| 19 | + pathlib.Path(__file__).resolve().parents[2] / "infrastructure" / "migrations", | |
| 20 | + ] | |
| 21 | + for cand in candidates: | |
| 22 | + if cand and pathlib.Path(cand).is_dir(): | |
| 23 | + return pathlib.Path(cand) | |
| 24 | + raise FileNotFoundError("dossier infrastructure/migrations introuvable") | |
| 25 | + | |
| 26 | + | |
| 27 | +async def run_migrations(database_url: str) -> list[str]: | |
| 28 | + """Applique les migrations manquantes. Retourne la liste des fichiers appliqués.""" | |
| 29 | + migrations_dir = _find_migrations_dir() | |
| 30 | + conn = await asyncpg.connect(database_url) | |
| 31 | + applied: list[str] = [] | |
| 32 | + try: | |
| 33 | + await conn.execute( | |
| 34 | + "CREATE TABLE IF NOT EXISTS schema_migrations (" | |
| 35 | + " version INTEGER PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())" | |
| 36 | + ) | |
| 37 | + done = {r["version"] for r in await conn.fetch("SELECT version FROM schema_migrations")} | |
| 38 | + for path in sorted(migrations_dir.glob("*.sql")): | |
| 39 | + match = re.match(r"^(\d+)_", path.name) | |
| 40 | + if not match or int(match.group(1)) in done: | |
| 41 | + continue | |
| 42 | + await conn.execute(path.read_text(encoding="utf-8")) | |
| 43 | + applied.append(path.name) | |
| 44 | + finally: | |
| 45 | + await conn.close() | |
| 46 | + return applied | |
added
M2M32brouve-ka/packages/logging/__init__.py
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +# Trouve-KA — logging structuré JSON | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Logs structurés (JSON une ligne) avec contexte crawl_id/url_id/domain_id/worker_id.""" | |
| 6 | + | |
| 7 | +import json | |
| 8 | +import logging | |
| 9 | +import sys | |
| 10 | +import time | |
| 11 | +from typing import Any | |
| 12 | + | |
| 13 | + | |
| 14 | +class JsonFormatter(logging.Formatter): | |
| 15 | + def format(self, record: logging.LogRecord) -> str: | |
| 16 | + payload: dict[str, Any] = { | |
| 17 | + "ts": round(time.time(), 3), | |
| 18 | + "level": record.levelname.lower(), | |
| 19 | + "logger": record.name, | |
| 20 | + "msg": record.getMessage(), | |
| 21 | + } | |
| 22 | + extra = getattr(record, "ctx", None) | |
| 23 | + if isinstance(extra, dict): | |
| 24 | + payload.update(extra) | |
| 25 | + if record.exc_info and record.exc_info[0] is not None: | |
| 26 | + payload["exc"] = self.formatException(record.exc_info) | |
| 27 | + return json.dumps(payload, ensure_ascii=False, default=str) | |
| 28 | + | |
| 29 | + | |
| 30 | +def get_logger(name: str) -> logging.Logger: | |
| 31 | + """Retourne un logger JSON prêt à l'emploi. Utiliser `log.info("msg", extra={"ctx": {...}})`.""" | |
| 32 | + logger = logging.getLogger(name) | |
| 33 | + if not logger.handlers: | |
| 34 | + handler = logging.StreamHandler(sys.stdout) | |
| 35 | + handler.setFormatter(JsonFormatter()) | |
| 36 | + logger.addHandler(handler) | |
| 37 | + logger.setLevel(logging.INFO) | |
| 38 | + logger.propagate = False | |
| 39 | + return logger | |
added
M2M32brouve-ka/packages/queue/__init__.py
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +# Trouve-KA — coordination Redis (politesse, pause, enrichissement) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Coordination inter-workers via Redis. | |
| 6 | + | |
| 7 | +- Politesse par hôte : lock SET NX PX — un seul fetch par hôte par fenêtre, | |
| 8 | + quel que soit le nombre de workers. | |
| 9 | +- Pause globale du crawler : simple clé drapeau. | |
| 10 | +- Enrichissement asynchrone : Redis Stream (jamais bloquant pour l'indexation). | |
| 11 | +""" | |
| 12 | + | |
| 13 | +import json | |
| 14 | +from typing import Any | |
| 15 | + | |
| 16 | +import redis.asyncio as aioredis | |
| 17 | + | |
| 18 | +PAUSE_KEY = "trouveka:crawler:paused" | |
| 19 | +HOST_LOCK_PREFIX = "trouveka:host-lock:" | |
| 20 | +ENRICH_STREAM = "trouveka:enrich" | |
| 21 | + | |
| 22 | + | |
| 23 | +class Coordination: | |
| 24 | + def __init__(self, redis_url: str): | |
| 25 | + self.redis: aioredis.Redis = aioredis.from_url(redis_url, decode_responses=True) | |
| 26 | + | |
| 27 | + async def close(self) -> None: | |
| 28 | + await self.redis.aclose() | |
| 29 | + | |
| 30 | + # ------------------------------------------------------------- politesse | |
| 31 | + async def acquire_host_slot(self, host: str, delay_seconds: float) -> bool: | |
| 32 | + """Réserve le droit de fetcher cet hôte. False = trop tôt, repasser plus tard.""" | |
| 33 | + px = max(int(delay_seconds * 1000), 100) | |
| 34 | + return bool(await self.redis.set(HOST_LOCK_PREFIX + host, "1", nx=True, px=px)) | |
| 35 | + | |
| 36 | + # ------------------------------------------------------------- pause | |
| 37 | + async def pause_crawler(self) -> None: | |
| 38 | + await self.redis.set(PAUSE_KEY, "1") | |
| 39 | + | |
| 40 | + async def resume_crawler(self) -> None: | |
| 41 | + await self.redis.delete(PAUSE_KEY) | |
| 42 | + | |
| 43 | + async def is_paused(self) -> bool: | |
| 44 | + return await self.redis.exists(PAUSE_KEY) == 1 | |
| 45 | + | |
| 46 | + # ------------------------------------------------------------- enrichissement | |
| 47 | + async def enqueue_enrichment(self, payload: dict[str, Any]) -> None: | |
| 48 | + await self.redis.xadd(ENRICH_STREAM, {"data": json.dumps(payload, default=str)}, maxlen=100_000) | |
| 49 | + | |
| 50 | + async def read_enrichment( | |
| 51 | + self, group: str, consumer: str, count: int = 10, block_ms: int = 5000 | |
| 52 | + ) -> list[tuple[str, dict[str, Any]]]: | |
| 53 | + try: | |
| 54 | + await self.redis.xgroup_create(ENRICH_STREAM, group, id="0", mkstream=True) | |
| 55 | + except aioredis.ResponseError as exc: | |
| 56 | + if "BUSYGROUP" not in str(exc): | |
| 57 | + raise | |
| 58 | + entries = await self.redis.xreadgroup( | |
| 59 | + group, consumer, {ENRICH_STREAM: ">"}, count=count, block=block_ms | |
| 60 | + ) | |
| 61 | + out: list[tuple[str, dict[str, Any]]] = [] | |
| 62 | + for _stream, items in entries or []: | |
| 63 | + for msg_id, fields in items: | |
| 64 | + out.append((msg_id, json.loads(fields["data"]))) | |
| 65 | + return out | |
| 66 | + | |
| 67 | + async def ack_enrichment(self, group: str, msg_id: str) -> None: | |
| 68 | + await self.redis.xack(ENRICH_STREAM, group, msg_id) | |
| 69 | + | |
| 70 | + async def enrich_backlog(self) -> int: | |
| 71 | + return await self.redis.xlen(ENRICH_STREAM) | |
added
M2M32brouve-ka/packages/search-core/__init__.py
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +# Trouve-KA — couche OpenSearch (client, mapping, indexation) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Couche OpenSearch : client async, mapping bilingue FR/EN, indexation immédiate.""" | |
| 6 | + | |
| 7 | +from .client import SearchCore | |
| 8 | +from .mapping import INDEX_SETTINGS | |
| 9 | + | |
| 10 | +__all__ = ["SearchCore", "INDEX_SETTINGS"] | |
added
M2M32brouve-ka/packages/search-core/client.py
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +# Trouve-KA — client OpenSearch | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Client OpenSearch async : création d'index, indexation immédiate, recherche, statut.""" | |
| 6 | + | |
| 7 | +import hashlib | |
| 8 | +from typing import Any | |
| 9 | + | |
| 10 | +from opensearchpy import AsyncOpenSearch | |
| 11 | +from opensearchpy.exceptions import NotFoundError | |
| 12 | + | |
| 13 | +from .mapping import INDEX_SETTINGS | |
| 14 | + | |
| 15 | + | |
| 16 | +def doc_id_for_url(url: str) -> str: | |
| 17 | + """ID de document stable dérivé de l'URL canonique.""" | |
| 18 | + return hashlib.sha256(url.encode("utf-8")).hexdigest()[:32] | |
| 19 | + | |
| 20 | + | |
| 21 | +class SearchCore: | |
| 22 | + def __init__(self, search_url: str, index: str): | |
| 23 | + self.index = index | |
| 24 | + self.client = AsyncOpenSearch(hosts=[search_url], timeout=15, max_retries=2, retry_on_timeout=True) | |
| 25 | + | |
| 26 | + async def close(self) -> None: | |
| 27 | + await self.client.close() | |
| 28 | + | |
| 29 | + async def ensure_index(self) -> None: | |
| 30 | + if not await self.client.indices.exists(index=self.index): | |
| 31 | + await self.client.indices.create(index=self.index, body=INDEX_SETTINGS) | |
| 32 | + | |
| 33 | + async def index_document(self, doc: dict[str, Any]) -> str: | |
| 34 | + """Indexation immédiate d'un document (cherchable au prochain refresh, ~1 s).""" | |
| 35 | + _id = doc_id_for_url(doc["canonical_url"] or doc["url"]) | |
| 36 | + await self.client.index(index=self.index, id=_id, body=doc) | |
| 37 | + return _id | |
| 38 | + | |
| 39 | + async def update_document(self, url: str, partial: dict[str, Any]) -> None: | |
| 40 | + """Mise à jour partielle (enrichissement asynchrone, étapes 2-3).""" | |
| 41 | + try: | |
| 42 | + await self.client.update(index=self.index, id=doc_id_for_url(url), body={"doc": partial}) | |
| 43 | + except NotFoundError: | |
| 44 | + pass | |
| 45 | + | |
| 46 | + async def delete_document(self, url: str) -> None: | |
| 47 | + try: | |
| 48 | + await self.client.delete(index=self.index, id=doc_id_for_url(url)) | |
| 49 | + except NotFoundError: | |
| 50 | + pass | |
| 51 | + | |
| 52 | + async def search(self, body: dict[str, Any]) -> dict[str, Any]: | |
| 53 | + return await self.client.search(index=self.index, body=body) | |
| 54 | + | |
| 55 | + async def count(self) -> int: | |
| 56 | + try: | |
| 57 | + res = await self.client.count(index=self.index) | |
| 58 | + return res["count"] | |
| 59 | + except NotFoundError: | |
| 60 | + return 0 | |
| 61 | + | |
| 62 | + async def ping(self) -> bool: | |
| 63 | + try: | |
| 64 | + return await self.client.ping() | |
| 65 | + except Exception: | |
| 66 | + return False | |
added
M2M32brouve-ka/packages/search-core/mapping.py
+124 −0
@@ -0,0 +1,124 @@ | ||
| 1 | +# Trouve-KA — mapping OpenSearch bilingue | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Mapping et settings de l'index documents. | |
| 6 | + | |
| 7 | +- Analyzers français ET anglais sur les champs texte (bilingue dès le départ). | |
| 8 | +- Filtre de synonymes bilingues (thermopompe ↔ heat pump) appliqué à la recherche | |
| 9 | + seulement : pas de traduction à l'ingestion. | |
| 10 | +- Refresh 1 s : une page indexée est cherchable en ~1 seconde (principe cardinal). | |
| 11 | +""" | |
| 12 | + | |
| 13 | +# Synonymes bilingues de départ — extensibles sans réindexation (search-time) | |
| 14 | +BILINGUAL_SYNONYMS = [ | |
| 15 | + "thermopompe, heat pump", | |
| 16 | + "subvention, grant, subsidy", | |
| 17 | + "emploi, job, employment", | |
| 18 | + "entreprise, company, business", | |
| 19 | + "université, university", | |
| 20 | + "cégep, cegep, college", | |
| 21 | + "garderie, daycare, cpe", | |
| 22 | + "impôt, tax, taxes", | |
| 23 | + "santé, health", | |
| 24 | + "plombier, plumber", | |
| 25 | + "électricien, electrician", | |
| 26 | + "déneigement, snow removal", | |
| 27 | + "assurance, insurance", | |
| 28 | + "logement, housing, apartment", | |
| 29 | + "ville, city, municipalité, municipality", | |
| 30 | + "gouvernement, government", | |
| 31 | + "permis, permit, license, licence", | |
| 32 | +] | |
| 33 | + | |
| 34 | +INDEX_SETTINGS = { | |
| 35 | + "settings": { | |
| 36 | + "number_of_shards": 1, | |
| 37 | + "number_of_replicas": 0, | |
| 38 | + "refresh_interval": "1s", | |
| 39 | + "analysis": { | |
| 40 | + "filter": { | |
| 41 | + "french_elision": { | |
| 42 | + "type": "elision", | |
| 43 | + "articles_case": True, | |
| 44 | + "articles": ["l", "m", "t", "qu", "n", "s", "j", "d", "c", "jusqu", "quoiqu", "lorsqu", "puisqu"], | |
| 45 | + }, | |
| 46 | + "french_stop": {"type": "stop", "stopwords": "_french_"}, | |
| 47 | + "french_stemmer": {"type": "stemmer", "language": "light_french"}, | |
| 48 | + "english_stop": {"type": "stop", "stopwords": "_english_"}, | |
| 49 | + "english_stemmer": {"type": "stemmer", "language": "light_english"}, | |
| 50 | + "bilingual_synonyms": { | |
| 51 | + "type": "synonym_graph", | |
| 52 | + "lenient": True, | |
| 53 | + "synonyms": BILINGUAL_SYNONYMS, | |
| 54 | + }, | |
| 55 | + }, | |
| 56 | + "analyzer": { | |
| 57 | + "fr_text": { | |
| 58 | + "tokenizer": "standard", | |
| 59 | + "filter": ["french_elision", "lowercase", "asciifolding", "french_stop", "french_stemmer"], | |
| 60 | + }, | |
| 61 | + "fr_search": { | |
| 62 | + "tokenizer": "standard", | |
| 63 | + "filter": ["french_elision", "lowercase", "asciifolding", "bilingual_synonyms", | |
| 64 | + "french_stop", "french_stemmer"], | |
| 65 | + }, | |
| 66 | + "en_text": { | |
| 67 | + "tokenizer": "standard", | |
| 68 | + "filter": ["lowercase", "asciifolding", "english_stop", "english_stemmer"], | |
| 69 | + }, | |
| 70 | + "en_search": { | |
| 71 | + "tokenizer": "standard", | |
| 72 | + "filter": ["lowercase", "asciifolding", "bilingual_synonyms", | |
| 73 | + "english_stop", "english_stemmer"], | |
| 74 | + }, | |
| 75 | + }, | |
| 76 | + }, | |
| 77 | + }, | |
| 78 | + "mappings": { | |
| 79 | + "properties": { | |
| 80 | + "url": {"type": "keyword"}, | |
| 81 | + "canonical_url": {"type": "keyword"}, | |
| 82 | + "domain": {"type": "keyword"}, | |
| 83 | + "title": { | |
| 84 | + "type": "text", | |
| 85 | + "analyzer": "fr_text", | |
| 86 | + "search_analyzer": "fr_search", | |
| 87 | + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 88 | + }, | |
| 89 | + "description": { | |
| 90 | + "type": "text", | |
| 91 | + "analyzer": "fr_text", | |
| 92 | + "search_analyzer": "fr_search", | |
| 93 | + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 94 | + }, | |
| 95 | + "body": { | |
| 96 | + "type": "text", | |
| 97 | + "analyzer": "fr_text", | |
| 98 | + "search_analyzer": "fr_search", | |
| 99 | + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 100 | + }, | |
| 101 | + "headings": { | |
| 102 | + "type": "text", | |
| 103 | + "analyzer": "fr_text", | |
| 104 | + "search_analyzer": "fr_search", | |
| 105 | + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 106 | + }, | |
| 107 | + "language": {"type": "keyword"}, | |
| 108 | + "page_quebec_score": {"type": "float"}, | |
| 109 | + "domain_quebec_score": {"type": "float"}, | |
| 110 | + "locations": {"type": "keyword"}, | |
| 111 | + "organizations": {"type": "keyword"}, | |
| 112 | + "people": {"type": "keyword"}, | |
| 113 | + "categories": {"type": "keyword"}, | |
| 114 | + "published_at": {"type": "date"}, | |
| 115 | + "crawled_at": {"type": "date"}, | |
| 116 | + "authority_score": {"type": "float"}, | |
| 117 | + "freshness_score": {"type": "float"}, | |
| 118 | + "quality_score": {"type": "float"}, | |
| 119 | + "spam_score": {"type": "float"}, | |
| 120 | + # embedding (dense_vector/knn) ajouté à l'étape sémantique (§17.7), | |
| 121 | + # le mapping est extensible sans réindexation pour un nouveau champ. | |
| 122 | + } | |
| 123 | + }, | |
| 124 | +} | |
added
M2M32brouve-ka/packages/shared/__init__.py
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +# Trouve-KA — utilitaires partagés | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Utilitaires partagés : canonicalisation d'URL, garde SSRF, hachage de contenu.""" | |
| 6 | + | |
| 7 | +from .urls import canonicalize_url, display_url, extract_domain, is_http_url | |
| 8 | +from .ssrf import is_safe_url, is_safe_ip | |
| 9 | +from .hashing import content_hash, text_fingerprint | |
| 10 | + | |
| 11 | +__all__ = [ | |
| 12 | + "canonicalize_url", | |
| 13 | + "display_url", | |
| 14 | + "extract_domain", | |
| 15 | + "is_http_url", | |
| 16 | + "is_safe_url", | |
| 17 | + "is_safe_ip", | |
| 18 | + "content_hash", | |
| 19 | + "text_fingerprint", | |
| 20 | +] | |
added
M2M32brouve-ka/packages/shared/hashing.py
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +# Trouve-KA — hachage de contenu et détection de changement | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Hachage de contenu pour la déduplication exacte et la détection de changement. | |
| 6 | + | |
| 7 | +Le hash est calculé sur le texte extrait normalisé (pas le HTML brut) pour | |
| 8 | +ignorer le bruit de balisage (nonces, timestamps de rendu, etc.). | |
| 9 | +""" | |
| 10 | + | |
| 11 | +import hashlib | |
| 12 | +import re | |
| 13 | + | |
| 14 | +_WHITESPACE = re.compile(r"\s+") | |
| 15 | + | |
| 16 | + | |
| 17 | +def text_fingerprint(text: str) -> str: | |
| 18 | + """Texte normalisé pour hachage : espaces réduits, minuscules.""" | |
| 19 | + return _WHITESPACE.sub(" ", text).strip().lower() | |
| 20 | + | |
| 21 | + | |
| 22 | +def content_hash(title: str, body: str) -> str: | |
| 23 | + """SHA-256 du contenu textuel normalisé (titre + corps).""" | |
| 24 | + payload = text_fingerprint(title) + "\n" + text_fingerprint(body) | |
| 25 | + return hashlib.sha256(payload.encode("utf-8")).hexdigest() | |
added
M2M32brouve-ka/packages/shared/ssrf.py
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +# Trouve-KA — prévention SSRF | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Garde SSRF du crawler (CLAUDE.md §5.7). | |
| 6 | + | |
| 7 | +Bloque localhost, plages privées IPv4/IPv6, link-local, métadonnées cloud, | |
| 8 | +schémas non-HTTP. La résolution DNS est revalidée à chaque saut de redirection | |
| 9 | +par l'appelant (fetcher). | |
| 10 | +""" | |
| 11 | + | |
| 12 | +import ipaddress | |
| 13 | +import socket | |
| 14 | +from urllib.parse import urlsplit | |
| 15 | + | |
| 16 | +BLOCKED_HOSTS = { | |
| 17 | + "localhost", | |
| 18 | + "metadata.google.internal", | |
| 19 | + "metadata.gke.internal", | |
| 20 | +} | |
| 21 | + | |
| 22 | +# Endpoint de métadonnées AWS/GCP/Azure/OpenStack | |
| 23 | +METADATA_IPS = {"169.254.169.254", "fd00:ec2::254"} | |
| 24 | + | |
| 25 | + | |
| 26 | +def is_safe_ip(ip_str: str) -> bool: | |
| 27 | + try: | |
| 28 | + ip = ipaddress.ip_address(ip_str) | |
| 29 | + except ValueError: | |
| 30 | + return False | |
| 31 | + if ip_str in METADATA_IPS: | |
| 32 | + return False | |
| 33 | + return not ( | |
| 34 | + ip.is_private | |
| 35 | + or ip.is_loopback | |
| 36 | + or ip.is_link_local | |
| 37 | + or ip.is_multicast | |
| 38 | + or ip.is_reserved | |
| 39 | + or ip.is_unspecified | |
| 40 | + ) | |
| 41 | + | |
| 42 | + | |
| 43 | +def resolve_host(host: str) -> list[str]: | |
| 44 | + """Résout un hôte en IPs (IPv4+IPv6). Lève socket.gaierror si introuvable.""" | |
| 45 | + infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP) | |
| 46 | + return list({info[4][0] for info in infos}) | |
| 47 | + | |
| 48 | + | |
| 49 | +def is_safe_url(url: str, *, resolve: bool = True) -> bool: | |
| 50 | + """Vérifie qu'une URL est sûre à fetcher (schéma, hôte, IPs résolues).""" | |
| 51 | + try: | |
| 52 | + parts = urlsplit(url) | |
| 53 | + except ValueError: | |
| 54 | + return False | |
| 55 | + if parts.scheme.lower() not in ("http", "https"): | |
| 56 | + return False | |
| 57 | + host = parts.hostname | |
| 58 | + if not host: | |
| 59 | + return False | |
| 60 | + host = host.lower().strip(".") | |
| 61 | + if host in BLOCKED_HOSTS or host.endswith(".localhost") or host.endswith(".internal"): | |
| 62 | + return False | |
| 63 | + # Hôte littéral IP | |
| 64 | + try: | |
| 65 | + ipaddress.ip_address(host) | |
| 66 | + return is_safe_ip(host) | |
| 67 | + except ValueError: | |
| 68 | + pass | |
| 69 | + if not resolve: | |
| 70 | + return True | |
| 71 | + try: | |
| 72 | + ips = resolve_host(host) | |
| 73 | + except (socket.gaierror, OSError): | |
| 74 | + return False | |
| 75 | + return bool(ips) and all(is_safe_ip(ip) for ip in ips) | |
added
M2M32brouve-ka/packages/shared/urls.py
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +# Trouve-KA — canonicalisation d'URL | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Canonicalisation d'URL prudente. | |
| 6 | + | |
| 7 | +Règle cardinale (CLAUDE.md §5.4) : ne JAMAIS fusionner deux ressources | |
| 8 | +distinctes par accident. On normalise seulement ce qui est sûr : | |
| 9 | +fragments, ports par défaut, casse de l'hôte, paramètres de tracking connus, | |
| 10 | +encodage. On ne touche ni à la casse du chemin ni aux paramètres inconnus. | |
| 11 | +""" | |
| 12 | + | |
| 13 | +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit, quote, unquote | |
| 14 | + | |
| 15 | +# Paramètres de tracking sûrs à retirer (jamais porteurs de contenu) | |
| 16 | +TRACKING_PARAMS = { | |
| 17 | + "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id", | |
| 18 | + "fbclid", "gclid", "gclsrc", "dclid", "msclkid", "twclid", "igshid", | |
| 19 | + "mc_cid", "mc_eid", "_ga", "_gl", "yclid", "wbraid", "gbraid", | |
| 20 | + "ref_src", "cmpid", "s_kwcid", "spm", | |
| 21 | +} | |
| 22 | + | |
| 23 | +DEFAULT_PORTS = {"http": 80, "https": 443} | |
| 24 | + | |
| 25 | + | |
| 26 | +def is_http_url(url: str) -> bool: | |
| 27 | + try: | |
| 28 | + scheme = urlsplit(url).scheme.lower() | |
| 29 | + except ValueError: | |
| 30 | + return False | |
| 31 | + return scheme in ("http", "https") | |
| 32 | + | |
| 33 | + | |
| 34 | +def canonicalize_url(url: str, base: str | None = None) -> str | None: | |
| 35 | + """Normalise une URL. Retourne None si l'URL n'est pas crawlable (schéma non http).""" | |
| 36 | + url = url.strip() | |
| 37 | + if not url: | |
| 38 | + return None | |
| 39 | + if base: | |
| 40 | + from urllib.parse import urljoin | |
| 41 | + | |
| 42 | + url = urljoin(base, url) | |
| 43 | + try: | |
| 44 | + parts = urlsplit(url) | |
| 45 | + except ValueError: | |
| 46 | + return None | |
| 47 | + scheme = parts.scheme.lower() | |
| 48 | + if scheme not in ("http", "https"): | |
| 49 | + return None | |
| 50 | + host = parts.hostname | |
| 51 | + if not host: | |
| 52 | + return None | |
| 53 | + host = host.strip(".").lower() | |
| 54 | + try: | |
| 55 | + host = host.encode("idna").decode("ascii") if any(ord(c) > 127 for c in host) else host | |
| 56 | + except UnicodeError: | |
| 57 | + return None | |
| 58 | + | |
| 59 | + port = parts.port | |
| 60 | + netloc = host | |
| 61 | + if port and port != DEFAULT_PORTS.get(scheme): | |
| 62 | + netloc = f"{host}:{port}" | |
| 63 | + | |
| 64 | + # Chemin : ré-encoder proprement sans changer la sémantique (casse préservée) | |
| 65 | + path = quote(unquote(parts.path or "/"), safe="/%:@!$&'()*+,;=~-._") | |
| 66 | + # Slashs multiples consécutifs → un seul (sûr pour HTTP) | |
| 67 | + while "//" in path: | |
| 68 | + path = path.replace("//", "/") | |
| 69 | + if not path: | |
| 70 | + path = "/" | |
| 71 | + | |
| 72 | + # Query : retirer uniquement les paramètres de tracking connus; préserver l'ordre | |
| 73 | + query = "" | |
| 74 | + if parts.query: | |
| 75 | + kept = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) | |
| 76 | + if k.lower() not in TRACKING_PARAMS] | |
| 77 | + query = urlencode(kept) | |
| 78 | + | |
| 79 | + # Fragment : toujours retiré (jamais envoyé au serveur) | |
| 80 | + return urlunsplit((scheme, netloc, path, query, "")) | |
| 81 | + | |
| 82 | + | |
| 83 | +def extract_domain(url: str) -> str | None: | |
| 84 | + """Domaine enregistrable approximatif : hôte sans le préfixe www.""" | |
| 85 | + try: | |
| 86 | + host = urlsplit(url).hostname | |
| 87 | + except ValueError: | |
| 88 | + return None | |
| 89 | + if not host: | |
| 90 | + return None | |
| 91 | + host = host.lower().strip(".") | |
| 92 | + return host[4:] if host.startswith("www.") else host | |
| 93 | + | |
| 94 | + | |
| 95 | +def display_url(url: str, max_len: int = 80) -> str: | |
| 96 | + """URL d'affichage façon breadcrumb : quebec.ca › services › permis.""" | |
| 97 | + parts = urlsplit(url) | |
| 98 | + host = (parts.hostname or "").removeprefix("www.") | |
| 99 | + segments = [unquote(s) for s in parts.path.split("/") if s] | |
| 100 | + crumb = " › ".join([host, *segments[:3]]) | |
| 101 | + if len(crumb) > max_len: | |
| 102 | + crumb = crumb[: max_len - 1] + "…" | |
| 103 | + return crumb | |
added
M2M32brouve-ka/packages/types/__init__.py
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +# Trouve-KA — types partagés du pipeline | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Modèles Pydantic partagés entre crawler, parser, classifier, indexer et API.""" | |
| 6 | + | |
| 7 | +from datetime import datetime | |
| 8 | +from enum import StrEnum | |
| 9 | + | |
| 10 | +from pydantic import BaseModel, Field | |
| 11 | + | |
| 12 | + | |
| 13 | +class ErrorCode(StrEnum): | |
| 14 | + DNS = "dns" | |
| 15 | + TIMEOUT = "timeout" | |
| 16 | + TLS = "tls" | |
| 17 | + HTTP_4XX = "http_4xx" | |
| 18 | + HTTP_5XX = "http_5xx" | |
| 19 | + ROBOTS_DENIED = "robots_denied" | |
| 20 | + PARSE_FAILED = "parse_failed" | |
| 21 | + UNSUPPORTED_CONTENT = "unsupported_content" | |
| 22 | + TOO_LARGE = "too_large" | |
| 23 | + DUPLICATE = "duplicate" | |
| 24 | + SPAM = "spam" | |
| 25 | + NOT_QUEBEC = "not_quebec" | |
| 26 | + SSRF_BLOCKED = "ssrf_blocked" | |
| 27 | + TOO_MANY_REDIRECTS = "too_many_redirects" | |
| 28 | + CONNECTION = "connection" | |
| 29 | + OK = "ok" | |
| 30 | + | |
| 31 | + | |
| 32 | +class Outcome(StrEnum): | |
| 33 | + INDEXED = "indexed" | |
| 34 | + DUPLICATE = "duplicate" | |
| 35 | + UNCHANGED = "unchanged" | |
| 36 | + ERROR = "error" | |
| 37 | + NOT_QUEBEC = "not_quebec" | |
| 38 | + ROBOTS_BLOCKED = "robots_blocked" | |
| 39 | + REDIRECT = "redirect" | |
| 40 | + | |
| 41 | + | |
| 42 | +class FetchResult(BaseModel): | |
| 43 | + url: str | |
| 44 | + final_url: str | |
| 45 | + status_code: int | None = None | |
| 46 | + error_code: ErrorCode = ErrorCode.OK | |
| 47 | + content_type: str | None = None | |
| 48 | + charset: str | None = None | |
| 49 | + body: bytes | None = None | |
| 50 | + etag: str | None = None | |
| 51 | + last_modified: str | None = None | |
| 52 | + duration_ms: int = 0 | |
| 53 | + redirect_chain: list[str] = Field(default_factory=list) | |
| 54 | + | |
| 55 | + | |
| 56 | +class ExtractedLink(BaseModel): | |
| 57 | + url: str | |
| 58 | + anchor: str = "" | |
| 59 | + nofollow: bool = False | |
| 60 | + | |
| 61 | + | |
| 62 | +class ParsedPage(BaseModel): | |
| 63 | + url: str | |
| 64 | + canonical_url: str | None = None | |
| 65 | + title: str = "" | |
| 66 | + description: str = "" | |
| 67 | + body: str = "" | |
| 68 | + headings: list[str] = Field(default_factory=list) | |
| 69 | + language: str | None = None | |
| 70 | + links: list[ExtractedLink] = Field(default_factory=list) | |
| 71 | + published_at: datetime | None = None | |
| 72 | + modified_at: datetime | None = None | |
| 73 | + author: str | None = None | |
| 74 | + noindex: bool = False | |
| 75 | + nofollow_page: bool = False | |
| 76 | + structured_hints: list[str] = Field(default_factory=list) # textes JSON-LD/OG utiles au scoring | |
| 77 | + | |
| 78 | + | |
| 79 | +class QuebecSignals(BaseModel): | |
| 80 | + score: float = 0.0 | |
| 81 | + locations: list[str] = Field(default_factory=list) | |
| 82 | + reasons: list[str] = Field(default_factory=list) | |
| 83 | + | |
| 84 | + | |
| 85 | +class SearchResult(BaseModel): | |
| 86 | + title: str | |
| 87 | + url: str | |
| 88 | + display_url: str | |
| 89 | + snippet: str | |
| 90 | + domain: str | |
| 91 | + language: str | None = None | |
| 92 | + quebec_score: float = 0.0 | |
| 93 | + badges: list[str] = Field(default_factory=list) | |
| 94 | + published_at: datetime | None = None | |
| 95 | + | |
| 96 | + | |
| 97 | +class SearchResponse(BaseModel): | |
| 98 | + query: str | |
| 99 | + total: int | |
| 100 | + took_ms: int | |
| 101 | + page: int | |
| 102 | + limit: int | |
| 103 | + results: list[SearchResult] | |
added
M2M32brouve-ka/pnpm-lock.yaml
+3967 −0
@@ -0,0 +1,3967 @@ | ||
| 1 | +lockfileVersion: '9.0' | |
| 2 | + | |
| 3 | +settings: | |
| 4 | + autoInstallPeers: true | |
| 5 | + excludeLinksFromLockfile: false | |
| 6 | + | |
| 7 | +importers: | |
| 8 | + | |
| 9 | + .: {} | |
| 10 | + | |
| 11 | + apps/web: | |
| 12 | + dependencies: | |
| 13 | + class-variance-authority: | |
| 14 | + specifier: ^0.7.1 | |
| 15 | + version: 0.7.1 | |
| 16 | + clsx: | |
| 17 | + specifier: ^2.1.1 | |
| 18 | + version: 2.1.1 | |
| 19 | + next: | |
| 20 | + specifier: ^15.3.5 | |
| 21 | + version: 15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8) | |
| 22 | + react: | |
| 23 | + specifier: ^19.1.0 | |
| 24 | + version: 19.2.8 | |
| 25 | + react-dom: | |
| 26 | + specifier: ^19.1.0 | |
| 27 | + version: 19.2.8(react@19.2.8) | |
| 28 | + tailwind-merge: | |
| 29 | + specifier: ^2.6.0 | |
| 30 | + version: 2.6.1 | |
| 31 | + devDependencies: | |
| 32 | + '@types/node': | |
| 33 | + specifier: ^22.15.0 | |
| 34 | + version: 22.20.1 | |
| 35 | + '@types/react': | |
| 36 | + specifier: ^19.1.0 | |
| 37 | + version: 19.2.18 | |
| 38 | + '@types/react-dom': | |
| 39 | + specifier: ^19.1.0 | |
| 40 | + version: 19.2.4(@types/react@19.2.18) | |
| 41 | + autoprefixer: | |
| 42 | + specifier: ^10.4.21 | |
| 43 | + version: 10.5.4(postcss@8.5.26) | |
| 44 | + eslint: | |
| 45 | + specifier: ^8.57.1 | |
| 46 | + version: 8.57.1 | |
| 47 | + eslint-config-next: | |
| 48 | + specifier: ^15.3.5 | |
| 49 | + version: 15.5.23(eslint@8.57.1)(typescript@5.9.3) | |
| 50 | + postcss: | |
| 51 | + specifier: ^8.5.3 | |
| 52 | + version: 8.5.26 | |
| 53 | + tailwindcss: | |
| 54 | + specifier: ^3.4.17 | |
| 55 | + version: 3.4.19 | |
| 56 | + typescript: | |
| 57 | + specifier: ^5.8.3 | |
| 58 | + version: 5.9.3 | |
| 59 | + | |
| 60 | +packages: | |
| 61 | + | |
| 62 | + '@alloc/quick-lru@5.2.0': | |
| 63 | + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} | |
| 64 | + engines: {node: '>=10'} | |
| 65 | + | |
| 66 | + '@emnapi/core@1.10.0': | |
| 67 | + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} | |
| 68 | + | |
| 69 | + '@emnapi/runtime@1.10.0': | |
| 70 | + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} | |
| 71 | + | |
| 72 | + '@emnapi/runtime@1.11.3': | |
| 73 | + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} | |
| 74 | + | |
| 75 | + '@emnapi/wasi-threads@1.2.1': | |
| 76 | + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} | |
| 77 | + | |
| 78 | + '@eslint-community/eslint-utils@4.10.1': | |
| 79 | + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} | |
| 80 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 81 | + peerDependencies: | |
| 82 | + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 | |
| 83 | + | |
| 84 | + '@eslint-community/regexpp@4.12.2': | |
| 85 | + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} | |
| 86 | + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} | |
| 87 | + | |
| 88 | + '@eslint/eslintrc@2.1.4': | |
| 89 | + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} | |
| 90 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 91 | + | |
| 92 | + '@eslint/js@8.57.1': | |
| 93 | + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} | |
| 94 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 95 | + | |
| 96 | + '@humanwhocodes/config-array@0.13.0': | |
| 97 | + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} | |
| 98 | + engines: {node: '>=10.10.0'} | |
| 99 | + deprecated: Use @eslint/config-array instead | |
| 100 | + | |
| 101 | + '@humanwhocodes/module-importer@1.0.1': | |
| 102 | + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} | |
| 103 | + engines: {node: '>=12.22'} | |
| 104 | + | |
| 105 | + '@humanwhocodes/object-schema@2.0.3': | |
| 106 | + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} | |
| 107 | + deprecated: Use @eslint/object-schema instead | |
| 108 | + | |
| 109 | + '@img/colour@1.1.0': | |
| 110 | + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} | |
| 111 | + engines: {node: '>=18'} | |
| 112 | + | |
| 113 | + '@img/sharp-darwin-arm64@0.34.5': | |
| 114 | + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} | |
| 115 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 116 | + cpu: [arm64] | |
| 117 | + os: [darwin] | |
| 118 | + | |
| 119 | + '@img/sharp-darwin-x64@0.34.5': | |
| 120 | + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} | |
| 121 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 122 | + cpu: [x64] | |
| 123 | + os: [darwin] | |
| 124 | + | |
| 125 | + '@img/sharp-libvips-darwin-arm64@1.2.4': | |
| 126 | + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} | |
| 127 | + cpu: [arm64] | |
| 128 | + os: [darwin] | |
| 129 | + | |
| 130 | + '@img/sharp-libvips-darwin-x64@1.2.4': | |
| 131 | + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} | |
| 132 | + cpu: [x64] | |
| 133 | + os: [darwin] | |
| 134 | + | |
| 135 | + '@img/sharp-libvips-linux-arm64@1.2.4': | |
| 136 | + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} | |
| 137 | + cpu: [arm64] | |
| 138 | + os: [linux] | |
| 139 | + libc: [glibc] | |
| 140 | + | |
| 141 | + '@img/sharp-libvips-linux-arm@1.2.4': | |
| 142 | + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} | |
| 143 | + cpu: [arm] | |
| 144 | + os: [linux] | |
| 145 | + libc: [glibc] | |
| 146 | + | |
| 147 | + '@img/sharp-libvips-linux-ppc64@1.2.4': | |
| 148 | + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} | |
| 149 | + cpu: [ppc64] | |
| 150 | + os: [linux] | |
| 151 | + libc: [glibc] | |
| 152 | + | |
| 153 | + '@img/sharp-libvips-linux-riscv64@1.2.4': | |
| 154 | + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} | |
| 155 | + cpu: [riscv64] | |
| 156 | + os: [linux] | |
| 157 | + libc: [glibc] | |
| 158 | + | |
| 159 | + '@img/sharp-libvips-linux-s390x@1.2.4': | |
| 160 | + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} | |
| 161 | + cpu: [s390x] | |
| 162 | + os: [linux] | |
| 163 | + libc: [glibc] | |
| 164 | + | |
| 165 | + '@img/sharp-libvips-linux-x64@1.2.4': | |
| 166 | + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} | |
| 167 | + cpu: [x64] | |
| 168 | + os: [linux] | |
| 169 | + libc: [glibc] | |
| 170 | + | |
| 171 | + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': | |
| 172 | + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} | |
| 173 | + cpu: [arm64] | |
| 174 | + os: [linux] | |
| 175 | + libc: [musl] | |
| 176 | + | |
| 177 | + '@img/sharp-libvips-linuxmusl-x64@1.2.4': | |
| 178 | + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} | |
| 179 | + cpu: [x64] | |
| 180 | + os: [linux] | |
| 181 | + libc: [musl] | |
| 182 | + | |
| 183 | + '@img/sharp-linux-arm64@0.34.5': | |
| 184 | + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} | |
| 185 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 186 | + cpu: [arm64] | |
| 187 | + os: [linux] | |
| 188 | + libc: [glibc] | |
| 189 | + | |
| 190 | + '@img/sharp-linux-arm@0.34.5': | |
| 191 | + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} | |
| 192 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 193 | + cpu: [arm] | |
| 194 | + os: [linux] | |
| 195 | + libc: [glibc] | |
| 196 | + | |
| 197 | + '@img/sharp-linux-ppc64@0.34.5': | |
| 198 | + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} | |
| 199 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 200 | + cpu: [ppc64] | |
| 201 | + os: [linux] | |
| 202 | + libc: [glibc] | |
| 203 | + | |
| 204 | + '@img/sharp-linux-riscv64@0.34.5': | |
| 205 | + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} | |
| 206 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 207 | + cpu: [riscv64] | |
| 208 | + os: [linux] | |
| 209 | + libc: [glibc] | |
| 210 | + | |
| 211 | + '@img/sharp-linux-s390x@0.34.5': | |
| 212 | + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} | |
| 213 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 214 | + cpu: [s390x] | |
| 215 | + os: [linux] | |
| 216 | + libc: [glibc] | |
| 217 | + | |
| 218 | + '@img/sharp-linux-x64@0.34.5': | |
| 219 | + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} | |
| 220 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 221 | + cpu: [x64] | |
| 222 | + os: [linux] | |
| 223 | + libc: [glibc] | |
| 224 | + | |
| 225 | + '@img/sharp-linuxmusl-arm64@0.34.5': | |
| 226 | + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} | |
| 227 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 228 | + cpu: [arm64] | |
| 229 | + os: [linux] | |
| 230 | + libc: [musl] | |
| 231 | + | |
| 232 | + '@img/sharp-linuxmusl-x64@0.34.5': | |
| 233 | + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} | |
| 234 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 235 | + cpu: [x64] | |
| 236 | + os: [linux] | |
| 237 | + libc: [musl] | |
| 238 | + | |
| 239 | + '@img/sharp-wasm32@0.34.5': | |
| 240 | + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} | |
| 241 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 242 | + cpu: [wasm32] | |
| 243 | + | |
| 244 | + '@img/sharp-win32-arm64@0.34.5': | |
| 245 | + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} | |
| 246 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 247 | + cpu: [arm64] | |
| 248 | + os: [win32] | |
| 249 | + | |
| 250 | + '@img/sharp-win32-ia32@0.34.5': | |
| 251 | + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} | |
| 252 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 253 | + cpu: [ia32] | |
| 254 | + os: [win32] | |
| 255 | + | |
| 256 | + '@img/sharp-win32-x64@0.34.5': | |
| 257 | + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} | |
| 258 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 259 | + cpu: [x64] | |
| 260 | + os: [win32] | |
| 261 | + | |
| 262 | + '@jridgewell/gen-mapping@0.3.13': | |
| 263 | + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} | |
| 264 | + | |
| 265 | + '@jridgewell/resolve-uri@3.1.2': | |
| 266 | + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} | |
| 267 | + engines: {node: '>=6.0.0'} | |
| 268 | + | |
| 269 | + '@jridgewell/sourcemap-codec@1.5.5': | |
| 270 | + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} | |
| 271 | + | |
| 272 | + '@jridgewell/trace-mapping@0.3.31': | |
| 273 | + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} | |
| 274 | + | |
| 275 | + '@napi-rs/wasm-runtime@1.2.3': | |
| 276 | + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} | |
| 277 | + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} | |
| 278 | + peerDependencies: | |
| 279 | + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 | |
| 280 | + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 | |
| 281 | + | |
| 282 | + '@next/env@15.5.23': | |
| 283 | + resolution: {integrity: sha512-Mv3Z9hVbFcPnoLevsZ6rnX1TBtyHb5E17yN7HTPDXSXxeNsGBjUFrdbjRXKKXIOhfth7/cg6Ay7PZ2UFawaWsQ==} | |
| 284 | + | |
| 285 | + '@next/eslint-plugin-next@15.5.23': | |
| 286 | + resolution: {integrity: sha512-0KnCFpiWVIsbwBhByZ0uIcjYM5xqGrFzN2eOPbwru/wuy5Z1dmA+3gP+PRbi4gl1Ny7an66BqAM/NHkX/50rbw==} | |
| 287 | + | |
| 288 | + '@next/swc-darwin-arm64@15.5.23': | |
| 289 | + resolution: {integrity: sha512-SrEwOROH/rhA03F59hHtdhgtfZMWGzr5duDBWgRQt2rS3mJhqMKOcnNx6txOd0/i3E3D3uFKYFvyHsEiwQxzag==} | |
| 290 | + engines: {node: '>= 10'} | |
| 291 | + cpu: [arm64] | |
| 292 | + os: [darwin] | |
| 293 | + | |
| 294 | + '@next/swc-darwin-x64@15.5.23': | |
| 295 | + resolution: {integrity: sha512-f0FpFbG2EhDCuptBGcfrLcYMDuQAhe6m1QA4VVfXFrIBoFXvXt/olGbBkYkloKlXQtmhuzvtdYyuu/6zf07GIg==} | |
| 296 | + engines: {node: '>= 10'} | |
| 297 | + cpu: [x64] | |
| 298 | + os: [darwin] | |
| 299 | + | |
| 300 | + '@next/swc-linux-arm64-gnu@15.5.23': | |
| 301 | + resolution: {integrity: sha512-WlNtfepUXKX2u2ZsJZ8c3c8+tJSRZqsYzoMwLOY72A8ucKCCgxgNhiePA3qzFYahVWrwcQd8jOeJmBinc+VFVQ==} | |
| 302 | + engines: {node: '>= 10'} | |
| 303 | + cpu: [arm64] | |
| 304 | + os: [linux] | |
| 305 | + libc: [glibc] | |
| 306 | + | |
| 307 | + '@next/swc-linux-arm64-musl@15.5.23': | |
| 308 | + resolution: {integrity: sha512-W/6qKk7UG93mg14PmQC+2urt69MIdwTBLNQ6MJyeC4wOCIHCjz+VfgssvS1pK7mgYBtLC1g6VKNoHD9xB0WWGg==} | |
| 309 | + engines: {node: '>= 10'} | |
| 310 | + cpu: [arm64] | |
| 311 | + os: [linux] | |
| 312 | + libc: [musl] | |
| 313 | + | |
| 314 | + '@next/swc-linux-x64-gnu@15.5.23': | |
| 315 | + resolution: {integrity: sha512-vzefI32mi6VMk96RaTAyxApgfGbiFzQBXVsekEjsDv1fr48mlABTWx0sUYhaYCBHWqCalxmz3DxbxFcbFvzNtw==} | |
| 316 | + engines: {node: '>= 10'} | |
| 317 | + cpu: [x64] | |
| 318 | + os: [linux] | |
| 319 | + libc: [glibc] | |
| 320 | + | |
| 321 | + '@next/swc-linux-x64-musl@15.5.23': | |
| 322 | + resolution: {integrity: sha512-qppK/3dTGOTI+aoWWBZc3DshFIhrzgL8guATlaN9V6M1QJxbkP/rhEZ22tdICsQ/2WWXopMZ2Jokzj2u3uKY3Q==} | |
| 323 | + engines: {node: '>= 10'} | |
| 324 | + cpu: [x64] | |
| 325 | + os: [linux] | |
| 326 | + libc: [musl] | |
| 327 | + | |
| 328 | + '@next/swc-win32-arm64-msvc@15.5.23': | |
| 329 | + resolution: {integrity: sha512-Wc29KFOdT7XBcII3Vtmw7aoU8Uk3Mes/FNJfhFeSHdYBFJWMcR/DsI8U9BCPUhq/uycsUVuqSKGthW15tLsigA==} | |
| 330 | + engines: {node: '>= 10'} | |
| 331 | + cpu: [arm64] | |
| 332 | + os: [win32] | |
| 333 | + | |
| 334 | + '@next/swc-win32-x64-msvc@15.5.23': | |
| 335 | + resolution: {integrity: sha512-/C7wRW4fa9s/PKA18zGPPpVmx8ycgVpP8yOxro4gzGTzjPJdscbAP3ODeFvgiIovxD176Z2J/SXO9t8PJKHLeQ==} | |
| 336 | + engines: {node: '>= 10'} | |
| 337 | + cpu: [x64] | |
| 338 | + os: [win32] | |
| 339 | + | |
| 340 | + '@nodelib/fs.scandir@2.1.5': | |
| 341 | + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} | |
| 342 | + engines: {node: '>= 8'} | |
| 343 | + | |
| 344 | + '@nodelib/fs.stat@2.0.5': | |
| 345 | + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} | |
| 346 | + engines: {node: '>= 8'} | |
| 347 | + | |
| 348 | + '@nodelib/fs.walk@1.2.8': | |
| 349 | + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} | |
| 350 | + engines: {node: '>= 8'} | |
| 351 | + | |
| 352 | + '@nolyfill/is-core-module@1.0.39': | |
| 353 | + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} | |
| 354 | + engines: {node: '>=12.4.0'} | |
| 355 | + | |
| 356 | + '@rtsao/scc@1.1.0': | |
| 357 | + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} | |
| 358 | + | |
| 359 | + '@rushstack/eslint-patch@1.16.1': | |
| 360 | + resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} | |
| 361 | + | |
| 362 | + '@swc/helpers@0.5.15': | |
| 363 | + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} | |
| 364 | + | |
| 365 | + '@tybys/wasm-util@0.10.3': | |
| 366 | + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} | |
| 367 | + | |
| 368 | + '@types/json5@0.0.29': | |
| 369 | + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} | |
| 370 | + | |
| 371 | + '@types/node@22.20.1': | |
| 372 | + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} | |
| 373 | + | |
| 374 | + '@types/react-dom@19.2.4': | |
| 375 | + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} | |
| 376 | + peerDependencies: | |
| 377 | + '@types/react': ^19.2.0 | |
| 378 | + | |
| 379 | + '@types/react@19.2.18': | |
| 380 | + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} | |
| 381 | + | |
| 382 | + '@typescript-eslint/eslint-plugin@8.67.0': | |
| 383 | + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} | |
| 384 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 385 | + peerDependencies: | |
| 386 | + '@typescript-eslint/parser': ^8.67.0 | |
| 387 | + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 388 | + typescript: '>=4.8.4 <6.1.0' | |
| 389 | + | |
| 390 | + '@typescript-eslint/parser@8.67.0': | |
| 391 | + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} | |
| 392 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 393 | + peerDependencies: | |
| 394 | + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 395 | + typescript: '>=4.8.4 <6.1.0' | |
| 396 | + | |
| 397 | + '@typescript-eslint/project-service@8.67.0': | |
| 398 | + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} | |
| 399 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 400 | + peerDependencies: | |
| 401 | + typescript: '>=4.8.4 <6.1.0' | |
| 402 | + | |
| 403 | + '@typescript-eslint/scope-manager@8.67.0': | |
| 404 | + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} | |
| 405 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 406 | + | |
| 407 | + '@typescript-eslint/tsconfig-utils@8.67.0': | |
| 408 | + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} | |
| 409 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 410 | + peerDependencies: | |
| 411 | + typescript: '>=4.8.4 <6.1.0' | |
| 412 | + | |
| 413 | + '@typescript-eslint/type-utils@8.67.0': | |
| 414 | + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} | |
| 415 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 416 | + peerDependencies: | |
| 417 | + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 418 | + typescript: '>=4.8.4 <6.1.0' | |
| 419 | + | |
| 420 | + '@typescript-eslint/types@8.67.0': | |
| 421 | + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} | |
| 422 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 423 | + | |
| 424 | + '@typescript-eslint/typescript-estree@8.67.0': | |
| 425 | + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} | |
| 426 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 427 | + peerDependencies: | |
| 428 | + typescript: '>=4.8.4 <6.1.0' | |
| 429 | + | |
| 430 | + '@typescript-eslint/utils@8.67.0': | |
| 431 | + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} | |
| 432 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 433 | + peerDependencies: | |
| 434 | + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 435 | + typescript: '>=4.8.4 <6.1.0' | |
| 436 | + | |
| 437 | + '@typescript-eslint/visitor-keys@8.67.0': | |
| 438 | + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} | |
| 439 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 440 | + | |
| 441 | + '@ungap/structured-clone@1.3.3': | |
| 442 | + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} | |
| 443 | + | |
| 444 | + '@unrs/resolver-binding-android-arm-eabi@1.12.2': | |
| 445 | + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} | |
| 446 | + cpu: [arm] | |
| 447 | + os: [android] | |
| 448 | + | |
| 449 | + '@unrs/resolver-binding-android-arm64@1.12.2': | |
| 450 | + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} | |
| 451 | + cpu: [arm64] | |
| 452 | + os: [android] | |
| 453 | + | |
| 454 | + '@unrs/resolver-binding-darwin-arm64@1.12.2': | |
| 455 | + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} | |
| 456 | + cpu: [arm64] | |
| 457 | + os: [darwin] | |
| 458 | + | |
| 459 | + '@unrs/resolver-binding-darwin-x64@1.12.2': | |
| 460 | + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} | |
| 461 | + cpu: [x64] | |
| 462 | + os: [darwin] | |
| 463 | + | |
| 464 | + '@unrs/resolver-binding-freebsd-x64@1.12.2': | |
| 465 | + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} | |
| 466 | + cpu: [x64] | |
| 467 | + os: [freebsd] | |
| 468 | + | |
| 469 | + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': | |
| 470 | + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} | |
| 471 | + cpu: [arm] | |
| 472 | + os: [linux] | |
| 473 | + | |
| 474 | + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': | |
| 475 | + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} | |
| 476 | + cpu: [arm] | |
| 477 | + os: [linux] | |
| 478 | + | |
| 479 | + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': | |
| 480 | + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} | |
| 481 | + cpu: [arm64] | |
| 482 | + os: [linux] | |
| 483 | + libc: [glibc] | |
| 484 | + | |
| 485 | + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': | |
| 486 | + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} | |
| 487 | + cpu: [arm64] | |
| 488 | + os: [linux] | |
| 489 | + libc: [musl] | |
| 490 | + | |
| 491 | + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': | |
| 492 | + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} | |
| 493 | + cpu: [loong64] | |
| 494 | + os: [linux] | |
| 495 | + libc: [glibc] | |
| 496 | + | |
| 497 | + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': | |
| 498 | + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} | |
| 499 | + cpu: [loong64] | |
| 500 | + os: [linux] | |
| 501 | + libc: [musl] | |
| 502 | + | |
| 503 | + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': | |
| 504 | + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} | |
| 505 | + cpu: [ppc64] | |
| 506 | + os: [linux] | |
| 507 | + libc: [glibc] | |
| 508 | + | |
| 509 | + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': | |
| 510 | + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} | |
| 511 | + cpu: [riscv64] | |
| 512 | + os: [linux] | |
| 513 | + libc: [glibc] | |
| 514 | + | |
| 515 | + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': | |
| 516 | + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} | |
| 517 | + cpu: [riscv64] | |
| 518 | + os: [linux] | |
| 519 | + libc: [musl] | |
| 520 | + | |
| 521 | + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': | |
| 522 | + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} | |
| 523 | + cpu: [s390x] | |
| 524 | + os: [linux] | |
| 525 | + libc: [glibc] | |
| 526 | + | |
| 527 | + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': | |
| 528 | + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} | |
| 529 | + cpu: [x64] | |
| 530 | + os: [linux] | |
| 531 | + libc: [glibc] | |
| 532 | + | |
| 533 | + '@unrs/resolver-binding-linux-x64-musl@1.12.2': | |
| 534 | + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} | |
| 535 | + cpu: [x64] | |
| 536 | + os: [linux] | |
| 537 | + libc: [musl] | |
| 538 | + | |
| 539 | + '@unrs/resolver-binding-openharmony-arm64@1.12.2': | |
| 540 | + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} | |
| 541 | + cpu: [arm64] | |
| 542 | + os: [openharmony] | |
| 543 | + | |
| 544 | + '@unrs/resolver-binding-wasm32-wasi@1.12.2': | |
| 545 | + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} | |
| 546 | + engines: {node: '>=14.0.0'} | |
| 547 | + cpu: [wasm32] | |
| 548 | + | |
| 549 | + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': | |
| 550 | + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} | |
| 551 | + cpu: [arm64] | |
| 552 | + os: [win32] | |
| 553 | + | |
| 554 | + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': | |
| 555 | + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} | |
| 556 | + cpu: [ia32] | |
| 557 | + os: [win32] | |
| 558 | + | |
| 559 | + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': | |
| 560 | + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} | |
| 561 | + cpu: [x64] | |
| 562 | + os: [win32] | |
| 563 | + | |
| 564 | + acorn-jsx@5.3.2: | |
| 565 | + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} | |
| 566 | + peerDependencies: | |
| 567 | + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 | |
| 568 | + | |
| 569 | + acorn@8.18.0: | |
| 570 | + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} | |
| 571 | + engines: {node: '>=0.4.0'} | |
| 572 | + hasBin: true | |
| 573 | + | |
| 574 | + ajv@6.15.0: | |
| 575 | + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} | |
| 576 | + | |
| 577 | + ansi-regex@5.0.1: | |
| 578 | + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} | |
| 579 | + engines: {node: '>=8'} | |
| 580 | + | |
| 581 | + ansi-styles@4.3.0: | |
| 582 | + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} | |
| 583 | + engines: {node: '>=8'} | |
| 584 | + | |
| 585 | + any-promise@1.3.0: | |
| 586 | + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} | |
| 587 | + | |
| 588 | + anymatch@3.1.3: | |
| 589 | + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} | |
| 590 | + engines: {node: '>= 8'} | |
| 591 | + | |
| 592 | + arg@5.0.2: | |
| 593 | + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} | |
| 594 | + | |
| 595 | + argparse@2.0.1: | |
| 596 | + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} | |
| 597 | + | |
| 598 | + aria-query@5.3.2: | |
| 599 | + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} | |
| 600 | + engines: {node: '>= 0.4'} | |
| 601 | + | |
| 602 | + array-buffer-byte-length@1.0.2: | |
| 603 | + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} | |
| 604 | + engines: {node: '>= 0.4'} | |
| 605 | + | |
| 606 | + array-includes@3.1.9: | |
| 607 | + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} | |
| 608 | + engines: {node: '>= 0.4'} | |
| 609 | + | |
| 610 | + array.prototype.findlast@1.2.5: | |
| 611 | + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} | |
| 612 | + engines: {node: '>= 0.4'} | |
| 613 | + | |
| 614 | + array.prototype.findlastindex@1.2.6: | |
| 615 | + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} | |
| 616 | + engines: {node: '>= 0.4'} | |
| 617 | + | |
| 618 | + array.prototype.flat@1.3.3: | |
| 619 | + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} | |
| 620 | + engines: {node: '>= 0.4'} | |
| 621 | + | |
| 622 | + array.prototype.flatmap@1.3.3: | |
| 623 | + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} | |
| 624 | + engines: {node: '>= 0.4'} | |
| 625 | + | |
| 626 | + array.prototype.tosorted@1.1.4: | |
| 627 | + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} | |
| 628 | + engines: {node: '>= 0.4'} | |
| 629 | + | |
| 630 | + arraybuffer.prototype.slice@1.0.4: | |
| 631 | + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} | |
| 632 | + engines: {node: '>= 0.4'} | |
| 633 | + | |
| 634 | + ast-types-flow@0.0.8: | |
| 635 | + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} | |
| 636 | + | |
| 637 | + async-function@1.0.0: | |
| 638 | + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} | |
| 639 | + engines: {node: '>= 0.4'} | |
| 640 | + | |
| 641 | + autoprefixer@10.5.4: | |
| 642 | + resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} | |
| 643 | + engines: {node: ^10 || ^12 || >=14} | |
| 644 | + hasBin: true | |
| 645 | + peerDependencies: | |
| 646 | + postcss: ^8.1.0 | |
| 647 | + | |
| 648 | + available-typed-arrays@1.0.7: | |
| 649 | + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} | |
| 650 | + engines: {node: '>= 0.4'} | |
| 651 | + | |
| 652 | + axe-core@4.13.0: | |
| 653 | + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} | |
| 654 | + engines: {node: '>=4'} | |
| 655 | + | |
| 656 | + axobject-query@4.1.0: | |
| 657 | + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} | |
| 658 | + engines: {node: '>= 0.4'} | |
| 659 | + | |
| 660 | + balanced-match@1.0.2: | |
| 661 | + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} | |
| 662 | + | |
| 663 | + balanced-match@4.0.4: | |
| 664 | + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} | |
| 665 | + engines: {node: 18 || 20 || >=22} | |
| 666 | + | |
| 667 | + baseline-browser-mapping@2.11.13: | |
| 668 | + resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} | |
| 669 | + engines: {node: '>=6.0.0'} | |
| 670 | + hasBin: true | |
| 671 | + | |
| 672 | + binary-extensions@2.3.0: | |
| 673 | + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} | |
| 674 | + engines: {node: '>=8'} | |
| 675 | + | |
| 676 | + brace-expansion@1.1.18: | |
| 677 | + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} | |
| 678 | + | |
| 679 | + brace-expansion@5.0.9: | |
| 680 | + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} | |
| 681 | + engines: {node: 20 || >=22} | |
| 682 | + | |
| 683 | + braces@3.0.3: | |
| 684 | + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} | |
| 685 | + engines: {node: '>=8'} | |
| 686 | + | |
| 687 | + browserslist@4.28.8: | |
| 688 | + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} | |
| 689 | + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} | |
| 690 | + hasBin: true | |
| 691 | + | |
| 692 | + call-bind-apply-helpers@1.0.2: | |
| 693 | + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} | |
| 694 | + engines: {node: '>= 0.4'} | |
| 695 | + | |
| 696 | + call-bind@1.0.9: | |
| 697 | + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} | |
| 698 | + engines: {node: '>= 0.4'} | |
| 699 | + | |
| 700 | + call-bound@1.0.4: | |
| 701 | + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} | |
| 702 | + engines: {node: '>= 0.4'} | |
| 703 | + | |
| 704 | + callsites@3.1.0: | |
| 705 | + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} | |
| 706 | + engines: {node: '>=6'} | |
| 707 | + | |
| 708 | + camelcase-css@2.0.1: | |
| 709 | + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} | |
| 710 | + engines: {node: '>= 6'} | |
| 711 | + | |
| 712 | + caniuse-lite@1.0.30001809: | |
| 713 | + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} | |
| 714 | + | |
| 715 | + chalk@4.1.2: | |
| 716 | + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} | |
| 717 | + engines: {node: '>=10'} | |
| 718 | + | |
| 719 | + chokidar@3.6.0: | |
| 720 | + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} | |
| 721 | + engines: {node: '>= 8.10.0'} | |
| 722 | + | |
| 723 | + class-variance-authority@0.7.1: | |
| 724 | + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} | |
| 725 | + | |
| 726 | + client-only@0.0.1: | |
| 727 | + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} | |
| 728 | + | |
| 729 | + clsx@2.1.1: | |
| 730 | + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} | |
| 731 | + engines: {node: '>=6'} | |
| 732 | + | |
| 733 | + color-convert@2.0.1: | |
| 734 | + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} | |
| 735 | + engines: {node: '>=7.0.0'} | |
| 736 | + | |
| 737 | + color-name@1.1.4: | |
| 738 | + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} | |
| 739 | + | |
| 740 | + commander@4.1.1: | |
| 741 | + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} | |
| 742 | + engines: {node: '>= 6'} | |
| 743 | + | |
| 744 | + concat-map@0.0.1: | |
| 745 | + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} | |
| 746 | + | |
| 747 | + cross-spawn@7.0.6: | |
| 748 | + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} | |
| 749 | + engines: {node: '>= 8'} | |
| 750 | + | |
| 751 | + cssesc@3.0.0: | |
| 752 | + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} | |
| 753 | + engines: {node: '>=4'} | |
| 754 | + hasBin: true | |
| 755 | + | |
| 756 | + csstype@3.2.3: | |
| 757 | + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} | |
| 758 | + | |
| 759 | + damerau-levenshtein@1.0.8: | |
| 760 | + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} | |
| 761 | + | |
| 762 | + data-view-buffer@1.0.2: | |
| 763 | + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} | |
| 764 | + engines: {node: '>= 0.4'} | |
| 765 | + | |
| 766 | + data-view-byte-length@1.0.2: | |
| 767 | + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} | |
| 768 | + engines: {node: '>= 0.4'} | |
| 769 | + | |
| 770 | + data-view-byte-offset@1.0.1: | |
| 771 | + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} | |
| 772 | + engines: {node: '>= 0.4'} | |
| 773 | + | |
| 774 | + debug@3.2.7: | |
| 775 | + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} | |
| 776 | + peerDependencies: | |
| 777 | + supports-color: '*' | |
| 778 | + peerDependenciesMeta: | |
| 779 | + supports-color: | |
| 780 | + optional: true | |
| 781 | + | |
| 782 | + debug@4.4.3: | |
| 783 | + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} | |
| 784 | + engines: {node: '>=6.0'} | |
| 785 | + peerDependencies: | |
| 786 | + supports-color: '*' | |
| 787 | + peerDependenciesMeta: | |
| 788 | + supports-color: | |
| 789 | + optional: true | |
| 790 | + | |
| 791 | + deep-is@0.1.4: | |
| 792 | + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} | |
| 793 | + | |
| 794 | + define-data-property@1.1.4: | |
| 795 | + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} | |
| 796 | + engines: {node: '>= 0.4'} | |
| 797 | + | |
| 798 | + define-properties@1.2.1: | |
| 799 | + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} | |
| 800 | + engines: {node: '>= 0.4'} | |
| 801 | + | |
| 802 | + detect-libc@2.1.2: | |
| 803 | + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} | |
| 804 | + engines: {node: '>=8'} | |
| 805 | + | |
| 806 | + didyoumean@1.2.2: | |
| 807 | + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} | |
| 808 | + | |
| 809 | + dlv@1.1.3: | |
| 810 | + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} | |
| 811 | + | |
| 812 | + doctrine@2.1.0: | |
| 813 | + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} | |
| 814 | + engines: {node: '>=0.10.0'} | |
| 815 | + | |
| 816 | + doctrine@3.0.0: | |
| 817 | + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} | |
| 818 | + engines: {node: '>=6.0.0'} | |
| 819 | + | |
| 820 | + dunder-proto@1.0.1: | |
| 821 | + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} | |
| 822 | + engines: {node: '>= 0.4'} | |
| 823 | + | |
| 824 | + electron-to-chromium@1.5.405: | |
| 825 | + resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==} | |
| 826 | + | |
| 827 | + emoji-regex@9.2.2: | |
| 828 | + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} | |
| 829 | + | |
| 830 | + es-abstract-get@1.0.0: | |
| 831 | + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} | |
| 832 | + engines: {node: '>= 0.4'} | |
| 833 | + | |
| 834 | + es-abstract@1.24.2: | |
| 835 | + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} | |
| 836 | + engines: {node: '>= 0.4'} | |
| 837 | + | |
| 838 | + es-define-property@1.0.1: | |
| 839 | + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} | |
| 840 | + engines: {node: '>= 0.4'} | |
| 841 | + | |
| 842 | + es-errors@1.3.0: | |
| 843 | + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} | |
| 844 | + engines: {node: '>= 0.4'} | |
| 845 | + | |
| 846 | + es-iterator-helpers@1.4.0: | |
| 847 | + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} | |
| 848 | + engines: {node: '>= 0.4'} | |
| 849 | + | |
| 850 | + es-object-atoms@1.1.2: | |
| 851 | + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} | |
| 852 | + engines: {node: '>= 0.4'} | |
| 853 | + | |
| 854 | + es-set-tostringtag@2.1.0: | |
| 855 | + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} | |
| 856 | + engines: {node: '>= 0.4'} | |
| 857 | + | |
| 858 | + es-shim-unscopables@1.1.0: | |
| 859 | + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} | |
| 860 | + engines: {node: '>= 0.4'} | |
| 861 | + | |
| 862 | + es-to-primitive@1.3.4: | |
| 863 | + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} | |
| 864 | + engines: {node: '>= 0.4'} | |
| 865 | + | |
| 866 | + escalade@3.2.0: | |
| 867 | + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} | |
| 868 | + engines: {node: '>=6'} | |
| 869 | + | |
| 870 | + escape-string-regexp@4.0.0: | |
| 871 | + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} | |
| 872 | + engines: {node: '>=10'} | |
| 873 | + | |
| 874 | + eslint-config-next@15.5.23: | |
| 875 | + resolution: {integrity: sha512-z4WcTXNqFHwMG4V8WHb2xrlEPJvwarZa+H/6CR28vxr53icRnQzGXviO11p748BwrMZGl52itdPRzZfzYo0SKw==} | |
| 876 | + peerDependencies: | |
| 877 | + eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 | |
| 878 | + typescript: '>=3.3.1' | |
| 879 | + peerDependenciesMeta: | |
| 880 | + typescript: | |
| 881 | + optional: true | |
| 882 | + | |
| 883 | + eslint-import-resolver-node@0.3.10: | |
| 884 | + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} | |
| 885 | + | |
| 886 | + eslint-import-resolver-typescript@3.10.1: | |
| 887 | + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} | |
| 888 | + engines: {node: ^14.18.0 || >=16.0.0} | |
| 889 | + peerDependencies: | |
| 890 | + eslint: '*' | |
| 891 | + eslint-plugin-import: '*' | |
| 892 | + eslint-plugin-import-x: '*' | |
| 893 | + peerDependenciesMeta: | |
| 894 | + eslint-plugin-import: | |
| 895 | + optional: true | |
| 896 | + eslint-plugin-import-x: | |
| 897 | + optional: true | |
| 898 | + | |
| 899 | + eslint-module-utils@2.14.0: | |
| 900 | + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} | |
| 901 | + engines: {node: '>=4'} | |
| 902 | + peerDependencies: | |
| 903 | + '@typescript-eslint/parser': '*' | |
| 904 | + eslint: '*' | |
| 905 | + eslint-import-resolver-node: '*' | |
| 906 | + eslint-import-resolver-typescript: '*' | |
| 907 | + eslint-import-resolver-webpack: '*' | |
| 908 | + peerDependenciesMeta: | |
| 909 | + '@typescript-eslint/parser': | |
| 910 | + optional: true | |
| 911 | + eslint: | |
| 912 | + optional: true | |
| 913 | + eslint-import-resolver-node: | |
| 914 | + optional: true | |
| 915 | + eslint-import-resolver-typescript: | |
| 916 | + optional: true | |
| 917 | + eslint-import-resolver-webpack: | |
| 918 | + optional: true | |
| 919 | + | |
| 920 | + eslint-plugin-import@2.32.0: | |
| 921 | + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} | |
| 922 | + engines: {node: '>=4'} | |
| 923 | + peerDependencies: | |
| 924 | + '@typescript-eslint/parser': '*' | |
| 925 | + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 | |
| 926 | + peerDependenciesMeta: | |
| 927 | + '@typescript-eslint/parser': | |
| 928 | + optional: true | |
| 929 | + | |
| 930 | + eslint-plugin-jsx-a11y@6.10.2: | |
| 931 | + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} | |
| 932 | + engines: {node: '>=4.0'} | |
| 933 | + peerDependencies: | |
| 934 | + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 | |
| 935 | + | |
| 936 | + eslint-plugin-react-hooks@5.2.0: | |
| 937 | + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} | |
| 938 | + engines: {node: '>=10'} | |
| 939 | + peerDependencies: | |
| 940 | + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 | |
| 941 | + | |
| 942 | + eslint-plugin-react@7.37.5: | |
| 943 | + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} | |
| 944 | + engines: {node: '>=4'} | |
| 945 | + peerDependencies: | |
| 946 | + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 | |
| 947 | + | |
| 948 | + eslint-scope@7.2.2: | |
| 949 | + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} | |
| 950 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 951 | + | |
| 952 | + eslint-visitor-keys@3.4.3: | |
| 953 | + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} | |
| 954 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 955 | + | |
| 956 | + eslint-visitor-keys@5.0.1: | |
| 957 | + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} | |
| 958 | + engines: {node: ^20.19.0 || ^22.13.0 || >=24} | |
| 959 | + | |
| 960 | + eslint@8.57.1: | |
| 961 | + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} | |
| 962 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 963 | + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. | |
| 964 | + hasBin: true | |
| 965 | + | |
| 966 | + espree@9.6.1: | |
| 967 | + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} | |
| 968 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 969 | + | |
| 970 | + esquery@1.7.0: | |
| 971 | + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} | |
| 972 | + engines: {node: '>=0.10'} | |
| 973 | + | |
| 974 | + esrecurse@4.3.0: | |
| 975 | + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} | |
| 976 | + engines: {node: '>=4.0'} | |
| 977 | + | |
| 978 | + estraverse@5.3.0: | |
| 979 | + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} | |
| 980 | + engines: {node: '>=4.0'} | |
| 981 | + | |
| 982 | + esutils@2.0.3: | |
| 983 | + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} | |
| 984 | + engines: {node: '>=0.10.0'} | |
| 985 | + | |
| 986 | + fast-deep-equal@3.1.3: | |
| 987 | + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} | |
| 988 | + | |
| 989 | + fast-glob@3.3.1: | |
| 990 | + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} | |
| 991 | + engines: {node: '>=8.6.0'} | |
| 992 | + | |
| 993 | + fast-glob@3.3.3: | |
| 994 | + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} | |
| 995 | + engines: {node: '>=8.6.0'} | |
| 996 | + | |
| 997 | + fast-json-stable-stringify@2.1.0: | |
| 998 | + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} | |
| 999 | + | |
| 1000 | + fast-levenshtein@2.0.6: | |
| 1001 | + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} | |
| 1002 | + | |
| 1003 | + fastq@1.20.1: | |
| 1004 | + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} | |
| 1005 | + | |
| 1006 | + fdir@6.5.0: | |
| 1007 | + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} | |
| 1008 | + engines: {node: '>=12.0.0'} | |
| 1009 | + peerDependencies: | |
| 1010 | + picomatch: ^3 || ^4 | |
| 1011 | + peerDependenciesMeta: | |
| 1012 | + picomatch: | |
| 1013 | + optional: true | |
| 1014 | + | |
| 1015 | + file-entry-cache@6.0.1: | |
| 1016 | + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} | |
| 1017 | + engines: {node: ^10.12.0 || >=12.0.0} | |
| 1018 | + | |
| 1019 | + fill-range@7.1.1: | |
| 1020 | + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} | |
| 1021 | + engines: {node: '>=8'} | |
| 1022 | + | |
| 1023 | + find-up@5.0.0: | |
| 1024 | + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} | |
| 1025 | + engines: {node: '>=10'} | |
| 1026 | + | |
| 1027 | + flat-cache@3.2.0: | |
| 1028 | + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} | |
| 1029 | + engines: {node: ^10.12.0 || >=12.0.0} | |
| 1030 | + | |
| 1031 | + flatted@3.4.4: | |
| 1032 | + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} | |
| 1033 | + | |
| 1034 | + for-each@0.3.5: | |
| 1035 | + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} | |
| 1036 | + engines: {node: '>= 0.4'} | |
| 1037 | + | |
| 1038 | + fraction.js@5.3.4: | |
| 1039 | + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} | |
| 1040 | + | |
| 1041 | + fs.realpath@1.0.0: | |
| 1042 | + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} | |
| 1043 | + | |
| 1044 | + fsevents@2.3.3: | |
| 1045 | + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} | |
| 1046 | + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} | |
| 1047 | + os: [darwin] | |
| 1048 | + | |
| 1049 | + function-bind@1.1.2: | |
| 1050 | + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} | |
| 1051 | + | |
| 1052 | + function.prototype.name@1.2.0: | |
| 1053 | + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} | |
| 1054 | + engines: {node: '>= 0.4'} | |
| 1055 | + | |
| 1056 | + functions-have-names@1.2.3: | |
| 1057 | + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} | |
| 1058 | + | |
| 1059 | + generator-function@2.0.1: | |
| 1060 | + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} | |
| 1061 | + engines: {node: '>= 0.4'} | |
| 1062 | + | |
| 1063 | + get-intrinsic@1.3.0: | |
| 1064 | + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} | |
| 1065 | + engines: {node: '>= 0.4'} | |
| 1066 | + | |
| 1067 | + get-proto@1.0.1: | |
| 1068 | + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} | |
| 1069 | + engines: {node: '>= 0.4'} | |
| 1070 | + | |
| 1071 | + get-symbol-description@1.1.0: | |
| 1072 | + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} | |
| 1073 | + engines: {node: '>= 0.4'} | |
| 1074 | + | |
| 1075 | + get-tsconfig@4.14.2: | |
| 1076 | + resolution: {integrity: sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==} | |
| 1077 | + | |
| 1078 | + glob-parent@5.1.2: | |
| 1079 | + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} | |
| 1080 | + engines: {node: '>= 6'} | |
| 1081 | + | |
| 1082 | + glob-parent@6.0.2: | |
| 1083 | + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} | |
| 1084 | + engines: {node: '>=10.13.0'} | |
| 1085 | + | |
| 1086 | + glob@7.2.3: | |
| 1087 | + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} | |
| 1088 | + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me | |
| 1089 | + | |
| 1090 | + globals@13.24.0: | |
| 1091 | + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} | |
| 1092 | + engines: {node: '>=8'} | |
| 1093 | + | |
| 1094 | + globalthis@1.0.4: | |
| 1095 | + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} | |
| 1096 | + engines: {node: '>= 0.4'} | |
| 1097 | + | |
| 1098 | + gopd@1.2.0: | |
| 1099 | + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} | |
| 1100 | + engines: {node: '>= 0.4'} | |
| 1101 | + | |
| 1102 | + graphemer@1.4.0: | |
| 1103 | + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} | |
| 1104 | + | |
| 1105 | + has-bigints@1.1.0: | |
| 1106 | + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} | |
| 1107 | + engines: {node: '>= 0.4'} | |
| 1108 | + | |
| 1109 | + has-flag@4.0.0: | |
| 1110 | + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} | |
| 1111 | + engines: {node: '>=8'} | |
| 1112 | + | |
| 1113 | + has-property-descriptors@1.0.2: | |
| 1114 | + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} | |
| 1115 | + | |
| 1116 | + has-proto@1.2.0: | |
| 1117 | + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} | |
| 1118 | + engines: {node: '>= 0.4'} | |
| 1119 | + | |
| 1120 | + has-symbols@1.1.0: | |
| 1121 | + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} | |
| 1122 | + engines: {node: '>= 0.4'} | |
| 1123 | + | |
| 1124 | + has-tostringtag@1.0.2: | |
| 1125 | + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} | |
| 1126 | + engines: {node: '>= 0.4'} | |
| 1127 | + | |
| 1128 | + hasown@2.0.4: | |
| 1129 | + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} | |
| 1130 | + engines: {node: '>= 0.4'} | |
| 1131 | + | |
| 1132 | + ignore@5.3.2: | |
| 1133 | + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} | |
| 1134 | + engines: {node: '>= 4'} | |
| 1135 | + | |
| 1136 | + ignore@7.0.6: | |
| 1137 | + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} | |
| 1138 | + engines: {node: '>= 4'} | |
| 1139 | + | |
| 1140 | + import-fresh@3.3.1: | |
| 1141 | + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} | |
| 1142 | + engines: {node: '>=6'} | |
| 1143 | + | |
| 1144 | + imurmurhash@0.1.4: | |
| 1145 | + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} | |
| 1146 | + engines: {node: '>=0.8.19'} | |
| 1147 | + | |
| 1148 | + inflight@1.0.6: | |
| 1149 | + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} | |
| 1150 | + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. | |
| 1151 | + | |
| 1152 | + inherits@2.0.4: | |
| 1153 | + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} | |
| 1154 | + | |
| 1155 | + internal-slot@1.1.0: | |
| 1156 | + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} | |
| 1157 | + engines: {node: '>= 0.4'} | |
| 1158 | + | |
| 1159 | + is-array-buffer@3.0.5: | |
| 1160 | + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} | |
| 1161 | + engines: {node: '>= 0.4'} | |
| 1162 | + | |
| 1163 | + is-async-function@2.1.1: | |
| 1164 | + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} | |
| 1165 | + engines: {node: '>= 0.4'} | |
| 1166 | + | |
| 1167 | + is-bigint@1.1.0: | |
| 1168 | + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} | |
| 1169 | + engines: {node: '>= 0.4'} | |
| 1170 | + | |
| 1171 | + is-binary-path@2.1.0: | |
| 1172 | + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} | |
| 1173 | + engines: {node: '>=8'} | |
| 1174 | + | |
| 1175 | + is-boolean-object@1.2.2: | |
| 1176 | + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} | |
| 1177 | + engines: {node: '>= 0.4'} | |
| 1178 | + | |
| 1179 | + is-bun-module@2.0.0: | |
| 1180 | + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} | |
| 1181 | + | |
| 1182 | + is-callable@1.2.7: | |
| 1183 | + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} | |
| 1184 | + engines: {node: '>= 0.4'} | |
| 1185 | + | |
| 1186 | + is-core-module@2.16.2: | |
| 1187 | + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} | |
| 1188 | + engines: {node: '>= 0.4'} | |
| 1189 | + | |
| 1190 | + is-data-view@1.0.2: | |
| 1191 | + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} | |
| 1192 | + engines: {node: '>= 0.4'} | |
| 1193 | + | |
| 1194 | + is-date-object@1.1.0: | |
| 1195 | + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} | |
| 1196 | + engines: {node: '>= 0.4'} | |
| 1197 | + | |
| 1198 | + is-document.all@1.0.0: | |
| 1199 | + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} | |
| 1200 | + engines: {node: '>= 0.4'} | |
| 1201 | + | |
| 1202 | + is-extglob@2.1.1: | |
| 1203 | + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} | |
| 1204 | + engines: {node: '>=0.10.0'} | |
| 1205 | + | |
| 1206 | + is-finalizationregistry@1.1.1: | |
| 1207 | + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} | |
| 1208 | + engines: {node: '>= 0.4'} | |
| 1209 | + | |
| 1210 | + is-generator-function@1.1.2: | |
| 1211 | + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} | |
| 1212 | + engines: {node: '>= 0.4'} | |
| 1213 | + | |
| 1214 | + is-glob@4.0.3: | |
| 1215 | + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} | |
| 1216 | + engines: {node: '>=0.10.0'} | |
| 1217 | + | |
| 1218 | + is-map@2.0.3: | |
| 1219 | + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} | |
| 1220 | + engines: {node: '>= 0.4'} | |
| 1221 | + | |
| 1222 | + is-negative-zero@2.0.3: | |
| 1223 | + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} | |
| 1224 | + engines: {node: '>= 0.4'} | |
| 1225 | + | |
| 1226 | + is-number-object@1.1.1: | |
| 1227 | + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} | |
| 1228 | + engines: {node: '>= 0.4'} | |
| 1229 | + | |
| 1230 | + is-number@7.0.0: | |
| 1231 | + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} | |
| 1232 | + engines: {node: '>=0.12.0'} | |
| 1233 | + | |
| 1234 | + is-path-inside@3.0.3: | |
| 1235 | + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} | |
| 1236 | + engines: {node: '>=8'} | |
| 1237 | + | |
| 1238 | + is-regex@1.2.1: | |
| 1239 | + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} | |
| 1240 | + engines: {node: '>= 0.4'} | |
| 1241 | + | |
| 1242 | + is-set@2.0.3: | |
| 1243 | + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} | |
| 1244 | + engines: {node: '>= 0.4'} | |
| 1245 | + | |
| 1246 | + is-shared-array-buffer@1.0.4: | |
| 1247 | + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} | |
| 1248 | + engines: {node: '>= 0.4'} | |
| 1249 | + | |
| 1250 | + is-string@1.1.1: | |
| 1251 | + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} | |
| 1252 | + engines: {node: '>= 0.4'} | |
| 1253 | + | |
| 1254 | + is-symbol@1.1.1: | |
| 1255 | + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} | |
| 1256 | + engines: {node: '>= 0.4'} | |
| 1257 | + | |
| 1258 | + is-typed-array@1.1.15: | |
| 1259 | + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} | |
| 1260 | + engines: {node: '>= 0.4'} | |
| 1261 | + | |
| 1262 | + is-weakmap@2.0.2: | |
| 1263 | + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} | |
| 1264 | + engines: {node: '>= 0.4'} | |
| 1265 | + | |
| 1266 | + is-weakref@1.1.1: | |
| 1267 | + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} | |
| 1268 | + engines: {node: '>= 0.4'} | |
| 1269 | + | |
| 1270 | + is-weakset@2.0.4: | |
| 1271 | + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} | |
| 1272 | + engines: {node: '>= 0.4'} | |
| 1273 | + | |
| 1274 | + isarray@2.0.5: | |
| 1275 | + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} | |
| 1276 | + | |
| 1277 | + isexe@2.0.0: | |
| 1278 | + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} | |
| 1279 | + | |
| 1280 | + iterator.prototype@1.1.5: | |
| 1281 | + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} | |
| 1282 | + engines: {node: '>= 0.4'} | |
| 1283 | + | |
| 1284 | + jiti@1.21.7: | |
| 1285 | + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} | |
| 1286 | + hasBin: true | |
| 1287 | + | |
| 1288 | + js-tokens@4.0.0: | |
| 1289 | + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} | |
| 1290 | + | |
| 1291 | + js-yaml@4.3.1: | |
| 1292 | + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} | |
| 1293 | + hasBin: true | |
| 1294 | + | |
| 1295 | + json-buffer@3.0.1: | |
| 1296 | + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} | |
| 1297 | + | |
| 1298 | + json-schema-traverse@0.4.1: | |
| 1299 | + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} | |
| 1300 | + | |
| 1301 | + json-stable-stringify-without-jsonify@1.0.1: | |
| 1302 | + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} | |
| 1303 | + | |
| 1304 | + json5@1.0.2: | |
| 1305 | + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} | |
| 1306 | + hasBin: true | |
| 1307 | + | |
| 1308 | + jsx-ast-utils@3.3.5: | |
| 1309 | + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} | |
| 1310 | + engines: {node: '>=4.0'} | |
| 1311 | + | |
| 1312 | + keyv@4.5.4: | |
| 1313 | + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} | |
| 1314 | + | |
| 1315 | + language-subtag-registry@0.3.23: | |
| 1316 | + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} | |
| 1317 | + | |
| 1318 | + language-tags@1.0.9: | |
| 1319 | + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} | |
| 1320 | + engines: {node: '>=0.10'} | |
| 1321 | + | |
| 1322 | + levn@0.4.1: | |
| 1323 | + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} | |
| 1324 | + engines: {node: '>= 0.8.0'} | |
| 1325 | + | |
| 1326 | + lilconfig@3.1.3: | |
| 1327 | + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} | |
| 1328 | + engines: {node: '>=14'} | |
| 1329 | + | |
| 1330 | + lines-and-columns@1.2.4: | |
| 1331 | + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} | |
| 1332 | + | |
| 1333 | + locate-path@6.0.0: | |
| 1334 | + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} | |
| 1335 | + engines: {node: '>=10'} | |
| 1336 | + | |
| 1337 | + lodash.merge@4.6.2: | |
| 1338 | + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} | |
| 1339 | + | |
| 1340 | + loose-envify@1.4.0: | |
| 1341 | + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} | |
| 1342 | + hasBin: true | |
| 1343 | + | |
| 1344 | + math-intrinsics@1.1.0: | |
| 1345 | + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} | |
| 1346 | + engines: {node: '>= 0.4'} | |
| 1347 | + | |
| 1348 | + merge2@1.4.1: | |
| 1349 | + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} | |
| 1350 | + engines: {node: '>= 8'} | |
| 1351 | + | |
| 1352 | + micromatch@4.0.8: | |
| 1353 | + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} | |
| 1354 | + engines: {node: '>=8.6'} | |
| 1355 | + | |
| 1356 | + minimatch@10.2.6: | |
| 1357 | + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} | |
| 1358 | + engines: {node: 18 || 20 || >=22} | |
| 1359 | + | |
| 1360 | + minimatch@3.1.5: | |
| 1361 | + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} | |
| 1362 | + | |
| 1363 | + minimist@1.2.8: | |
| 1364 | + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} | |
| 1365 | + | |
| 1366 | + ms@2.1.3: | |
| 1367 | + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} | |
| 1368 | + | |
| 1369 | + mz@2.7.0: | |
| 1370 | + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} | |
| 1371 | + | |
| 1372 | + nanoid@3.3.18: | |
| 1373 | + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} | |
| 1374 | + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} | |
| 1375 | + hasBin: true | |
| 1376 | + | |
| 1377 | + napi-postinstall@0.3.4: | |
| 1378 | + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} | |
| 1379 | + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} | |
| 1380 | + hasBin: true | |
| 1381 | + | |
| 1382 | + natural-compare@1.4.0: | |
| 1383 | + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} | |
| 1384 | + | |
| 1385 | + next@15.5.23: | |
| 1386 | + resolution: {integrity: sha512-Gvd2WKgvxIXCGotxcI1im/Uf3rS3J3oZGw0g/uskg6AVBZhyE3aAbujkYWzS3xLmEPEtTLfkaVQUKK0KMTSIkA==} | |
| 1387 | + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} | |
| 1388 | + hasBin: true | |
| 1389 | + peerDependencies: | |
| 1390 | + '@opentelemetry/api': ^1.1.0 | |
| 1391 | + '@playwright/test': ^1.51.1 | |
| 1392 | + babel-plugin-react-compiler: '*' | |
| 1393 | + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 | |
| 1394 | + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 | |
| 1395 | + sass: ^1.3.0 | |
| 1396 | + peerDependenciesMeta: | |
| 1397 | + '@opentelemetry/api': | |
| 1398 | + optional: true | |
| 1399 | + '@playwright/test': | |
| 1400 | + optional: true | |
| 1401 | + babel-plugin-react-compiler: | |
| 1402 | + optional: true | |
| 1403 | + sass: | |
| 1404 | + optional: true | |
| 1405 | + | |
| 1406 | + node-exports-info@1.6.2: | |
| 1407 | + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} | |
| 1408 | + engines: {node: '>= 0.4'} | |
| 1409 | + | |
| 1410 | + node-releases@2.0.53: | |
| 1411 | + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} | |
| 1412 | + engines: {node: '>=18'} | |
| 1413 | + | |
| 1414 | + normalize-path@3.0.0: | |
| 1415 | + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} | |
| 1416 | + engines: {node: '>=0.10.0'} | |
| 1417 | + | |
| 1418 | + object-assign@4.1.1: | |
| 1419 | + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} | |
| 1420 | + engines: {node: '>=0.10.0'} | |
| 1421 | + | |
| 1422 | + object-hash@3.0.0: | |
| 1423 | + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} | |
| 1424 | + engines: {node: '>= 6'} | |
| 1425 | + | |
| 1426 | + object-inspect@1.13.4: | |
| 1427 | + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} | |
| 1428 | + engines: {node: '>= 0.4'} | |
| 1429 | + | |
| 1430 | + object-keys@1.1.1: | |
| 1431 | + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} | |
| 1432 | + engines: {node: '>= 0.4'} | |
| 1433 | + | |
| 1434 | + object.assign@4.1.7: | |
| 1435 | + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} | |
| 1436 | + engines: {node: '>= 0.4'} | |
| 1437 | + | |
| 1438 | + object.entries@1.1.9: | |
| 1439 | + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} | |
| 1440 | + engines: {node: '>= 0.4'} | |
| 1441 | + | |
| 1442 | + object.fromentries@2.0.8: | |
| 1443 | + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} | |
| 1444 | + engines: {node: '>= 0.4'} | |
| 1445 | + | |
| 1446 | + object.groupby@1.0.3: | |
| 1447 | + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} | |
| 1448 | + engines: {node: '>= 0.4'} | |
| 1449 | + | |
| 1450 | + object.values@1.2.1: | |
| 1451 | + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} | |
| 1452 | + engines: {node: '>= 0.4'} | |
| 1453 | + | |
| 1454 | + once@1.4.0: | |
| 1455 | + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} | |
| 1456 | + | |
| 1457 | + optionator@0.9.4: | |
| 1458 | + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} | |
| 1459 | + engines: {node: '>= 0.8.0'} | |
| 1460 | + | |
| 1461 | + own-keys@1.0.2: | |
| 1462 | + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} | |
| 1463 | + engines: {node: '>= 0.4'} | |
| 1464 | + | |
| 1465 | + p-limit@3.1.0: | |
| 1466 | + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} | |
| 1467 | + engines: {node: '>=10'} | |
| 1468 | + | |
| 1469 | + p-locate@5.0.0: | |
| 1470 | + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} | |
| 1471 | + engines: {node: '>=10'} | |
| 1472 | + | |
| 1473 | + parent-module@1.0.1: | |
| 1474 | + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} | |
| 1475 | + engines: {node: '>=6'} | |
| 1476 | + | |
| 1477 | + path-exists@4.0.0: | |
| 1478 | + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} | |
| 1479 | + engines: {node: '>=8'} | |
| 1480 | + | |
| 1481 | + path-is-absolute@1.0.1: | |
| 1482 | + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} | |
| 1483 | + engines: {node: '>=0.10.0'} | |
| 1484 | + | |
| 1485 | + path-key@3.1.1: | |
| 1486 | + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} | |
| 1487 | + engines: {node: '>=8'} | |
| 1488 | + | |
| 1489 | + path-parse@1.0.7: | |
| 1490 | + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} | |
| 1491 | + | |
| 1492 | + picocolors@1.1.1: | |
| 1493 | + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} | |
| 1494 | + | |
| 1495 | + picomatch@2.3.2: | |
| 1496 | + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} | |
| 1497 | + engines: {node: '>=8.6'} | |
| 1498 | + | |
| 1499 | + picomatch@4.0.5: | |
| 1500 | + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} | |
| 1501 | + engines: {node: '>=12'} | |
| 1502 | + | |
| 1503 | + pify@2.3.0: | |
| 1504 | + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} | |
| 1505 | + engines: {node: '>=0.10.0'} | |
| 1506 | + | |
| 1507 | + pirates@4.0.7: | |
| 1508 | + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} | |
| 1509 | + engines: {node: '>= 6'} | |
| 1510 | + | |
| 1511 | + possible-typed-array-names@1.1.0: | |
| 1512 | + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} | |
| 1513 | + engines: {node: '>= 0.4'} | |
| 1514 | + | |
| 1515 | + postcss-import@15.1.0: | |
| 1516 | + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} | |
| 1517 | + engines: {node: '>=14.0.0'} | |
| 1518 | + peerDependencies: | |
| 1519 | + postcss: ^8.0.0 | |
| 1520 | + | |
| 1521 | + postcss-js@4.1.0: | |
| 1522 | + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} | |
| 1523 | + engines: {node: ^12 || ^14 || >= 16} | |
| 1524 | + peerDependencies: | |
| 1525 | + postcss: ^8.4.21 | |
| 1526 | + | |
| 1527 | + postcss-load-config@6.0.1: | |
| 1528 | + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} | |
| 1529 | + engines: {node: '>= 18'} | |
| 1530 | + peerDependencies: | |
| 1531 | + jiti: '>=1.21.0' | |
| 1532 | + postcss: '>=8.0.9' | |
| 1533 | + tsx: ^4.8.1 | |
| 1534 | + yaml: ^2.4.2 | |
| 1535 | + peerDependenciesMeta: | |
| 1536 | + jiti: | |
| 1537 | + optional: true | |
| 1538 | + postcss: | |
| 1539 | + optional: true | |
| 1540 | + tsx: | |
| 1541 | + optional: true | |
| 1542 | + yaml: | |
| 1543 | + optional: true | |
| 1544 | + | |
| 1545 | + postcss-nested@6.2.0: | |
| 1546 | + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} | |
| 1547 | + engines: {node: '>=12.0'} | |
| 1548 | + peerDependencies: | |
| 1549 | + postcss: ^8.2.14 | |
| 1550 | + | |
| 1551 | + postcss-selector-parser@6.1.4: | |
| 1552 | + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} | |
| 1553 | + engines: {node: '>=4'} | |
| 1554 | + | |
| 1555 | + postcss-value-parser@4.2.0: | |
| 1556 | + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} | |
| 1557 | + | |
| 1558 | + postcss@8.4.31: | |
| 1559 | + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} | |
| 1560 | + engines: {node: ^10 || ^12 || >=14} | |
| 1561 | + | |
| 1562 | + postcss@8.5.26: | |
| 1563 | + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} | |
| 1564 | + engines: {node: ^10 || ^12 || >=14} | |
| 1565 | + | |
| 1566 | + prelude-ls@1.2.1: | |
| 1567 | + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} | |
| 1568 | + engines: {node: '>= 0.8.0'} | |
| 1569 | + | |
| 1570 | + prop-types@15.8.1: | |
| 1571 | + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} | |
| 1572 | + | |
| 1573 | + punycode@2.3.1: | |
| 1574 | + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} | |
| 1575 | + engines: {node: '>=6'} | |
| 1576 | + | |
| 1577 | + queue-microtask@1.2.3: | |
| 1578 | + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} | |
| 1579 | + | |
| 1580 | + react-dom@19.2.8: | |
| 1581 | + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} | |
| 1582 | + peerDependencies: | |
| 1583 | + react: ^19.2.8 | |
| 1584 | + | |
| 1585 | + react-is@16.13.1: | |
| 1586 | + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} | |
| 1587 | + | |
| 1588 | + react@19.2.8: | |
| 1589 | + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} | |
| 1590 | + engines: {node: '>=0.10.0'} | |
| 1591 | + | |
| 1592 | + read-cache@1.0.0: | |
| 1593 | + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} | |
| 1594 | + | |
| 1595 | + readdirp@3.6.0: | |
| 1596 | + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} | |
| 1597 | + engines: {node: '>=8.10.0'} | |
| 1598 | + | |
| 1599 | + reflect.getprototypeof@1.0.10: | |
| 1600 | + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} | |
| 1601 | + engines: {node: '>= 0.4'} | |
| 1602 | + | |
| 1603 | + regexp.prototype.flags@1.5.4: | |
| 1604 | + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} | |
| 1605 | + engines: {node: '>= 0.4'} | |
| 1606 | + | |
| 1607 | + resolve-from@4.0.0: | |
| 1608 | + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} | |
| 1609 | + engines: {node: '>=4'} | |
| 1610 | + | |
| 1611 | + resolve-pkg-maps@1.0.0: | |
| 1612 | + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} | |
| 1613 | + | |
| 1614 | + resolve@1.22.12: | |
| 1615 | + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} | |
| 1616 | + engines: {node: '>= 0.4'} | |
| 1617 | + hasBin: true | |
| 1618 | + | |
| 1619 | + resolve@2.0.0-next.7: | |
| 1620 | + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} | |
| 1621 | + engines: {node: '>= 0.4'} | |
| 1622 | + hasBin: true | |
| 1623 | + | |
| 1624 | + reusify@1.1.0: | |
| 1625 | + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} | |
| 1626 | + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} | |
| 1627 | + | |
| 1628 | + rimraf@3.0.2: | |
| 1629 | + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} | |
| 1630 | + deprecated: Rimraf versions prior to v4 are no longer supported | |
| 1631 | + hasBin: true | |
| 1632 | + | |
| 1633 | + run-parallel@1.2.0: | |
| 1634 | + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} | |
| 1635 | + | |
| 1636 | + safe-array-concat@1.1.4: | |
| 1637 | + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} | |
| 1638 | + engines: {node: '>=0.4'} | |
| 1639 | + | |
| 1640 | + safe-push-apply@1.0.0: | |
| 1641 | + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} | |
| 1642 | + engines: {node: '>= 0.4'} | |
| 1643 | + | |
| 1644 | + safe-regex-test@1.1.0: | |
| 1645 | + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} | |
| 1646 | + engines: {node: '>= 0.4'} | |
| 1647 | + | |
| 1648 | + scheduler@0.27.0: | |
| 1649 | + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} | |
| 1650 | + | |
| 1651 | + semver@6.3.1: | |
| 1652 | + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} | |
| 1653 | + hasBin: true | |
| 1654 | + | |
| 1655 | + semver@7.8.5: | |
| 1656 | + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} | |
| 1657 | + engines: {node: '>=10'} | |
| 1658 | + hasBin: true | |
| 1659 | + | |
| 1660 | + set-function-length@1.2.2: | |
| 1661 | + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} | |
| 1662 | + engines: {node: '>= 0.4'} | |
| 1663 | + | |
| 1664 | + set-function-name@2.0.2: | |
| 1665 | + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} | |
| 1666 | + engines: {node: '>= 0.4'} | |
| 1667 | + | |
| 1668 | + set-proto@1.0.0: | |
| 1669 | + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} | |
| 1670 | + engines: {node: '>= 0.4'} | |
| 1671 | + | |
| 1672 | + sharp@0.34.5: | |
| 1673 | + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} | |
| 1674 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 1675 | + | |
| 1676 | + shebang-command@2.0.0: | |
| 1677 | + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} | |
| 1678 | + engines: {node: '>=8'} | |
| 1679 | + | |
| 1680 | + shebang-regex@3.0.0: | |
| 1681 | + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} | |
| 1682 | + engines: {node: '>=8'} | |
| 1683 | + | |
| 1684 | + side-channel-list@1.0.1: | |
| 1685 | + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} | |
| 1686 | + engines: {node: '>= 0.4'} | |
| 1687 | + | |
| 1688 | + side-channel-map@1.0.1: | |
| 1689 | + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} | |
| 1690 | + engines: {node: '>= 0.4'} | |
| 1691 | + | |
| 1692 | + side-channel-weakmap@1.0.2: | |
| 1693 | + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} | |
| 1694 | + engines: {node: '>= 0.4'} | |
| 1695 | + | |
| 1696 | + side-channel@1.1.1: | |
| 1697 | + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} | |
| 1698 | + engines: {node: '>= 0.4'} | |
| 1699 | + | |
| 1700 | + source-map-js@1.2.1: | |
| 1701 | + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} | |
| 1702 | + engines: {node: '>=0.10.0'} | |
| 1703 | + | |
| 1704 | + stable-hash@0.0.5: | |
| 1705 | + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} | |
| 1706 | + | |
| 1707 | + stop-iteration-iterator@1.1.0: | |
| 1708 | + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} | |
| 1709 | + engines: {node: '>= 0.4'} | |
| 1710 | + | |
| 1711 | + string.prototype.includes@2.0.1: | |
| 1712 | + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} | |
| 1713 | + engines: {node: '>= 0.4'} | |
| 1714 | + | |
| 1715 | + string.prototype.matchall@4.0.12: | |
| 1716 | + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} | |
| 1717 | + engines: {node: '>= 0.4'} | |
| 1718 | + | |
| 1719 | + string.prototype.repeat@1.0.0: | |
| 1720 | + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} | |
| 1721 | + | |
| 1722 | + string.prototype.trim@1.2.11: | |
| 1723 | + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} | |
| 1724 | + engines: {node: '>= 0.4'} | |
| 1725 | + | |
| 1726 | + string.prototype.trimend@1.0.10: | |
| 1727 | + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} | |
| 1728 | + engines: {node: '>= 0.4'} | |
| 1729 | + | |
| 1730 | + string.prototype.trimstart@1.0.8: | |
| 1731 | + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} | |
| 1732 | + engines: {node: '>= 0.4'} | |
| 1733 | + | |
| 1734 | + strip-ansi@6.0.1: | |
| 1735 | + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} | |
| 1736 | + engines: {node: '>=8'} | |
| 1737 | + | |
| 1738 | + strip-bom@3.0.0: | |
| 1739 | + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} | |
| 1740 | + engines: {node: '>=4'} | |
| 1741 | + | |
| 1742 | + strip-json-comments@3.1.1: | |
| 1743 | + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} | |
| 1744 | + engines: {node: '>=8'} | |
| 1745 | + | |
| 1746 | + styled-jsx@5.1.6: | |
| 1747 | + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} | |
| 1748 | + engines: {node: '>= 12.0.0'} | |
| 1749 | + peerDependencies: | |
| 1750 | + '@babel/core': '*' | |
| 1751 | + babel-plugin-macros: '*' | |
| 1752 | + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' | |
| 1753 | + peerDependenciesMeta: | |
| 1754 | + '@babel/core': | |
| 1755 | + optional: true | |
| 1756 | + babel-plugin-macros: | |
| 1757 | + optional: true | |
| 1758 | + | |
| 1759 | + sucrase@3.35.1: | |
| 1760 | + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} | |
| 1761 | + engines: {node: '>=16 || 14 >=14.17'} | |
| 1762 | + hasBin: true | |
| 1763 | + | |
| 1764 | + supports-color@7.2.0: | |
| 1765 | + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} | |
| 1766 | + engines: {node: '>=8'} | |
| 1767 | + | |
| 1768 | + supports-preserve-symlinks-flag@1.0.0: | |
| 1769 | + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} | |
| 1770 | + engines: {node: '>= 0.4'} | |
| 1771 | + | |
| 1772 | + tailwind-merge@2.6.1: | |
| 1773 | + resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} | |
| 1774 | + | |
| 1775 | + tailwindcss@3.4.19: | |
| 1776 | + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} | |
| 1777 | + engines: {node: '>=14.0.0'} | |
| 1778 | + hasBin: true | |
| 1779 | + | |
| 1780 | + text-table@0.2.0: | |
| 1781 | + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} | |
| 1782 | + | |
| 1783 | + thenify-all@1.6.0: | |
| 1784 | + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} | |
| 1785 | + engines: {node: '>=0.8'} | |
| 1786 | + | |
| 1787 | + thenify@3.3.1: | |
| 1788 | + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} | |
| 1789 | + | |
| 1790 | + tinyglobby@0.2.17: | |
| 1791 | + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} | |
| 1792 | + engines: {node: '>=12.0.0'} | |
| 1793 | + | |
| 1794 | + to-regex-range@5.0.1: | |
| 1795 | + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} | |
| 1796 | + engines: {node: '>=8.0'} | |
| 1797 | + | |
| 1798 | + ts-api-utils@2.5.0: | |
| 1799 | + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} | |
| 1800 | + engines: {node: '>=18.12'} | |
| 1801 | + peerDependencies: | |
| 1802 | + typescript: '>=4.8.4' | |
| 1803 | + | |
| 1804 | + ts-interface-checker@0.1.13: | |
| 1805 | + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} | |
| 1806 | + | |
| 1807 | + tsconfig-paths@3.15.0: | |
| 1808 | + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} | |
| 1809 | + | |
| 1810 | + tslib@2.8.1: | |
| 1811 | + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} | |
| 1812 | + | |
| 1813 | + type-check@0.4.0: | |
| 1814 | + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} | |
| 1815 | + engines: {node: '>= 0.8.0'} | |
| 1816 | + | |
| 1817 | + type-fest@0.20.2: | |
| 1818 | + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} | |
| 1819 | + engines: {node: '>=10'} | |
| 1820 | + | |
| 1821 | + typed-array-buffer@1.0.3: | |
| 1822 | + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} | |
| 1823 | + engines: {node: '>= 0.4'} | |
| 1824 | + | |
| 1825 | + typed-array-byte-length@1.0.3: | |
| 1826 | + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} | |
| 1827 | + engines: {node: '>= 0.4'} | |
| 1828 | + | |
| 1829 | + typed-array-byte-offset@1.0.4: | |
| 1830 | + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} | |
| 1831 | + engines: {node: '>= 0.4'} | |
| 1832 | + | |
| 1833 | + typed-array-length@1.0.8: | |
| 1834 | + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} | |
| 1835 | + engines: {node: '>= 0.4'} | |
| 1836 | + | |
| 1837 | + typescript@5.9.3: | |
| 1838 | + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} | |
| 1839 | + engines: {node: '>=14.17'} | |
| 1840 | + hasBin: true | |
| 1841 | + | |
| 1842 | + unbox-primitive@1.1.0: | |
| 1843 | + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} | |
| 1844 | + engines: {node: '>= 0.4'} | |
| 1845 | + | |
| 1846 | + undici-types@6.21.0: | |
| 1847 | + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} | |
| 1848 | + | |
| 1849 | + unrs-resolver@1.12.2: | |
| 1850 | + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} | |
| 1851 | + | |
| 1852 | + update-browserslist-db@1.3.1: | |
| 1853 | + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} | |
| 1854 | + hasBin: true | |
| 1855 | + peerDependencies: | |
| 1856 | + browserslist: '>= 4.21.0' | |
| 1857 | + | |
| 1858 | + uri-js@4.4.1: | |
| 1859 | + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} | |
| 1860 | + | |
| 1861 | + util-deprecate@1.0.2: | |
| 1862 | + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} | |
| 1863 | + | |
| 1864 | + which-boxed-primitive@1.1.1: | |
| 1865 | + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} | |
| 1866 | + engines: {node: '>= 0.4'} | |
| 1867 | + | |
| 1868 | + which-builtin-type@1.2.1: | |
| 1869 | + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} | |
| 1870 | + engines: {node: '>= 0.4'} | |
| 1871 | + | |
| 1872 | + which-collection@1.0.2: | |
| 1873 | + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} | |
| 1874 | + engines: {node: '>= 0.4'} | |
| 1875 | + | |
| 1876 | + which-typed-array@1.1.22: | |
| 1877 | + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} | |
| 1878 | + engines: {node: '>= 0.4'} | |
| 1879 | + | |
| 1880 | + which@2.0.2: | |
| 1881 | + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} | |
| 1882 | + engines: {node: '>= 8'} | |
| 1883 | + hasBin: true | |
| 1884 | + | |
| 1885 | + word-wrap@1.2.5: | |
| 1886 | + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} | |
| 1887 | + engines: {node: '>=0.10.0'} | |
| 1888 | + | |
| 1889 | + wrappy@1.0.2: | |
| 1890 | + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} | |
| 1891 | + | |
| 1892 | + yocto-queue@0.1.0: | |
| 1893 | + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} | |
| 1894 | + engines: {node: '>=10'} | |
| 1895 | + | |
| 1896 | +snapshots: | |
| 1897 | + | |
| 1898 | + '@alloc/quick-lru@5.2.0': {} | |
| 1899 | + | |
| 1900 | + '@emnapi/core@1.10.0': | |
| 1901 | + dependencies: | |
| 1902 | + '@emnapi/wasi-threads': 1.2.1 | |
| 1903 | + tslib: 2.8.1 | |
| 1904 | + optional: true | |
| 1905 | + | |
| 1906 | + '@emnapi/runtime@1.10.0': | |
| 1907 | + dependencies: | |
| 1908 | + tslib: 2.8.1 | |
| 1909 | + optional: true | |
| 1910 | + | |
| 1911 | + '@emnapi/runtime@1.11.3': | |
| 1912 | + dependencies: | |
| 1913 | + tslib: 2.8.1 | |
| 1914 | + optional: true | |
| 1915 | + | |
| 1916 | + '@emnapi/wasi-threads@1.2.1': | |
| 1917 | + dependencies: | |
| 1918 | + tslib: 2.8.1 | |
| 1919 | + optional: true | |
| 1920 | + | |
| 1921 | + '@eslint-community/eslint-utils@4.10.1(eslint@8.57.1)': | |
| 1922 | + dependencies: | |
| 1923 | + eslint: 8.57.1 | |
| 1924 | + eslint-visitor-keys: 3.4.3 | |
| 1925 | + | |
| 1926 | + '@eslint-community/regexpp@4.12.2': {} | |
| 1927 | + | |
| 1928 | + '@eslint/eslintrc@2.1.4': | |
| 1929 | + dependencies: | |
| 1930 | + ajv: 6.15.0 | |
| 1931 | + debug: 4.4.3 | |
| 1932 | + espree: 9.6.1 | |
| 1933 | + globals: 13.24.0 | |
| 1934 | + ignore: 5.3.2 | |
| 1935 | + import-fresh: 3.3.1 | |
| 1936 | + js-yaml: 4.3.1 | |
| 1937 | + minimatch: 3.1.5 | |
| 1938 | + strip-json-comments: 3.1.1 | |
| 1939 | + transitivePeerDependencies: | |
| 1940 | + - supports-color | |
| 1941 | + | |
| 1942 | + '@eslint/js@8.57.1': {} | |
| 1943 | + | |
| 1944 | + '@humanwhocodes/config-array@0.13.0': | |
| 1945 | + dependencies: | |
| 1946 | + '@humanwhocodes/object-schema': 2.0.3 | |
| 1947 | + debug: 4.4.3 | |
| 1948 | + minimatch: 3.1.5 | |
| 1949 | + transitivePeerDependencies: | |
| 1950 | + - supports-color | |
| 1951 | + | |
| 1952 | + '@humanwhocodes/module-importer@1.0.1': {} | |
| 1953 | + | |
| 1954 | + '@humanwhocodes/object-schema@2.0.3': {} | |
| 1955 | + | |
| 1956 | + '@img/colour@1.1.0': | |
| 1957 | + optional: true | |
| 1958 | + | |
| 1959 | + '@img/sharp-darwin-arm64@0.34.5': | |
| 1960 | + optionalDependencies: | |
| 1961 | + '@img/sharp-libvips-darwin-arm64': 1.2.4 | |
| 1962 | + optional: true | |
| 1963 | + | |
| 1964 | + '@img/sharp-darwin-x64@0.34.5': | |
| 1965 | + optionalDependencies: | |
| 1966 | + '@img/sharp-libvips-darwin-x64': 1.2.4 | |
| 1967 | + optional: true | |
| 1968 | + | |
| 1969 | + '@img/sharp-libvips-darwin-arm64@1.2.4': | |
| 1970 | + optional: true | |
| 1971 | + | |
| 1972 | + '@img/sharp-libvips-darwin-x64@1.2.4': | |
| 1973 | + optional: true | |
| 1974 | + | |
| 1975 | + '@img/sharp-libvips-linux-arm64@1.2.4': | |
| 1976 | + optional: true | |
| 1977 | + | |
| 1978 | + '@img/sharp-libvips-linux-arm@1.2.4': | |
| 1979 | + optional: true | |
| 1980 | + | |
| 1981 | + '@img/sharp-libvips-linux-ppc64@1.2.4': | |
| 1982 | + optional: true | |
| 1983 | + | |
| 1984 | + '@img/sharp-libvips-linux-riscv64@1.2.4': | |
| 1985 | + optional: true | |
| 1986 | + | |
| 1987 | + '@img/sharp-libvips-linux-s390x@1.2.4': | |
| 1988 | + optional: true | |
| 1989 | + | |
| 1990 | + '@img/sharp-libvips-linux-x64@1.2.4': | |
| 1991 | + optional: true | |
| 1992 | + | |
| 1993 | + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': | |
| 1994 | + optional: true | |
| 1995 | + | |
| 1996 | + '@img/sharp-libvips-linuxmusl-x64@1.2.4': | |
| 1997 | + optional: true | |
| 1998 | + | |
| 1999 | + '@img/sharp-linux-arm64@0.34.5': | |
| 2000 | + optionalDependencies: | |
| 2001 | + '@img/sharp-libvips-linux-arm64': 1.2.4 | |
| 2002 | + optional: true | |
| 2003 | + | |
| 2004 | + '@img/sharp-linux-arm@0.34.5': | |
| 2005 | + optionalDependencies: | |
| 2006 | + '@img/sharp-libvips-linux-arm': 1.2.4 | |
| 2007 | + optional: true | |
| 2008 | + | |
| 2009 | + '@img/sharp-linux-ppc64@0.34.5': | |
| 2010 | + optionalDependencies: | |
| 2011 | + '@img/sharp-libvips-linux-ppc64': 1.2.4 | |
| 2012 | + optional: true | |
| 2013 | + | |
| 2014 | + '@img/sharp-linux-riscv64@0.34.5': | |
| 2015 | + optionalDependencies: | |
| 2016 | + '@img/sharp-libvips-linux-riscv64': 1.2.4 | |
| 2017 | + optional: true | |
| 2018 | + | |
| 2019 | + '@img/sharp-linux-s390x@0.34.5': | |
| 2020 | + optionalDependencies: | |
| 2021 | + '@img/sharp-libvips-linux-s390x': 1.2.4 | |
| 2022 | + optional: true | |
| 2023 | + | |
| 2024 | + '@img/sharp-linux-x64@0.34.5': | |
| 2025 | + optionalDependencies: | |
| 2026 | + '@img/sharp-libvips-linux-x64': 1.2.4 | |
| 2027 | + optional: true | |
| 2028 | + | |
| 2029 | + '@img/sharp-linuxmusl-arm64@0.34.5': | |
| 2030 | + optionalDependencies: | |
| 2031 | + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 | |
| 2032 | + optional: true | |
| 2033 | + | |
| 2034 | + '@img/sharp-linuxmusl-x64@0.34.5': | |
| 2035 | + optionalDependencies: | |
| 2036 | + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 | |
| 2037 | + optional: true | |
| 2038 | + | |
| 2039 | + '@img/sharp-wasm32@0.34.5': | |
| 2040 | + dependencies: | |
| 2041 | + '@emnapi/runtime': 1.11.3 | |
| 2042 | + optional: true | |
| 2043 | + | |
| 2044 | + '@img/sharp-win32-arm64@0.34.5': | |
| 2045 | + optional: true | |
| 2046 | + | |
| 2047 | + '@img/sharp-win32-ia32@0.34.5': | |
| 2048 | + optional: true | |
| 2049 | + | |
| 2050 | + '@img/sharp-win32-x64@0.34.5': | |
| 2051 | + optional: true | |
| 2052 | + | |
| 2053 | + '@jridgewell/gen-mapping@0.3.13': | |
| 2054 | + dependencies: | |
| 2055 | + '@jridgewell/sourcemap-codec': 1.5.5 | |
| 2056 | + '@jridgewell/trace-mapping': 0.3.31 | |
| 2057 | + | |
| 2058 | + '@jridgewell/resolve-uri@3.1.2': {} | |
| 2059 | + | |
| 2060 | + '@jridgewell/sourcemap-codec@1.5.5': {} | |
| 2061 | + | |
| 2062 | + '@jridgewell/trace-mapping@0.3.31': | |
| 2063 | + dependencies: | |
| 2064 | + '@jridgewell/resolve-uri': 3.1.2 | |
| 2065 | + '@jridgewell/sourcemap-codec': 1.5.5 | |
| 2066 | + | |
| 2067 | + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': | |
| 2068 | + dependencies: | |
| 2069 | + '@emnapi/core': 1.10.0 | |
| 2070 | + '@emnapi/runtime': 1.10.0 | |
| 2071 | + '@tybys/wasm-util': 0.10.3 | |
| 2072 | + optional: true | |
| 2073 | + | |
| 2074 | + '@next/env@15.5.23': {} | |
| 2075 | + | |
| 2076 | + '@next/eslint-plugin-next@15.5.23': | |
| 2077 | + dependencies: | |
| 2078 | + fast-glob: 3.3.1 | |
| 2079 | + | |
| 2080 | + '@next/swc-darwin-arm64@15.5.23': | |
| 2081 | + optional: true | |
| 2082 | + | |
| 2083 | + '@next/swc-darwin-x64@15.5.23': | |
| 2084 | + optional: true | |
| 2085 | + | |
| 2086 | + '@next/swc-linux-arm64-gnu@15.5.23': | |
| 2087 | + optional: true | |
| 2088 | + | |
| 2089 | + '@next/swc-linux-arm64-musl@15.5.23': | |
| 2090 | + optional: true | |
| 2091 | + | |
| 2092 | + '@next/swc-linux-x64-gnu@15.5.23': | |
| 2093 | + optional: true | |
| 2094 | + | |
| 2095 | + '@next/swc-linux-x64-musl@15.5.23': | |
| 2096 | + optional: true | |
| 2097 | + | |
| 2098 | + '@next/swc-win32-arm64-msvc@15.5.23': | |
| 2099 | + optional: true | |
| 2100 | + | |
| 2101 | + '@next/swc-win32-x64-msvc@15.5.23': | |
| 2102 | + optional: true | |
| 2103 | + | |
| 2104 | + '@nodelib/fs.scandir@2.1.5': | |
| 2105 | + dependencies: | |
| 2106 | + '@nodelib/fs.stat': 2.0.5 | |
| 2107 | + run-parallel: 1.2.0 | |
| 2108 | + | |
| 2109 | + '@nodelib/fs.stat@2.0.5': {} | |
| 2110 | + | |
| 2111 | + '@nodelib/fs.walk@1.2.8': | |
| 2112 | + dependencies: | |
| 2113 | + '@nodelib/fs.scandir': 2.1.5 | |
| 2114 | + fastq: 1.20.1 | |
| 2115 | + | |
| 2116 | + '@nolyfill/is-core-module@1.0.39': {} | |
| 2117 | + | |
| 2118 | + '@rtsao/scc@1.1.0': {} | |
| 2119 | + | |
| 2120 | + '@rushstack/eslint-patch@1.16.1': {} | |
| 2121 | + | |
| 2122 | + '@swc/helpers@0.5.15': | |
| 2123 | + dependencies: | |
| 2124 | + tslib: 2.8.1 | |
| 2125 | + | |
| 2126 | + '@tybys/wasm-util@0.10.3': | |
| 2127 | + dependencies: | |
| 2128 | + tslib: 2.8.1 | |
| 2129 | + optional: true | |
| 2130 | + | |
| 2131 | + '@types/json5@0.0.29': {} | |
| 2132 | + | |
| 2133 | + '@types/node@22.20.1': | |
| 2134 | + dependencies: | |
| 2135 | + undici-types: 6.21.0 | |
| 2136 | + | |
| 2137 | + '@types/react-dom@19.2.4(@types/react@19.2.18)': | |
| 2138 | + dependencies: | |
| 2139 | + '@types/react': 19.2.18 | |
| 2140 | + | |
| 2141 | + '@types/react@19.2.18': | |
| 2142 | + dependencies: | |
| 2143 | + csstype: 3.2.3 | |
| 2144 | + | |
| 2145 | + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': | |
| 2146 | + dependencies: | |
| 2147 | + '@eslint-community/regexpp': 4.12.2 | |
| 2148 | + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2149 | + '@typescript-eslint/scope-manager': 8.67.0 | |
| 2150 | + '@typescript-eslint/type-utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2151 | + '@typescript-eslint/utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2152 | + '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2153 | + eslint: 8.57.1 | |
| 2154 | + ignore: 7.0.6 | |
| 2155 | + natural-compare: 1.4.0 | |
| 2156 | + ts-api-utils: 2.5.0(typescript@5.9.3) | |
| 2157 | + typescript: 5.9.3 | |
| 2158 | + transitivePeerDependencies: | |
| 2159 | + - supports-color | |
| 2160 | + | |
| 2161 | + '@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3)': | |
| 2162 | + dependencies: | |
| 2163 | + '@typescript-eslint/scope-manager': 8.67.0 | |
| 2164 | + '@typescript-eslint/types': 8.67.0 | |
| 2165 | + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) | |
| 2166 | + '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2167 | + debug: 4.4.3 | |
| 2168 | + eslint: 8.57.1 | |
| 2169 | + typescript: 5.9.3 | |
| 2170 | + transitivePeerDependencies: | |
| 2171 | + - supports-color | |
| 2172 | + | |
| 2173 | + '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': | |
| 2174 | + dependencies: | |
| 2175 | + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) | |
| 2176 | + '@typescript-eslint/types': 8.67.0 | |
| 2177 | + debug: 4.4.3 | |
| 2178 | + typescript: 5.9.3 | |
| 2179 | + transitivePeerDependencies: | |
| 2180 | + - supports-color | |
| 2181 | + | |
| 2182 | + '@typescript-eslint/scope-manager@8.67.0': | |
| 2183 | + dependencies: | |
| 2184 | + '@typescript-eslint/types': 8.67.0 | |
| 2185 | + '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2186 | + | |
| 2187 | + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)': | |
| 2188 | + dependencies: | |
| 2189 | + typescript: 5.9.3 | |
| 2190 | + | |
| 2191 | + '@typescript-eslint/type-utils@8.67.0(eslint@8.57.1)(typescript@5.9.3)': | |
| 2192 | + dependencies: | |
| 2193 | + '@typescript-eslint/types': 8.67.0 | |
| 2194 | + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) | |
| 2195 | + '@typescript-eslint/utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2196 | + debug: 4.4.3 | |
| 2197 | + eslint: 8.57.1 | |
| 2198 | + ts-api-utils: 2.5.0(typescript@5.9.3) | |
| 2199 | + typescript: 5.9.3 | |
| 2200 | + transitivePeerDependencies: | |
| 2201 | + - supports-color | |
| 2202 | + | |
| 2203 | + '@typescript-eslint/types@8.67.0': {} | |
| 2204 | + | |
| 2205 | + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': | |
| 2206 | + dependencies: | |
| 2207 | + '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3) | |
| 2208 | + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) | |
| 2209 | + '@typescript-eslint/types': 8.67.0 | |
| 2210 | + '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2211 | + debug: 4.4.3 | |
| 2212 | + minimatch: 10.2.6 | |
| 2213 | + semver: 7.8.5 | |
| 2214 | + tinyglobby: 0.2.17 | |
| 2215 | + ts-api-utils: 2.5.0(typescript@5.9.3) | |
| 2216 | + typescript: 5.9.3 | |
| 2217 | + transitivePeerDependencies: | |
| 2218 | + - supports-color | |
| 2219 | + | |
| 2220 | + '@typescript-eslint/utils@8.67.0(eslint@8.57.1)(typescript@5.9.3)': | |
| 2221 | + dependencies: | |
| 2222 | + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1) | |
| 2223 | + '@typescript-eslint/scope-manager': 8.67.0 | |
| 2224 | + '@typescript-eslint/types': 8.67.0 | |
| 2225 | + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) | |
| 2226 | + eslint: 8.57.1 | |
| 2227 | + typescript: 5.9.3 | |
| 2228 | + transitivePeerDependencies: | |
| 2229 | + - supports-color | |
| 2230 | + | |
| 2231 | + '@typescript-eslint/visitor-keys@8.67.0': | |
| 2232 | + dependencies: | |
| 2233 | + '@typescript-eslint/types': 8.67.0 | |
| 2234 | + eslint-visitor-keys: 5.0.1 | |
| 2235 | + | |
| 2236 | + '@ungap/structured-clone@1.3.3': {} | |
| 2237 | + | |
| 2238 | + '@unrs/resolver-binding-android-arm-eabi@1.12.2': | |
| 2239 | + optional: true | |
| 2240 | + | |
| 2241 | + '@unrs/resolver-binding-android-arm64@1.12.2': | |
| 2242 | + optional: true | |
| 2243 | + | |
| 2244 | + '@unrs/resolver-binding-darwin-arm64@1.12.2': | |
| 2245 | + optional: true | |
| 2246 | + | |
| 2247 | + '@unrs/resolver-binding-darwin-x64@1.12.2': | |
| 2248 | + optional: true | |
| 2249 | + | |
| 2250 | + '@unrs/resolver-binding-freebsd-x64@1.12.2': | |
| 2251 | + optional: true | |
| 2252 | + | |
| 2253 | + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': | |
| 2254 | + optional: true | |
| 2255 | + | |
| 2256 | + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': | |
| 2257 | + optional: true | |
| 2258 | + | |
| 2259 | + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': | |
| 2260 | + optional: true | |
| 2261 | + | |
| 2262 | + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': | |
| 2263 | + optional: true | |
| 2264 | + | |
| 2265 | + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': | |
| 2266 | + optional: true | |
| 2267 | + | |
| 2268 | + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': | |
| 2269 | + optional: true | |
| 2270 | + | |
| 2271 | + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': | |
| 2272 | + optional: true | |
| 2273 | + | |
| 2274 | + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': | |
| 2275 | + optional: true | |
| 2276 | + | |
| 2277 | + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': | |
| 2278 | + optional: true | |
| 2279 | + | |
| 2280 | + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': | |
| 2281 | + optional: true | |
| 2282 | + | |
| 2283 | + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': | |
| 2284 | + optional: true | |
| 2285 | + | |
| 2286 | + '@unrs/resolver-binding-linux-x64-musl@1.12.2': | |
| 2287 | + optional: true | |
| 2288 | + | |
| 2289 | + '@unrs/resolver-binding-openharmony-arm64@1.12.2': | |
| 2290 | + optional: true | |
| 2291 | + | |
| 2292 | + '@unrs/resolver-binding-wasm32-wasi@1.12.2': | |
| 2293 | + dependencies: | |
| 2294 | + '@emnapi/core': 1.10.0 | |
| 2295 | + '@emnapi/runtime': 1.10.0 | |
| 2296 | + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) | |
| 2297 | + optional: true | |
| 2298 | + | |
| 2299 | + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': | |
| 2300 | + optional: true | |
| 2301 | + | |
| 2302 | + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': | |
| 2303 | + optional: true | |
| 2304 | + | |
| 2305 | + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': | |
| 2306 | + optional: true | |
| 2307 | + | |
| 2308 | + acorn-jsx@5.3.2(acorn@8.18.0): | |
| 2309 | + dependencies: | |
| 2310 | + acorn: 8.18.0 | |
| 2311 | + | |
| 2312 | + acorn@8.18.0: {} | |
| 2313 | + | |
| 2314 | + ajv@6.15.0: | |
| 2315 | + dependencies: | |
| 2316 | + fast-deep-equal: 3.1.3 | |
| 2317 | + fast-json-stable-stringify: 2.1.0 | |
| 2318 | + json-schema-traverse: 0.4.1 | |
| 2319 | + uri-js: 4.4.1 | |
| 2320 | + | |
| 2321 | + ansi-regex@5.0.1: {} | |
| 2322 | + | |
| 2323 | + ansi-styles@4.3.0: | |
| 2324 | + dependencies: | |
| 2325 | + color-convert: 2.0.1 | |
| 2326 | + | |
| 2327 | + any-promise@1.3.0: {} | |
| 2328 | + | |
| 2329 | + anymatch@3.1.3: | |
| 2330 | + dependencies: | |
| 2331 | + normalize-path: 3.0.0 | |
| 2332 | + picomatch: 2.3.2 | |
| 2333 | + | |
| 2334 | + arg@5.0.2: {} | |
| 2335 | + | |
| 2336 | + argparse@2.0.1: {} | |
| 2337 | + | |
| 2338 | + aria-query@5.3.2: {} | |
| 2339 | + | |
| 2340 | + array-buffer-byte-length@1.0.2: | |
| 2341 | + dependencies: | |
| 2342 | + call-bound: 1.0.4 | |
| 2343 | + is-array-buffer: 3.0.5 | |
| 2344 | + | |
| 2345 | + array-includes@3.1.9: | |
| 2346 | + dependencies: | |
| 2347 | + call-bind: 1.0.9 | |
| 2348 | + call-bound: 1.0.4 | |
| 2349 | + define-properties: 1.2.1 | |
| 2350 | + es-abstract: 1.24.2 | |
| 2351 | + es-object-atoms: 1.1.2 | |
| 2352 | + get-intrinsic: 1.3.0 | |
| 2353 | + is-string: 1.1.1 | |
| 2354 | + math-intrinsics: 1.1.0 | |
| 2355 | + | |
| 2356 | + array.prototype.findlast@1.2.5: | |
| 2357 | + dependencies: | |
| 2358 | + call-bind: 1.0.9 | |
| 2359 | + define-properties: 1.2.1 | |
| 2360 | + es-abstract: 1.24.2 | |
| 2361 | + es-errors: 1.3.0 | |
| 2362 | + es-object-atoms: 1.1.2 | |
| 2363 | + es-shim-unscopables: 1.1.0 | |
| 2364 | + | |
| 2365 | + array.prototype.findlastindex@1.2.6: | |
| 2366 | + dependencies: | |
| 2367 | + call-bind: 1.0.9 | |
| 2368 | + call-bound: 1.0.4 | |
| 2369 | + define-properties: 1.2.1 | |
| 2370 | + es-abstract: 1.24.2 | |
| 2371 | + es-errors: 1.3.0 | |
| 2372 | + es-object-atoms: 1.1.2 | |
| 2373 | + es-shim-unscopables: 1.1.0 | |
| 2374 | + | |
| 2375 | + array.prototype.flat@1.3.3: | |
| 2376 | + dependencies: | |
| 2377 | + call-bind: 1.0.9 | |
| 2378 | + define-properties: 1.2.1 | |
| 2379 | + es-abstract: 1.24.2 | |
| 2380 | + es-shim-unscopables: 1.1.0 | |
| 2381 | + | |
| 2382 | + array.prototype.flatmap@1.3.3: | |
| 2383 | + dependencies: | |
| 2384 | + call-bind: 1.0.9 | |
| 2385 | + define-properties: 1.2.1 | |
| 2386 | + es-abstract: 1.24.2 | |
| 2387 | + es-shim-unscopables: 1.1.0 | |
| 2388 | + | |
| 2389 | + array.prototype.tosorted@1.1.4: | |
| 2390 | + dependencies: | |
| 2391 | + call-bind: 1.0.9 | |
| 2392 | + define-properties: 1.2.1 | |
| 2393 | + es-abstract: 1.24.2 | |
| 2394 | + es-errors: 1.3.0 | |
| 2395 | + es-shim-unscopables: 1.1.0 | |
| 2396 | + | |
| 2397 | + arraybuffer.prototype.slice@1.0.4: | |
| 2398 | + dependencies: | |
| 2399 | + array-buffer-byte-length: 1.0.2 | |
| 2400 | + call-bind: 1.0.9 | |
| 2401 | + define-properties: 1.2.1 | |
| 2402 | + es-abstract: 1.24.2 | |
| 2403 | + es-errors: 1.3.0 | |
| 2404 | + get-intrinsic: 1.3.0 | |
| 2405 | + is-array-buffer: 3.0.5 | |
| 2406 | + | |
| 2407 | + ast-types-flow@0.0.8: {} | |
| 2408 | + | |
| 2409 | + async-function@1.0.0: {} | |
| 2410 | + | |
| 2411 | + autoprefixer@10.5.4(postcss@8.5.26): | |
| 2412 | + dependencies: | |
| 2413 | + browserslist: 4.28.8 | |
| 2414 | + caniuse-lite: 1.0.30001809 | |
| 2415 | + fraction.js: 5.3.4 | |
| 2416 | + picocolors: 1.1.1 | |
| 2417 | + postcss: 8.5.26 | |
| 2418 | + postcss-value-parser: 4.2.0 | |
| 2419 | + | |
| 2420 | + available-typed-arrays@1.0.7: | |
| 2421 | + dependencies: | |
| 2422 | + possible-typed-array-names: 1.1.0 | |
| 2423 | + | |
| 2424 | + axe-core@4.13.0: {} | |
| 2425 | + | |
| 2426 | + axobject-query@4.1.0: {} | |
| 2427 | + | |
| 2428 | + balanced-match@1.0.2: {} | |
| 2429 | + | |
| 2430 | + balanced-match@4.0.4: {} | |
| 2431 | + | |
| 2432 | + baseline-browser-mapping@2.11.13: {} | |
| 2433 | + | |
| 2434 | + binary-extensions@2.3.0: {} | |
| 2435 | + | |
| 2436 | + brace-expansion@1.1.18: | |
| 2437 | + dependencies: | |
| 2438 | + balanced-match: 1.0.2 | |
| 2439 | + concat-map: 0.0.1 | |
| 2440 | + | |
| 2441 | + brace-expansion@5.0.9: | |
| 2442 | + dependencies: | |
| 2443 | + balanced-match: 4.0.4 | |
| 2444 | + | |
| 2445 | + braces@3.0.3: | |
| 2446 | + dependencies: | |
| 2447 | + fill-range: 7.1.1 | |
| 2448 | + | |
| 2449 | + browserslist@4.28.8: | |
| 2450 | + dependencies: | |
| 2451 | + baseline-browser-mapping: 2.11.13 | |
| 2452 | + caniuse-lite: 1.0.30001809 | |
| 2453 | + electron-to-chromium: 1.5.405 | |
| 2454 | + node-releases: 2.0.53 | |
| 2455 | + update-browserslist-db: 1.3.1(browserslist@4.28.8) | |
| 2456 | + | |
| 2457 | + call-bind-apply-helpers@1.0.2: | |
| 2458 | + dependencies: | |
| 2459 | + es-errors: 1.3.0 | |
| 2460 | + function-bind: 1.1.2 | |
| 2461 | + | |
| 2462 | + call-bind@1.0.9: | |
| 2463 | + dependencies: | |
| 2464 | + call-bind-apply-helpers: 1.0.2 | |
| 2465 | + es-define-property: 1.0.1 | |
| 2466 | + get-intrinsic: 1.3.0 | |
| 2467 | + set-function-length: 1.2.2 | |
| 2468 | + | |
| 2469 | + call-bound@1.0.4: | |
| 2470 | + dependencies: | |
| 2471 | + call-bind-apply-helpers: 1.0.2 | |
| 2472 | + get-intrinsic: 1.3.0 | |
| 2473 | + | |
| 2474 | + callsites@3.1.0: {} | |
| 2475 | + | |
| 2476 | + camelcase-css@2.0.1: {} | |
| 2477 | + | |
| 2478 | + caniuse-lite@1.0.30001809: {} | |
| 2479 | + | |
| 2480 | + chalk@4.1.2: | |
| 2481 | + dependencies: | |
| 2482 | + ansi-styles: 4.3.0 | |
| 2483 | + supports-color: 7.2.0 | |
| 2484 | + | |
| 2485 | + chokidar@3.6.0: | |
| 2486 | + dependencies: | |
| 2487 | + anymatch: 3.1.3 | |
| 2488 | + braces: 3.0.3 | |
| 2489 | + glob-parent: 5.1.2 | |
| 2490 | + is-binary-path: 2.1.0 | |
| 2491 | + is-glob: 4.0.3 | |
| 2492 | + normalize-path: 3.0.0 | |
| 2493 | + readdirp: 3.6.0 | |
| 2494 | + optionalDependencies: | |
| 2495 | + fsevents: 2.3.3 | |
| 2496 | + | |
| 2497 | + class-variance-authority@0.7.1: | |
| 2498 | + dependencies: | |
| 2499 | + clsx: 2.1.1 | |
| 2500 | + | |
| 2501 | + client-only@0.0.1: {} | |
| 2502 | + | |
| 2503 | + clsx@2.1.1: {} | |
| 2504 | + | |
| 2505 | + color-convert@2.0.1: | |
| 2506 | + dependencies: | |
| 2507 | + color-name: 1.1.4 | |
| 2508 | + | |
| 2509 | + color-name@1.1.4: {} | |
| 2510 | + | |
| 2511 | + commander@4.1.1: {} | |
| 2512 | + | |
| 2513 | + concat-map@0.0.1: {} | |
| 2514 | + | |
| 2515 | + cross-spawn@7.0.6: | |
| 2516 | + dependencies: | |
| 2517 | + path-key: 3.1.1 | |
| 2518 | + shebang-command: 2.0.0 | |
| 2519 | + which: 2.0.2 | |
| 2520 | + | |
| 2521 | + cssesc@3.0.0: {} | |
| 2522 | + | |
| 2523 | + csstype@3.2.3: {} | |
| 2524 | + | |
| 2525 | + damerau-levenshtein@1.0.8: {} | |
| 2526 | + | |
| 2527 | + data-view-buffer@1.0.2: | |
| 2528 | + dependencies: | |
| 2529 | + call-bound: 1.0.4 | |
| 2530 | + es-errors: 1.3.0 | |
| 2531 | + is-data-view: 1.0.2 | |
| 2532 | + | |
| 2533 | + data-view-byte-length@1.0.2: | |
| 2534 | + dependencies: | |
| 2535 | + call-bound: 1.0.4 | |
| 2536 | + es-errors: 1.3.0 | |
| 2537 | + is-data-view: 1.0.2 | |
| 2538 | + | |
| 2539 | + data-view-byte-offset@1.0.1: | |
| 2540 | + dependencies: | |
| 2541 | + call-bound: 1.0.4 | |
| 2542 | + es-errors: 1.3.0 | |
| 2543 | + is-data-view: 1.0.2 | |
| 2544 | + | |
| 2545 | + debug@3.2.7: | |
| 2546 | + dependencies: | |
| 2547 | + ms: 2.1.3 | |
| 2548 | + | |
| 2549 | + debug@4.4.3: | |
| 2550 | + dependencies: | |
| 2551 | + ms: 2.1.3 | |
| 2552 | + | |
| 2553 | + deep-is@0.1.4: {} | |
| 2554 | + | |
| 2555 | + define-data-property@1.1.4: | |
| 2556 | + dependencies: | |
| 2557 | + es-define-property: 1.0.1 | |
| 2558 | + es-errors: 1.3.0 | |
| 2559 | + gopd: 1.2.0 | |
| 2560 | + | |
| 2561 | + define-properties@1.2.1: | |
| 2562 | + dependencies: | |
| 2563 | + define-data-property: 1.1.4 | |
| 2564 | + has-property-descriptors: 1.0.2 | |
| 2565 | + object-keys: 1.1.1 | |
| 2566 | + | |
| 2567 | + detect-libc@2.1.2: | |
| 2568 | + optional: true | |
| 2569 | + | |
| 2570 | + didyoumean@1.2.2: {} | |
| 2571 | + | |
| 2572 | + dlv@1.1.3: {} | |
| 2573 | + | |
| 2574 | + doctrine@2.1.0: | |
| 2575 | + dependencies: | |
| 2576 | + esutils: 2.0.3 | |
| 2577 | + | |
| 2578 | + doctrine@3.0.0: | |
| 2579 | + dependencies: | |
| 2580 | + esutils: 2.0.3 | |
| 2581 | + | |
| 2582 | + dunder-proto@1.0.1: | |
| 2583 | + dependencies: | |
| 2584 | + call-bind-apply-helpers: 1.0.2 | |
| 2585 | + es-errors: 1.3.0 | |
| 2586 | + gopd: 1.2.0 | |
| 2587 | + | |
| 2588 | + electron-to-chromium@1.5.405: {} | |
| 2589 | + | |
| 2590 | + emoji-regex@9.2.2: {} | |
| 2591 | + | |
| 2592 | + es-abstract-get@1.0.0: | |
| 2593 | + dependencies: | |
| 2594 | + es-errors: 1.3.0 | |
| 2595 | + es-object-atoms: 1.1.2 | |
| 2596 | + is-callable: 1.2.7 | |
| 2597 | + object-inspect: 1.13.4 | |
| 2598 | + | |
| 2599 | + es-abstract@1.24.2: | |
| 2600 | + dependencies: | |
| 2601 | + array-buffer-byte-length: 1.0.2 | |
| 2602 | + arraybuffer.prototype.slice: 1.0.4 | |
| 2603 | + available-typed-arrays: 1.0.7 | |
| 2604 | + call-bind: 1.0.9 | |
| 2605 | + call-bound: 1.0.4 | |
| 2606 | + data-view-buffer: 1.0.2 | |
| 2607 | + data-view-byte-length: 1.0.2 | |
| 2608 | + data-view-byte-offset: 1.0.1 | |
| 2609 | + es-define-property: 1.0.1 | |
| 2610 | + es-errors: 1.3.0 | |
| 2611 | + es-object-atoms: 1.1.2 | |
| 2612 | + es-set-tostringtag: 2.1.0 | |
| 2613 | + es-to-primitive: 1.3.4 | |
| 2614 | + function.prototype.name: 1.2.0 | |
| 2615 | + get-intrinsic: 1.3.0 | |
| 2616 | + get-proto: 1.0.1 | |
| 2617 | + get-symbol-description: 1.1.0 | |
| 2618 | + globalthis: 1.0.4 | |
| 2619 | + gopd: 1.2.0 | |
| 2620 | + has-property-descriptors: 1.0.2 | |
| 2621 | + has-proto: 1.2.0 | |
| 2622 | + has-symbols: 1.1.0 | |
| 2623 | + hasown: 2.0.4 | |
| 2624 | + internal-slot: 1.1.0 | |
| 2625 | + is-array-buffer: 3.0.5 | |
| 2626 | + is-callable: 1.2.7 | |
| 2627 | + is-data-view: 1.0.2 | |
| 2628 | + is-negative-zero: 2.0.3 | |
| 2629 | + is-regex: 1.2.1 | |
| 2630 | + is-set: 2.0.3 | |
| 2631 | + is-shared-array-buffer: 1.0.4 | |
| 2632 | + is-string: 1.1.1 | |
| 2633 | + is-typed-array: 1.1.15 | |
| 2634 | + is-weakref: 1.1.1 | |
| 2635 | + math-intrinsics: 1.1.0 | |
| 2636 | + object-inspect: 1.13.4 | |
| 2637 | + object-keys: 1.1.1 | |
| 2638 | + object.assign: 4.1.7 | |
| 2639 | + own-keys: 1.0.2 | |
| 2640 | + regexp.prototype.flags: 1.5.4 | |
| 2641 | + safe-array-concat: 1.1.4 | |
| 2642 | + safe-push-apply: 1.0.0 | |
| 2643 | + safe-regex-test: 1.1.0 | |
| 2644 | + set-proto: 1.0.0 | |
| 2645 | + stop-iteration-iterator: 1.1.0 | |
| 2646 | + string.prototype.trim: 1.2.11 | |
| 2647 | + string.prototype.trimend: 1.0.10 | |
| 2648 | + string.prototype.trimstart: 1.0.8 | |
| 2649 | + typed-array-buffer: 1.0.3 | |
| 2650 | + typed-array-byte-length: 1.0.3 | |
| 2651 | + typed-array-byte-offset: 1.0.4 | |
| 2652 | + typed-array-length: 1.0.8 | |
| 2653 | + unbox-primitive: 1.1.0 | |
| 2654 | + which-typed-array: 1.1.22 | |
| 2655 | + | |
| 2656 | + es-define-property@1.0.1: {} | |
| 2657 | + | |
| 2658 | + es-errors@1.3.0: {} | |
| 2659 | + | |
| 2660 | + es-iterator-helpers@1.4.0: | |
| 2661 | + dependencies: | |
| 2662 | + call-bind: 1.0.9 | |
| 2663 | + call-bound: 1.0.4 | |
| 2664 | + define-properties: 1.2.1 | |
| 2665 | + es-abstract: 1.24.2 | |
| 2666 | + es-errors: 1.3.0 | |
| 2667 | + es-set-tostringtag: 2.1.0 | |
| 2668 | + function-bind: 1.1.2 | |
| 2669 | + get-intrinsic: 1.3.0 | |
| 2670 | + globalthis: 1.0.4 | |
| 2671 | + gopd: 1.2.0 | |
| 2672 | + has-property-descriptors: 1.0.2 | |
| 2673 | + has-proto: 1.2.0 | |
| 2674 | + has-symbols: 1.1.0 | |
| 2675 | + internal-slot: 1.1.0 | |
| 2676 | + iterator.prototype: 1.1.5 | |
| 2677 | + math-intrinsics: 1.1.0 | |
| 2678 | + | |
| 2679 | + es-object-atoms@1.1.2: | |
| 2680 | + dependencies: | |
| 2681 | + es-errors: 1.3.0 | |
| 2682 | + | |
| 2683 | + es-set-tostringtag@2.1.0: | |
| 2684 | + dependencies: | |
| 2685 | + es-errors: 1.3.0 | |
| 2686 | + get-intrinsic: 1.3.0 | |
| 2687 | + has-tostringtag: 1.0.2 | |
| 2688 | + hasown: 2.0.4 | |
| 2689 | + | |
| 2690 | + es-shim-unscopables@1.1.0: | |
| 2691 | + dependencies: | |
| 2692 | + hasown: 2.0.4 | |
| 2693 | + | |
| 2694 | + es-to-primitive@1.3.4: | |
| 2695 | + dependencies: | |
| 2696 | + es-abstract-get: 1.0.0 | |
| 2697 | + es-define-property: 1.0.1 | |
| 2698 | + es-errors: 1.3.0 | |
| 2699 | + is-callable: 1.2.7 | |
| 2700 | + is-date-object: 1.1.0 | |
| 2701 | + is-symbol: 1.1.1 | |
| 2702 | + | |
| 2703 | + escalade@3.2.0: {} | |
| 2704 | + | |
| 2705 | + escape-string-regexp@4.0.0: {} | |
| 2706 | + | |
| 2707 | + eslint-config-next@15.5.23(eslint@8.57.1)(typescript@5.9.3): | |
| 2708 | + dependencies: | |
| 2709 | + '@next/eslint-plugin-next': 15.5.23 | |
| 2710 | + '@rushstack/eslint-patch': 1.16.1 | |
| 2711 | + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) | |
| 2712 | + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2713 | + eslint: 8.57.1 | |
| 2714 | + eslint-import-resolver-node: 0.3.10 | |
| 2715 | + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) | |
| 2716 | + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) | |
| 2717 | + eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) | |
| 2718 | + eslint-plugin-react: 7.37.5(eslint@8.57.1) | |
| 2719 | + eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) | |
| 2720 | + optionalDependencies: | |
| 2721 | + typescript: 5.9.3 | |
| 2722 | + transitivePeerDependencies: | |
| 2723 | + - eslint-import-resolver-webpack | |
| 2724 | + - eslint-plugin-import-x | |
| 2725 | + - supports-color | |
| 2726 | + | |
| 2727 | + eslint-import-resolver-node@0.3.10: | |
| 2728 | + dependencies: | |
| 2729 | + debug: 3.2.7 | |
| 2730 | + is-core-module: 2.16.2 | |
| 2731 | + resolve: 2.0.0-next.7 | |
| 2732 | + transitivePeerDependencies: | |
| 2733 | + - supports-color | |
| 2734 | + | |
| 2735 | + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): | |
| 2736 | + dependencies: | |
| 2737 | + '@nolyfill/is-core-module': 1.0.39 | |
| 2738 | + debug: 4.4.3 | |
| 2739 | + eslint: 8.57.1 | |
| 2740 | + get-tsconfig: 4.14.2 | |
| 2741 | + is-bun-module: 2.0.0 | |
| 2742 | + stable-hash: 0.0.5 | |
| 2743 | + tinyglobby: 0.2.17 | |
| 2744 | + unrs-resolver: 1.12.2 | |
| 2745 | + optionalDependencies: | |
| 2746 | + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) | |
| 2747 | + transitivePeerDependencies: | |
| 2748 | + - supports-color | |
| 2749 | + | |
| 2750 | + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): | |
| 2751 | + dependencies: | |
| 2752 | + debug: 3.2.7 | |
| 2753 | + optionalDependencies: | |
| 2754 | + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2755 | + eslint: 8.57.1 | |
| 2756 | + eslint-import-resolver-node: 0.3.10 | |
| 2757 | + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) | |
| 2758 | + transitivePeerDependencies: | |
| 2759 | + - supports-color | |
| 2760 | + | |
| 2761 | + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): | |
| 2762 | + dependencies: | |
| 2763 | + '@rtsao/scc': 1.1.0 | |
| 2764 | + array-includes: 3.1.9 | |
| 2765 | + array.prototype.findlastindex: 1.2.6 | |
| 2766 | + array.prototype.flat: 1.3.3 | |
| 2767 | + array.prototype.flatmap: 1.3.3 | |
| 2768 | + debug: 3.2.7 | |
| 2769 | + doctrine: 2.1.0 | |
| 2770 | + eslint: 8.57.1 | |
| 2771 | + eslint-import-resolver-node: 0.3.10 | |
| 2772 | + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) | |
| 2773 | + hasown: 2.0.4 | |
| 2774 | + is-core-module: 2.16.2 | |
| 2775 | + is-glob: 4.0.3 | |
| 2776 | + minimatch: 3.1.5 | |
| 2777 | + object.fromentries: 2.0.8 | |
| 2778 | + object.groupby: 1.0.3 | |
| 2779 | + object.values: 1.2.1 | |
| 2780 | + semver: 6.3.1 | |
| 2781 | + string.prototype.trimend: 1.0.10 | |
| 2782 | + tsconfig-paths: 3.15.0 | |
| 2783 | + optionalDependencies: | |
| 2784 | + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2785 | + transitivePeerDependencies: | |
| 2786 | + - eslint-import-resolver-typescript | |
| 2787 | + - eslint-import-resolver-webpack | |
| 2788 | + - supports-color | |
| 2789 | + | |
| 2790 | + eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1): | |
| 2791 | + dependencies: | |
| 2792 | + aria-query: 5.3.2 | |
| 2793 | + array-includes: 3.1.9 | |
| 2794 | + array.prototype.flatmap: 1.3.3 | |
| 2795 | + ast-types-flow: 0.0.8 | |
| 2796 | + axe-core: 4.13.0 | |
| 2797 | + axobject-query: 4.1.0 | |
| 2798 | + damerau-levenshtein: 1.0.8 | |
| 2799 | + emoji-regex: 9.2.2 | |
| 2800 | + eslint: 8.57.1 | |
| 2801 | + hasown: 2.0.4 | |
| 2802 | + jsx-ast-utils: 3.3.5 | |
| 2803 | + language-tags: 1.0.9 | |
| 2804 | + minimatch: 3.1.5 | |
| 2805 | + object.fromentries: 2.0.8 | |
| 2806 | + safe-regex-test: 1.1.0 | |
| 2807 | + string.prototype.includes: 2.0.1 | |
| 2808 | + | |
| 2809 | + eslint-plugin-react-hooks@5.2.0(eslint@8.57.1): | |
| 2810 | + dependencies: | |
| 2811 | + eslint: 8.57.1 | |
| 2812 | + | |
| 2813 | + eslint-plugin-react@7.37.5(eslint@8.57.1): | |
| 2814 | + dependencies: | |
| 2815 | + array-includes: 3.1.9 | |
| 2816 | + array.prototype.findlast: 1.2.5 | |
| 2817 | + array.prototype.flatmap: 1.3.3 | |
| 2818 | + array.prototype.tosorted: 1.1.4 | |
| 2819 | + doctrine: 2.1.0 | |
| 2820 | + es-iterator-helpers: 1.4.0 | |
| 2821 | + eslint: 8.57.1 | |
| 2822 | + estraverse: 5.3.0 | |
| 2823 | + hasown: 2.0.4 | |
| 2824 | + jsx-ast-utils: 3.3.5 | |
| 2825 | + minimatch: 3.1.5 | |
| 2826 | + object.entries: 1.1.9 | |
| 2827 | + object.fromentries: 2.0.8 | |
| 2828 | + object.values: 1.2.1 | |
| 2829 | + prop-types: 15.8.1 | |
| 2830 | + resolve: 2.0.0-next.7 | |
| 2831 | + semver: 6.3.1 | |
| 2832 | + string.prototype.matchall: 4.0.12 | |
| 2833 | + string.prototype.repeat: 1.0.0 | |
| 2834 | + | |
| 2835 | + eslint-scope@7.2.2: | |
| 2836 | + dependencies: | |
| 2837 | + esrecurse: 4.3.0 | |
| 2838 | + estraverse: 5.3.0 | |
| 2839 | + | |
| 2840 | + eslint-visitor-keys@3.4.3: {} | |
| 2841 | + | |
| 2842 | + eslint-visitor-keys@5.0.1: {} | |
| 2843 | + | |
| 2844 | + eslint@8.57.1: | |
| 2845 | + dependencies: | |
| 2846 | + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1) | |
| 2847 | + '@eslint-community/regexpp': 4.12.2 | |
| 2848 | + '@eslint/eslintrc': 2.1.4 | |
| 2849 | + '@eslint/js': 8.57.1 | |
| 2850 | + '@humanwhocodes/config-array': 0.13.0 | |
| 2851 | + '@humanwhocodes/module-importer': 1.0.1 | |
| 2852 | + '@nodelib/fs.walk': 1.2.8 | |
| 2853 | + '@ungap/structured-clone': 1.3.3 | |
| 2854 | + ajv: 6.15.0 | |
| 2855 | + chalk: 4.1.2 | |
| 2856 | + cross-spawn: 7.0.6 | |
| 2857 | + debug: 4.4.3 | |
| 2858 | + doctrine: 3.0.0 | |
| 2859 | + escape-string-regexp: 4.0.0 | |
| 2860 | + eslint-scope: 7.2.2 | |
| 2861 | + eslint-visitor-keys: 3.4.3 | |
| 2862 | + espree: 9.6.1 | |
| 2863 | + esquery: 1.7.0 | |
| 2864 | + esutils: 2.0.3 | |
| 2865 | + fast-deep-equal: 3.1.3 | |
| 2866 | + file-entry-cache: 6.0.1 | |
| 2867 | + find-up: 5.0.0 | |
| 2868 | + glob-parent: 6.0.2 | |
| 2869 | + globals: 13.24.0 | |
| 2870 | + graphemer: 1.4.0 | |
| 2871 | + ignore: 5.3.2 | |
| 2872 | + imurmurhash: 0.1.4 | |
| 2873 | + is-glob: 4.0.3 | |
| 2874 | + is-path-inside: 3.0.3 | |
| 2875 | + js-yaml: 4.3.1 | |
| 2876 | + json-stable-stringify-without-jsonify: 1.0.1 | |
| 2877 | + levn: 0.4.1 | |
| 2878 | + lodash.merge: 4.6.2 | |
| 2879 | + minimatch: 3.1.5 | |
| 2880 | + natural-compare: 1.4.0 | |
| 2881 | + optionator: 0.9.4 | |
| 2882 | + strip-ansi: 6.0.1 | |
| 2883 | + text-table: 0.2.0 | |
| 2884 | + transitivePeerDependencies: | |
| 2885 | + - supports-color | |
| 2886 | + | |
| 2887 | + espree@9.6.1: | |
| 2888 | + dependencies: | |
| 2889 | + acorn: 8.18.0 | |
| 2890 | + acorn-jsx: 5.3.2(acorn@8.18.0) | |
| 2891 | + eslint-visitor-keys: 3.4.3 | |
| 2892 | + | |
| 2893 | + esquery@1.7.0: | |
| 2894 | + dependencies: | |
| 2895 | + estraverse: 5.3.0 | |
| 2896 | + | |
| 2897 | + esrecurse@4.3.0: | |
| 2898 | + dependencies: | |
| 2899 | + estraverse: 5.3.0 | |
| 2900 | + | |
| 2901 | + estraverse@5.3.0: {} | |
| 2902 | + | |
| 2903 | + esutils@2.0.3: {} | |
| 2904 | + | |
| 2905 | + fast-deep-equal@3.1.3: {} | |
| 2906 | + | |
| 2907 | + fast-glob@3.3.1: | |
| 2908 | + dependencies: | |
| 2909 | + '@nodelib/fs.stat': 2.0.5 | |
| 2910 | + '@nodelib/fs.walk': 1.2.8 | |
| 2911 | + glob-parent: 5.1.2 | |
| 2912 | + merge2: 1.4.1 | |
| 2913 | + micromatch: 4.0.8 | |
| 2914 | + | |
| 2915 | + fast-glob@3.3.3: | |
| 2916 | + dependencies: | |
| 2917 | + '@nodelib/fs.stat': 2.0.5 | |
| 2918 | + '@nodelib/fs.walk': 1.2.8 | |
| 2919 | + glob-parent: 5.1.2 | |
| 2920 | + merge2: 1.4.1 | |
| 2921 | + micromatch: 4.0.8 | |
| 2922 | + | |
| 2923 | + fast-json-stable-stringify@2.1.0: {} | |
| 2924 | + | |
| 2925 | + fast-levenshtein@2.0.6: {} | |
| 2926 | + | |
| 2927 | + fastq@1.20.1: | |
| 2928 | + dependencies: | |
| 2929 | + reusify: 1.1.0 | |
| 2930 | + | |
| 2931 | + fdir@6.5.0(picomatch@4.0.5): | |
| 2932 | + optionalDependencies: | |
| 2933 | + picomatch: 4.0.5 | |
| 2934 | + | |
| 2935 | + file-entry-cache@6.0.1: | |
| 2936 | + dependencies: | |
| 2937 | + flat-cache: 3.2.0 | |
| 2938 | + | |
| 2939 | + fill-range@7.1.1: | |
| 2940 | + dependencies: | |
| 2941 | + to-regex-range: 5.0.1 | |
| 2942 | + | |
| 2943 | + find-up@5.0.0: | |
| 2944 | + dependencies: | |
| 2945 | + locate-path: 6.0.0 | |
| 2946 | + path-exists: 4.0.0 | |
| 2947 | + | |
| 2948 | + flat-cache@3.2.0: | |
| 2949 | + dependencies: | |
| 2950 | + flatted: 3.4.4 | |
| 2951 | + keyv: 4.5.4 | |
| 2952 | + rimraf: 3.0.2 | |
| 2953 | + | |
| 2954 | + flatted@3.4.4: {} | |
| 2955 | + | |
| 2956 | + for-each@0.3.5: | |
| 2957 | + dependencies: | |
| 2958 | + is-callable: 1.2.7 | |
| 2959 | + | |
| 2960 | + fraction.js@5.3.4: {} | |
| 2961 | + | |
| 2962 | + fs.realpath@1.0.0: {} | |
| 2963 | + | |
| 2964 | + fsevents@2.3.3: | |
| 2965 | + optional: true | |
| 2966 | + | |
| 2967 | + function-bind@1.1.2: {} | |
| 2968 | + | |
| 2969 | + function.prototype.name@1.2.0: | |
| 2970 | + dependencies: | |
| 2971 | + call-bind: 1.0.9 | |
| 2972 | + call-bound: 1.0.4 | |
| 2973 | + es-define-property: 1.0.1 | |
| 2974 | + es-errors: 1.3.0 | |
| 2975 | + functions-have-names: 1.2.3 | |
| 2976 | + has-property-descriptors: 1.0.2 | |
| 2977 | + hasown: 2.0.4 | |
| 2978 | + is-callable: 1.2.7 | |
| 2979 | + is-document.all: 1.0.0 | |
| 2980 | + | |
| 2981 | + functions-have-names@1.2.3: {} | |
| 2982 | + | |
| 2983 | + generator-function@2.0.1: {} | |
| 2984 | + | |
| 2985 | + get-intrinsic@1.3.0: | |
| 2986 | + dependencies: | |
| 2987 | + call-bind-apply-helpers: 1.0.2 | |
| 2988 | + es-define-property: 1.0.1 | |
| 2989 | + es-errors: 1.3.0 | |
| 2990 | + es-object-atoms: 1.1.2 | |
| 2991 | + function-bind: 1.1.2 | |
| 2992 | + get-proto: 1.0.1 | |
| 2993 | + gopd: 1.2.0 | |
| 2994 | + has-symbols: 1.1.0 | |
| 2995 | + hasown: 2.0.4 | |
| 2996 | + math-intrinsics: 1.1.0 | |
| 2997 | + | |
| 2998 | + get-proto@1.0.1: | |
| 2999 | + dependencies: | |
| 3000 | + dunder-proto: 1.0.1 | |
| 3001 | + es-object-atoms: 1.1.2 | |
| 3002 | + | |
| 3003 | + get-symbol-description@1.1.0: | |
| 3004 | + dependencies: | |
| 3005 | + call-bound: 1.0.4 | |
| 3006 | + es-errors: 1.3.0 | |
| 3007 | + get-intrinsic: 1.3.0 | |
| 3008 | + | |
| 3009 | + get-tsconfig@4.14.2: | |
| 3010 | + dependencies: | |
| 3011 | + resolve-pkg-maps: 1.0.0 | |
| 3012 | + | |
| 3013 | + glob-parent@5.1.2: | |
| 3014 | + dependencies: | |
| 3015 | + is-glob: 4.0.3 | |
| 3016 | + | |
| 3017 | + glob-parent@6.0.2: | |
| 3018 | + dependencies: | |
| 3019 | + is-glob: 4.0.3 | |
| 3020 | + | |
| 3021 | + glob@7.2.3: | |
| 3022 | + dependencies: | |
| 3023 | + fs.realpath: 1.0.0 | |
| 3024 | + inflight: 1.0.6 | |
| 3025 | + inherits: 2.0.4 | |
| 3026 | + minimatch: 3.1.5 | |
| 3027 | + once: 1.4.0 | |
| 3028 | + path-is-absolute: 1.0.1 | |
| 3029 | + | |
| 3030 | + globals@13.24.0: | |
| 3031 | + dependencies: | |
| 3032 | + type-fest: 0.20.2 | |
| 3033 | + | |
| 3034 | + globalthis@1.0.4: | |
| 3035 | + dependencies: | |
| 3036 | + define-properties: 1.2.1 | |
| 3037 | + gopd: 1.2.0 | |
| 3038 | + | |
| 3039 | + gopd@1.2.0: {} | |
| 3040 | + | |
| 3041 | + graphemer@1.4.0: {} | |
| 3042 | + | |
| 3043 | + has-bigints@1.1.0: {} | |
| 3044 | + | |
| 3045 | + has-flag@4.0.0: {} | |
| 3046 | + | |
| 3047 | + has-property-descriptors@1.0.2: | |
| 3048 | + dependencies: | |
| 3049 | + es-define-property: 1.0.1 | |
| 3050 | + | |
| 3051 | + has-proto@1.2.0: | |
| 3052 | + dependencies: | |
| 3053 | + dunder-proto: 1.0.1 | |
| 3054 | + | |
| 3055 | + has-symbols@1.1.0: {} | |
| 3056 | + | |
| 3057 | + has-tostringtag@1.0.2: | |
| 3058 | + dependencies: | |
| 3059 | + has-symbols: 1.1.0 | |
| 3060 | + | |
| 3061 | + hasown@2.0.4: | |
| 3062 | + dependencies: | |
| 3063 | + function-bind: 1.1.2 | |
| 3064 | + | |
| 3065 | + ignore@5.3.2: {} | |
| 3066 | + | |
| 3067 | + ignore@7.0.6: {} | |
| 3068 | + | |
| 3069 | + import-fresh@3.3.1: | |
| 3070 | + dependencies: | |
| 3071 | + parent-module: 1.0.1 | |
| 3072 | + resolve-from: 4.0.0 | |
| 3073 | + | |
| 3074 | + imurmurhash@0.1.4: {} | |
| 3075 | + | |
| 3076 | + inflight@1.0.6: | |
| 3077 | + dependencies: | |
| 3078 | + once: 1.4.0 | |
| 3079 | + wrappy: 1.0.2 | |
| 3080 | + | |
| 3081 | + inherits@2.0.4: {} | |
| 3082 | + | |
| 3083 | + internal-slot@1.1.0: | |
| 3084 | + dependencies: | |
| 3085 | + es-errors: 1.3.0 | |
| 3086 | + hasown: 2.0.4 | |
| 3087 | + side-channel: 1.1.1 | |
| 3088 | + | |
| 3089 | + is-array-buffer@3.0.5: | |
| 3090 | + dependencies: | |
| 3091 | + call-bind: 1.0.9 | |
| 3092 | + call-bound: 1.0.4 | |
| 3093 | + get-intrinsic: 1.3.0 | |
| 3094 | + | |
| 3095 | + is-async-function@2.1.1: | |
| 3096 | + dependencies: | |
| 3097 | + async-function: 1.0.0 | |
| 3098 | + call-bound: 1.0.4 | |
| 3099 | + get-proto: 1.0.1 | |
| 3100 | + has-tostringtag: 1.0.2 | |
| 3101 | + safe-regex-test: 1.1.0 | |
| 3102 | + | |
| 3103 | + is-bigint@1.1.0: | |
| 3104 | + dependencies: | |
| 3105 | + has-bigints: 1.1.0 | |
| 3106 | + | |
| 3107 | + is-binary-path@2.1.0: | |
| 3108 | + dependencies: | |
| 3109 | + binary-extensions: 2.3.0 | |
| 3110 | + | |
| 3111 | + is-boolean-object@1.2.2: | |
| 3112 | + dependencies: | |
| 3113 | + call-bound: 1.0.4 | |
| 3114 | + has-tostringtag: 1.0.2 | |
| 3115 | + | |
| 3116 | + is-bun-module@2.0.0: | |
| 3117 | + dependencies: | |
| 3118 | + semver: 7.8.5 | |
| 3119 | + | |
| 3120 | + is-callable@1.2.7: {} | |
| 3121 | + | |
| 3122 | + is-core-module@2.16.2: | |
| 3123 | + dependencies: | |
| 3124 | + hasown: 2.0.4 | |
| 3125 | + | |
| 3126 | + is-data-view@1.0.2: | |
| 3127 | + dependencies: | |
| 3128 | + call-bound: 1.0.4 | |
| 3129 | + get-intrinsic: 1.3.0 | |
| 3130 | + is-typed-array: 1.1.15 | |
| 3131 | + | |
| 3132 | + is-date-object@1.1.0: | |
| 3133 | + dependencies: | |
| 3134 | + call-bound: 1.0.4 | |
| 3135 | + has-tostringtag: 1.0.2 | |
| 3136 | + | |
| 3137 | + is-document.all@1.0.0: | |
| 3138 | + dependencies: | |
| 3139 | + call-bound: 1.0.4 | |
| 3140 | + | |
| 3141 | + is-extglob@2.1.1: {} | |
| 3142 | + | |
| 3143 | + is-finalizationregistry@1.1.1: | |
| 3144 | + dependencies: | |
| 3145 | + call-bound: 1.0.4 | |
| 3146 | + | |
| 3147 | + is-generator-function@1.1.2: | |
| 3148 | + dependencies: | |
| 3149 | + call-bound: 1.0.4 | |
| 3150 | + generator-function: 2.0.1 | |
| 3151 | + get-proto: 1.0.1 | |
| 3152 | + has-tostringtag: 1.0.2 | |
| 3153 | + safe-regex-test: 1.1.0 | |
| 3154 | + | |
| 3155 | + is-glob@4.0.3: | |
| 3156 | + dependencies: | |
| 3157 | + is-extglob: 2.1.1 | |
| 3158 | + | |
| 3159 | + is-map@2.0.3: {} | |
| 3160 | + | |
| 3161 | + is-negative-zero@2.0.3: {} | |
| 3162 | + | |
| 3163 | + is-number-object@1.1.1: | |
| 3164 | + dependencies: | |
| 3165 | + call-bound: 1.0.4 | |
| 3166 | + has-tostringtag: 1.0.2 | |
| 3167 | + | |
| 3168 | + is-number@7.0.0: {} | |
| 3169 | + | |
| 3170 | + is-path-inside@3.0.3: {} | |
| 3171 | + | |
| 3172 | + is-regex@1.2.1: | |
| 3173 | + dependencies: | |
| 3174 | + call-bound: 1.0.4 | |
| 3175 | + gopd: 1.2.0 | |
| 3176 | + has-tostringtag: 1.0.2 | |
| 3177 | + hasown: 2.0.4 | |
| 3178 | + | |
| 3179 | + is-set@2.0.3: {} | |
| 3180 | + | |
| 3181 | + is-shared-array-buffer@1.0.4: | |
| 3182 | + dependencies: | |
| 3183 | + call-bound: 1.0.4 | |
| 3184 | + | |
| 3185 | + is-string@1.1.1: | |
| 3186 | + dependencies: | |
| 3187 | + call-bound: 1.0.4 | |
| 3188 | + has-tostringtag: 1.0.2 | |
| 3189 | + | |
| 3190 | + is-symbol@1.1.1: | |
| 3191 | + dependencies: | |
| 3192 | + call-bound: 1.0.4 | |
| 3193 | + has-symbols: 1.1.0 | |
| 3194 | + safe-regex-test: 1.1.0 | |
| 3195 | + | |
| 3196 | + is-typed-array@1.1.15: | |
| 3197 | + dependencies: | |
| 3198 | + which-typed-array: 1.1.22 | |
| 3199 | + | |
| 3200 | + is-weakmap@2.0.2: {} | |
| 3201 | + | |
| 3202 | + is-weakref@1.1.1: | |
| 3203 | + dependencies: | |
| 3204 | + call-bound: 1.0.4 | |
| 3205 | + | |
| 3206 | + is-weakset@2.0.4: | |
| 3207 | + dependencies: | |
| 3208 | + call-bound: 1.0.4 | |
| 3209 | + get-intrinsic: 1.3.0 | |
| 3210 | + | |
| 3211 | + isarray@2.0.5: {} | |
| 3212 | + | |
| 3213 | + isexe@2.0.0: {} | |
| 3214 | + | |
| 3215 | + iterator.prototype@1.1.5: | |
| 3216 | + dependencies: | |
| 3217 | + define-data-property: 1.1.4 | |
| 3218 | + es-object-atoms: 1.1.2 | |
| 3219 | + get-intrinsic: 1.3.0 | |
| 3220 | + get-proto: 1.0.1 | |
| 3221 | + has-symbols: 1.1.0 | |
| 3222 | + set-function-name: 2.0.2 | |
| 3223 | + | |
| 3224 | + jiti@1.21.7: {} | |
| 3225 | + | |
| 3226 | + js-tokens@4.0.0: {} | |
| 3227 | + | |
| 3228 | + js-yaml@4.3.1: | |
| 3229 | + dependencies: | |
| 3230 | + argparse: 2.0.1 | |
| 3231 | + | |
| 3232 | + json-buffer@3.0.1: {} | |
| 3233 | + | |
| 3234 | + json-schema-traverse@0.4.1: {} | |
| 3235 | + | |
| 3236 | + json-stable-stringify-without-jsonify@1.0.1: {} | |
| 3237 | + | |
| 3238 | + json5@1.0.2: | |
| 3239 | + dependencies: | |
| 3240 | + minimist: 1.2.8 | |
| 3241 | + | |
| 3242 | + jsx-ast-utils@3.3.5: | |
| 3243 | + dependencies: | |
| 3244 | + array-includes: 3.1.9 | |
| 3245 | + array.prototype.flat: 1.3.3 | |
| 3246 | + object.assign: 4.1.7 | |
| 3247 | + object.values: 1.2.1 | |
| 3248 | + | |
| 3249 | + keyv@4.5.4: | |
| 3250 | + dependencies: | |
| 3251 | + json-buffer: 3.0.1 | |
| 3252 | + | |
| 3253 | + language-subtag-registry@0.3.23: {} | |
| 3254 | + | |
| 3255 | + language-tags@1.0.9: | |
| 3256 | + dependencies: | |
| 3257 | + language-subtag-registry: 0.3.23 | |
| 3258 | + | |
| 3259 | + levn@0.4.1: | |
| 3260 | + dependencies: | |
| 3261 | + prelude-ls: 1.2.1 | |
| 3262 | + type-check: 0.4.0 | |
| 3263 | + | |
| 3264 | + lilconfig@3.1.3: {} | |
| 3265 | + | |
| 3266 | + lines-and-columns@1.2.4: {} | |
| 3267 | + | |
| 3268 | + locate-path@6.0.0: | |
| 3269 | + dependencies: | |
| 3270 | + p-locate: 5.0.0 | |
| 3271 | + | |
| 3272 | + lodash.merge@4.6.2: {} | |
| 3273 | + | |
| 3274 | + loose-envify@1.4.0: | |
| 3275 | + dependencies: | |
| 3276 | + js-tokens: 4.0.0 | |
| 3277 | + | |
| 3278 | + math-intrinsics@1.1.0: {} | |
| 3279 | + | |
| 3280 | + merge2@1.4.1: {} | |
| 3281 | + | |
| 3282 | + micromatch@4.0.8: | |
| 3283 | + dependencies: | |
| 3284 | + braces: 3.0.3 | |
| 3285 | + picomatch: 2.3.2 | |
| 3286 | + | |
| 3287 | + minimatch@10.2.6: | |
| 3288 | + dependencies: | |
| 3289 | + brace-expansion: 5.0.9 | |
| 3290 | + | |
| 3291 | + minimatch@3.1.5: | |
| 3292 | + dependencies: | |
| 3293 | + brace-expansion: 1.1.18 | |
| 3294 | + | |
| 3295 | + minimist@1.2.8: {} | |
| 3296 | + | |
| 3297 | + ms@2.1.3: {} | |
| 3298 | + | |
| 3299 | + mz@2.7.0: | |
| 3300 | + dependencies: | |
| 3301 | + any-promise: 1.3.0 | |
| 3302 | + object-assign: 4.1.1 | |
| 3303 | + thenify-all: 1.6.0 | |
| 3304 | + | |
| 3305 | + nanoid@3.3.18: {} | |
| 3306 | + | |
| 3307 | + napi-postinstall@0.3.4: {} | |
| 3308 | + | |
| 3309 | + natural-compare@1.4.0: {} | |
| 3310 | + | |
| 3311 | + next@15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8): | |
| 3312 | + dependencies: | |
| 3313 | + '@next/env': 15.5.23 | |
| 3314 | + '@swc/helpers': 0.5.15 | |
| 3315 | + caniuse-lite: 1.0.30001809 | |
| 3316 | + postcss: 8.4.31 | |
| 3317 | + react: 19.2.8 | |
| 3318 | + react-dom: 19.2.8(react@19.2.8) | |
| 3319 | + styled-jsx: 5.1.6(react@19.2.8) | |
| 3320 | + optionalDependencies: | |
| 3321 | + '@next/swc-darwin-arm64': 15.5.23 | |
| 3322 | + '@next/swc-darwin-x64': 15.5.23 | |
| 3323 | + '@next/swc-linux-arm64-gnu': 15.5.23 | |
| 3324 | + '@next/swc-linux-arm64-musl': 15.5.23 | |
| 3325 | + '@next/swc-linux-x64-gnu': 15.5.23 | |
| 3326 | + '@next/swc-linux-x64-musl': 15.5.23 | |
| 3327 | + '@next/swc-win32-arm64-msvc': 15.5.23 | |
| 3328 | + '@next/swc-win32-x64-msvc': 15.5.23 | |
| 3329 | + sharp: 0.34.5 | |
| 3330 | + transitivePeerDependencies: | |
| 3331 | + - '@babel/core' | |
| 3332 | + - babel-plugin-macros | |
| 3333 | + | |
| 3334 | + node-exports-info@1.6.2: | |
| 3335 | + dependencies: | |
| 3336 | + array.prototype.flatmap: 1.3.3 | |
| 3337 | + es-errors: 1.3.0 | |
| 3338 | + object.entries: 1.1.9 | |
| 3339 | + semver: 6.3.1 | |
| 3340 | + | |
| 3341 | + node-releases@2.0.53: {} | |
| 3342 | + | |
| 3343 | + normalize-path@3.0.0: {} | |
| 3344 | + | |
| 3345 | + object-assign@4.1.1: {} | |
| 3346 | + | |
| 3347 | + object-hash@3.0.0: {} | |
| 3348 | + | |
| 3349 | + object-inspect@1.13.4: {} | |
| 3350 | + | |
| 3351 | + object-keys@1.1.1: {} | |
| 3352 | + | |
| 3353 | + object.assign@4.1.7: | |
| 3354 | + dependencies: | |
| 3355 | + call-bind: 1.0.9 | |
| 3356 | + call-bound: 1.0.4 | |
| 3357 | + define-properties: 1.2.1 | |
| 3358 | + es-object-atoms: 1.1.2 | |
| 3359 | + has-symbols: 1.1.0 | |
| 3360 | + object-keys: 1.1.1 | |
| 3361 | + | |
| 3362 | + object.entries@1.1.9: | |
| 3363 | + dependencies: | |
| 3364 | + call-bind: 1.0.9 | |
| 3365 | + call-bound: 1.0.4 | |
| 3366 | + define-properties: 1.2.1 | |
| 3367 | + es-object-atoms: 1.1.2 | |
| 3368 | + | |
| 3369 | + object.fromentries@2.0.8: | |
| 3370 | + dependencies: | |
| 3371 | + call-bind: 1.0.9 | |
| 3372 | + define-properties: 1.2.1 | |
| 3373 | + es-abstract: 1.24.2 | |
| 3374 | + es-object-atoms: 1.1.2 | |
| 3375 | + | |
| 3376 | + object.groupby@1.0.3: | |
| 3377 | + dependencies: | |
| 3378 | + call-bind: 1.0.9 | |
| 3379 | + define-properties: 1.2.1 | |
| 3380 | + es-abstract: 1.24.2 | |
| 3381 | + | |
| 3382 | + object.values@1.2.1: | |
| 3383 | + dependencies: | |
| 3384 | + call-bind: 1.0.9 | |
| 3385 | + call-bound: 1.0.4 | |
| 3386 | + define-properties: 1.2.1 | |
| 3387 | + es-object-atoms: 1.1.2 | |
| 3388 | + | |
| 3389 | + once@1.4.0: | |
| 3390 | + dependencies: | |
| 3391 | + wrappy: 1.0.2 | |
| 3392 | + | |
| 3393 | + optionator@0.9.4: | |
| 3394 | + dependencies: | |
| 3395 | + deep-is: 0.1.4 | |
| 3396 | + fast-levenshtein: 2.0.6 | |
| 3397 | + levn: 0.4.1 | |
| 3398 | + prelude-ls: 1.2.1 | |
| 3399 | + type-check: 0.4.0 | |
| 3400 | + word-wrap: 1.2.5 | |
| 3401 | + | |
| 3402 | + own-keys@1.0.2: | |
| 3403 | + dependencies: | |
| 3404 | + call-bound: 1.0.4 | |
| 3405 | + get-intrinsic: 1.3.0 | |
| 3406 | + object-keys: 1.1.1 | |
| 3407 | + safe-push-apply: 1.0.0 | |
| 3408 | + | |
| 3409 | + p-limit@3.1.0: | |
| 3410 | + dependencies: | |
| 3411 | + yocto-queue: 0.1.0 | |
| 3412 | + | |
| 3413 | + p-locate@5.0.0: | |
| 3414 | + dependencies: | |
| 3415 | + p-limit: 3.1.0 | |
| 3416 | + | |
| 3417 | + parent-module@1.0.1: | |
| 3418 | + dependencies: | |
| 3419 | + callsites: 3.1.0 | |
| 3420 | + | |
| 3421 | + path-exists@4.0.0: {} | |
| 3422 | + | |
| 3423 | + path-is-absolute@1.0.1: {} | |
| 3424 | + | |
| 3425 | + path-key@3.1.1: {} | |
| 3426 | + | |
| 3427 | + path-parse@1.0.7: {} | |
| 3428 | + | |
| 3429 | + picocolors@1.1.1: {} | |
| 3430 | + | |
| 3431 | + picomatch@2.3.2: {} | |
| 3432 | + | |
| 3433 | + picomatch@4.0.5: {} | |
| 3434 | + | |
| 3435 | + pify@2.3.0: {} | |
| 3436 | + | |
| 3437 | + pirates@4.0.7: {} | |
| 3438 | + | |
| 3439 | + possible-typed-array-names@1.1.0: {} | |
| 3440 | + | |
| 3441 | + postcss-import@15.1.0(postcss@8.5.26): | |
| 3442 | + dependencies: | |
| 3443 | + postcss: 8.5.26 | |
| 3444 | + postcss-value-parser: 4.2.0 | |
| 3445 | + read-cache: 1.0.0 | |
| 3446 | + resolve: 1.22.12 | |
| 3447 | + | |
| 3448 | + postcss-js@4.1.0(postcss@8.5.26): | |
| 3449 | + dependencies: | |
| 3450 | + camelcase-css: 2.0.1 | |
| 3451 | + postcss: 8.5.26 | |
| 3452 | + | |
| 3453 | + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26): | |
| 3454 | + dependencies: | |
| 3455 | + lilconfig: 3.1.3 | |
| 3456 | + optionalDependencies: | |
| 3457 | + jiti: 1.21.7 | |
| 3458 | + postcss: 8.5.26 | |
| 3459 | + | |
| 3460 | + postcss-nested@6.2.0(postcss@8.5.26): | |
| 3461 | + dependencies: | |
| 3462 | + postcss: 8.5.26 | |
| 3463 | + postcss-selector-parser: 6.1.4 | |
| 3464 | + | |
| 3465 | + postcss-selector-parser@6.1.4: | |
| 3466 | + dependencies: | |
| 3467 | + cssesc: 3.0.0 | |
| 3468 | + util-deprecate: 1.0.2 | |
| 3469 | + | |
| 3470 | + postcss-value-parser@4.2.0: {} | |
| 3471 | + | |
| 3472 | + postcss@8.4.31: | |
| 3473 | + dependencies: | |
| 3474 | + nanoid: 3.3.18 | |
| 3475 | + picocolors: 1.1.1 | |
| 3476 | + source-map-js: 1.2.1 | |
| 3477 | + | |
| 3478 | + postcss@8.5.26: | |
| 3479 | + dependencies: | |
| 3480 | + nanoid: 3.3.18 | |
| 3481 | + picocolors: 1.1.1 | |
| 3482 | + source-map-js: 1.2.1 | |
| 3483 | + | |
| 3484 | + prelude-ls@1.2.1: {} | |
| 3485 | + | |
| 3486 | + prop-types@15.8.1: | |
| 3487 | + dependencies: | |
| 3488 | + loose-envify: 1.4.0 | |
| 3489 | + object-assign: 4.1.1 | |
| 3490 | + react-is: 16.13.1 | |
| 3491 | + | |
| 3492 | + punycode@2.3.1: {} | |
| 3493 | + | |
| 3494 | + queue-microtask@1.2.3: {} | |
| 3495 | + | |
| 3496 | + react-dom@19.2.8(react@19.2.8): | |
| 3497 | + dependencies: | |
| 3498 | + react: 19.2.8 | |
| 3499 | + scheduler: 0.27.0 | |
| 3500 | + | |
| 3501 | + react-is@16.13.1: {} | |
| 3502 | + | |
| 3503 | + react@19.2.8: {} | |
| 3504 | + | |
| 3505 | + read-cache@1.0.0: | |
| 3506 | + dependencies: | |
| 3507 | + pify: 2.3.0 | |
| 3508 | + | |
| 3509 | + readdirp@3.6.0: | |
| 3510 | + dependencies: | |
| 3511 | + picomatch: 2.3.2 | |
| 3512 | + | |
| 3513 | + reflect.getprototypeof@1.0.10: | |
| 3514 | + dependencies: | |
| 3515 | + call-bind: 1.0.9 | |
| 3516 | + define-properties: 1.2.1 | |
| 3517 | + es-abstract: 1.24.2 | |
| 3518 | + es-errors: 1.3.0 | |
| 3519 | + es-object-atoms: 1.1.2 | |
| 3520 | + get-intrinsic: 1.3.0 | |
| 3521 | + get-proto: 1.0.1 | |
| 3522 | + which-builtin-type: 1.2.1 | |
| 3523 | + | |
| 3524 | + regexp.prototype.flags@1.5.4: | |
| 3525 | + dependencies: | |
| 3526 | + call-bind: 1.0.9 | |
| 3527 | + define-properties: 1.2.1 | |
| 3528 | + es-errors: 1.3.0 | |
| 3529 | + get-proto: 1.0.1 | |
| 3530 | + gopd: 1.2.0 | |
| 3531 | + set-function-name: 2.0.2 | |
| 3532 | + | |
| 3533 | + resolve-from@4.0.0: {} | |
| 3534 | + | |
| 3535 | + resolve-pkg-maps@1.0.0: {} | |
| 3536 | + | |
| 3537 | + resolve@1.22.12: | |
| 3538 | + dependencies: | |
| 3539 | + es-errors: 1.3.0 | |
| 3540 | + is-core-module: 2.16.2 | |
| 3541 | + path-parse: 1.0.7 | |
| 3542 | + supports-preserve-symlinks-flag: 1.0.0 | |
| 3543 | + | |
| 3544 | + resolve@2.0.0-next.7: | |
| 3545 | + dependencies: | |
| 3546 | + es-errors: 1.3.0 | |
| 3547 | + is-core-module: 2.16.2 | |
| 3548 | + node-exports-info: 1.6.2 | |
| 3549 | + object-keys: 1.1.1 | |
| 3550 | + path-parse: 1.0.7 | |
| 3551 | + supports-preserve-symlinks-flag: 1.0.0 | |
| 3552 | + | |
| 3553 | + reusify@1.1.0: {} | |
| 3554 | + | |
| 3555 | + rimraf@3.0.2: | |
| 3556 | + dependencies: | |
| 3557 | + glob: 7.2.3 | |
| 3558 | + | |
| 3559 | + run-parallel@1.2.0: | |
| 3560 | + dependencies: | |
| 3561 | + queue-microtask: 1.2.3 | |
| 3562 | + | |
| 3563 | + safe-array-concat@1.1.4: | |
| 3564 | + dependencies: | |
| 3565 | + call-bind: 1.0.9 | |
| 3566 | + call-bound: 1.0.4 | |
| 3567 | + get-intrinsic: 1.3.0 | |
| 3568 | + has-symbols: 1.1.0 | |
| 3569 | + isarray: 2.0.5 | |
| 3570 | + | |
| 3571 | + safe-push-apply@1.0.0: | |
| 3572 | + dependencies: | |
| 3573 | + es-errors: 1.3.0 | |
| 3574 | + isarray: 2.0.5 | |
| 3575 | + | |
| 3576 | + safe-regex-test@1.1.0: | |
| 3577 | + dependencies: | |
| 3578 | + call-bound: 1.0.4 | |
| 3579 | + es-errors: 1.3.0 | |
| 3580 | + is-regex: 1.2.1 | |
| 3581 | + | |
| 3582 | + scheduler@0.27.0: {} | |
| 3583 | + | |
| 3584 | + semver@6.3.1: {} | |
| 3585 | + | |
| 3586 | + semver@7.8.5: {} | |
| 3587 | + | |
| 3588 | + set-function-length@1.2.2: | |
| 3589 | + dependencies: | |
| 3590 | + define-data-property: 1.1.4 | |
| 3591 | + es-errors: 1.3.0 | |
| 3592 | + function-bind: 1.1.2 | |
| 3593 | + get-intrinsic: 1.3.0 | |
| 3594 | + gopd: 1.2.0 | |
| 3595 | + has-property-descriptors: 1.0.2 | |
| 3596 | + | |
| 3597 | + set-function-name@2.0.2: | |
| 3598 | + dependencies: | |
| 3599 | + define-data-property: 1.1.4 | |
| 3600 | + es-errors: 1.3.0 | |
| 3601 | + functions-have-names: 1.2.3 | |
| 3602 | + has-property-descriptors: 1.0.2 | |
| 3603 | + | |
| 3604 | + set-proto@1.0.0: | |
| 3605 | + dependencies: | |
| 3606 | + dunder-proto: 1.0.1 | |
| 3607 | + es-errors: 1.3.0 | |
| 3608 | + es-object-atoms: 1.1.2 | |
| 3609 | + | |
| 3610 | + sharp@0.34.5: | |
| 3611 | + dependencies: | |
| 3612 | + '@img/colour': 1.1.0 | |
| 3613 | + detect-libc: 2.1.2 | |
| 3614 | + semver: 7.8.5 | |
| 3615 | + optionalDependencies: | |
| 3616 | + '@img/sharp-darwin-arm64': 0.34.5 | |
| 3617 | + '@img/sharp-darwin-x64': 0.34.5 | |
| 3618 | + '@img/sharp-libvips-darwin-arm64': 1.2.4 | |
| 3619 | + '@img/sharp-libvips-darwin-x64': 1.2.4 | |
| 3620 | + '@img/sharp-libvips-linux-arm': 1.2.4 | |
| 3621 | + '@img/sharp-libvips-linux-arm64': 1.2.4 | |
| 3622 | + '@img/sharp-libvips-linux-ppc64': 1.2.4 | |
| 3623 | + '@img/sharp-libvips-linux-riscv64': 1.2.4 | |
| 3624 | + '@img/sharp-libvips-linux-s390x': 1.2.4 | |
| 3625 | + '@img/sharp-libvips-linux-x64': 1.2.4 | |
| 3626 | + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 | |
| 3627 | + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 | |
| 3628 | + '@img/sharp-linux-arm': 0.34.5 | |
| 3629 | + '@img/sharp-linux-arm64': 0.34.5 | |
| 3630 | + '@img/sharp-linux-ppc64': 0.34.5 | |
| 3631 | + '@img/sharp-linux-riscv64': 0.34.5 | |
| 3632 | + '@img/sharp-linux-s390x': 0.34.5 | |
| 3633 | + '@img/sharp-linux-x64': 0.34.5 | |
| 3634 | + '@img/sharp-linuxmusl-arm64': 0.34.5 | |
| 3635 | + '@img/sharp-linuxmusl-x64': 0.34.5 | |
| 3636 | + '@img/sharp-wasm32': 0.34.5 | |
| 3637 | + '@img/sharp-win32-arm64': 0.34.5 | |
| 3638 | + '@img/sharp-win32-ia32': 0.34.5 | |
| 3639 | + '@img/sharp-win32-x64': 0.34.5 | |
| 3640 | + optional: true | |
| 3641 | + | |
| 3642 | + shebang-command@2.0.0: | |
| 3643 | + dependencies: | |
| 3644 | + shebang-regex: 3.0.0 | |
| 3645 | + | |
| 3646 | + shebang-regex@3.0.0: {} | |
| 3647 | + | |
| 3648 | + side-channel-list@1.0.1: | |
| 3649 | + dependencies: | |
| 3650 | + es-errors: 1.3.0 | |
| 3651 | + object-inspect: 1.13.4 | |
| 3652 | + | |
| 3653 | + side-channel-map@1.0.1: | |
| 3654 | + dependencies: | |
| 3655 | + call-bound: 1.0.4 | |
| 3656 | + es-errors: 1.3.0 | |
| 3657 | + get-intrinsic: 1.3.0 | |
| 3658 | + object-inspect: 1.13.4 | |
| 3659 | + | |
| 3660 | + side-channel-weakmap@1.0.2: | |
| 3661 | + dependencies: | |
| 3662 | + call-bound: 1.0.4 | |
| 3663 | + es-errors: 1.3.0 | |
| 3664 | + get-intrinsic: 1.3.0 | |
| 3665 | + object-inspect: 1.13.4 | |
| 3666 | + side-channel-map: 1.0.1 | |
| 3667 | + | |
| 3668 | + side-channel@1.1.1: | |
| 3669 | + dependencies: | |
| 3670 | + es-errors: 1.3.0 | |
| 3671 | + object-inspect: 1.13.4 | |
| 3672 | + side-channel-list: 1.0.1 | |
| 3673 | + side-channel-map: 1.0.1 | |
| 3674 | + side-channel-weakmap: 1.0.2 | |
| 3675 | + | |
| 3676 | + source-map-js@1.2.1: {} | |
| 3677 | + | |
| 3678 | + stable-hash@0.0.5: {} | |
| 3679 | + | |
| 3680 | + stop-iteration-iterator@1.1.0: | |
| 3681 | + dependencies: | |
| 3682 | + es-errors: 1.3.0 | |
| 3683 | + internal-slot: 1.1.0 | |
| 3684 | + | |
| 3685 | + string.prototype.includes@2.0.1: | |
| 3686 | + dependencies: | |
| 3687 | + call-bind: 1.0.9 | |
| 3688 | + define-properties: 1.2.1 | |
| 3689 | + es-abstract: 1.24.2 | |
| 3690 | + | |
| 3691 | + string.prototype.matchall@4.0.12: | |
| 3692 | + dependencies: | |
| 3693 | + call-bind: 1.0.9 | |
| 3694 | + call-bound: 1.0.4 | |
| 3695 | + define-properties: 1.2.1 | |
| 3696 | + es-abstract: 1.24.2 | |
| 3697 | + es-errors: 1.3.0 | |
| 3698 | + es-object-atoms: 1.1.2 | |
| 3699 | + get-intrinsic: 1.3.0 | |
| 3700 | + gopd: 1.2.0 | |
| 3701 | + has-symbols: 1.1.0 | |
| 3702 | + internal-slot: 1.1.0 | |
| 3703 | + regexp.prototype.flags: 1.5.4 | |
| 3704 | + set-function-name: 2.0.2 | |
| 3705 | + side-channel: 1.1.1 | |
| 3706 | + | |
| 3707 | + string.prototype.repeat@1.0.0: | |
| 3708 | + dependencies: | |
| 3709 | + define-properties: 1.2.1 | |
| 3710 | + es-abstract: 1.24.2 | |
| 3711 | + | |
| 3712 | + string.prototype.trim@1.2.11: | |
| 3713 | + dependencies: | |
| 3714 | + call-bind: 1.0.9 | |
| 3715 | + call-bound: 1.0.4 | |
| 3716 | + define-data-property: 1.1.4 | |
| 3717 | + define-properties: 1.2.1 | |
| 3718 | + es-abstract: 1.24.2 | |
| 3719 | + es-object-atoms: 1.1.2 | |
| 3720 | + has-property-descriptors: 1.0.2 | |
| 3721 | + safe-regex-test: 1.1.0 | |
| 3722 | + | |
| 3723 | + string.prototype.trimend@1.0.10: | |
| 3724 | + dependencies: | |
| 3725 | + call-bind: 1.0.9 | |
| 3726 | + call-bound: 1.0.4 | |
| 3727 | + define-properties: 1.2.1 | |
| 3728 | + es-object-atoms: 1.1.2 | |
| 3729 | + | |
| 3730 | + string.prototype.trimstart@1.0.8: | |
| 3731 | + dependencies: | |
| 3732 | + call-bind: 1.0.9 | |
| 3733 | + define-properties: 1.2.1 | |
| 3734 | + es-object-atoms: 1.1.2 | |
| 3735 | + | |
| 3736 | + strip-ansi@6.0.1: | |
| 3737 | + dependencies: | |
| 3738 | + ansi-regex: 5.0.1 | |
| 3739 | + | |
| 3740 | + strip-bom@3.0.0: {} | |
| 3741 | + | |
| 3742 | + strip-json-comments@3.1.1: {} | |
| 3743 | + | |
| 3744 | + styled-jsx@5.1.6(react@19.2.8): | |
| 3745 | + dependencies: | |
| 3746 | + client-only: 0.0.1 | |
| 3747 | + react: 19.2.8 | |
| 3748 | + | |
| 3749 | + sucrase@3.35.1: | |
| 3750 | + dependencies: | |
| 3751 | + '@jridgewell/gen-mapping': 0.3.13 | |
| 3752 | + commander: 4.1.1 | |
| 3753 | + lines-and-columns: 1.2.4 | |
| 3754 | + mz: 2.7.0 | |
| 3755 | + pirates: 4.0.7 | |
| 3756 | + tinyglobby: 0.2.17 | |
| 3757 | + ts-interface-checker: 0.1.13 | |
| 3758 | + | |
| 3759 | + supports-color@7.2.0: | |
| 3760 | + dependencies: | |
| 3761 | + has-flag: 4.0.0 | |
| 3762 | + | |
| 3763 | + supports-preserve-symlinks-flag@1.0.0: {} | |
| 3764 | + | |
| 3765 | + tailwind-merge@2.6.1: {} | |
| 3766 | + | |
| 3767 | + tailwindcss@3.4.19: | |
| 3768 | + dependencies: | |
| 3769 | + '@alloc/quick-lru': 5.2.0 | |
| 3770 | + arg: 5.0.2 | |
| 3771 | + chokidar: 3.6.0 | |
| 3772 | + didyoumean: 1.2.2 | |
| 3773 | + dlv: 1.1.3 | |
| 3774 | + fast-glob: 3.3.3 | |
| 3775 | + glob-parent: 6.0.2 | |
| 3776 | + is-glob: 4.0.3 | |
| 3777 | + jiti: 1.21.7 | |
| 3778 | + lilconfig: 3.1.3 | |
| 3779 | + micromatch: 4.0.8 | |
| 3780 | + normalize-path: 3.0.0 | |
| 3781 | + object-hash: 3.0.0 | |
| 3782 | + picocolors: 1.1.1 | |
| 3783 | + postcss: 8.5.26 | |
| 3784 | + postcss-import: 15.1.0(postcss@8.5.26) | |
| 3785 | + postcss-js: 4.1.0(postcss@8.5.26) | |
| 3786 | + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26) | |
| 3787 | + postcss-nested: 6.2.0(postcss@8.5.26) | |
| 3788 | + postcss-selector-parser: 6.1.4 | |
| 3789 | + resolve: 1.22.12 | |
| 3790 | + sucrase: 3.35.1 | |
| 3791 | + transitivePeerDependencies: | |
| 3792 | + - tsx | |
| 3793 | + - yaml | |
| 3794 | + | |
| 3795 | + text-table@0.2.0: {} | |
| 3796 | + | |
| 3797 | + thenify-all@1.6.0: | |
| 3798 | + dependencies: | |
| 3799 | + thenify: 3.3.1 | |
| 3800 | + | |
| 3801 | + thenify@3.3.1: | |
| 3802 | + dependencies: | |
| 3803 | + any-promise: 1.3.0 | |
| 3804 | + | |
| 3805 | + tinyglobby@0.2.17: | |
| 3806 | + dependencies: | |
| 3807 | + fdir: 6.5.0(picomatch@4.0.5) | |
| 3808 | + picomatch: 4.0.5 | |
| 3809 | + | |
| 3810 | + to-regex-range@5.0.1: | |
| 3811 | + dependencies: | |
| 3812 | + is-number: 7.0.0 | |
| 3813 | + | |
| 3814 | + ts-api-utils@2.5.0(typescript@5.9.3): | |
| 3815 | + dependencies: | |
| 3816 | + typescript: 5.9.3 | |
| 3817 | + | |
| 3818 | + ts-interface-checker@0.1.13: {} | |
| 3819 | + | |
| 3820 | + tsconfig-paths@3.15.0: | |
| 3821 | + dependencies: | |
| 3822 | + '@types/json5': 0.0.29 | |
| 3823 | + json5: 1.0.2 | |
| 3824 | + minimist: 1.2.8 | |
| 3825 | + strip-bom: 3.0.0 | |
| 3826 | + | |
| 3827 | + tslib@2.8.1: {} | |
| 3828 | + | |
| 3829 | + type-check@0.4.0: | |
| 3830 | + dependencies: | |
| 3831 | + prelude-ls: 1.2.1 | |
| 3832 | + | |
| 3833 | + type-fest@0.20.2: {} | |
| 3834 | + | |
| 3835 | + typed-array-buffer@1.0.3: | |
| 3836 | + dependencies: | |
| 3837 | + call-bound: 1.0.4 | |
| 3838 | + es-errors: 1.3.0 | |
| 3839 | + is-typed-array: 1.1.15 | |
| 3840 | + | |
| 3841 | + typed-array-byte-length@1.0.3: | |
| 3842 | + dependencies: | |
| 3843 | + call-bind: 1.0.9 | |
| 3844 | + for-each: 0.3.5 | |
| 3845 | + gopd: 1.2.0 | |
| 3846 | + has-proto: 1.2.0 | |
| 3847 | + is-typed-array: 1.1.15 | |
| 3848 | + | |
| 3849 | + typed-array-byte-offset@1.0.4: | |
| 3850 | + dependencies: | |
| 3851 | + available-typed-arrays: 1.0.7 | |
| 3852 | + call-bind: 1.0.9 | |
| 3853 | + for-each: 0.3.5 | |
| 3854 | + gopd: 1.2.0 | |
| 3855 | + has-proto: 1.2.0 | |
| 3856 | + is-typed-array: 1.1.15 | |
| 3857 | + reflect.getprototypeof: 1.0.10 | |
| 3858 | + | |
| 3859 | + typed-array-length@1.0.8: | |
| 3860 | + dependencies: | |
| 3861 | + call-bind: 1.0.9 | |
| 3862 | + for-each: 0.3.5 | |
| 3863 | + gopd: 1.2.0 | |
| 3864 | + is-typed-array: 1.1.15 | |
| 3865 | + possible-typed-array-names: 1.1.0 | |
| 3866 | + reflect.getprototypeof: 1.0.10 | |
| 3867 | + | |
| 3868 | + typescript@5.9.3: {} | |
| 3869 | + | |
| 3870 | + unbox-primitive@1.1.0: | |
| 3871 | + dependencies: | |
| 3872 | + call-bound: 1.0.4 | |
| 3873 | + has-bigints: 1.1.0 | |
| 3874 | + has-symbols: 1.1.0 | |
| 3875 | + which-boxed-primitive: 1.1.1 | |
| 3876 | + | |
| 3877 | + undici-types@6.21.0: {} | |
| 3878 | + | |
| 3879 | + unrs-resolver@1.12.2: | |
| 3880 | + dependencies: | |
| 3881 | + napi-postinstall: 0.3.4 | |
| 3882 | + optionalDependencies: | |
| 3883 | + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 | |
| 3884 | + '@unrs/resolver-binding-android-arm64': 1.12.2 | |
| 3885 | + '@unrs/resolver-binding-darwin-arm64': 1.12.2 | |
| 3886 | + '@unrs/resolver-binding-darwin-x64': 1.12.2 | |
| 3887 | + '@unrs/resolver-binding-freebsd-x64': 1.12.2 | |
| 3888 | + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 | |
| 3889 | + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 | |
| 3890 | + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 | |
| 3891 | + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 | |
| 3892 | + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 | |
| 3893 | + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 | |
| 3894 | + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 | |
| 3895 | + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 | |
| 3896 | + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 | |
| 3897 | + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 | |
| 3898 | + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 | |
| 3899 | + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 | |
| 3900 | + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 | |
| 3901 | + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 | |
| 3902 | + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 | |
| 3903 | + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 | |
| 3904 | + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 | |
| 3905 | + | |
| 3906 | + update-browserslist-db@1.3.1(browserslist@4.28.8): | |
| 3907 | + dependencies: | |
| 3908 | + browserslist: 4.28.8 | |
| 3909 | + escalade: 3.2.0 | |
| 3910 | + picocolors: 1.1.1 | |
| 3911 | + | |
| 3912 | + uri-js@4.4.1: | |
| 3913 | + dependencies: | |
| 3914 | + punycode: 2.3.1 | |
| 3915 | + | |
| 3916 | + util-deprecate@1.0.2: {} | |
| 3917 | + | |
| 3918 | + which-boxed-primitive@1.1.1: | |
| 3919 | + dependencies: | |
| 3920 | + is-bigint: 1.1.0 | |
| 3921 | + is-boolean-object: 1.2.2 | |
| 3922 | + is-number-object: 1.1.1 | |
| 3923 | + is-string: 1.1.1 | |
| 3924 | + is-symbol: 1.1.1 | |
| 3925 | + | |
| 3926 | + which-builtin-type@1.2.1: | |
| 3927 | + dependencies: | |
| 3928 | + call-bound: 1.0.4 | |
| 3929 | + function.prototype.name: 1.2.0 | |
| 3930 | + has-tostringtag: 1.0.2 | |
| 3931 | + is-async-function: 2.1.1 | |
| 3932 | + is-date-object: 1.1.0 | |
| 3933 | + is-finalizationregistry: 1.1.1 | |
| 3934 | + is-generator-function: 1.1.2 | |
| 3935 | + is-regex: 1.2.1 | |
| 3936 | + is-weakref: 1.1.1 | |
| 3937 | + isarray: 2.0.5 | |
| 3938 | + which-boxed-primitive: 1.1.1 | |
| 3939 | + which-collection: 1.0.2 | |
| 3940 | + which-typed-array: 1.1.22 | |
| 3941 | + | |
| 3942 | + which-collection@1.0.2: | |
| 3943 | + dependencies: | |
| 3944 | + is-map: 2.0.3 | |
| 3945 | + is-set: 2.0.3 | |
| 3946 | + is-weakmap: 2.0.2 | |
| 3947 | + is-weakset: 2.0.4 | |
| 3948 | + | |
| 3949 | + which-typed-array@1.1.22: | |
| 3950 | + dependencies: | |
| 3951 | + available-typed-arrays: 1.0.7 | |
| 3952 | + call-bind: 1.0.9 | |
| 3953 | + call-bound: 1.0.4 | |
| 3954 | + for-each: 0.3.5 | |
| 3955 | + get-proto: 1.0.1 | |
| 3956 | + gopd: 1.2.0 | |
| 3957 | + has-tostringtag: 1.0.2 | |
| 3958 | + | |
| 3959 | + which@2.0.2: | |
| 3960 | + dependencies: | |
| 3961 | + isexe: 2.0.0 | |
| 3962 | + | |
| 3963 | + word-wrap@1.2.5: {} | |
| 3964 | + | |
| 3965 | + wrappy@1.0.2: {} | |
| 3966 | + | |
| 3967 | + yocto-queue@0.1.0: {} | |
added
M2M32brouve-ka/pnpm-workspace.yaml
+8 −0
@@ -0,0 +1,8 @@ | ||
| 1 | +# Trouve-KA — pnpm workspace | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | +packages: | |
| 5 | + - "apps/web" | |
| 6 | +onlyBuiltDependencies: | |
| 7 | + - sharp | |
| 8 | + - unrs-resolver | |
added
M2M32brouve-ka/pyproject.toml
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +# Trouve-KA — Python monorepo build configuration | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +[project] | |
| 6 | +name = "trouveka" | |
| 7 | +version = "0.1.0" | |
| 8 | +description = "Trouve-KA — moteur de recherche web indépendant, Québec-first" | |
| 9 | +requires-python = ">=3.12" | |
| 10 | +authors = [{ name = "Simon-Pierre Boucher", email = "contact@spboucher.ai" }] | |
| 11 | +dependencies = [ | |
| 12 | + "fastapi>=0.115", | |
| 13 | + "uvicorn[standard]>=0.30", | |
| 14 | + "httpx>=0.27", | |
| 15 | + "brotli>=1.1", | |
| 16 | + "selectolax>=0.3.21", | |
| 17 | + "protego>=0.3.1", | |
| 18 | + "asyncpg>=0.29", | |
| 19 | + "redis>=5.0", | |
| 20 | + "opensearch-py[async]>=2.6", | |
| 21 | + "pydantic>=2.7", | |
| 22 | + "pydantic-settings>=2.3", | |
| 23 | + "langdetect>=1.0.9", | |
| 24 | + "python-dateutil>=2.9", | |
| 25 | +] | |
| 26 | + | |
| 27 | +[project.optional-dependencies] | |
| 28 | +dev = [ | |
| 29 | + "pytest>=8", | |
| 30 | + "pytest-asyncio>=0.23", | |
| 31 | + "ruff>=0.5", | |
| 32 | +] | |
| 33 | + | |
| 34 | +[build-system] | |
| 35 | +requires = ["setuptools>=69"] | |
| 36 | +build-backend = "setuptools.build_meta" | |
| 37 | + | |
| 38 | +# The "trouveka" namespace package is assembled from the monorepo layout: | |
| 39 | +# packages/* hold shared libraries, services/* hold pipeline services, | |
| 40 | +# apps/api holds the FastAPI application (see CLAUDE.md §2). | |
| 41 | +[tool.setuptools] | |
| 42 | +packages = [ | |
| 43 | + "trouveka.config", | |
| 44 | + "trouveka.logging", | |
| 45 | + "trouveka.types", | |
| 46 | + "trouveka.shared", | |
| 47 | + "trouveka.database", | |
| 48 | + "trouveka.queue", | |
| 49 | + "trouveka.search_core", | |
| 50 | + "trouveka.crawler", | |
| 51 | + "trouveka.frontier", | |
| 52 | + "trouveka.parser", | |
| 53 | + "trouveka.classifier", | |
| 54 | + "trouveka.indexer", | |
| 55 | + "trouveka.ranking", | |
| 56 | + "trouveka.scheduler", | |
| 57 | + "trouveka.enrichment", | |
| 58 | + "trouveka.api", | |
| 59 | +] | |
| 60 | + | |
| 61 | +[tool.setuptools.package-dir] | |
| 62 | +"trouveka.config" = "packages/config" | |
| 63 | +"trouveka.logging" = "packages/logging" | |
| 64 | +"trouveka.types" = "packages/types" | |
| 65 | +"trouveka.shared" = "packages/shared" | |
| 66 | +"trouveka.database" = "packages/database" | |
| 67 | +"trouveka.queue" = "packages/queue" | |
| 68 | +"trouveka.search_core" = "packages/search-core" | |
| 69 | +"trouveka.crawler" = "services/crawler" | |
| 70 | +"trouveka.frontier" = "services/frontier" | |
| 71 | +"trouveka.parser" = "services/parser" | |
| 72 | +"trouveka.classifier" = "services/classifier" | |
| 73 | +"trouveka.indexer" = "services/indexer" | |
| 74 | +"trouveka.ranking" = "services/ranking" | |
| 75 | +"trouveka.scheduler" = "services/scheduler" | |
| 76 | +"trouveka.enrichment" = "services/enrichment" | |
| 77 | +"trouveka.api" = "apps/api" | |
| 78 | + | |
| 79 | +[tool.pytest.ini_options] | |
| 80 | +asyncio_mode = "auto" | |
| 81 | +testpaths = ["tests"] | |
| 82 | + | |
| 83 | +[tool.ruff] | |
| 84 | +line-length = 110 | |
| 85 | +target-version = "py312" | |
added
M2M32brouve-ka/scripts/bootstrap-seeds/seed.sh
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Trouve-KA — chargement des seeds dans le frontier | |
| 3 | +# Author: Simon-Pierre Boucher | |
| 4 | +# Contact: contact@spboucher.ai | |
| 5 | +set -euo pipefail | |
| 6 | +cd "$(dirname "$0")/../.." | |
| 7 | + | |
| 8 | +if [ -x .venv/bin/python ]; then | |
| 9 | + PY=.venv/bin/python | |
| 10 | +else | |
| 11 | + PY=python3 | |
| 12 | +fi | |
| 13 | + | |
| 14 | +exec "$PY" -m trouveka.crawler.seed "${1:-scripts/bootstrap-seeds/seeds.txt}" | |
added
M2M32brouve-ka/scripts/bootstrap-seeds/seeds.txt
+80 −0
@@ -0,0 +1,80 @@ | ||
| 1 | +# Trouve-KA — seeds de démarrage (qualité > quantité, CLAUDE.md §8) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | +# Nœuds fortement connectés du web québécois. Une URL par ligne, # = commentaire. | |
| 5 | + | |
| 6 | +# --- Gouvernement du Québec --- | |
| 7 | +https://www.quebec.ca/ | |
| 8 | +https://www.assnat.qc.ca/ | |
| 9 | +https://www.revenuquebec.ca/ | |
| 10 | +https://www.ramq.gouv.qc.ca/ | |
| 11 | +https://saaq.gouv.qc.ca/ | |
| 12 | +https://www.cnesst.gouv.qc.ca/ | |
| 13 | +https://www.hydroquebec.com/ | |
| 14 | +https://www.investquebec.com/ | |
| 15 | +https://www.transitionenergetique.gouv.qc.ca/ | |
| 16 | + | |
| 17 | +# --- Municipalités --- | |
| 18 | +https://montreal.ca/ | |
| 19 | +https://www.ville.quebec.qc.ca/ | |
| 20 | +https://www.laval.ca/ | |
| 21 | +https://www.gatineau.ca/ | |
| 22 | +https://www.sherbrooke.ca/ | |
| 23 | +https://www.longueuil.quebec/ | |
| 24 | +https://www.trois-rivieres.ca/ | |
| 25 | +https://ville.saguenay.ca/ | |
| 26 | +https://www.levis.ca/ | |
| 27 | +https://www.terrebonne.ca/ | |
| 28 | +https://www.drummondville.ca/ | |
| 29 | +https://www.rimouski.ca/ | |
| 30 | + | |
| 31 | +# --- Universités et cégeps --- | |
| 32 | +https://www.ulaval.ca/ | |
| 33 | +https://www.umontreal.ca/ | |
| 34 | +https://www.mcgill.ca/ | |
| 35 | +https://uqam.ca/ | |
| 36 | +https://www.usherbrooke.ca/ | |
| 37 | +https://www.concordia.ca/ | |
| 38 | +https://www.polymtl.ca/ | |
| 39 | +https://www.etsmtl.ca/ | |
| 40 | +https://www.hec.ca/ | |
| 41 | +https://www.uqac.ca/ | |
| 42 | +https://www.uqtr.ca/ | |
| 43 | +https://www.uqar.ca/ | |
| 44 | +https://uqo.ca/ | |
| 45 | +https://www.inrs.ca/ | |
| 46 | +https://www.cegepsquebec.ca/ | |
| 47 | + | |
| 48 | +# --- Médias --- | |
| 49 | +https://www.lapresse.ca/ | |
| 50 | +https://www.ledevoir.com/ | |
| 51 | +https://www.journaldemontreal.com/ | |
| 52 | +https://www.journaldequebec.com/ | |
| 53 | +https://ici.radio-canada.ca/ | |
| 54 | +https://www.tvanouvelles.ca/ | |
| 55 | +https://www.lesoleil.com/ | |
| 56 | +https://www.ledroit.com/ | |
| 57 | +https://www.latribune.ca/ | |
| 58 | +https://www.lenouvelliste.ca/ | |
| 59 | +https://www.noovo.info/ | |
| 60 | +https://www.lactualite.com/ | |
| 61 | + | |
| 62 | +# --- Affaires, annuaires, associations --- | |
| 63 | +https://www.registreentreprises.gouv.qc.ca/ | |
| 64 | +https://www.desjardins.com/ | |
| 65 | +https://www.fccq.ca/ | |
| 66 | +https://www.ccmm.ca/ | |
| 67 | +https://www.pagesjaunes.ca/ | |
| 68 | +https://quebec.craigslist.org/ | |
| 69 | +https://www.cqcd.org/ | |
| 70 | +https://www.manufacturiersquebec.ca/ | |
| 71 | + | |
| 72 | +# --- Tourisme et culture --- | |
| 73 | +https://www.bonjourquebec.com/ | |
| 74 | +https://www.mtl.org/ | |
| 75 | +https://www.quebec-cite.com/ | |
| 76 | +https://www.tourisme-charlevoix.com/ | |
| 77 | +https://www.tourismegaspesie.com/ | |
| 78 | +https://www.sepaq.com/ | |
| 79 | +https://www.lavitrine.com/ | |
| 80 | +https://www.banq.qc.ca/ | |
added
M2M32brouve-ka/scripts/check-headers.py
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# Trouve-KA — vérification CI des headers d'auteur (CLAUDE.md §0.1) | |
| 3 | +# Author: Simon-Pierre Boucher | |
| 4 | +# Contact: contact@spboucher.ai | |
| 5 | + | |
| 6 | +"""Vérifie que chaque fichier source porte le header auteur obligatoire.""" | |
| 7 | + | |
| 8 | +import pathlib | |
| 9 | +import sys | |
| 10 | + | |
| 11 | +ROOT = pathlib.Path(__file__).resolve().parents[1] | |
| 12 | +EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".mjs", ".cjs", ".sql", ".sh", ".css", ".yml", ".yaml", ".toml"} | |
| 13 | +SKIP_DIRS = {"node_modules", ".next", ".venv", "__pycache__", ".git", "dist", ".pytest_cache", ".ruff_cache"} | |
| 14 | +SKIP_FILES = {"pnpm-lock.yaml", "next-env.d.ts"} | |
| 15 | +REQUIRED = "Author: Simon-Pierre Boucher" | |
| 16 | + | |
| 17 | + | |
| 18 | +def main() -> int: | |
| 19 | + missing: list[pathlib.Path] = [] | |
| 20 | + for path in ROOT.rglob("*"): | |
| 21 | + if not path.is_file() or path.suffix not in EXTENSIONS or path.name in SKIP_FILES: | |
| 22 | + continue | |
| 23 | + if any(part in SKIP_DIRS for part in path.parts): | |
| 24 | + continue | |
| 25 | + head = path.read_text(encoding="utf-8", errors="replace")[:600] | |
| 26 | + if REQUIRED not in head: | |
| 27 | + missing.append(path.relative_to(ROOT)) | |
| 28 | + if missing: | |
| 29 | + print(f"✗ {len(missing)} fichier(s) sans header auteur :") | |
| 30 | + for p in missing: | |
| 31 | + print(f" - {p}") | |
| 32 | + return 1 | |
| 33 | + print("✓ Tous les fichiers source portent le header auteur.") | |
| 34 | + return 0 | |
| 35 | + | |
| 36 | + | |
| 37 | +if __name__ == "__main__": | |
| 38 | + sys.exit(main()) | |
added
M2M32brouve-ka/scripts/cleanup-garbage.py
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# Trouve-KA — purge des documents mal décodés de l'index | |
| 3 | +# Author: Simon-Pierre Boucher | |
| 4 | +# Contact: contact@spboucher.ai | |
| 5 | + | |
| 6 | +"""Scanne l'index, supprime les documents au contenu charabia (binaire mal décodé) | |
| 7 | +et remet leurs URLs en recrawl (le fetcher corrigé les réindexera proprement). | |
| 8 | + | |
| 9 | +Usage : python -m scripts... non — exécuter depuis la racine : | |
| 10 | + .venv/bin/python scripts/cleanup-garbage.py # dev local | |
| 11 | + (sur m2m32 : docker compose run --rm --entrypoint python api /app/scripts/cleanup-garbage.py) | |
| 12 | +""" | |
| 13 | + | |
| 14 | +import asyncio | |
| 15 | + | |
| 16 | +from trouveka.config import get_settings | |
| 17 | +from trouveka.database import Database | |
| 18 | +from trouveka.parser import looks_like_garbage | |
| 19 | +from trouveka.search_core import SearchCore | |
| 20 | + | |
| 21 | + | |
| 22 | +async def main() -> None: | |
| 23 | + s = get_settings() | |
| 24 | + db = Database(s.database_url, pool_min=1, pool_max=3) | |
| 25 | + await db.connect() | |
| 26 | + search = SearchCore(s.search_url, s.search_index) | |
| 27 | + | |
| 28 | + removed = 0 | |
| 29 | + scanned = 0 | |
| 30 | + # Scroll de tout l'index par pages de 200 | |
| 31 | + body = {"query": {"match_all": {}}, "size": 200, "_source": ["url", "title", "description", "body"]} | |
| 32 | + resp = await search.client.search(index=s.search_index, body=body, scroll="2m") | |
| 33 | + scroll_id = resp.get("_scroll_id") | |
| 34 | + try: | |
| 35 | + while True: | |
| 36 | + hits = resp["hits"]["hits"] | |
| 37 | + if not hits: | |
| 38 | + break | |
| 39 | + for hit in hits: | |
| 40 | + scanned += 1 | |
| 41 | + src = hit["_source"] | |
| 42 | + text = " ".join([src.get("title") or "", src.get("description") or "", | |
| 43 | + (src.get("body") or "")[:20_000]]) | |
| 44 | + if looks_like_garbage(text, threshold=0.02): | |
| 45 | + await search.client.delete(index=s.search_index, id=hit["_id"], ignore=[404]) | |
| 46 | + await db.requeue_url(src["url"]) | |
| 47 | + await db.pool.execute( | |
| 48 | + "DELETE FROM documents WHERE url_id = (SELECT id FROM urls WHERE url = $1)", | |
| 49 | + src["url"], | |
| 50 | + ) | |
| 51 | + removed += 1 | |
| 52 | + print(f"purgé : {src['url']}") | |
| 53 | + resp = await search.client.scroll(scroll_id=scroll_id, scroll="2m") | |
| 54 | + finally: | |
| 55 | + if scroll_id: | |
| 56 | + await search.client.clear_scroll(scroll_id=scroll_id) | |
| 57 | + await search.close() | |
| 58 | + await db.close() | |
| 59 | + print(f"\n{scanned} documents scannés, {removed} purgés et remis en recrawl.") | |
| 60 | + | |
| 61 | + | |
| 62 | +if __name__ == "__main__": | |
| 63 | + asyncio.run(main()) | |
added
M2M32brouve-ka/scripts/eval/ranking-eval.yaml
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +# Trouve-KA — dataset d'évaluation du ranking (§16) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | +# Chaque cas : requête + domaines attendus dans le top-k. Mesurable, reproductible. | |
| 5 | +cases: | |
| 6 | + - query: "université québec" | |
| 7 | + expect_any_of: [uqam.ca, ulaval.ca, umontreal.ca, uqtr.ca, uqac.ca, uqar.ca, uquebec.ca, usherbrooke.ca] | |
| 8 | + top_k: 5 | |
| 9 | + - query: "plombier gatineau" | |
| 10 | + expect_any_of: [gatineau.ca, pagesjaunes.ca] | |
| 11 | + top_k: 10 | |
| 12 | + - query: "subvention thermopompe" | |
| 13 | + expect_any_of: [quebec.ca, hydroquebec.com, transitionenergetique.gouv.qc.ca] | |
| 14 | + top_k: 10 | |
| 15 | + - query: "heat pump rebate" | |
| 16 | + expect_any_of: [quebec.ca, hydroquebec.com, transitionenergetique.gouv.qc.ca] | |
| 17 | + top_k: 10 | |
| 18 | + - query: "impôt revenu" | |
| 19 | + expect_any_of: [revenuquebec.ca, quebec.ca] | |
| 20 | + top_k: 5 | |
| 21 | + - query: "tourisme gaspésie" | |
| 22 | + expect_any_of: [tourismegaspesie.com, bonjourquebec.com, quebecmaritime.ca, sepaq.com] | |
| 23 | + top_k: 5 | |
| 24 | + - query: "nouvelles montréal" | |
| 25 | + expect_any_of: [lapresse.ca, journaldemontreal.com, radio-canada.ca, ledevoir.com, tvanouvelles.ca, montreal.ca, mtl.org] | |
| 26 | + top_k: 10 | |
| 27 | + - query: "permis de conduire" | |
| 28 | + expect_any_of: [saaq.gouv.qc.ca, quebec.ca] | |
| 29 | + top_k: 5 | |
added
M2M32brouve-ka/scripts/eval/run-eval.py
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# Trouve-KA — évaluation du ranking contre le dataset (§16) | |
| 3 | +# Author: Simon-Pierre Boucher | |
| 4 | +# Contact: contact@spboucher.ai | |
| 5 | + | |
| 6 | +"""Exécute le dataset d'évaluation contre l'API de recherche. | |
| 7 | + | |
| 8 | +Usage : python3 scripts/eval/run-eval.py [--api http://localhost:8080] | |
| 9 | +Un cas passe si au moins un domaine attendu apparaît dans le top-k. | |
| 10 | +L'index grandit en continu : les scores s'améliorent avec la couverture. | |
| 11 | +""" | |
| 12 | + | |
| 13 | +import argparse | |
| 14 | +import json | |
| 15 | +import pathlib | |
| 16 | +import re | |
| 17 | +import urllib.parse | |
| 18 | +import urllib.request | |
| 19 | + | |
| 20 | +HERE = pathlib.Path(__file__).parent | |
| 21 | + | |
| 22 | + | |
| 23 | +def load_cases() -> list[dict]: | |
| 24 | + """Parse minimaliste du YAML du dataset (pas de dépendance PyYAML).""" | |
| 25 | + cases, current = [], None | |
| 26 | + for line in (HERE / "ranking-eval.yaml").read_text(encoding="utf-8").splitlines(): | |
| 27 | + if m := re.match(r'\s*- query: "(.*)"', line): | |
| 28 | + current = {"query": m.group(1)} | |
| 29 | + cases.append(current) | |
| 30 | + elif m := re.match(r"\s*expect_any_of: \[(.*)\]", line): | |
| 31 | + current["expect_any_of"] = [d.strip() for d in m.group(1).split(",")] | |
| 32 | + elif m := re.match(r"\s*top_k: (\d+)", line): | |
| 33 | + current["top_k"] = int(m.group(1)) | |
| 34 | + return cases | |
| 35 | + | |
| 36 | + | |
| 37 | +def main() -> int: | |
| 38 | + ap = argparse.ArgumentParser() | |
| 39 | + ap.add_argument("--api", default="http://localhost:8080") | |
| 40 | + args = ap.parse_args() | |
| 41 | + | |
| 42 | + passed = 0 | |
| 43 | + cases = load_cases() | |
| 44 | + for case in cases: | |
| 45 | + q = urllib.parse.quote(case["query"]) | |
| 46 | + url = f"{args.api}/api/search?q={q}&limit={case['top_k']}" | |
| 47 | + try: | |
| 48 | + with urllib.request.urlopen(url, timeout=10) as resp: | |
| 49 | + data = json.load(resp) | |
| 50 | + except Exception as exc: | |
| 51 | + print(f"✗ {case['query']!r} — API injoignable : {exc}") | |
| 52 | + continue | |
| 53 | + domains = [r["domain"] for r in data["results"]] | |
| 54 | + hit = next((d for d in domains if any(d == e or d.endswith("." + e) for e in case["expect_any_of"])), None) | |
| 55 | + if hit: | |
| 56 | + passed += 1 | |
| 57 | + print(f"✓ {case['query']!r} → {hit} (rang {domains.index(hit) + 1}, {data['took_ms']} ms)") | |
| 58 | + else: | |
| 59 | + print(f"✗ {case['query']!r} → top-{case['top_k']}: {domains[:5]}") | |
| 60 | + print(f"\n{passed}/{len(cases)} cas réussis") | |
| 61 | + return 0 if passed == len(cases) else 1 | |
| 62 | + | |
| 63 | + | |
| 64 | +if __name__ == "__main__": | |
| 65 | + raise SystemExit(main()) | |
added
M2M32brouve-ka/scripts/health-check/check.sh
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Trouve-KA — vérification de santé du stack | |
| 3 | +# Author: Simon-Pierre Boucher | |
| 4 | +# Contact: contact@spboucher.ai | |
| 5 | +set -uo pipefail | |
| 6 | + | |
| 7 | +API_URL="${API_URL:-http://localhost:8080}" | |
| 8 | +SEARCH_URL="${SEARCH_URL:-http://localhost:9200}" | |
| 9 | +WEB_URL="${WEB_URL:-http://localhost:3000}" | |
| 10 | +FAIL=0 | |
| 11 | + | |
| 12 | +check() { | |
| 13 | + local name="$1" url="$2" | |
| 14 | + if curl -sf --max-time 5 "$url" > /dev/null; then | |
| 15 | + echo "✓ $name ($url)" | |
| 16 | + else | |
| 17 | + echo "✗ $name ($url)" | |
| 18 | + FAIL=1 | |
| 19 | + fi | |
| 20 | +} | |
| 21 | + | |
| 22 | +check "API santé" "$API_URL/api/health" | |
| 23 | +check "API statut" "$API_URL/api/status" | |
| 24 | +check "OpenSearch" "$SEARCH_URL/_cluster/health" | |
| 25 | +check "Web" "$WEB_URL/" | |
| 26 | + | |
| 27 | +exit $FAIL | |
added
M2M32brouve-ka/scripts/start-crawler/start.sh
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Trouve-KA — démarrage local d'un worker de crawl | |
| 3 | +# Author: Simon-Pierre Boucher | |
| 4 | +# Contact: contact@spboucher.ai | |
| 5 | +set -euo pipefail | |
| 6 | +cd "$(dirname "$0")/../.." | |
| 7 | + | |
| 8 | +if [ -x .venv/bin/python ]; then | |
| 9 | + PY=.venv/bin/python | |
| 10 | +else | |
| 11 | + PY=python3 | |
| 12 | +fi | |
| 13 | + | |
| 14 | +exec "$PY" -m trouveka.crawler.worker | |
added
M2M32brouve-ka/services/classifier/__init__.py
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +# Trouve-KA — classification Québec | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Détection de pertinence québécoise (CLAUDE.md §7). | |
| 6 | + | |
| 7 | +Deux scores distincts : page_quebec_score (calculé ici) et domain_quebec_score | |
| 8 | +(agrégé en base au fil des pages). Un article du NYT sur Montréal peut être | |
| 9 | +pertinent sans que le domaine le soit. | |
| 10 | +""" | |
| 11 | + | |
| 12 | +from .scorer import score_page | |
| 13 | +from .gazetteer import QUEBEC_TOPONYMS, QUEBEC_REGIONS | |
| 14 | + | |
| 15 | +__all__ = ["score_page", "QUEBEC_TOPONYMS", "QUEBEC_REGIONS"] | |
added
M2M32brouve-ka/services/classifier/gazetteer.py
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +# Trouve-KA — gazetteer québécois | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Toponymes, régions et signaux institutionnels du Québec. | |
| 6 | + | |
| 7 | +Liste de départ volontairement conservatrice : des noms non ambigus d'abord. | |
| 8 | +Les toponymes ambigus (Laval existe en France, Hull au Royaume-Uni…) portent | |
| 9 | +un poids réduit dans le scorer. | |
| 10 | +""" | |
| 11 | + | |
| 12 | +# Municipalités et lieux à fort signal (poids plein) | |
| 13 | +QUEBEC_TOPONYMS: set[str] = { | |
| 14 | + "québec", "quebec", "montréal", "montreal", "gatineau", "sherbrooke", | |
| 15 | + "trois-rivières", "trois-rivieres", "saguenay", "lévis", "levis", | |
| 16 | + "longueuil", "terrebonne", "brossard", "repentigny", "drummondville", | |
| 17 | + "saint-jérôme", "saint-jerome", "granby", "blainville", "shawinigan", | |
| 18 | + "dollard-des-ormeaux", "rimouski", "victoriaville", "saint-hyacinthe", | |
| 19 | + "boucherville", "salaberry-de-valleyfield", "vaudreuil-dorion", "châteauguay", | |
| 20 | + "chateauguay", "rouyn-noranda", "joliette", "sorel-tracy", "val-d'or", | |
| 21 | + "alma", "sept-îles", "sept-iles", "baie-comeau", "chicoutimi", "jonquière", | |
| 22 | + "jonquiere", "rivière-du-loup", "riviere-du-loup", "matane", "gaspé", "gaspe", | |
| 23 | + "magog", "thetford mines", "sainte-thérèse", "sainte-therese", "mirabel", | |
| 24 | + "beloeil", "mascouche", "varennes", "chambly", "saint-eustache", | |
| 25 | + "l'assomption", "lachute", "mont-tremblant", "sainte-adèle", "sainte-adele", | |
| 26 | + "kuujjuaq", "chibougamau", "amos", "la tuque", "cowansville", "beauharnois", | |
| 27 | + "outaouais", "estrie", "montérégie", "monteregie", "mauricie", "lanaudière", | |
| 28 | + "lanaudiere", "laurentides", "abitibi-témiscamingue", "abitibi-temiscamingue", | |
| 29 | + "bas-saint-laurent", "côte-nord", "cote-nord", "nord-du-québec", "nord-du-quebec", | |
| 30 | + "gaspésie", "gaspesie", "chaudière-appalaches", "chaudiere-appalaches", | |
| 31 | + "centre-du-québec", "centre-du-quebec", "saguenay-lac-saint-jean", | |
| 32 | + "nunavik", "îles-de-la-madeleine", "iles-de-la-madeleine", | |
| 33 | + "hochelaga", "rosemont", "ahuntsic", "verdun", "outremont", "westmount", | |
| 34 | + "plateau-mont-royal", "villeray", "anjou", "lasalle", "lachine", "dorval", | |
| 35 | + "pointe-claire", "kirkland", "beaconsfield", "côte-des-neiges", "cote-des-neiges", | |
| 36 | +} | |
| 37 | + | |
| 38 | +# Toponymes ambigus (existent ailleurs) — poids réduit | |
| 39 | +AMBIGUOUS_TOPONYMS: set[str] = { | |
| 40 | + "laval", "hull", "aylmer", "candiac", "delson", "bromont", "sutton", | |
| 41 | + "stanstead", "asbestos", "windsor", "richmond", "warwick", "kingsey", | |
| 42 | +} | |
| 43 | + | |
| 44 | +QUEBEC_REGIONS: set[str] = { | |
| 45 | + "outaouais", "estrie", "montérégie", "mauricie", "lanaudière", "laurentides", | |
| 46 | + "abitibi-témiscamingue", "bas-saint-laurent", "côte-nord", "gaspésie", | |
| 47 | + "chaudière-appalaches", "centre-du-québec", "saguenay-lac-saint-jean", "nunavik", | |
| 48 | +} | |
| 49 | + | |
| 50 | +# Institutions et organisations québécoises fortement identifiantes | |
| 51 | +QUEBEC_ORGS: set[str] = { | |
| 52 | + "hydro-québec", "hydro-quebec", "hydroquébec", "hydroquebec", | |
| 53 | + "desjardins", "gouvernement du québec", "gouvernement du quebec", | |
| 54 | + "assemblée nationale", "assemblee nationale", "revenu québec", "revenu quebec", | |
| 55 | + "saq", "sqdc", "caq", "société de transport de montréal", "stm", | |
| 56 | + "réseau de transport de la capitale", "rtc", "exo", "via rail", | |
| 57 | + "université laval", "universite laval", "université de montréal", | |
| 58 | + "universite de montreal", "mcgill", "uqam", "uqac", "uqtr", "uqar", "uqo", "uqat", | |
| 59 | + "université de sherbrooke", "universite de sherbrooke", "polytechnique montréal", | |
| 60 | + "polytechnique montreal", "hec montréal", "hec montreal", "éts", "ets", | |
| 61 | + "concordia", "inrs", "cégep", "cegep", "clsc", "chum", "chu de québec", | |
| 62 | + "chu sainte-justine", "ramq", "saaq", "cnesst", "sûreté du québec", | |
| 63 | + "surete du quebec", "loto-québec", "loto-quebec", "investissement québec", | |
| 64 | + "investissement quebec", "caisse de dépôt", "caisse de depot", "cdpq", | |
| 65 | + "radio-canada", "télé-québec", "tele-quebec", "la presse", "le devoir", | |
| 66 | + "journal de montréal", "journal de montreal", "journal de québec", | |
| 67 | + "journal de quebec", "tva", "noovo", "bonjour québec", "bonjour quebec", | |
| 68 | + "tourisme québec", "tourisme quebec", | |
| 69 | +} | |
| 70 | + | |
| 71 | +# Indicatifs téléphoniques du Québec (signal faible) | |
| 72 | +QUEBEC_AREA_CODES: set[str] = {"418", "514", "438", "450", "579", "819", "873", "367", "263", "354", "468"} | |
| 73 | + | |
| 74 | +# TLD / suffixes de domaine à signal fort | |
| 75 | +STRONG_DOMAIN_SUFFIXES: tuple[str, ...] = (".qc.ca", ".quebec", ".gouv.qc.ca", ".montreal.ca") | |
added
M2M32brouve-ka/services/classifier/scorer.py
+115 −0
@@ -0,0 +1,115 @@ | ||
| 1 | +# Trouve-KA — scoreur Québec | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Calcul de page_quebec_score ∈ [0, 1]. | |
| 6 | + | |
| 7 | +Combinaison de signaux hétérogènes (CLAUDE.md §7) : TLD, toponymes, codes | |
| 8 | +postaux QC, indicatifs téléphoniques, organisations connues, langue française, | |
| 9 | +mentions structurées de la province. Aucun signal seul ne suffit (un .ca seul | |
| 10 | +ne prouve rien); le score sature progressivement via une somme amortie. | |
| 11 | +""" | |
| 12 | + | |
| 13 | +import re | |
| 14 | + | |
| 15 | +from trouveka.types import ParsedPage, QuebecSignals | |
| 16 | + | |
| 17 | +from .gazetteer import ( | |
| 18 | + AMBIGUOUS_TOPONYMS, | |
| 19 | + QUEBEC_AREA_CODES, | |
| 20 | + QUEBEC_ORGS, | |
| 21 | + QUEBEC_TOPONYMS, | |
| 22 | + STRONG_DOMAIN_SUFFIXES, | |
| 23 | +) | |
| 24 | + | |
| 25 | +# Codes postaux du Québec : G, H, J en première lettre (format A1A 1A1) | |
| 26 | +_POSTAL_RE = re.compile(r"\b[GHJ]\d[A-Z]\s?\d[A-Z]\d\b", re.IGNORECASE) | |
| 27 | +_PHONE_RE = re.compile(r"(?:\+?1[\s.-]?)?\(?(\d{3})\)?[\s.-]?\d{3}[\s.-]?\d{4}\b") | |
| 28 | +_PROVINCE_RE = re.compile( | |
| 29 | + r"\b(?:province\s+(?:de\s+|du\s+)?qu[ée]bec|qu[ée]bec\s*\(qc\)|,\s*(?:qc|qu[ée]bec)\b)", | |
| 30 | + re.IGNORECASE, | |
| 31 | +) | |
| 32 | +_WORD_BOUNDARY = r"(?<![\w-]){}(?![\w-])" | |
| 33 | + | |
| 34 | + | |
| 35 | +def _count_terms(text: str, terms: set[str], cap: int = 10) -> tuple[int, list[str]]: | |
| 36 | + found: list[str] = [] | |
| 37 | + total = 0 | |
| 38 | + for term in terms: | |
| 39 | + pattern = re.compile(_WORD_BOUNDARY.format(re.escape(term)), re.IGNORECASE) | |
| 40 | + n = len(pattern.findall(text)) | |
| 41 | + if n: | |
| 42 | + found.append(term) | |
| 43 | + total += min(n, 4) # une page qui répète 200× « Montréal » n'est pas 200× plus québécoise | |
| 44 | + if total >= cap: | |
| 45 | + break | |
| 46 | + return min(total, cap), found | |
| 47 | + | |
| 48 | + | |
| 49 | +def score_page(page: ParsedPage, domain: str) -> QuebecSignals: | |
| 50 | + """Score Québec d'une page. Déterministe, sans LLM, économique (§12).""" | |
| 51 | + signals = QuebecSignals() | |
| 52 | + reasons: list[str] = [] | |
| 53 | + points = 0.0 | |
| 54 | + | |
| 55 | + text = " ".join([page.title, page.description, " ".join(page.headings), page.body[:20_000]]) | |
| 56 | + text_with_hints = text + " " + " ".join(page.structured_hints) | |
| 57 | + lower = text_with_hints.lower() | |
| 58 | + | |
| 59 | + # 1. Domaine (signal fort mais pas suffisant seul) | |
| 60 | + host = domain.lower() | |
| 61 | + if any(host.endswith(suffix) or host == suffix.lstrip(".") for suffix in STRONG_DOMAIN_SUFFIXES): | |
| 62 | + points += 4.0 | |
| 63 | + reasons.append("tld_quebec") | |
| 64 | + | |
| 65 | + # 2. Toponymes non ambigus (titre/headings pèsent plus que le corps) | |
| 66 | + head_text = " ".join([page.title, page.description, " ".join(page.headings)]) | |
| 67 | + head_hits, head_names = _count_terms(head_text, QUEBEC_TOPONYMS, cap=6) | |
| 68 | + body_hits, body_names = _count_terms(page.body[:20_000], QUEBEC_TOPONYMS, cap=8) | |
| 69 | + if head_hits: | |
| 70 | + points += 1.2 * head_hits | |
| 71 | + reasons.append("toponymes_titre") | |
| 72 | + if body_hits: | |
| 73 | + points += 0.4 * body_hits | |
| 74 | + reasons.append("toponymes_corps") | |
| 75 | + signals.locations = sorted({*head_names, *body_names})[:12] | |
| 76 | + | |
| 77 | + # 3. Toponymes ambigus — poids réduit | |
| 78 | + amb_hits, amb_names = _count_terms(lower, AMBIGUOUS_TOPONYMS, cap=3) | |
| 79 | + if amb_hits: | |
| 80 | + points += 0.15 * amb_hits | |
| 81 | + reasons.append("toponymes_ambigus") | |
| 82 | + signals.locations = sorted({*signals.locations, *amb_names})[:12] | |
| 83 | + | |
| 84 | + # 4. Codes postaux QC (signal fort : preuve d'adresse physique) | |
| 85 | + postal_hits = len(set(_POSTAL_RE.findall(text_with_hints))) | |
| 86 | + if postal_hits: | |
| 87 | + points += min(postal_hits, 3) * 1.5 | |
| 88 | + reasons.append("code_postal_qc") | |
| 89 | + | |
| 90 | + # 5. Mention structurée de la province (adresses, footers) | |
| 91 | + if _PROVINCE_RE.search(text_with_hints): | |
| 92 | + points += 1.5 | |
| 93 | + reasons.append("province_quebec") | |
| 94 | + | |
| 95 | + # 6. Indicatifs téléphoniques (signal faible) | |
| 96 | + area_codes = {m for m in _PHONE_RE.findall(text_with_hints) if m in QUEBEC_AREA_CODES} | |
| 97 | + if area_codes: | |
| 98 | + points += min(len(area_codes), 2) * 0.6 | |
| 99 | + reasons.append("indicatif_qc") | |
| 100 | + | |
| 101 | + # 7. Organisations québécoises connues | |
| 102 | + org_hits, _ = _count_terms(lower, QUEBEC_ORGS, cap=6) | |
| 103 | + if org_hits: | |
| 104 | + points += 0.8 * org_hits | |
| 105 | + reasons.append("organisations_qc") | |
| 106 | + | |
| 107 | + # 8. Langue : le français augmente la probabilité sans la prouver | |
| 108 | + if page.language == "fr": | |
| 109 | + points += 0.8 | |
| 110 | + reasons.append("francais") | |
| 111 | + | |
| 112 | + # Saturation douce : 0 pt → 0, ~3 pts → 0.5, ≥9 pts → ~0.95 | |
| 113 | + signals.score = round(points / (points + 3.0), 4) if points > 0 else 0.0 | |
| 114 | + signals.reasons = reasons | |
| 115 | + return signals | |
added
M2M32brouve-ka/services/crawler/__init__.py
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +# Trouve-KA — crawler | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Le crawler de Trouve-KA : frontier Postgres, politesse par hôte, robots.txt, | |
| 6 | +fetch HTTP sécurisé (SSRF), pièges de crawl, pipeline inline fetch→parse→score→index.""" | |
| 7 | + | |
| 8 | +from .robots import RobotsCache | |
| 9 | +from .fetcher import Fetcher | |
| 10 | +from .traps import looks_like_trap | |
| 11 | + | |
| 12 | +__all__ = ["RobotsCache", "Fetcher", "looks_like_trap"] | |
added
M2M32brouve-ka/services/crawler/fetcher.py
+148 −0
@@ -0,0 +1,148 @@ | ||
| 1 | +# Trouve-KA — fetcher HTTP sécurisé | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Fetch HTTP avec garde SSRF revalidée à chaque redirection, limites de taille, | |
| 6 | +gestion des content-types et cache HTTP conditionnel (ETag / Last-Modified). | |
| 7 | + | |
| 8 | +Jamais de Chromium ici (CLAUDE.md §5.10) : fetch HTTP pur. | |
| 9 | +""" | |
| 10 | + | |
| 11 | +import time | |
| 12 | +from urllib.parse import urlsplit | |
| 13 | + | |
| 14 | +import httpx | |
| 15 | + | |
| 16 | +from trouveka.config import Settings | |
| 17 | +from trouveka.shared import canonicalize_url, is_safe_url | |
| 18 | +from trouveka.types import ErrorCode, FetchResult | |
| 19 | + | |
| 20 | +ACCEPTED_CONTENT_TYPES = ("text/html", "application/xhtml+xml", "text/plain") | |
| 21 | + | |
| 22 | + | |
| 23 | +class Fetcher: | |
| 24 | + def __init__(self, client: httpx.AsyncClient, settings: Settings): | |
| 25 | + self._client = client | |
| 26 | + self._s = settings | |
| 27 | + | |
| 28 | + async def fetch( | |
| 29 | + self, url: str, *, etag: str | None = None, last_modified: str | None = None | |
| 30 | + ) -> FetchResult: | |
| 31 | + """Fetch une URL en suivant manuellement les redirections (revalidation SSRF à chaque saut).""" | |
| 32 | + start = time.monotonic() | |
| 33 | + chain: list[str] = [] | |
| 34 | + current = url | |
| 35 | + | |
| 36 | + for _hop in range(self._s.max_redirects + 1): | |
| 37 | + if not is_safe_url(current): | |
| 38 | + return self._fail(url, current, ErrorCode.SSRF_BLOCKED, start, chain) | |
| 39 | + | |
| 40 | + # Accept-Encoding volontairement absent : httpx annonce lui-même les | |
| 41 | + # encodages qu'il sait décoder (br seulement si brotli est installé). | |
| 42 | + # L'annoncer à la main fait indexer du binaire compressé (bug itum.qc.ca). | |
| 43 | + headers = { | |
| 44 | + "User-Agent": self._s.crawler_user_agent, | |
| 45 | + "Accept": "text/html,application/xhtml+xml;q=0.9,text/plain;q=0.5,*/*;q=0.1", | |
| 46 | + "Accept-Language": "fr-CA,fr;q=0.9,en-CA;q=0.8,en;q=0.7", | |
| 47 | + } | |
| 48 | + if etag and current == url: | |
| 49 | + headers["If-None-Match"] = etag | |
| 50 | + if last_modified and current == url: | |
| 51 | + headers["If-Modified-Since"] = last_modified | |
| 52 | + | |
| 53 | + try: | |
| 54 | + async with self._client.stream( | |
| 55 | + "GET", current, headers=headers, timeout=self._s.fetch_timeout, follow_redirects=False | |
| 56 | + ) as resp: | |
| 57 | + if resp.status_code in (301, 302, 303, 307, 308): | |
| 58 | + location = resp.headers.get("location") | |
| 59 | + if not location: | |
| 60 | + return self._fail(url, current, ErrorCode.HTTP_4XX, start, chain, resp.status_code) | |
| 61 | + next_url = canonicalize_url(location, base=current) | |
| 62 | + if not next_url: | |
| 63 | + return self._fail(url, current, ErrorCode.SSRF_BLOCKED, start, chain, resp.status_code) | |
| 64 | + chain.append(next_url) | |
| 65 | + current = next_url | |
| 66 | + continue | |
| 67 | + | |
| 68 | + if resp.status_code == 304: | |
| 69 | + return FetchResult( | |
| 70 | + url=url, final_url=current, status_code=304, | |
| 71 | + duration_ms=self._ms(start), redirect_chain=chain, | |
| 72 | + ) | |
| 73 | + if 400 <= resp.status_code < 500: | |
| 74 | + return self._fail(url, current, ErrorCode.HTTP_4XX, start, chain, resp.status_code) | |
| 75 | + if resp.status_code >= 500: | |
| 76 | + return self._fail(url, current, ErrorCode.HTTP_5XX, start, chain, resp.status_code) | |
| 77 | + | |
| 78 | + # X-Robots-Tag : noindex au niveau HTTP | |
| 79 | + x_robots = (resp.headers.get("x-robots-tag") or "").lower() | |
| 80 | + | |
| 81 | + raw_ct = resp.headers.get("content-type") or "" | |
| 82 | + content_type = raw_ct.split(";")[0].strip().lower() | |
| 83 | + charset = None | |
| 84 | + if "charset=" in raw_ct.lower(): | |
| 85 | + charset = raw_ct.lower().split("charset=")[-1].split(";")[0].strip(' "\'') or None | |
| 86 | + if content_type and not any(content_type.startswith(t) for t in ACCEPTED_CONTENT_TYPES): | |
| 87 | + return self._fail( | |
| 88 | + url, current, ErrorCode.UNSUPPORTED_CONTENT, start, chain, resp.status_code | |
| 89 | + ) | |
| 90 | + | |
| 91 | + declared = resp.headers.get("content-length") | |
| 92 | + if declared and int(declared) > self._s.max_response_bytes: | |
| 93 | + return self._fail(url, current, ErrorCode.TOO_LARGE, start, chain, resp.status_code) | |
| 94 | + | |
| 95 | + body = b"" | |
| 96 | + async for part in resp.aiter_bytes(): | |
| 97 | + body += part | |
| 98 | + if len(body) > self._s.max_response_bytes: | |
| 99 | + return self._fail(url, current, ErrorCode.TOO_LARGE, start, chain, resp.status_code) | |
| 100 | + | |
| 101 | + result = FetchResult( | |
| 102 | + url=url, | |
| 103 | + final_url=current, | |
| 104 | + status_code=resp.status_code, | |
| 105 | + content_type=content_type or None, | |
| 106 | + charset=charset, | |
| 107 | + body=body, | |
| 108 | + etag=resp.headers.get("etag"), | |
| 109 | + last_modified=resp.headers.get("last-modified"), | |
| 110 | + duration_ms=self._ms(start), | |
| 111 | + redirect_chain=chain, | |
| 112 | + ) | |
| 113 | + if "noindex" in x_robots: | |
| 114 | + result.error_code = ErrorCode.ROBOTS_DENIED | |
| 115 | + return result | |
| 116 | + | |
| 117 | + except httpx.TimeoutException: | |
| 118 | + return self._fail(url, current, ErrorCode.TIMEOUT, start, chain) | |
| 119 | + except httpx.ConnectError as exc: | |
| 120 | + code = ErrorCode.TLS if "SSL" in str(exc) or "certificate" in str(exc).lower() else ErrorCode.DNS | |
| 121 | + return self._fail(url, current, code, start, chain) | |
| 122 | + except httpx.HTTPError: | |
| 123 | + return self._fail(url, current, ErrorCode.CONNECTION, start, chain) | |
| 124 | + | |
| 125 | + return self._fail(url, current, ErrorCode.TOO_MANY_REDIRECTS, start, chain) | |
| 126 | + | |
| 127 | + @staticmethod | |
| 128 | + def _ms(start: float) -> int: | |
| 129 | + return int((time.monotonic() - start) * 1000) | |
| 130 | + | |
| 131 | + def _fail( | |
| 132 | + self, | |
| 133 | + url: str, | |
| 134 | + final_url: str, | |
| 135 | + code: ErrorCode, | |
| 136 | + start: float, | |
| 137 | + chain: list[str], | |
| 138 | + status: int | None = None, | |
| 139 | + ) -> FetchResult: | |
| 140 | + return FetchResult( | |
| 141 | + url=url, final_url=final_url, status_code=status, error_code=code, | |
| 142 | + duration_ms=self._ms(start), redirect_chain=chain, | |
| 143 | + ) | |
| 144 | + | |
| 145 | + | |
| 146 | +def scheme_host(url: str) -> str: | |
| 147 | + parts = urlsplit(url) | |
| 148 | + return f"{parts.scheme}://{parts.netloc}" | |
added
M2M32brouve-ka/services/crawler/robots.py
+84 −0
@@ -0,0 +1,84 @@ | ||
| 1 | +# Trouve-KA — gestion robots.txt | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Cache robots.txt conforme aux standards (Protego, le parseur de Scrapy). | |
| 6 | + | |
| 7 | +Les règles sont persistées en base (domains.robots_body) et rafraîchies | |
| 8 | +après 24 h. Un robots.txt introuvable (404) = tout permis; une erreur | |
| 9 | +serveur = prudence, on refuse temporairement. | |
| 10 | +""" | |
| 11 | + | |
| 12 | +from datetime import UTC, datetime, timedelta | |
| 13 | + | |
| 14 | +import httpx | |
| 15 | +from protego import Protego | |
| 16 | + | |
| 17 | +from trouveka.logging import get_logger | |
| 18 | + | |
| 19 | +log = get_logger("crawler.robots") | |
| 20 | + | |
| 21 | +ROBOTS_TTL = timedelta(hours=24) | |
| 22 | +_MAX_ROBOTS_BYTES = 512_000 | |
| 23 | + | |
| 24 | + | |
| 25 | +class RobotsCache: | |
| 26 | + def __init__(self, db, client: httpx.AsyncClient, user_agent: str): | |
| 27 | + self._db = db | |
| 28 | + self._client = client | |
| 29 | + self._ua = user_agent | |
| 30 | + self._parsers: dict[int, tuple[Protego | None, datetime]] = {} | |
| 31 | + | |
| 32 | + async def _fetch_robots(self, scheme_host: str) -> tuple[str | None, str]: | |
| 33 | + """Retourne (body, status) : ok | not_found | error.""" | |
| 34 | + try: | |
| 35 | + resp = await self._client.get( | |
| 36 | + f"{scheme_host}/robots.txt", | |
| 37 | + headers={"User-Agent": self._ua}, | |
| 38 | + timeout=15, | |
| 39 | + follow_redirects=True, | |
| 40 | + ) | |
| 41 | + except httpx.HTTPError: | |
| 42 | + return None, "error" | |
| 43 | + if resp.status_code == 200: | |
| 44 | + return resp.text[:_MAX_ROBOTS_BYTES], "ok" | |
| 45 | + if 400 <= resp.status_code < 500: | |
| 46 | + return None, "not_found" # 4xx = pas de robots → tout permis (standard) | |
| 47 | + return None, "error" | |
| 48 | + | |
| 49 | + async def get_parser(self, domain_id: int, scheme_host: str) -> tuple[Protego | None, str, int | None]: | |
| 50 | + """Parseur robots pour un domaine. Retourne (parser|None, status, crawl_delay_ms).""" | |
| 51 | + cached = self._parsers.get(domain_id) | |
| 52 | + now = datetime.now(UTC) | |
| 53 | + if cached and now - cached[1] < ROBOTS_TTL: | |
| 54 | + parser = cached[0] | |
| 55 | + return parser, "ok" if parser else "not_found", None | |
| 56 | + | |
| 57 | + row = await self._db.get_domain_by_id(domain_id) | |
| 58 | + if row and row["robots_fetched_at"] and now - row["robots_fetched_at"] < ROBOTS_TTL: | |
| 59 | + body, status = row["robots_body"], row["robots_status"] | |
| 60 | + else: | |
| 61 | + body, status = await self._fetch_robots(scheme_host) | |
| 62 | + delay_ms = None | |
| 63 | + if body: | |
| 64 | + delay = Protego.parse(body).crawl_delay(self._ua) | |
| 65 | + delay_ms = int(delay * 1000) if delay else None | |
| 66 | + await self._db.save_robots(domain_id, body, status, delay_ms) | |
| 67 | + | |
| 68 | + parser = Protego.parse(body) if body else None | |
| 69 | + self._parsers[domain_id] = (parser, now) | |
| 70 | + if len(self._parsers) > 5000: # borne mémoire du cache local | |
| 71 | + self._parsers.pop(next(iter(self._parsers))) | |
| 72 | + return parser, status, row["crawl_delay_ms"] if row else None | |
| 73 | + | |
| 74 | + async def allowed(self, domain_id: int, url: str, scheme_host: str) -> tuple[bool, float | None]: | |
| 75 | + """(autorisé, crawl_delay_secondes). Erreur robots = refus temporaire (prudence).""" | |
| 76 | + parser, status, delay_ms = await self.get_parser(domain_id, scheme_host) | |
| 77 | + if status == "error": | |
| 78 | + return False, None | |
| 79 | + if parser is None: | |
| 80 | + return True, None | |
| 81 | + delay = parser.crawl_delay(self._ua) | |
| 82 | + if delay is None and delay_ms: | |
| 83 | + delay = delay_ms / 1000 | |
| 84 | + return parser.can_fetch(url, self._ua), delay | |
added
M2M32brouve-ka/services/crawler/seed.py
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +# Trouve-KA — chargeur de seeds | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Charge les seeds dans le frontier avec priorité maximale. | |
| 6 | + | |
| 7 | +Usage : python -m trouveka.crawler.seed [chemin/vers/seeds.txt] | |
| 8 | +""" | |
| 9 | + | |
| 10 | +import asyncio | |
| 11 | +import pathlib | |
| 12 | +import sys | |
| 13 | + | |
| 14 | +from trouveka.config import get_settings | |
| 15 | +from trouveka.database import Database, run_migrations | |
| 16 | +from trouveka.logging import get_logger | |
| 17 | +from trouveka.shared import canonicalize_url, extract_domain | |
| 18 | + | |
| 19 | +log = get_logger("crawler.seed") | |
| 20 | + | |
| 21 | +DEFAULT_SEEDS = pathlib.Path(__file__).resolve().parents[2] / "scripts" / "bootstrap-seeds" / "seeds.txt" | |
| 22 | + | |
| 23 | + | |
| 24 | +async def load_seeds(path: pathlib.Path) -> int: | |
| 25 | + settings = get_settings() | |
| 26 | + applied = await run_migrations(settings.database_url) | |
| 27 | + if applied: | |
| 28 | + log.info("migrations appliquées", extra={"ctx": {"files": applied}}) | |
| 29 | + | |
| 30 | + db = Database(settings.database_url, pool_min=1, pool_max=3) | |
| 31 | + await db.connect() | |
| 32 | + added = 0 | |
| 33 | + try: | |
| 34 | + for line in path.read_text(encoding="utf-8").splitlines(): | |
| 35 | + line = line.strip() | |
| 36 | + if not line or line.startswith("#"): | |
| 37 | + continue | |
| 38 | + url = canonicalize_url(line) | |
| 39 | + domain = extract_domain(url) if url else None | |
| 40 | + if not url or not domain: | |
| 41 | + log.info("seed ignorée (URL invalide)", extra={"ctx": {"line": line}}) | |
| 42 | + continue | |
| 43 | + if await db.enqueue_url(url, domain, priority=1.0, depth=0, is_seed=True): | |
| 44 | + added += 1 | |
| 45 | + finally: | |
| 46 | + await db.close() | |
| 47 | + log.info("seeds chargées", extra={"ctx": {"added": added, "file": str(path)}}) | |
| 48 | + return added | |
| 49 | + | |
| 50 | + | |
| 51 | +def main() -> None: | |
| 52 | + path = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_SEEDS | |
| 53 | + asyncio.run(load_seeds(path)) | |
| 54 | + | |
| 55 | + | |
| 56 | +if __name__ == "__main__": | |
| 57 | + main() | |
added
M2M32brouve-ka/services/crawler/traps.py
+59 −0
@@ -0,0 +1,59 @@ | ||
| 1 | +# Trouve-KA — détection de pièges de crawl | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Heuristiques anti-pièges (CLAUDE.md §5.8) : calendriers infinis, session IDs, | |
| 6 | +explosions de facettes, pagination infinie, chemins répétitifs.""" | |
| 7 | + | |
| 8 | +import re | |
| 9 | +from urllib.parse import parse_qsl, urlsplit | |
| 10 | + | |
| 11 | +_SESSION_PARAMS = {"sid", "sessionid", "session_id", "phpsessid", "jsessionid", "aspsessionid", "cfid", "cftoken"} | |
| 12 | +_CALENDAR_RE = re.compile(r"/(?:19|20)\d{2}[-/](?:0?\d|1[0-2])(?:[-/](?:0?\d|[12]\d|3[01]))?/?$") | |
| 13 | +_LONG_NUMBER_RE = re.compile(r"\d{10,}") | |
| 14 | + | |
| 15 | + | |
| 16 | +def looks_like_trap( | |
| 17 | + url: str, | |
| 18 | + *, | |
| 19 | + max_query_params: int = 8, | |
| 20 | + max_path_segments: int = 12, | |
| 21 | + max_url_length: int = 1024, | |
| 22 | +) -> bool: | |
| 23 | + """True si l'URL ressemble à un piège de crawl et ne doit pas entrer au frontier.""" | |
| 24 | + if len(url) > max_url_length: | |
| 25 | + return True | |
| 26 | + try: | |
| 27 | + parts = urlsplit(url) | |
| 28 | + except ValueError: | |
| 29 | + return True | |
| 30 | + | |
| 31 | + params = parse_qsl(parts.query, keep_blank_values=True) | |
| 32 | + if len(params) > max_query_params: | |
| 33 | + return True | |
| 34 | + keys = {k.lower() for k, _ in params} | |
| 35 | + if keys & _SESSION_PARAMS: | |
| 36 | + return True | |
| 37 | + # Même paramètre répété (facettes qui explosent : ?filter=a&filter=b&filter=c…) | |
| 38 | + raw_keys = [k.lower() for k, _ in params] | |
| 39 | + if any(raw_keys.count(k) > 3 for k in set(raw_keys)): | |
| 40 | + return True | |
| 41 | + | |
| 42 | + segments = [s for s in parts.path.split("/") if s] | |
| 43 | + if len(segments) > max_path_segments: | |
| 44 | + return True | |
| 45 | + # Segment répété (boucles : /a/b/a/b/a/b) | |
| 46 | + if any(segments.count(s) > 3 for s in set(segments)): | |
| 47 | + return True | |
| 48 | + # Calendriers profonds (au-delà de l'année-mois raisonnable) : /events/2031/05/17 | |
| 49 | + if _CALENDAR_RE.search(parts.path): | |
| 50 | + year_match = re.search(r"/((?:19|20)\d{2})[-/]", parts.path) | |
| 51 | + if year_match and not (1995 <= int(year_match.group(1)) <= 2027): | |
| 52 | + return True | |
| 53 | + # Pagination excessive | |
| 54 | + for key, value in params: | |
| 55 | + if key.lower() in ("page", "p", "offset", "start") and value.isdigit() and int(value) > 500: | |
| 56 | + return True | |
| 57 | + if _LONG_NUMBER_RE.search(parts.query): | |
| 58 | + return True | |
| 59 | + return False | |
added
M2M32brouve-ka/services/crawler/worker.py
+447 −0
@@ -0,0 +1,447 @@ | ||
| 1 | +# Trouve-KA — worker de crawl (pipeline complet) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Worker de crawl : réclame des URLs au frontier et exécute le pipeline inline | |
| 6 | +fetch → parse → score Québec → indexation IMMÉDIATE (principe cardinal §0.3). | |
| 7 | + | |
| 8 | +Chaque page traitée avec succès est cherchable en quelques secondes. | |
| 9 | +L'enrichissement (étapes 2-3) part dans Redis Streams et ne bloque jamais. | |
| 10 | +N workers peuvent tourner en parallèle (frontier SKIP LOCKED + locks Redis par hôte). | |
| 11 | +""" | |
| 12 | + | |
| 13 | +import asyncio | |
| 14 | +import os | |
| 15 | +import random | |
| 16 | +import signal | |
| 17 | +import uuid | |
| 18 | +from datetime import UTC, datetime, timedelta | |
| 19 | + | |
| 20 | +import httpx | |
| 21 | + | |
| 22 | +from trouveka.classifier import score_page | |
| 23 | +from trouveka.config import get_settings | |
| 24 | +from trouveka.database import Database | |
| 25 | +from trouveka.frontier import compute_priority, next_recrawl_delay, retry_delay | |
| 26 | +from trouveka.indexer import build_search_document | |
| 27 | +from trouveka.logging import get_logger | |
| 28 | +from trouveka.parser import looks_like_garbage, parse_html | |
| 29 | +from trouveka.queue import Coordination | |
| 30 | +from trouveka.search_core import SearchCore | |
| 31 | +from trouveka.shared import canonicalize_url, content_hash, extract_domain | |
| 32 | +from trouveka.types import ErrorCode, Outcome | |
| 33 | + | |
| 34 | +from .fetcher import Fetcher, scheme_host | |
| 35 | +from .robots import RobotsCache | |
| 36 | +from .traps import looks_like_trap | |
| 37 | + | |
| 38 | +log = get_logger("crawler.worker") | |
| 39 | + | |
| 40 | +TRANSIENT_ERRORS = {ErrorCode.TIMEOUT, ErrorCode.HTTP_5XX, ErrorCode.CONNECTION, ErrorCode.DNS} | |
| 41 | +MAX_RETRIES = 3 | |
| 42 | + | |
| 43 | + | |
| 44 | +class CrawlerWorker: | |
| 45 | + def __init__(self) -> None: | |
| 46 | + self.s = get_settings() | |
| 47 | + self.worker_id = f"crawler-{os.getpid()}-{uuid.uuid4().hex[:6]}" | |
| 48 | + self.db = Database(self.s.database_url, pool_min=self.s.pg_pool_min, pool_max=self.s.pg_pool_max) | |
| 49 | + self.coord = Coordination(self.s.redis_url) | |
| 50 | + self.search = SearchCore(self.s.search_url, self.s.search_index) | |
| 51 | + self.client = httpx.AsyncClient( | |
| 52 | + limits=httpx.Limits( | |
| 53 | + max_connections=self.s.max_global_concurrency, | |
| 54 | + max_keepalive_connections=self.s.max_global_concurrency, | |
| 55 | + ), | |
| 56 | + http2=False, | |
| 57 | + ) | |
| 58 | + self.fetcher = Fetcher(self.client, self.s) | |
| 59 | + self.robots: RobotsCache | None = None | |
| 60 | + self.stop_event = asyncio.Event() | |
| 61 | + self._domain_cache: dict[int, dict] = {} | |
| 62 | + # Cache domaine→row pour la découverte : évite un SELECT par lien sortant | |
| 63 | + # (une page peut avoir 300 liens, souvent vers les mêmes domaines). | |
| 64 | + self._domain_by_name: dict[str, tuple[object, datetime]] = {} | |
| 65 | + | |
| 66 | + # ------------------------------------------------------------------ cycle de vie | |
| 67 | + | |
| 68 | + async def start(self) -> None: | |
| 69 | + await self.db.connect() | |
| 70 | + self.robots = RobotsCache(self.db, self.client, self.s.crawler_user_agent) | |
| 71 | + await self.search.ensure_index() | |
| 72 | + log.info("worker démarré", extra={"ctx": {"worker_id": self.worker_id}}) | |
| 73 | + | |
| 74 | + loop = asyncio.get_running_loop() | |
| 75 | + for sig in (signal.SIGINT, signal.SIGTERM): | |
| 76 | + loop.add_signal_handler(sig, self.stop_event.set) | |
| 77 | + | |
| 78 | + # Pool continu : on réclame de nouvelles URLs dès qu'un slot se libère. | |
| 79 | + # (Un gather par lot créait du head-of-line blocking : une page lente | |
| 80 | + # bloquait tout le lot avant la réclamation suivante.) | |
| 81 | + in_flight: set[asyncio.Task] = set() | |
| 82 | + idle_sleep = 1.0 | |
| 83 | + | |
| 84 | + async def run_one(item) -> None: | |
| 85 | + try: | |
| 86 | + await self.process_item(item) | |
| 87 | + except Exception: | |
| 88 | + log.exception( | |
| 89 | + "échec inattendu du pipeline", | |
| 90 | + extra={"ctx": {"url": item["url"], "worker_id": self.worker_id}}, | |
| 91 | + ) | |
| 92 | + await self.db.release_item( | |
| 93 | + item["url_id"], status="failed", error_code="parse_failed" | |
| 94 | + ) | |
| 95 | + | |
| 96 | + while not self.stop_event.is_set(): | |
| 97 | + if await self.coord.is_paused(): | |
| 98 | + await asyncio.sleep(3) | |
| 99 | + continue | |
| 100 | + free = self.s.max_global_concurrency - len(in_flight) | |
| 101 | + if free <= 0: | |
| 102 | + await asyncio.wait(in_flight, return_when=asyncio.FIRST_COMPLETED) | |
| 103 | + continue | |
| 104 | + batch = await self.db.claim_batch(self.worker_id, limit=free) | |
| 105 | + if not batch: | |
| 106 | + if in_flight: | |
| 107 | + await asyncio.wait( | |
| 108 | + in_flight, return_when=asyncio.FIRST_COMPLETED, timeout=idle_sleep | |
| 109 | + ) | |
| 110 | + else: | |
| 111 | + await asyncio.sleep(idle_sleep) | |
| 112 | + idle_sleep = min(idle_sleep * 1.5, 15) | |
| 113 | + continue | |
| 114 | + idle_sleep = 1.0 | |
| 115 | + for item in batch: | |
| 116 | + task = asyncio.create_task(run_one(item)) | |
| 117 | + in_flight.add(task) | |
| 118 | + task.add_done_callback(in_flight.discard) | |
| 119 | + | |
| 120 | + if in_flight: | |
| 121 | + await asyncio.gather(*in_flight, return_exceptions=True) | |
| 122 | + await self.shutdown() | |
| 123 | + | |
| 124 | + async def shutdown(self) -> None: | |
| 125 | + await self.client.aclose() | |
| 126 | + await self.search.close() | |
| 127 | + await self.coord.close() | |
| 128 | + await self.db.close() | |
| 129 | + log.info("worker arrêté", extra={"ctx": {"worker_id": self.worker_id}}) | |
| 130 | + | |
| 131 | + # ------------------------------------------------------------------ helpers | |
| 132 | + | |
| 133 | + async def _domain(self, domain_id: int) -> dict: | |
| 134 | + cached = self._domain_cache.get(domain_id) | |
| 135 | + if cached and (datetime.now(UTC) - cached["_at"]).total_seconds() < 60: | |
| 136 | + return cached | |
| 137 | + row = await self.db.get_domain_by_id(domain_id) | |
| 138 | + entry = dict(row) | {"_at": datetime.now(UTC)} | |
| 139 | + self._domain_cache[domain_id] = entry | |
| 140 | + if len(self._domain_cache) > 5000: | |
| 141 | + self._domain_cache.pop(next(iter(self._domain_cache))) | |
| 142 | + return entry | |
| 143 | + | |
| 144 | + async def _defer(self, url_id: int, seconds: float) -> None: | |
| 145 | + """Repousse un item sans compter d'erreur (politesse : trop tôt pour cet hôte). | |
| 146 | + | |
| 147 | + Jitter aléatoire pour désynchroniser les grappes d'URLs d'un même hôte | |
| 148 | + (sinon elles reviennent toutes en tête de file au même instant et churnent). | |
| 149 | + """ | |
| 150 | + await self.db.release_item( | |
| 151 | + url_id, | |
| 152 | + status="pending", | |
| 153 | + next_crawl_at=datetime.now(UTC) + timedelta(seconds=seconds + random.uniform(0.2, 3.0)), | |
| 154 | + ) | |
| 155 | + | |
| 156 | + async def _domain_row_by_name(self, domain: str): | |
| 157 | + """Row domaine (ou None si inconnu), avec cache TTL 60 s.""" | |
| 158 | + cached = self._domain_by_name.get(domain) | |
| 159 | + now = datetime.now(UTC) | |
| 160 | + if cached and (now - cached[1]).total_seconds() < 60: | |
| 161 | + return cached[0] | |
| 162 | + row = await self.db.get_domain(domain) | |
| 163 | + self._domain_by_name[domain] = (row, now) | |
| 164 | + if len(self._domain_by_name) > 20_000: | |
| 165 | + self._domain_by_name.pop(next(iter(self._domain_by_name))) | |
| 166 | + return row | |
| 167 | + | |
| 168 | + def _previous_delay_hours(self, item) -> float | None: | |
| 169 | + if item["last_crawled_at"] and item["next_crawl_at"]: | |
| 170 | + delta = (item["next_crawl_at"] - item["last_crawled_at"]).total_seconds() / 3600 | |
| 171 | + return max(delta, 0.1) | |
| 172 | + return None | |
| 173 | + | |
| 174 | + # ------------------------------------------------------------------ pipeline | |
| 175 | + | |
| 176 | + async def process_item(self, item) -> None: | |
| 177 | + url: str = item["url"] | |
| 178 | + url_id: int = item["url_id"] | |
| 179 | + domain_row = await self._domain(item["domain_id"]) | |
| 180 | + domain: str = domain_row["domain"] | |
| 181 | + | |
| 182 | + if domain_row["blocked"]: | |
| 183 | + await self.db.release_item(url_id, status="blocked") | |
| 184 | + return | |
| 185 | + | |
| 186 | + host = scheme_host(url) | |
| 187 | + | |
| 188 | + # robots.txt d'abord (le fetch de robots ne compte pas dans la politesse) | |
| 189 | + allowed, robots_delay = await self.robots.allowed(item["domain_id"], url, host) | |
| 190 | + if not allowed: | |
| 191 | + await self.db.record_attempt( | |
| 192 | + url_id, status_code=None, error_code=ErrorCode.ROBOTS_DENIED, | |
| 193 | + outcome=Outcome.ROBOTS_BLOCKED, | |
| 194 | + ) | |
| 195 | + await self.db.release_item(url_id, status="done", error_code=ErrorCode.ROBOTS_DENIED) | |
| 196 | + return | |
| 197 | + | |
| 198 | + # Politesse par hôte, tous workers confondus | |
| 199 | + delay = max(robots_delay or 0, self.s.default_host_delay) | |
| 200 | + if not await self.coord.acquire_host_slot(domain, delay): | |
| 201 | + await self._defer(url_id, delay + 0.5) | |
| 202 | + return | |
| 203 | + | |
| 204 | + # Cache HTTP conditionnel | |
| 205 | + existing = await self.db.get_document(url_id) | |
| 206 | + result = await self.fetcher.fetch( | |
| 207 | + url, | |
| 208 | + etag=existing["etag"] if existing else None, | |
| 209 | + last_modified=existing["last_modified"] if existing else None, | |
| 210 | + ) | |
| 211 | + | |
| 212 | + # --- 304 : inchangé | |
| 213 | + if result.status_code == 304: | |
| 214 | + await self._finish_unchanged(item, existing) | |
| 215 | + return | |
| 216 | + | |
| 217 | + # --- erreurs | |
| 218 | + if result.error_code not in (ErrorCode.OK, ErrorCode.ROBOTS_DENIED) or result.body is None: | |
| 219 | + await self._finish_error(item, result) | |
| 220 | + return | |
| 221 | + | |
| 222 | + # --- redirection vers une autre URL canonique : suivre la cible | |
| 223 | + final = canonicalize_url(result.final_url) or result.final_url | |
| 224 | + if final != url: | |
| 225 | + final_domain = extract_domain(final) | |
| 226 | + if final_domain and not looks_like_trap(final): | |
| 227 | + await self.db.enqueue_url( | |
| 228 | + final, final_domain, priority=item["priority"], depth=item["depth"], | |
| 229 | + source_url_id=url_id, max_urls_per_domain=self.s.max_urls_per_domain, | |
| 230 | + ) | |
| 231 | + await self.db.set_canonical(url_id, final) | |
| 232 | + await self.db.record_attempt( | |
| 233 | + url_id, status_code=result.status_code, error_code=ErrorCode.OK, | |
| 234 | + outcome=Outcome.REDIRECT, redirect_url=final, duration_ms=result.duration_ms, | |
| 235 | + ) | |
| 236 | + await self.db.release_item(url_id, status="done") | |
| 237 | + return | |
| 238 | + | |
| 239 | + # --- parse | |
| 240 | + try: | |
| 241 | + page = parse_html( | |
| 242 | + url, result.body, max_links=self.s.max_links_per_page, charset=result.charset | |
| 243 | + ) | |
| 244 | + except Exception: | |
| 245 | + await self.db.record_attempt( | |
| 246 | + url_id, status_code=result.status_code, error_code=ErrorCode.PARSE_FAILED, | |
| 247 | + outcome=Outcome.ERROR, duration_ms=result.duration_ms, | |
| 248 | + ) | |
| 249 | + await self.db.release_item(url_id, status="failed", error_code=ErrorCode.PARSE_FAILED) | |
| 250 | + return | |
| 251 | + | |
| 252 | + # --- garde anti-charabia : du binaire/mal décodé ne doit JAMAIS être indexé | |
| 253 | + if looks_like_garbage(page.title) or looks_like_garbage(page.body): | |
| 254 | + await self.db.record_attempt( | |
| 255 | + url_id, status_code=result.status_code, error_code=ErrorCode.PARSE_FAILED, | |
| 256 | + outcome=Outcome.ERROR, duration_ms=result.duration_ms, title=page.title[:80], | |
| 257 | + ) | |
| 258 | + await self.db.release_item(url_id, status="failed", error_code=ErrorCode.PARSE_FAILED) | |
| 259 | + return | |
| 260 | + | |
| 261 | + # --- score Québec (immédiat, déterministe) | |
| 262 | + signals = score_page(page, domain) | |
| 263 | + | |
| 264 | + # --- détection de changement | |
| 265 | + chash = content_hash(page.title, page.body) | |
| 266 | + if existing and existing["content_hash"] == chash: | |
| 267 | + await self._finish_unchanged(item, existing, status_code=result.status_code) | |
| 268 | + await self._discover_links(item, page, signals) # les liens peuvent avoir changé de priorité | |
| 269 | + return | |
| 270 | + | |
| 271 | + # --- doublon exact sur une autre URL | |
| 272 | + duplicate = await self.db.find_duplicate(chash, url_id) | |
| 273 | + if duplicate: | |
| 274 | + await self.db.record_attempt( | |
| 275 | + url_id, status_code=result.status_code, error_code=ErrorCode.DUPLICATE, | |
| 276 | + outcome=Outcome.DUPLICATE, content_hash=chash, duration_ms=result.duration_ms, | |
| 277 | + title=page.title, quebec_score=signals.score, | |
| 278 | + ) | |
| 279 | + await self.db.release_item( | |
| 280 | + url_id, status="done", | |
| 281 | + next_crawl_at=datetime.now(UTC) + timedelta(hours=self.s.max_recrawl_hours), | |
| 282 | + ) | |
| 283 | + return | |
| 284 | + | |
| 285 | + indexable = ( | |
| 286 | + not page.noindex | |
| 287 | + and result.error_code != ErrorCode.ROBOTS_DENIED # X-Robots-Tag: noindex | |
| 288 | + and signals.score >= self.s.min_quebec_score_to_index | |
| 289 | + and len(page.body) >= self.s.min_body_length | |
| 290 | + ) | |
| 291 | + | |
| 292 | + if indexable: | |
| 293 | + # ------- INDEXATION IMMÉDIATE : la page est cherchable en ~1 s ------- | |
| 294 | + canonical_target = page.canonical_url or url | |
| 295 | + doc = build_search_document( | |
| 296 | + page, signals, | |
| 297 | + domain=domain, | |
| 298 | + domain_quebec_score=float(domain_row["quebec_score"]), | |
| 299 | + authority_score=float(domain_row["authority_score"]), | |
| 300 | + ) | |
| 301 | + await self.search.index_document(doc) | |
| 302 | + await self.db.upsert_document( | |
| 303 | + url_id, | |
| 304 | + content_hash=chash, | |
| 305 | + etag=result.etag, | |
| 306 | + last_modified=result.last_modified, | |
| 307 | + title=page.title, | |
| 308 | + description=page.description, | |
| 309 | + language=page.language, | |
| 310 | + page_quebec_score=signals.score, | |
| 311 | + published_at=page.published_at, | |
| 312 | + changed=existing is not None, | |
| 313 | + ) | |
| 314 | + if page.canonical_url and page.canonical_url != url: | |
| 315 | + await self.db.set_canonical(url_id, canonical_target) | |
| 316 | + # Enrichissement asynchrone (jamais bloquant) | |
| 317 | + await self.coord.enqueue_enrichment( | |
| 318 | + {"url": canonical_target, "url_id": url_id, "domain_id": item["domain_id"], "stage": 2} | |
| 319 | + ) | |
| 320 | + outcome = Outcome.INDEXED | |
| 321 | + else: | |
| 322 | + outcome = Outcome.NOT_QUEBEC if signals.score < self.s.min_quebec_score_to_index else Outcome.UNCHANGED | |
| 323 | + | |
| 324 | + await self.db.update_domain_after_page(item["domain_id"], signals.score, page.language) | |
| 325 | + self._domain_cache.pop(item["domain_id"], None) | |
| 326 | + | |
| 327 | + await self.db.record_attempt( | |
| 328 | + url_id, status_code=result.status_code, error_code=ErrorCode.OK, | |
| 329 | + outcome=outcome, content_hash=chash, num_bytes=len(result.body), | |
| 330 | + duration_ms=result.duration_ms, title=page.title, quebec_score=signals.score, | |
| 331 | + ) | |
| 332 | + recrawl = next_recrawl_delay( | |
| 333 | + changed=True, previous_delay_hours=self._previous_delay_hours(item), | |
| 334 | + min_hours=self.s.min_recrawl_hours, max_hours=self.s.max_recrawl_hours, | |
| 335 | + default_hours=self.s.default_recrawl_hours, | |
| 336 | + ) | |
| 337 | + await self.db.release_item( | |
| 338 | + url_id, status="pending", next_crawl_at=datetime.now(UTC) + recrawl, | |
| 339 | + ) | |
| 340 | + | |
| 341 | + await self._discover_links(item, page, signals) | |
| 342 | + log.info( | |
| 343 | + "page traitée", | |
| 344 | + extra={"ctx": { | |
| 345 | + "worker_id": self.worker_id, "url_id": url_id, "domain_id": item["domain_id"], | |
| 346 | + "url": url, "outcome": str(outcome), "quebec_score": signals.score, | |
| 347 | + "links": len(page.links), "ms": result.duration_ms, | |
| 348 | + }}, | |
| 349 | + ) | |
| 350 | + | |
| 351 | + # ------------------------------------------------------------------ issues terminales | |
| 352 | + | |
| 353 | + async def _finish_unchanged(self, item, existing, status_code: int | None = 304) -> None: | |
| 354 | + recrawl = next_recrawl_delay( | |
| 355 | + changed=False, previous_delay_hours=self._previous_delay_hours(item), | |
| 356 | + min_hours=self.s.min_recrawl_hours, max_hours=self.s.max_recrawl_hours, | |
| 357 | + default_hours=self.s.default_recrawl_hours, | |
| 358 | + ) | |
| 359 | + await self.db.record_attempt( | |
| 360 | + item["url_id"], status_code=status_code, error_code=ErrorCode.OK, | |
| 361 | + outcome=Outcome.UNCHANGED, | |
| 362 | + content_hash=existing["content_hash"] if existing else None, | |
| 363 | + ) | |
| 364 | + await self.db.release_item( | |
| 365 | + item["url_id"], status="pending", | |
| 366 | + next_crawl_at=datetime.now(UTC) + recrawl, | |
| 367 | + ) | |
| 368 | + | |
| 369 | + async def _finish_error(self, item, result) -> None: | |
| 370 | + url_id = item["url_id"] | |
| 371 | + await self.db.record_attempt( | |
| 372 | + url_id, status_code=result.status_code, error_code=result.error_code, | |
| 373 | + outcome=Outcome.ERROR, duration_ms=result.duration_ms, | |
| 374 | + ) | |
| 375 | + transient = result.error_code in TRANSIENT_ERRORS | |
| 376 | + if transient and item["retries"] < MAX_RETRIES: | |
| 377 | + await self.db.release_item( | |
| 378 | + url_id, status="pending", error_code=result.error_code, | |
| 379 | + next_crawl_at=datetime.now(UTC) + retry_delay(item["retries"]), | |
| 380 | + increment_retries=True, | |
| 381 | + ) | |
| 382 | + else: | |
| 383 | + await self.db.release_item(url_id, status="failed", error_code=result.error_code) | |
| 384 | + | |
| 385 | + # ------------------------------------------------------------------ découverte | |
| 386 | + | |
| 387 | + async def _discover_links(self, item, page, signals) -> None: | |
| 388 | + """Boucle de découverte (§8) : liens sortants → scoring → frontier.""" | |
| 389 | + if item["depth"] >= self.s.max_crawl_depth: | |
| 390 | + return | |
| 391 | + domain_row = await self._domain(item["domain_id"]) | |
| 392 | + source_domain = domain_row["domain"] | |
| 393 | + source_quebec = max(float(domain_row["quebec_score"]), signals.score) | |
| 394 | + | |
| 395 | + outlink_domains: dict[int, int] = {} | |
| 396 | + enqueued = 0 | |
| 397 | + for link in page.links: | |
| 398 | + if link.nofollow or looks_like_trap( | |
| 399 | + link.url, | |
| 400 | + max_query_params=self.s.max_query_params, | |
| 401 | + max_path_segments=self.s.max_path_segments, | |
| 402 | + ): | |
| 403 | + continue | |
| 404 | + target_domain = extract_domain(link.url) | |
| 405 | + if not target_domain: | |
| 406 | + continue | |
| 407 | + same_domain = target_domain == source_domain | |
| 408 | + target_row = await self._domain_row_by_name(target_domain) | |
| 409 | + is_new = target_row is None | |
| 410 | + target_quebec = float(target_row["quebec_score"]) if target_row else 0.0 | |
| 411 | + # Un domaine découvert depuis une page québécoise hérite d'un a priori Québec | |
| 412 | + effective_quebec = target_quebec if not is_new else source_quebec * 0.7 | |
| 413 | + if same_domain: | |
| 414 | + effective_quebec = max(effective_quebec, source_quebec) | |
| 415 | + | |
| 416 | + priority = compute_priority( | |
| 417 | + domain_quebec_score=effective_quebec, | |
| 418 | + authority_score=float(target_row["authority_score"]) if target_row else 0.0, | |
| 419 | + link_signal=0.5 if not same_domain else 0.2, | |
| 420 | + is_new_domain=is_new, | |
| 421 | + depth=item["depth"] + 1, | |
| 422 | + ) | |
| 423 | + # Économie de crawl : ignorer les cibles au signal Québec quasi nul | |
| 424 | + if priority < 0.1: | |
| 425 | + continue | |
| 426 | + url_id = await self.db.enqueue_url( | |
| 427 | + link.url, target_domain, | |
| 428 | + priority=priority, depth=item["depth"] + 1, source_url_id=item["url_id"], | |
| 429 | + max_urls_per_domain=self.s.max_urls_per_domain, | |
| 430 | + ) | |
| 431 | + if url_id: | |
| 432 | + enqueued += 1 | |
| 433 | + if not same_domain: | |
| 434 | + target_id = (target_row["id"] if target_row | |
| 435 | + else (await self.db.upsert_domain(target_domain))) | |
| 436 | + outlink_domains[target_id] = outlink_domains.get(target_id, 0) + 1 | |
| 437 | + | |
| 438 | + if outlink_domains: | |
| 439 | + await self.db.record_domain_links(item["domain_id"], outlink_domains) | |
| 440 | + | |
| 441 | + | |
| 442 | +def main() -> None: | |
| 443 | + asyncio.run(CrawlerWorker().start()) | |
| 444 | + | |
| 445 | + | |
| 446 | +if __name__ == "__main__": | |
| 447 | + main() | |
added
M2M32brouve-ka/services/enrichment/__init__.py
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +# Trouve-KA — enrichissement asynchrone | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Enrichissement asynchrone des documents (étapes 2-3, CLAUDE.md §4). | |
| 6 | + | |
| 7 | +Ne bloque JAMAIS l'indexation : consomme Redis Streams et met à jour | |
| 8 | +les documents déjà cherchables (scores de domaine, fraîcheur, plus tard | |
| 9 | +embeddings et entités). | |
| 10 | +""" | |
| 11 | + | |
| 12 | +from .worker import EnrichmentWorker | |
| 13 | + | |
| 14 | +__all__ = ["EnrichmentWorker"] | |
added
M2M32brouve-ka/services/enrichment/worker.py
+82 −0
@@ -0,0 +1,82 @@ | ||
| 1 | +# Trouve-KA — worker d'enrichissement | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Worker d'enrichissement : consomme trouveka:enrich (Redis Streams). | |
| 6 | + | |
| 7 | +Étape 2 (implémentée) : propage domain_quebec_score et authority_score à jour | |
| 8 | +dans le document indexé (le domaine apprend au fil du crawl, les documents déjà | |
| 9 | +indexés en profitent rétroactivement). | |
| 10 | +Étapes futures : embeddings, entités, classification thématique — même canal, | |
| 11 | +même contrat : mise à jour partielle du document, jamais bloquante. | |
| 12 | +""" | |
| 13 | + | |
| 14 | +import asyncio | |
| 15 | +import os | |
| 16 | +import signal | |
| 17 | +import uuid | |
| 18 | + | |
| 19 | +from trouveka.config import get_settings | |
| 20 | +from trouveka.database import Database | |
| 21 | +from trouveka.logging import get_logger | |
| 22 | +from trouveka.queue import Coordination | |
| 23 | +from trouveka.search_core import SearchCore | |
| 24 | + | |
| 25 | +log = get_logger("enrichment") | |
| 26 | + | |
| 27 | +GROUP = "enrichers" | |
| 28 | + | |
| 29 | + | |
| 30 | +class EnrichmentWorker: | |
| 31 | + def __init__(self) -> None: | |
| 32 | + self.s = get_settings() | |
| 33 | + self.consumer = f"enrich-{os.getpid()}-{uuid.uuid4().hex[:6]}" | |
| 34 | + self.db = Database(self.s.database_url, pool_min=1, pool_max=3) | |
| 35 | + self.coord = Coordination(self.s.redis_url) | |
| 36 | + self.search = SearchCore(self.s.search_url, self.s.search_index) | |
| 37 | + self.stop_event = asyncio.Event() | |
| 38 | + | |
| 39 | + async def enrich(self, payload: dict) -> None: | |
| 40 | + domain_row = await self.db.get_domain_by_id(int(payload["domain_id"])) | |
| 41 | + if not domain_row: | |
| 42 | + return | |
| 43 | + await self.search.update_document( | |
| 44 | + payload["url"], | |
| 45 | + { | |
| 46 | + "domain_quebec_score": round(float(domain_row["quebec_score"]), 4), | |
| 47 | + "authority_score": round(float(domain_row["authority_score"]), 4), | |
| 48 | + }, | |
| 49 | + ) | |
| 50 | + | |
| 51 | + async def start(self) -> None: | |
| 52 | + await self.db.connect() | |
| 53 | + loop = asyncio.get_running_loop() | |
| 54 | + for sig in (signal.SIGINT, signal.SIGTERM): | |
| 55 | + loop.add_signal_handler(sig, self.stop_event.set) | |
| 56 | + log.info("worker d'enrichissement démarré", extra={"ctx": {"consumer": self.consumer}}) | |
| 57 | + | |
| 58 | + while not self.stop_event.is_set(): | |
| 59 | + try: | |
| 60 | + messages = await self.coord.read_enrichment(GROUP, self.consumer, count=20, block_ms=5000) | |
| 61 | + for msg_id, payload in messages: | |
| 62 | + try: | |
| 63 | + await self.enrich(payload) | |
| 64 | + except Exception: | |
| 65 | + log.exception("échec enrichissement", extra={"ctx": payload}) | |
| 66 | + finally: | |
| 67 | + await self.coord.ack_enrichment(GROUP, msg_id) | |
| 68 | + except Exception: | |
| 69 | + log.exception("erreur boucle enrichissement (on continue)") | |
| 70 | + await asyncio.sleep(2) | |
| 71 | + | |
| 72 | + await self.search.close() | |
| 73 | + await self.coord.close() | |
| 74 | + await self.db.close() | |
| 75 | + | |
| 76 | + | |
| 77 | +def main() -> None: | |
| 78 | + asyncio.run(EnrichmentWorker().start()) | |
| 79 | + | |
| 80 | + | |
| 81 | +if __name__ == "__main__": | |
| 82 | + main() | |
added
M2M32brouve-ka/services/frontier/__init__.py
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +# Trouve-KA — politique du frontier | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Politique du frontier : fonction de priorité et recrawl adaptatif (CLAUDE.md §5.3, §5.5).""" | |
| 6 | + | |
| 7 | +from .policy import compute_priority, next_recrawl_delay, retry_delay | |
| 8 | + | |
| 9 | +__all__ = ["compute_priority", "next_recrawl_delay", "retry_delay"] | |
added
M2M32brouve-ka/services/frontier/policy.py
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +# Trouve-KA — priorités et recrawl adaptatif | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Fonction de priorité du frontier et scheduling de recrawl. | |
| 6 | + | |
| 7 | +P = w_q·Q + w_a·A + w_f·F + w_l·L + w_n·N − w_d·D − w_s·S (CLAUDE.md §5.3) | |
| 8 | +Les poids sont des constantes nommées, destinées à être calibrées par mesures. | |
| 9 | +""" | |
| 10 | + | |
| 11 | +from datetime import timedelta | |
| 12 | + | |
| 13 | +# Poids de la fonction de priorité — à calibrer empiriquement, jamais figés | |
| 14 | +W_QUEBEC = 0.40 | |
| 15 | +W_AUTHORITY = 0.15 | |
| 16 | +W_FRESHNESS = 0.10 | |
| 17 | +W_LINKS = 0.10 | |
| 18 | +W_NOVELTY = 0.15 | |
| 19 | +W_DEPTH = 0.05 | |
| 20 | +W_SPAM = 0.30 | |
| 21 | + | |
| 22 | + | |
| 23 | +def compute_priority( | |
| 24 | + *, | |
| 25 | + domain_quebec_score: float, | |
| 26 | + authority_score: float = 0.0, | |
| 27 | + freshness_hint: float = 0.0, | |
| 28 | + link_signal: float = 0.0, | |
| 29 | + is_new_domain: bool = False, | |
| 30 | + depth: int = 0, | |
| 31 | + spam_signal: float = 0.0, | |
| 32 | + is_seed: bool = False, | |
| 33 | +) -> float: | |
| 34 | + """Priorité ∈ [0, 1]. Les seeds démarrent au maximum.""" | |
| 35 | + if is_seed: | |
| 36 | + return 1.0 | |
| 37 | + p = ( | |
| 38 | + W_QUEBEC * domain_quebec_score | |
| 39 | + + W_AUTHORITY * min(authority_score, 1.0) | |
| 40 | + + W_FRESHNESS * min(freshness_hint, 1.0) | |
| 41 | + + W_LINKS * min(link_signal, 1.0) | |
| 42 | + + W_NOVELTY * (1.0 if is_new_domain else 0.3) | |
| 43 | + - W_DEPTH * min(depth, 10) / 10.0 | |
| 44 | + - W_SPAM * min(spam_signal, 1.0) | |
| 45 | + ) | |
| 46 | + return max(0.0, min(1.0, round(p, 4))) | |
| 47 | + | |
| 48 | + | |
| 49 | +def next_recrawl_delay( | |
| 50 | + *, | |
| 51 | + changed: bool, | |
| 52 | + previous_delay_hours: float | None, | |
| 53 | + min_hours: float = 1.0, | |
| 54 | + max_hours: float = 24 * 30.0, | |
| 55 | + default_hours: float = 24.0, | |
| 56 | +) -> timedelta: | |
| 57 | + """Recrawl adaptatif (§5.5) : page inchangée → intervalle ×2; page volatile → intervalle ÷2.""" | |
| 58 | + prev = previous_delay_hours or default_hours | |
| 59 | + hours = max(min_hours, prev / 2.0) if changed else min(max_hours, prev * 2.0) | |
| 60 | + return timedelta(hours=hours) | |
| 61 | + | |
| 62 | + | |
| 63 | +def retry_delay(retries: int) -> timedelta: | |
| 64 | + """Backoff exponentiel plafonné pour les erreurs transitoires.""" | |
| 65 | + return timedelta(minutes=min(15 * (2**retries), 60 * 24)) | |
added
M2M32brouve-ka/services/indexer/__init__.py
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +# Trouve-KA — indexeur | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Construction et indexation immédiate des documents (étape 1, CLAUDE.md §4). | |
| 6 | + | |
| 7 | +L'indexation ne dépend JAMAIS de l'enrichissement : les champs des étapes 2-3 | |
| 8 | +(embedding, entités, autorité…) sont remplis plus tard par mise à jour partielle. | |
| 9 | +""" | |
| 10 | + | |
| 11 | +from .build import build_search_document | |
| 12 | + | |
| 13 | +__all__ = ["build_search_document"] | |
added
M2M32brouve-ka/services/indexer/build.py
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +# Trouve-KA — construction du document d'index | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Transforme une page parsée + signaux Québec en document OpenSearch (étape 1).""" | |
| 6 | + | |
| 7 | +from datetime import UTC, datetime | |
| 8 | +from typing import Any | |
| 9 | + | |
| 10 | +from trouveka.types import ParsedPage, QuebecSignals | |
| 11 | + | |
| 12 | +# Catégorisation grossière par domaine — raffinée en asynchrone (étape 2) | |
| 13 | +_GOV_SUFFIXES = (".gouv.qc.ca", ".gc.ca", ".quebec.ca") | |
| 14 | +_GOV_DOMAINS = {"quebec.ca", "canada.ca", "montreal.ca", "laval.ca", "gatineau.ca", "sherbrooke.ca"} | |
| 15 | +_NEWS_DOMAINS = { | |
| 16 | + "lapresse.ca", "ledevoir.com", "journaldemontreal.com", "journaldequebec.com", | |
| 17 | + "radio-canada.ca", "tvanouvelles.ca", "lesoleil.com", "ledroit.com", | |
| 18 | + "latribune.ca", "lenouvelliste.ca", "lequotidien.com", "lavoixdelest.ca", | |
| 19 | + "noovo.info", "24heures.ca", "montrealgazette.com", | |
| 20 | +} | |
| 21 | +_EDU_SUFFIXES = (".ulaval.ca", ".umontreal.ca", ".mcgill.ca", ".uqam.ca", ".usherbrooke.ca", | |
| 22 | + ".concordia.ca", ".polymtl.ca", ".etsmtl.ca", ".hec.ca") | |
| 23 | + | |
| 24 | + | |
| 25 | +def categorize_domain(domain: str) -> list[str]: | |
| 26 | + d = domain.lower() | |
| 27 | + cats: list[str] = [] | |
| 28 | + if d in _GOV_DOMAINS or any(d.endswith(s) for s in _GOV_SUFFIXES) or ".gouv." in d: | |
| 29 | + cats.append("government") | |
| 30 | + if d in _NEWS_DOMAINS: | |
| 31 | + cats.append("news") | |
| 32 | + if any(d.endswith(s) or d == s.lstrip(".") for s in _EDU_SUFFIXES) or d.endswith(".edu"): | |
| 33 | + cats.append("education") | |
| 34 | + return cats | |
| 35 | + | |
| 36 | + | |
| 37 | +def build_search_document( | |
| 38 | + page: ParsedPage, | |
| 39 | + signals: QuebecSignals, | |
| 40 | + *, | |
| 41 | + domain: str, | |
| 42 | + domain_quebec_score: float, | |
| 43 | + authority_score: float = 0.0, | |
| 44 | +) -> dict[str, Any]: | |
| 45 | + return { | |
| 46 | + "url": page.url, | |
| 47 | + "canonical_url": page.canonical_url or page.url, | |
| 48 | + "domain": domain, | |
| 49 | + "title": page.title, | |
| 50 | + "description": page.description, | |
| 51 | + "body": page.body[:100_000], | |
| 52 | + "headings": page.headings, | |
| 53 | + "language": page.language, | |
| 54 | + "page_quebec_score": signals.score, | |
| 55 | + "domain_quebec_score": round(domain_quebec_score, 4), | |
| 56 | + "locations": signals.locations, | |
| 57 | + "organizations": [], # enrichissement étape 2 | |
| 58 | + "people": [], # enrichissement étape 2 | |
| 59 | + "categories": categorize_domain(domain), | |
| 60 | + "published_at": page.published_at.isoformat() if page.published_at else None, | |
| 61 | + "crawled_at": datetime.now(UTC).isoformat(), | |
| 62 | + "authority_score": round(authority_score, 4), | |
| 63 | + "freshness_score": 0.0, # enrichissement étape 3 | |
| 64 | + "quality_score": 0.0, # enrichissement étape 3 | |
| 65 | + "spam_score": 0.0, # enrichissement étape 3 | |
| 66 | + } | |
added
M2M32brouve-ka/services/parser/__init__.py
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +# Trouve-KA — extraction de contenu | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Extraction de contenu des pages crawlées (CLAUDE.md §6). | |
| 6 | + | |
| 7 | +Chaque page est traitée comme non fiable : texte extrait puis assaini, | |
| 8 | +URLs re-canonicalisées, MIME types jamais crus sur parole. | |
| 9 | +""" | |
| 10 | + | |
| 11 | +from .html_parser import parse_html, decode_html, looks_like_garbage | |
| 12 | + | |
| 13 | +__all__ = ["parse_html", "decode_html", "looks_like_garbage"] | |
added
M2M32brouve-ka/services/parser/html_parser.py
+246 −0
@@ -0,0 +1,246 @@ | ||
| 1 | +# Trouve-KA — parseur HTML | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Extraction HTML avec selectolax (rapide, tolérant). | |
| 6 | + | |
| 7 | +Extrait : titre, meta description, corps principal (boilerplate retiré), | |
| 8 | +headings, liens + anchors + nofollow, canonical, langue, dates de publication, | |
| 9 | +auteur, indices structurés (JSON-LD/OpenGraph) pour le scoring Québec. | |
| 10 | +""" | |
| 11 | + | |
| 12 | +import json | |
| 13 | +import re | |
| 14 | +from datetime import datetime | |
| 15 | + | |
| 16 | +from dateutil import parser as dateparser | |
| 17 | +from langdetect import DetectorFactory, LangDetectException, detect | |
| 18 | +from selectolax.parser import HTMLParser | |
| 19 | + | |
| 20 | +from trouveka.shared import canonicalize_url | |
| 21 | +from trouveka.types import ExtractedLink, ParsedPage | |
| 22 | + | |
| 23 | +DetectorFactory.seed = 42 # détection de langue déterministe | |
| 24 | + | |
| 25 | +# Éléments retirés avant extraction du corps (navigation, pub, scripts…) | |
| 26 | +_STRIP_SELECTORS = ( | |
| 27 | + "script", "style", "noscript", "template", "svg", "iframe", "form", | |
| 28 | + "nav", "header", "footer", "aside", | |
| 29 | + "[role=navigation]", "[role=banner]", "[role=contentinfo]", "[role=complementary]", | |
| 30 | + "[aria-hidden=true]", ".cookie-banner", "#cookie-banner", ".cookies", "[class*=cookie-consent]", | |
| 31 | +) | |
| 32 | + | |
| 33 | +_MAIN_SELECTORS = ("main", "article", "[role=main]", "#main", "#content", ".main-content", ".content") | |
| 34 | + | |
| 35 | +_WS = re.compile(r"\s+") | |
| 36 | +_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") | |
| 37 | + | |
| 38 | + | |
| 39 | +def _clean(text: str | None) -> str: | |
| 40 | + if not text: | |
| 41 | + return "" | |
| 42 | + return _WS.sub(" ", _CTRL.sub(" ", text)).strip() | |
| 43 | + | |
| 44 | + | |
| 45 | +def _parse_date(value: str | None) -> datetime | None: | |
| 46 | + if not value: | |
| 47 | + return None | |
| 48 | + try: | |
| 49 | + dt = dateparser.parse(value) | |
| 50 | + except (ValueError, OverflowError, TypeError): | |
| 51 | + return None | |
| 52 | + if dt and dt.year >= 1990: | |
| 53 | + return dt | |
| 54 | + return None | |
| 55 | + | |
| 56 | + | |
| 57 | +def _meta(tree: HTMLParser, *names: str) -> str | None: | |
| 58 | + for name in names: | |
| 59 | + for attr in ("name", "property", "itemprop"): | |
| 60 | + node = tree.css_first(f'meta[{attr}="{name}"]') | |
| 61 | + if node: | |
| 62 | + content = node.attributes.get("content") | |
| 63 | + if content and content.strip(): | |
| 64 | + return content.strip() | |
| 65 | + return None | |
| 66 | + | |
| 67 | + | |
| 68 | +def _extract_jsonld_hints(tree: HTMLParser, max_blocks: int = 10) -> tuple[list[str], datetime | None]: | |
| 69 | + """Extrait des textes utiles des blocs JSON-LD (adresses, noms d'org) + datePublished.""" | |
| 70 | + hints: list[str] = [] | |
| 71 | + published: datetime | None = None | |
| 72 | + for node in tree.css('script[type="application/ld+json"]')[:max_blocks]: | |
| 73 | + raw = node.text() | |
| 74 | + if not raw or len(raw) > 100_000: | |
| 75 | + continue | |
| 76 | + try: | |
| 77 | + data = json.loads(raw) | |
| 78 | + except (json.JSONDecodeError, ValueError): | |
| 79 | + continue | |
| 80 | + stack = [data] | |
| 81 | + while stack: | |
| 82 | + item = stack.pop() | |
| 83 | + if isinstance(item, list): | |
| 84 | + stack.extend(item[:20]) | |
| 85 | + elif isinstance(item, dict): | |
| 86 | + for key in ("name", "legalName", "addressLocality", "addressRegion", | |
| 87 | + "postalCode", "streetAddress", "telephone"): | |
| 88 | + val = item.get(key) | |
| 89 | + if isinstance(val, str) and len(val) < 200: | |
| 90 | + hints.append(val) | |
| 91 | + if published is None: | |
| 92 | + published = _parse_date(item.get("datePublished")) or _parse_date(item.get("dateCreated")) | |
| 93 | + stack.extend(v for v in item.values() if isinstance(v, (dict, list))) | |
| 94 | + if len(hints) > 60: | |
| 95 | + break | |
| 96 | + return hints[:60], published | |
| 97 | + | |
| 98 | + | |
| 99 | +_META_CHARSET_RE = re.compile( | |
| 100 | + rb'<meta[^>]+charset=["\']?\s*([a-zA-Z0-9_-]{2,20})', re.IGNORECASE | |
| 101 | +) | |
| 102 | +_CHARSET_ALIASES = {"iso-8859-1": "cp1252", "latin-1": "cp1252", "latin1": "cp1252", "ansi": "cp1252"} | |
| 103 | + | |
| 104 | + | |
| 105 | +def decode_html(body: bytes, declared_charset: str | None = None) -> str: | |
| 106 | + """Décode le HTML : charset HTTP déclaré → meta charset → UTF-8 strict → cp1252. | |
| 107 | + | |
| 108 | + Les vieux sites québécois sont souvent en ISO-8859-1/cp1252; décoder | |
| 109 | + aveuglément en UTF-8 produit du charabia qui finirait dans l'index. | |
| 110 | + """ | |
| 111 | + candidates: list[str] = [] | |
| 112 | + if declared_charset: | |
| 113 | + candidates.append(declared_charset.lower()) | |
| 114 | + meta = _META_CHARSET_RE.search(body[:4096]) | |
| 115 | + if meta: | |
| 116 | + candidates.append(meta.group(1).decode("ascii", errors="ignore").lower()) | |
| 117 | + candidates.extend(["utf-8", "cp1252"]) | |
| 118 | + for charset in candidates: | |
| 119 | + charset = _CHARSET_ALIASES.get(charset, charset) | |
| 120 | + try: | |
| 121 | + return body.decode(charset) | |
| 122 | + except (UnicodeDecodeError, LookupError): | |
| 123 | + continue | |
| 124 | + return body.decode("utf-8", errors="replace") | |
| 125 | + | |
| 126 | + | |
| 127 | +def looks_like_garbage(text: str, *, threshold: float = 0.03) -> bool: | |
| 128 | + """True si le texte contient trop de caractères de remplacement/contrôle | |
| 129 | + (contenu binaire ou mal décodé — ne doit jamais être indexé).""" | |
| 130 | + if not text: | |
| 131 | + return False | |
| 132 | + sample = text[:20_000] | |
| 133 | + bad = sum(1 for c in sample if c == "�" or (ord(c) < 32 and c not in "\t\n\r")) | |
| 134 | + return bad / len(sample) > threshold | |
| 135 | + | |
| 136 | + | |
| 137 | +def parse_html( | |
| 138 | + url: str, html: bytes | str, *, max_links: int = 300, charset: str | None = None | |
| 139 | +) -> ParsedPage: | |
| 140 | + """Parse une page HTML en ParsedPage. Ne lève pas sur du HTML dégueulasse.""" | |
| 141 | + if isinstance(html, bytes): | |
| 142 | + html = decode_html(html, charset) | |
| 143 | + tree = HTMLParser(html) | |
| 144 | + | |
| 145 | + # Directives robots de la page | |
| 146 | + robots_meta = (_meta(tree, "robots", "googlebot") or "").lower() | |
| 147 | + noindex = "noindex" in robots_meta | |
| 148 | + nofollow_page = "nofollow" in robots_meta | |
| 149 | + | |
| 150 | + # Canonical | |
| 151 | + canonical = None | |
| 152 | + link_canonical = tree.css_first('link[rel="canonical"]') | |
| 153 | + if link_canonical: | |
| 154 | + canonical = canonicalize_url(link_canonical.attributes.get("href") or "", base=url) | |
| 155 | + | |
| 156 | + # Titre / description | |
| 157 | + title = _clean(tree.css_first("title").text() if tree.css_first("title") else None) | |
| 158 | + og_title = _meta(tree, "og:title") | |
| 159 | + if not title and og_title: | |
| 160 | + title = _clean(og_title) | |
| 161 | + description = _clean(_meta(tree, "description", "og:description") or "") | |
| 162 | + | |
| 163 | + # Langue déclarée | |
| 164 | + declared_lang = None | |
| 165 | + html_node = tree.css_first("html") | |
| 166 | + if html_node: | |
| 167 | + lang_attr = (html_node.attributes.get("lang") or "").strip().lower() | |
| 168 | + if lang_attr: | |
| 169 | + declared_lang = lang_attr[:2] | |
| 170 | + | |
| 171 | + # Dates / auteur | |
| 172 | + published = _parse_date(_meta(tree, "article:published_time", "datePublished", "date", "dc.date")) | |
| 173 | + modified = _parse_date(_meta(tree, "article:modified_time", "dateModified")) | |
| 174 | + author = _meta(tree, "author", "article:author") | |
| 175 | + | |
| 176 | + # Indices structurés (avant strip : JSON-LD est dans <script>) | |
| 177 | + structured_hints, jsonld_published = _extract_jsonld_hints(tree) | |
| 178 | + published = published or jsonld_published | |
| 179 | + | |
| 180 | + # Liens (avant strip : les liens de nav comptent pour la découverte) | |
| 181 | + links: list[ExtractedLink] = [] | |
| 182 | + seen: set[str] = set() | |
| 183 | + for node in tree.css("a[href]"): | |
| 184 | + if len(links) >= max_links: | |
| 185 | + break | |
| 186 | + href = node.attributes.get("href") or "" | |
| 187 | + if href.startswith(("#", "javascript:", "mailto:", "tel:", "data:")): | |
| 188 | + continue | |
| 189 | + normalized = canonicalize_url(href, base=url) | |
| 190 | + if not normalized or normalized in seen: | |
| 191 | + continue | |
| 192 | + seen.add(normalized) | |
| 193 | + rel = (node.attributes.get("rel") or "").lower() | |
| 194 | + links.append( | |
| 195 | + ExtractedLink( | |
| 196 | + url=normalized, | |
| 197 | + anchor=_clean(node.text())[:200], | |
| 198 | + nofollow="nofollow" in rel or nofollow_page, | |
| 199 | + ) | |
| 200 | + ) | |
| 201 | + | |
| 202 | + # Headings | |
| 203 | + headings = [_clean(n.text()) for n in tree.css("h1, h2, h3")[:40]] | |
| 204 | + headings = [h for h in headings if h] | |
| 205 | + | |
| 206 | + # Corps principal : strip du boilerplate, puis zone principale si identifiable | |
| 207 | + for selector in _STRIP_SELECTORS: | |
| 208 | + for node in tree.css(selector): | |
| 209 | + node.decompose() | |
| 210 | + body_node = None | |
| 211 | + for selector in _MAIN_SELECTORS: | |
| 212 | + body_node = tree.css_first(selector) | |
| 213 | + if body_node: | |
| 214 | + break | |
| 215 | + if body_node is None: | |
| 216 | + body_node = tree.css_first("body") or tree.root | |
| 217 | + body = _clean(body_node.text(separator=" ", deep=True))[:200_000] | |
| 218 | + | |
| 219 | + # Langue : déclaration HTML validée/complétée par détection statistique | |
| 220 | + language = declared_lang if declared_lang in ("fr", "en") else None | |
| 221 | + sample = body[:4000] or title | |
| 222 | + if sample and len(sample) > 40: | |
| 223 | + try: | |
| 224 | + detected = detect(sample) | |
| 225 | + if detected in ("fr", "en"): | |
| 226 | + # La détection l'emporte si elle contredit une déclaration douteuse | |
| 227 | + language = detected if language is None or detected != language else language | |
| 228 | + except LangDetectException: | |
| 229 | + pass | |
| 230 | + | |
| 231 | + return ParsedPage( | |
| 232 | + url=url, | |
| 233 | + canonical_url=canonical, | |
| 234 | + title=title[:500], | |
| 235 | + description=description[:1000], | |
| 236 | + body=body, | |
| 237 | + headings=headings, | |
| 238 | + language=language, | |
| 239 | + links=links, | |
| 240 | + published_at=published, | |
| 241 | + modified_at=modified, | |
| 242 | + author=_clean(author)[:200] if author else None, | |
| 243 | + noindex=noindex, | |
| 244 | + nofollow_page=nofollow_page, | |
| 245 | + structured_hints=structured_hints, | |
| 246 | + ) | |
added
M2M32brouve-ka/services/ranking/__init__.py
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +# Trouve-KA — ranking | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Ranking Québec-first (CLAUDE.md §9). | |
| 6 | + | |
| 7 | +Score(d,q) = w_b·BM25 + w_q·Québec + w_a·Autorité + w_f·Fraîcheur + w_l·Localité | |
| 8 | +Chaque composant avancé est optionnel : BM25 fonctionne même si tout le reste tombe. | |
| 9 | +""" | |
| 10 | + | |
| 11 | +from .query import analyze_query, build_search_body | |
| 12 | + | |
| 13 | +__all__ = ["analyze_query", "build_search_body"] | |
added
M2M32brouve-ka/services/ranking/query.py
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +# Trouve-KA — pipeline de requête et ranking | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Pipeline de requête : normalisation → langue → lieux → requête OpenSearch. | |
| 6 | + | |
| 7 | +BM25 (multi_match bilingue) au cœur, function_score par-dessus : | |
| 8 | +Québec, autorité, fraîcheur, localité. Poids nommés, non figés (§9). | |
| 9 | +""" | |
| 10 | + | |
| 11 | +import re | |
| 12 | +import unicodedata | |
| 13 | +from typing import Any | |
| 14 | + | |
| 15 | +from trouveka.classifier import QUEBEC_TOPONYMS | |
| 16 | + | |
| 17 | +# Poids de ranking — à calibrer avec le dataset d'évaluation (§16) | |
| 18 | +W_PAGE_QUEBEC = 1.6 | |
| 19 | +W_DOMAIN_QUEBEC = 1.0 | |
| 20 | +W_AUTHORITY = 0.8 | |
| 21 | +W_FRESHNESS = 0.5 | |
| 22 | +W_LOCALITY = 2.0 | |
| 23 | + | |
| 24 | +_FR_HINTS = {"le", "la", "les", "des", "une", "un", "du", "de", "et", "ou", "pour", | |
| 25 | + "avec", "dans", "sur", "meilleur", "meilleure", "comment", "où", "quel", "quelle"} | |
| 26 | +_EN_HINTS = {"the", "a", "an", "of", "and", "or", "for", "with", "in", "on", "best", "how", "what", "where"} | |
| 27 | + | |
| 28 | + | |
| 29 | +def _fold(text: str) -> str: | |
| 30 | + return "".join(c for c in unicodedata.normalize("NFD", text) if unicodedata.category(c) != "Mn") | |
| 31 | + | |
| 32 | + | |
| 33 | +def analyze_query(q: str) -> dict[str, Any]: | |
| 34 | + """Normalisation, détection de langue heuristique, extraction de lieux québécois.""" | |
| 35 | + normalized = re.sub(r"\s+", " ", q).strip()[:200] | |
| 36 | + tokens = [t.lower() for t in re.findall(r"[\w'-]+", normalized, re.UNICODE)] | |
| 37 | + | |
| 38 | + fr_hits = sum(1 for t in tokens if t in _FR_HINTS) | |
| 39 | + en_hits = sum(1 for t in tokens if t in _EN_HINTS) | |
| 40 | + has_accents = normalized != _fold(normalized) | |
| 41 | + language = "fr" if (fr_hits > en_hits or has_accents) else ("en" if en_hits > fr_hits else None) | |
| 42 | + | |
| 43 | + # Lieux : tokens simples + bigrammes contre le gazetteer | |
| 44 | + folded_gazetteer = {_fold(t): t for t in QUEBEC_TOPONYMS} | |
| 45 | + locations: list[str] = [] | |
| 46 | + candidates = tokens + [f"{a}-{b}" for a, b in zip(tokens, tokens[1:])] + [ | |
| 47 | + f"{a} {b}" for a, b in zip(tokens, tokens[1:]) | |
| 48 | + ] | |
| 49 | + for cand in candidates: | |
| 50 | + folded = _fold(cand) | |
| 51 | + if folded in folded_gazetteer: | |
| 52 | + locations.append(folded_gazetteer[folded]) | |
| 53 | + return {"query": normalized, "language": language, "locations": sorted(set(locations))} | |
| 54 | + | |
| 55 | + | |
| 56 | +def build_search_body( | |
| 57 | + q: str, | |
| 58 | + *, | |
| 59 | + page: int = 1, | |
| 60 | + limit: int = 10, | |
| 61 | + language: str | None = None, | |
| 62 | + category: str | None = None, | |
| 63 | + quebec_only: bool = False, | |
| 64 | + freshness: str | None = None, | |
| 65 | +) -> tuple[dict[str, Any], dict[str, Any]]: | |
| 66 | + """Construit le corps de requête OpenSearch. Retourne (body, analyse).""" | |
| 67 | + analysis = analyze_query(q) | |
| 68 | + normalized = analysis["query"] | |
| 69 | + | |
| 70 | + # BM25 bilingue : les deux analyzers interrogés, le meilleur champ gagne | |
| 71 | + text_query: dict[str, Any] = { | |
| 72 | + "multi_match": { | |
| 73 | + "query": normalized, | |
| 74 | + "type": "most_fields", | |
| 75 | + "fields": [ | |
| 76 | + "title^4", "title.en^4", | |
| 77 | + "headings^2", "headings.en^2", | |
| 78 | + "description^2", "description.en^2", | |
| 79 | + "body", "body.en", | |
| 80 | + ], | |
| 81 | + "fuzziness": "AUTO", | |
| 82 | + "prefix_length": 2, | |
| 83 | + } | |
| 84 | + } | |
| 85 | + | |
| 86 | + filters: list[dict[str, Any]] = [] | |
| 87 | + if language in ("fr", "en"): | |
| 88 | + filters.append({"term": {"language": language}}) | |
| 89 | + if category: | |
| 90 | + filters.append({"term": {"categories": category}}) | |
| 91 | + if quebec_only: | |
| 92 | + filters.append({ | |
| 93 | + "bool": { | |
| 94 | + "should": [ | |
| 95 | + {"range": {"page_quebec_score": {"gte": 0.45}}}, | |
| 96 | + {"range": {"domain_quebec_score": {"gte": 0.6}}}, | |
| 97 | + ], | |
| 98 | + "minimum_should_match": 1, | |
| 99 | + } | |
| 100 | + }) | |
| 101 | + if freshness in ("day", "week", "month", "year"): | |
| 102 | + filters.append({"range": {"crawled_at": {"gte": f"now-1{freshness[0]}/d"}}}) | |
| 103 | + | |
| 104 | + functions: list[dict[str, Any]] = [ | |
| 105 | + {"field_value_factor": {"field": "page_quebec_score", "factor": W_PAGE_QUEBEC, "missing": 0}}, | |
| 106 | + {"field_value_factor": {"field": "domain_quebec_score", "factor": W_DOMAIN_QUEBEC, "missing": 0}}, | |
| 107 | + {"field_value_factor": {"field": "authority_score", "factor": W_AUTHORITY, "missing": 0}}, | |
| 108 | + { | |
| 109 | + "gauss": {"published_at": {"origin": "now", "scale": "180d", "decay": 0.6}}, | |
| 110 | + "weight": W_FRESHNESS, | |
| 111 | + }, | |
| 112 | + ] | |
| 113 | + # Localité : « plombier Gatineau » booste les documents avec preuve géographique explicite | |
| 114 | + if analysis["locations"]: | |
| 115 | + functions.append({ | |
| 116 | + "filter": {"terms": {"locations": analysis["locations"]}}, | |
| 117 | + "weight": W_LOCALITY, | |
| 118 | + }) | |
| 119 | + | |
| 120 | + body = { | |
| 121 | + "from": max(page - 1, 0) * limit, | |
| 122 | + "size": limit, | |
| 123 | + "query": { | |
| 124 | + "function_score": { | |
| 125 | + "query": {"bool": {"must": [text_query], "filter": filters}}, | |
| 126 | + "functions": functions, | |
| 127 | + "score_mode": "sum", | |
| 128 | + "boost_mode": "sum", | |
| 129 | + } | |
| 130 | + }, | |
| 131 | + "highlight": { | |
| 132 | + "pre_tags": ["<em>"], | |
| 133 | + "post_tags": ["</em>"], | |
| 134 | + "fields": { | |
| 135 | + "body": {"fragment_size": 180, "number_of_fragments": 2}, | |
| 136 | + "description": {"fragment_size": 180, "number_of_fragments": 1}, | |
| 137 | + }, | |
| 138 | + "encoder": "html", | |
| 139 | + }, | |
| 140 | + "_source": [ | |
| 141 | + "url", "canonical_url", "domain", "title", "description", "language", | |
| 142 | + "page_quebec_score", "domain_quebec_score", "categories", "published_at", | |
| 143 | + ], | |
| 144 | + "track_total_hits": True, | |
| 145 | + } | |
| 146 | + return body, analysis | |
added
M2M32brouve-ka/services/scheduler/__init__.py
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +# Trouve-KA — scheduler | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Boucle de maintenance : items abandonnés, autorité de domaine, hygiène du frontier.""" | |
| 6 | + | |
| 7 | +from .loop import SchedulerLoop | |
| 8 | + | |
| 9 | +__all__ = ["SchedulerLoop"] | |
added
M2M32brouve-ka/services/scheduler/loop.py
+81 −0
@@ -0,0 +1,81 @@ | ||
| 1 | +# Trouve-KA — boucle de maintenance | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Scheduler : tâches périodiques légères. | |
| 6 | + | |
| 7 | +- Relance les items in_progress abandonnés (worker mort) — dégradation gracieuse §13. | |
| 8 | +- Recalcule l'autorité de domaine à partir du graphe de liens (inlinks pondérés | |
| 9 | + par le score Québec des domaines source) — version simple, PageRank-like plus tard. | |
| 10 | +""" | |
| 11 | + | |
| 12 | +import asyncio | |
| 13 | +import signal | |
| 14 | + | |
| 15 | +from trouveka.config import get_settings | |
| 16 | +from trouveka.database import Database | |
| 17 | +from trouveka.logging import get_logger | |
| 18 | + | |
| 19 | +log = get_logger("scheduler") | |
| 20 | + | |
| 21 | +STALE_RESET_INTERVAL = 60 # secondes | |
| 22 | +AUTHORITY_INTERVAL = 15 * 60 # secondes | |
| 23 | + | |
| 24 | + | |
| 25 | +class SchedulerLoop: | |
| 26 | + def __init__(self) -> None: | |
| 27 | + self.s = get_settings() | |
| 28 | + self.db = Database(self.s.database_url, pool_min=1, pool_max=3) | |
| 29 | + self.stop_event = asyncio.Event() | |
| 30 | + | |
| 31 | + async def recompute_authority(self) -> None: | |
| 32 | + """Autorité ∈ [0,1] : log-saturation des inlinks pondérés par le Québec-score des sources.""" | |
| 33 | + await self.db.pool.execute( | |
| 34 | + """ | |
| 35 | + WITH weighted AS ( | |
| 36 | + SELECT dl.to_domain_id AS id, | |
| 37 | + sum(least(dl.link_count, 50) * greatest(d.quebec_score, 0.1)) AS w, | |
| 38 | + count(DISTINCT dl.from_domain_id) AS in_domains | |
| 39 | + FROM domain_links dl JOIN domains d ON d.id = dl.from_domain_id | |
| 40 | + GROUP BY dl.to_domain_id | |
| 41 | + ) | |
| 42 | + UPDATE domains SET | |
| 43 | + authority_score = least(1.0, ln(1 + w.w) / ln(1 + 5000)), | |
| 44 | + inlink_domains = w.in_domains | |
| 45 | + FROM weighted w WHERE domains.id = w.id | |
| 46 | + """ | |
| 47 | + ) | |
| 48 | + log.info("autorité de domaine recalculée") | |
| 49 | + | |
| 50 | + async def start(self) -> None: | |
| 51 | + await self.db.connect() | |
| 52 | + loop = asyncio.get_running_loop() | |
| 53 | + for sig in (signal.SIGINT, signal.SIGTERM): | |
| 54 | + loop.add_signal_handler(sig, self.stop_event.set) | |
| 55 | + log.info("scheduler démarré") | |
| 56 | + | |
| 57 | + elapsed_authority = AUTHORITY_INTERVAL # premier calcul immédiat | |
| 58 | + while not self.stop_event.is_set(): | |
| 59 | + try: | |
| 60 | + reset = await self.db.reset_stale_items(older_than_minutes=30) | |
| 61 | + if reset: | |
| 62 | + log.info("items abandonnés relancés", extra={"ctx": {"count": reset}}) | |
| 63 | + if elapsed_authority >= AUTHORITY_INTERVAL: | |
| 64 | + await self.recompute_authority() | |
| 65 | + elapsed_authority = 0 | |
| 66 | + except Exception: | |
| 67 | + log.exception("erreur scheduler (on continue)") | |
| 68 | + try: | |
| 69 | + await asyncio.wait_for(self.stop_event.wait(), timeout=STALE_RESET_INTERVAL) | |
| 70 | + except TimeoutError: | |
| 71 | + pass | |
| 72 | + elapsed_authority += STALE_RESET_INTERVAL | |
| 73 | + await self.db.close() | |
| 74 | + | |
| 75 | + | |
| 76 | +def main() -> None: | |
| 77 | + asyncio.run(SchedulerLoop().start()) | |
| 78 | + | |
| 79 | + | |
| 80 | +if __name__ == "__main__": | |
| 81 | + main() | |
added
M2M32brouve-ka/tests/test_fetcher.py
+110 −0
@@ -0,0 +1,110 @@ | ||
| 1 | +# Trouve-KA — tests du fetcher (web de fixtures via MockTransport) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +import functools | |
| 6 | + | |
| 7 | +import httpx | |
| 8 | +import pytest | |
| 9 | + | |
| 10 | +import trouveka.crawler.fetcher as fetcher_mod | |
| 11 | +from trouveka.config import Settings | |
| 12 | +from trouveka.crawler.fetcher import Fetcher | |
| 13 | +from trouveka.shared import is_safe_url | |
| 14 | +from trouveka.types import ErrorCode | |
| 15 | + | |
| 16 | + | |
| 17 | +@pytest.fixture(autouse=True) | |
| 18 | +def no_dns(monkeypatch): | |
| 19 | + # Les hôtes de fixtures ne résolvent pas en vrai DNS : on garde toute la | |
| 20 | + # logique SSRF (schémas, hôtes bloqués, IP littérales) sans résolution. | |
| 21 | + monkeypatch.setattr(fetcher_mod, "is_safe_url", functools.partial(is_safe_url, resolve=False)) | |
| 22 | + | |
| 23 | + | |
| 24 | +def make_fetcher(handler, **overrides) -> Fetcher: | |
| 25 | + settings = Settings(max_response_bytes=1000, max_redirects=3, fetch_timeout=5, **overrides) | |
| 26 | + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) | |
| 27 | + return Fetcher(client, settings) | |
| 28 | + | |
| 29 | + | |
| 30 | +async def test_simple_fetch(): | |
| 31 | + def handler(request): | |
| 32 | + return httpx.Response(200, html="<html><title>ok</title></html>", | |
| 33 | + headers={"content-type": "text/html; charset=utf-8"}) | |
| 34 | + | |
| 35 | + result = await make_fetcher(handler).fetch("https://site.qc.ca/page") | |
| 36 | + assert result.status_code == 200 | |
| 37 | + assert result.error_code == ErrorCode.OK | |
| 38 | + assert b"ok" in result.body | |
| 39 | + | |
| 40 | + | |
| 41 | +async def test_redirect_followed_with_ssrf_recheck(): | |
| 42 | + def handler(request): | |
| 43 | + if request.url.path == "/a": | |
| 44 | + return httpx.Response(301, headers={"location": "/b"}) | |
| 45 | + return httpx.Response(200, html="fini", headers={"content-type": "text/html"}) | |
| 46 | + | |
| 47 | + result = await make_fetcher(handler).fetch("https://site.qc.ca/a") | |
| 48 | + assert result.status_code == 200 | |
| 49 | + assert result.final_url == "https://site.qc.ca/b" | |
| 50 | + assert result.redirect_chain == ["https://site.qc.ca/b"] | |
| 51 | + | |
| 52 | + | |
| 53 | +async def test_redirect_to_private_ip_blocked(): | |
| 54 | + def handler(request): | |
| 55 | + return httpx.Response(302, headers={"location": "http://169.254.169.254/latest/meta-data/"}) | |
| 56 | + | |
| 57 | + result = await make_fetcher(handler).fetch("https://site.qc.ca/evil") | |
| 58 | + assert result.error_code == ErrorCode.SSRF_BLOCKED | |
| 59 | + | |
| 60 | + | |
| 61 | +async def test_too_many_redirects(): | |
| 62 | + def handler(request): | |
| 63 | + n = int(request.url.path.strip("/") or 0) | |
| 64 | + return httpx.Response(301, headers={"location": f"/{n + 1}"}) | |
| 65 | + | |
| 66 | + result = await make_fetcher(handler).fetch("https://site.qc.ca/0") | |
| 67 | + assert result.error_code == ErrorCode.TOO_MANY_REDIRECTS | |
| 68 | + | |
| 69 | + | |
| 70 | +async def test_body_size_limit(): | |
| 71 | + def handler(request): | |
| 72 | + return httpx.Response(200, content=b"x" * 5000, headers={"content-type": "text/html"}) | |
| 73 | + | |
| 74 | + result = await make_fetcher(handler).fetch("https://site.qc.ca/gros") | |
| 75 | + assert result.error_code == ErrorCode.TOO_LARGE | |
| 76 | + | |
| 77 | + | |
| 78 | +async def test_unsupported_content_type(): | |
| 79 | + def handler(request): | |
| 80 | + return httpx.Response(200, content=b"%PDF-1.4", headers={"content-type": "application/pdf"}) | |
| 81 | + | |
| 82 | + result = await make_fetcher(handler).fetch("https://site.qc.ca/doc.pdf") | |
| 83 | + assert result.error_code == ErrorCode.UNSUPPORTED_CONTENT | |
| 84 | + | |
| 85 | + | |
| 86 | +async def test_conditional_304(): | |
| 87 | + def handler(request): | |
| 88 | + assert request.headers.get("if-none-match") == 'W/"abc"' | |
| 89 | + return httpx.Response(304) | |
| 90 | + | |
| 91 | + result = await make_fetcher(handler).fetch("https://site.qc.ca/page", etag='W/"abc"') | |
| 92 | + assert result.status_code == 304 | |
| 93 | + | |
| 94 | + | |
| 95 | +async def test_4xx_and_5xx_classified(): | |
| 96 | + async def run(status): | |
| 97 | + def handler(request): | |
| 98 | + return httpx.Response(status) | |
| 99 | + return await make_fetcher(handler).fetch("https://site.qc.ca/x") | |
| 100 | + | |
| 101 | + assert (await run(404)).error_code == ErrorCode.HTTP_4XX | |
| 102 | + assert (await run(503)).error_code == ErrorCode.HTTP_5XX | |
| 103 | + | |
| 104 | + | |
| 105 | +async def test_ssrf_blocked_upfront(): | |
| 106 | + def handler(request): # pragma: no cover — ne doit jamais être appelé | |
| 107 | + raise AssertionError("fetch ne doit pas partir") | |
| 108 | + | |
| 109 | + result = await make_fetcher(handler).fetch("http://127.0.0.1:8080/admin") | |
| 110 | + assert result.error_code == ErrorCode.SSRF_BLOCKED | |
added
M2M32brouve-ka/tests/test_frontier_policy.py
+44 −0
@@ -0,0 +1,44 @@ | ||
| 1 | +# Trouve-KA — tests de la politique du frontier | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +from trouveka.frontier import compute_priority, next_recrawl_delay, retry_delay | |
| 6 | + | |
| 7 | + | |
| 8 | +def test_seed_priority_is_max(): | |
| 9 | + assert compute_priority(domain_quebec_score=0, is_seed=True) == 1.0 | |
| 10 | + | |
| 11 | + | |
| 12 | +def test_priority_bounded(): | |
| 13 | + assert 0.0 <= compute_priority(domain_quebec_score=1.0, authority_score=1.0, | |
| 14 | + link_signal=1.0, is_new_domain=True) <= 1.0 | |
| 15 | + assert compute_priority(domain_quebec_score=0.0, spam_signal=1.0, depth=10) == 0.0 | |
| 16 | + | |
| 17 | + | |
| 18 | +def test_quebec_dominates_priority(): | |
| 19 | + high_q = compute_priority(domain_quebec_score=0.9, depth=2) | |
| 20 | + low_q = compute_priority(domain_quebec_score=0.1, depth=2) | |
| 21 | + assert high_q > low_q + 0.2 | |
| 22 | + | |
| 23 | + | |
| 24 | +def test_recrawl_backoff_doubles_when_unchanged(): | |
| 25 | + d = next_recrawl_delay(changed=False, previous_delay_hours=24.0) | |
| 26 | + assert d.total_seconds() == 48 * 3600 | |
| 27 | + | |
| 28 | + | |
| 29 | +def test_recrawl_halves_when_changed(): | |
| 30 | + d = next_recrawl_delay(changed=True, previous_delay_hours=24.0) | |
| 31 | + assert d.total_seconds() == 12 * 3600 | |
| 32 | + | |
| 33 | + | |
| 34 | +def test_recrawl_bounded(): | |
| 35 | + fast = next_recrawl_delay(changed=True, previous_delay_hours=0.5, min_hours=1.0) | |
| 36 | + slow = next_recrawl_delay(changed=False, previous_delay_hours=10_000, max_hours=720) | |
| 37 | + assert fast.total_seconds() == 3600 | |
| 38 | + assert slow.total_seconds() == 720 * 3600 | |
| 39 | + | |
| 40 | + | |
| 41 | +def test_retry_delay_grows_and_caps(): | |
| 42 | + assert retry_delay(0).total_seconds() == 15 * 60 | |
| 43 | + assert retry_delay(1).total_seconds() == 30 * 60 | |
| 44 | + assert retry_delay(10).total_seconds() == 24 * 3600 | |
added
M2M32brouve-ka/tests/test_parser.py
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +# Trouve-KA — tests d'extraction HTML | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +from trouveka.parser import decode_html, looks_like_garbage, parse_html | |
| 6 | + | |
| 7 | +FIXTURE = """<!DOCTYPE html> | |
| 8 | +<html lang="fr"> | |
| 9 | +<head> | |
| 10 | + <title>Plomberie XYZ — Plombier à Gatineau</title> | |
| 11 | + <meta name="description" content="Service de plomberie en Outaouais depuis 1998."> | |
| 12 | + <link rel="canonical" href="https://plomberiexyz.qc.ca/services"> | |
| 13 | + <meta property="article:published_time" content="2024-06-12T10:00:00-04:00"> | |
| 14 | + <script type="application/ld+json"> | |
| 15 | + {"@type": "Plumber", "name": "Plomberie XYZ", | |
| 16 | + "address": {"addressLocality": "Gatineau", "addressRegion": "QC", "postalCode": "J8X 3X3"}} | |
| 17 | + </script> | |
| 18 | +</head> | |
| 19 | +<body> | |
| 20 | + <nav><a href="/menu1">Menu qui ne doit pas polluer le corps</a></nav> | |
| 21 | + <main> | |
| 22 | + <h1>Plombier Gatineau</h1> | |
| 23 | + <h2>Nos services</h2> | |
| 24 | + <p>Nous desservons Gatineau et tout l'Outaouais depuis 1998.</p> | |
| 25 | + <a href="/tarifs?utm_source=nav">Tarifs</a> | |
| 26 | + <a href="https://autresite.qc.ca/page" rel="nofollow">Partenaire</a> | |
| 27 | + <a href="mailto:info@x.ca">Courriel</a> | |
| 28 | + <a href="javascript:void(0)">JS</a> | |
| 29 | + </main> | |
| 30 | + <footer>123 rue Principale, Gatineau (Québec) J8X 3X3 — 819-555-1234</footer> | |
| 31 | + <script>trackingStuff();</script> | |
| 32 | +</body> | |
| 33 | +</html>""" | |
| 34 | + | |
| 35 | + | |
| 36 | +def test_full_extraction(): | |
| 37 | + page = parse_html("https://plomberiexyz.qc.ca/services/", FIXTURE) | |
| 38 | + assert page.title == "Plomberie XYZ — Plombier à Gatineau" | |
| 39 | + assert "Outaouais" in page.description | |
| 40 | + assert page.canonical_url == "https://plomberiexyz.qc.ca/services" | |
| 41 | + assert page.language == "fr" | |
| 42 | + assert "Plombier Gatineau" in page.headings | |
| 43 | + assert page.published_at and page.published_at.year == 2024 | |
| 44 | + # Corps : contenu principal présent, nav/script exclus | |
| 45 | + assert "Nous desservons Gatineau" in page.body | |
| 46 | + assert "Menu qui ne doit pas" not in page.body | |
| 47 | + assert "trackingStuff" not in page.body | |
| 48 | + # JSON-LD → indices structurés | |
| 49 | + assert "Gatineau" in page.structured_hints | |
| 50 | + | |
| 51 | + | |
| 52 | +def test_links_normalized_and_flagged(): | |
| 53 | + page = parse_html("https://plomberiexyz.qc.ca/services/", FIXTURE) | |
| 54 | + urls = {link.url for link in page.links} | |
| 55 | + assert "https://plomberiexyz.qc.ca/tarifs" in urls # utm retiré, relatif résolu | |
| 56 | + nofollow = {link.url: link.nofollow for link in page.links} | |
| 57 | + assert nofollow["https://autresite.qc.ca/page"] is True | |
| 58 | + assert not any(u.startswith(("mailto:", "javascript:")) for u in urls) | |
| 59 | + | |
| 60 | + | |
| 61 | +def test_noindex_detected(): | |
| 62 | + html = '<html><head><meta name="robots" content="noindex, nofollow"><title>x</title></head><body>corps</body></html>' | |
| 63 | + page = parse_html("https://x.qc.ca/", html) | |
| 64 | + assert page.noindex is True | |
| 65 | + assert page.nofollow_page is True | |
| 66 | + | |
| 67 | + | |
| 68 | +def test_garbage_html_does_not_crash(): | |
| 69 | + page = parse_html("https://x.qc.ca/", b"\x00\xffPas du HTML <div <<< &&& </span>") | |
| 70 | + assert page.url == "https://x.qc.ca/" | |
| 71 | + | |
| 72 | + | |
| 73 | +def test_decode_latin1_quebec_site(): | |
| 74 | + # Vieux site québécois en ISO-8859-1 : les accents doivent survivre | |
| 75 | + body = "<html><head><meta charset=\"iso-8859-1\"><title>Éducation à Québec</title></head><body>Ministère de l'Éducation</body></html>".encode("cp1252") | |
| 76 | + assert "Éducation à Québec" in decode_html(body) | |
| 77 | + page = parse_html("https://vieux-site.qc.ca/", body) | |
| 78 | + assert page.title == "Éducation à Québec" | |
| 79 | + | |
| 80 | + | |
| 81 | +def test_decode_http_charset_priority(): | |
| 82 | + body = "<html><title>Montréal</title></html>".encode("cp1252") | |
| 83 | + assert "Montréal" in decode_html(body, "iso-8859-1") | |
| 84 | + | |
| 85 | + | |
| 86 | +def test_binary_garbage_detected(): | |
| 87 | + # Contenu binaire (ex. brotli non décodé) décodé de force → détecté comme charabia | |
| 88 | + binary = bytes(range(256)) * 40 | |
| 89 | + text = binary.decode("utf-8", errors="replace") | |
| 90 | + assert looks_like_garbage(text) | |
| 91 | + assert not looks_like_garbage("Une page normale sur le Québec, avec des accents é à ç.") | |
| 92 | + assert not looks_like_garbage("") | |
added
M2M32brouve-ka/tests/test_quebec_scoring.py
+88 −0
@@ -0,0 +1,88 @@ | ||
| 1 | +# Trouve-KA — tests du scoring Québec | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +from trouveka.classifier import score_page | |
| 6 | +from trouveka.types import ParsedPage | |
| 7 | + | |
| 8 | + | |
| 9 | +def _page(**kwargs) -> ParsedPage: | |
| 10 | + defaults = {"url": "https://example.com/page", "title": "", "body": ""} | |
| 11 | + return ParsedPage(**(defaults | kwargs)) | |
| 12 | + | |
| 13 | + | |
| 14 | +def test_quebec_business_scores_high(): | |
| 15 | + page = _page( | |
| 16 | + url="https://plomberiexyz.qc.ca/services", | |
| 17 | + title="Plomberie XYZ — Plombier à Gatineau", | |
| 18 | + description="Service de plomberie résidentielle en Outaouais", | |
| 19 | + body="Plomberie XYZ dessert Gatineau et l'Outaouais. 123 rue Principale, Gatineau (Québec) J8X 3X3. " | |
| 20 | + "Appelez-nous au 819-555-1234. Urgences 24h partout au Québec.", | |
| 21 | + headings=["Plombier Gatineau", "Nos services"], | |
| 22 | + language="fr", | |
| 23 | + ) | |
| 24 | + signals = score_page(page, "plomberiexyz.qc.ca") | |
| 25 | + assert signals.score > 0.7 | |
| 26 | + assert "gatineau" in signals.locations | |
| 27 | + assert "tld_quebec" in signals.reasons | |
| 28 | + assert "code_postal_qc" in signals.reasons | |
| 29 | + | |
| 30 | + | |
| 31 | +def test_ontario_business_scores_low(): | |
| 32 | + page = _page( | |
| 33 | + url="https://ottawaplumbing.ca/", | |
| 34 | + title="Ottawa Plumbing Services", | |
| 35 | + description="Plumbing services in Ottawa, Ontario", | |
| 36 | + body="We serve Ottawa, Kanata and Nepean. 456 Bank Street, Ottawa, ON K1S 3T4. Call 613-555-9999.", | |
| 37 | + headings=["Plumbing Ottawa"], | |
| 38 | + language="en", | |
| 39 | + ) | |
| 40 | + signals = score_page(page, "ottawaplumbing.ca") | |
| 41 | + assert signals.score < 0.2 | |
| 42 | + | |
| 43 | + | |
| 44 | +def test_nyt_article_about_montreal_gets_page_signal(): | |
| 45 | + # Un article international SUR Montréal : le domaine n'est pas québécois, | |
| 46 | + # mais la page a un signal réel (page_quebec_score > 0, sans être maximal). | |
| 47 | + page = _page( | |
| 48 | + url="https://www.nytimes.com/2026/01/01/travel/montreal.html", | |
| 49 | + title="36 Hours in Montreal", | |
| 50 | + description="What to do in Montreal, Quebec's largest city", | |
| 51 | + body="Montreal is the largest city in Quebec. From the Plateau-Mont-Royal to Old Montreal, " | |
| 52 | + "the city offers poutine, festivals and more. Nearby Laval and Longueuil...", | |
| 53 | + headings=["36 Hours in Montreal"], | |
| 54 | + language="en", | |
| 55 | + ) | |
| 56 | + signals = score_page(page, "nytimes.com") | |
| 57 | + assert 0.2 < signals.score < 0.8 | |
| 58 | + assert "montreal" in signals.locations | |
| 59 | + | |
| 60 | + | |
| 61 | +def test_generic_english_page_scores_near_zero(): | |
| 62 | + page = _page( | |
| 63 | + url="https://techblog.com/post", | |
| 64 | + title="Understanding Rust lifetimes", | |
| 65 | + body="Rust lifetimes are a way to express the scope of references in your program. " * 20, | |
| 66 | + language="en", | |
| 67 | + ) | |
| 68 | + signals = score_page(page, "techblog.com") | |
| 69 | + assert signals.score < 0.1 | |
| 70 | + | |
| 71 | + | |
| 72 | +def test_french_alone_is_not_proof(): | |
| 73 | + # Une page française de France ne doit pas passer le seuil sur la langue seule | |
| 74 | + page = _page( | |
| 75 | + url="https://lemonde.fr/article", | |
| 76 | + title="Actualités françaises", | |
| 77 | + body="La France annonce de nouvelles mesures. Paris, Lyon et Marseille concernées. " * 10, | |
| 78 | + language="fr", | |
| 79 | + ) | |
| 80 | + signals = score_page(page, "lemonde.fr") | |
| 81 | + assert signals.score < 0.3 | |
| 82 | + | |
| 83 | + | |
| 84 | +def test_score_saturates_below_one(): | |
| 85 | + body = "Montréal Québec Gatineau Sherbrooke Laval " * 100 + " H2X 1Y6 G1R 4S9 514-555-0000 Hydro-Québec" | |
| 86 | + page = _page(url="https://x.qc.ca/", title="Montréal Québec", body=body, language="fr") | |
| 87 | + signals = score_page(page, "x.qc.ca") | |
| 88 | + assert 0.8 < signals.score <= 1.0 | |
added
M2M32brouve-ka/tests/test_ranking.py
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +# Trouve-KA — tests du pipeline de requête et du ranking | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +from trouveka.ranking import analyze_query, build_search_body | |
| 6 | + | |
| 7 | + | |
| 8 | +def test_language_detection_heuristics(): | |
| 9 | + assert analyze_query("meilleur programme de subvention pour thermopompe")["language"] == "fr" | |
| 10 | + assert analyze_query("best heat pump rebate program")["language"] == "en" | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_location_extraction_with_accents(): | |
| 14 | + a = analyze_query("plombier gatineau") | |
| 15 | + assert "gatineau" in a["locations"] | |
| 16 | + b = analyze_query("subvention Trois-Rivières") | |
| 17 | + assert any("trois-rivieres" in loc or "trois-rivières" in loc for loc in b["locations"]) | |
| 18 | + | |
| 19 | + | |
| 20 | +def test_body_structure_bm25_core(): | |
| 21 | + body, analysis = build_search_body("plombier Gatineau", page=2, limit=10) | |
| 22 | + assert body["from"] == 10 and body["size"] == 10 | |
| 23 | + fs = body["query"]["function_score"] | |
| 24 | + fields = fs["query"]["bool"]["must"][0]["multi_match"]["fields"] | |
| 25 | + assert "title^4" in fields and "body" in fields and "body.en" in fields | |
| 26 | + # Localité : fonction de boost présente quand un lieu est détecté | |
| 27 | + locality = [f for f in fs["functions"] if "filter" in f] | |
| 28 | + assert locality and locality[0]["filter"]["terms"]["locations"] == ["gatineau"] | |
| 29 | + | |
| 30 | + | |
| 31 | +def test_filters(): | |
| 32 | + body, _ = build_search_body("test", language="fr", category="government", quebec_only=True) | |
| 33 | + filters = body["query"]["function_score"]["query"]["bool"]["filter"] | |
| 34 | + assert {"term": {"language": "fr"}} in filters | |
| 35 | + assert {"term": {"categories": "government"}} in filters | |
| 36 | + assert any("bool" in f for f in filters) # quebec_only | |
| 37 | + | |
| 38 | + | |
| 39 | +def test_no_locality_function_without_location(): | |
| 40 | + body, _ = build_search_body("recette de tourtière") | |
| 41 | + assert not [f for f in body["query"]["function_score"]["functions"] if "filter" in f] | |
added
M2M32brouve-ka/tests/test_ssrf.py
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +# Trouve-KA — tests de prévention SSRF | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +from trouveka.shared import is_safe_ip, is_safe_url | |
| 6 | + | |
| 7 | + | |
| 8 | +def test_blocks_loopback_and_private(): | |
| 9 | + for ip in ("127.0.0.1", "10.0.0.5", "192.168.2.10", "172.16.0.1", "0.0.0.0", "::1", "fc00::1"): | |
| 10 | + assert not is_safe_ip(ip), ip | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_blocks_link_local_and_metadata(): | |
| 14 | + assert not is_safe_ip("169.254.169.254") | |
| 15 | + assert not is_safe_ip("169.254.1.1") | |
| 16 | + assert not is_safe_ip("fe80::1") | |
| 17 | + | |
| 18 | + | |
| 19 | +def test_allows_public_ips(): | |
| 20 | + assert is_safe_ip("142.226.10.10") | |
| 21 | + assert is_safe_ip("2607:f8b0::1") | |
| 22 | + | |
| 23 | + | |
| 24 | +def test_blocks_localhost_and_schemes(): | |
| 25 | + assert not is_safe_url("http://localhost/admin", resolve=False) | |
| 26 | + assert not is_safe_url("http://foo.localhost/x", resolve=False) | |
| 27 | + assert not is_safe_url("http://metadata.google.internal/", resolve=False) | |
| 28 | + assert not is_safe_url("file:///etc/passwd", resolve=False) | |
| 29 | + assert not is_safe_url("gopher://x.ca", resolve=False) | |
| 30 | + | |
| 31 | + | |
| 32 | +def test_blocks_ip_literal_urls(): | |
| 33 | + assert not is_safe_url("http://127.0.0.1:8080/", resolve=False) | |
| 34 | + assert not is_safe_url("http://169.254.169.254/latest/meta-data/", resolve=False) | |
| 35 | + assert not is_safe_url("http://[::1]/", resolve=False) | |
| 36 | + | |
| 37 | + | |
| 38 | +def test_allows_normal_url_without_resolution(): | |
| 39 | + assert is_safe_url("https://www.quebec.ca/", resolve=False) | |
added
M2M32brouve-ka/tests/test_traps.py
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +# Trouve-KA — tests de détection de pièges | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +from trouveka.crawler.traps import looks_like_trap | |
| 6 | + | |
| 7 | + | |
| 8 | +def test_session_ids(): | |
| 9 | + assert looks_like_trap("https://x.ca/p?PHPSESSID=abc123") | |
| 10 | + assert looks_like_trap("https://x.ca/p?sid=9f8e7d") | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_too_many_params(): | |
| 14 | + url = "https://x.ca/p?" + "&".join(f"f{i}={i}" for i in range(12)) | |
| 15 | + assert looks_like_trap(url) | |
| 16 | + | |
| 17 | + | |
| 18 | +def test_repeated_facet_param(): | |
| 19 | + assert looks_like_trap("https://x.ca/s?f=a&f=b&f=c&f=d&f=e") | |
| 20 | + | |
| 21 | + | |
| 22 | +def test_deep_paths_and_loops(): | |
| 23 | + assert looks_like_trap("https://x.ca/" + "/".join(["seg"] * 15)) | |
| 24 | + assert looks_like_trap("https://x.ca/a/b/a/b/a/b/a/b") | |
| 25 | + | |
| 26 | + | |
| 27 | +def test_far_future_calendar(): | |
| 28 | + assert looks_like_trap("https://x.ca/events/2085/05/17") | |
| 29 | + assert not looks_like_trap("https://x.ca/nouvelles/2024/06/12") | |
| 30 | + | |
| 31 | + | |
| 32 | +def test_excessive_pagination(): | |
| 33 | + assert looks_like_trap("https://x.ca/liste?page=9999") | |
| 34 | + assert not looks_like_trap("https://x.ca/liste?page=3") | |
| 35 | + | |
| 36 | + | |
| 37 | +def test_normal_urls_pass(): | |
| 38 | + assert not looks_like_trap("https://www.quebec.ca/services/permis?type=conduire") | |
| 39 | + assert not looks_like_trap("https://ici.radio-canada.ca/nouvelle/2024/ceci-est-un-titre") | |
added
M2M32brouve-ka/tests/test_urls.py
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +# Trouve-KA — tests de canonicalisation d'URL | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +from trouveka.shared import canonicalize_url, display_url, extract_domain | |
| 6 | + | |
| 7 | + | |
| 8 | +def test_removes_fragment_and_default_port(): | |
| 9 | + assert canonicalize_url("https://quebec.ca:443/services#section") == "https://quebec.ca/services" | |
| 10 | + assert canonicalize_url("http://quebec.ca:80/") == "http://quebec.ca/" | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_preserves_non_default_port(): | |
| 14 | + assert canonicalize_url("http://example.qc.ca:8080/x") == "http://example.qc.ca:8080/x" | |
| 15 | + | |
| 16 | + | |
| 17 | +def test_lowercases_host_but_preserves_path_case(): | |
| 18 | + assert canonicalize_url("HTTPS://WWW.Quebec.CA/Services/PDF") == "https://www.quebec.ca/Services/PDF" | |
| 19 | + | |
| 20 | + | |
| 21 | +def test_strips_tracking_params_only(): | |
| 22 | + url = "https://ledevoir.com/a?utm_source=fb&fbclid=xyz&id=42&gclid=1" | |
| 23 | + assert canonicalize_url(url) == "https://ledevoir.com/a?id=42" | |
| 24 | + | |
| 25 | + | |
| 26 | +def test_preserves_unknown_params_and_order(): | |
| 27 | + # Deux ressources distinctes ne doivent jamais fusionner : ordre préservé | |
| 28 | + assert canonicalize_url("https://x.ca/p?b=2&a=1") == "https://x.ca/p?b=2&a=1" | |
| 29 | + assert canonicalize_url("https://x.ca/p?a=1&b=2") == "https://x.ca/p?a=1&b=2" | |
| 30 | + | |
| 31 | + | |
| 32 | +def test_rejects_non_http_schemes(): | |
| 33 | + assert canonicalize_url("file:///etc/passwd") is None | |
| 34 | + assert canonicalize_url("javascript:alert(1)") is None | |
| 35 | + assert canonicalize_url("ftp://x.ca/f") is None | |
| 36 | + | |
| 37 | + | |
| 38 | +def test_relative_resolution(): | |
| 39 | + assert canonicalize_url("../b", base="https://x.ca/a/c/") == "https://x.ca/a/b" | |
| 40 | + | |
| 41 | + | |
| 42 | +def test_collapses_duplicate_slashes(): | |
| 43 | + assert canonicalize_url("https://x.ca//a///b") == "https://x.ca/a/b" | |
| 44 | + | |
| 45 | + | |
| 46 | +def test_extract_domain_strips_www(): | |
| 47 | + assert extract_domain("https://www.quebec.ca/services") == "quebec.ca" | |
| 48 | + assert extract_domain("https://ici.radio-canada.ca/n") == "ici.radio-canada.ca" | |
| 49 | + | |
| 50 | + | |
| 51 | +def test_display_url_breadcrumb(): | |
| 52 | + crumb = display_url("https://www.quebec.ca/services/permis/conduire") | |
| 53 | + assert crumb.startswith("quebec.ca › services › permis") | |
added
M2M32crouve-ka/.env.example
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +# Trouve-KA — variables d'environnement (exemple, sans secrets) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +# --- Bases de données --- | |
| 6 | +DATABASE_URL=postgresql://trouveka:trouveka@localhost:5432/trouveka | |
| 7 | +REDIS_URL=redis://localhost:6379/0 | |
| 8 | +SEARCH_URL=http://localhost:9200 | |
| 9 | + | |
| 10 | +# --- Crawler --- | |
| 11 | +CRAWLER_USER_AGENT="Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)" | |
| 12 | +CRAWLER_CONTACT_URL=https://www.trouve-ka.com/trouveka-bot | |
| 13 | +MAX_GLOBAL_CONCURRENCY=24 | |
| 14 | +MAX_PER_HOST_CONCURRENCY=2 | |
| 15 | +DEFAULT_HOST_DELAY=2.0 | |
| 16 | +MAX_RESPONSE_BYTES=3000000 | |
| 17 | +MAX_REDIRECTS=5 | |
| 18 | +FETCH_TIMEOUT=20 | |
| 19 | +MAX_CRAWL_DEPTH=8 | |
| 20 | +MAX_LINKS_PER_PAGE=300 | |
| 21 | +MAX_URLS_PER_DOMAIN=5000 | |
| 22 | + | |
| 23 | +# --- API --- | |
| 24 | +API_HOST=0.0.0.0 | |
| 25 | +API_PORT=8080 | |
| 26 | +ADMIN_TOKEN=change-me-admin-token | |
| 27 | + | |
| 28 | +# --- Web --- | |
| 29 | +PUBLIC_URL=https://www.trouve-ka.com | |
| 30 | +NGROK_DOMAIN=www.trouve-ka.com | |
| 31 | +API_URL=http://localhost:8080 | |
| 32 | + | |
| 33 | +# --- Index --- | |
| 34 | +SEARCH_INDEX=trouveka-docs | |
added
M2M32crouve-ka/.gitignore
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +node_modules/ | |
| 2 | +.next/ | |
| 3 | +dist/ | |
| 4 | +__pycache__/ | |
| 5 | +*.pyc | |
| 6 | +.venv/ | |
| 7 | +.env | |
| 8 | +.env.local | |
| 9 | +*.egg-info/ | |
| 10 | +.pytest_cache/ | |
| 11 | +.ruff_cache/ | |
| 12 | +data/ | |
| 13 | +.DS_Store | |
added
M2M32crouve-ka/CLAUDE.md
+363 −0
@@ -0,0 +1,363 @@ | ||
| 1 | +# CLAUDE.md — Trouve-KA | |
| 2 | + | |
| 3 | +**Projet :** Trouve-KA — *Cherche le Québec.* | |
| 4 | +**Mission :** Moteur de recherche web indépendant, Québec-first, avec son propre crawler, index, ranking, API et application web publique. | |
| 5 | + | |
| 6 | +Trouve-KA n'est **pas** un métamoteur. Aucune dépendance à Google, Bing ou Brave pour les résultats primaires. L'objectif : un index structuré et continuellement mis à jour du web québécois. | |
| 7 | + | |
| 8 | +``` | |
| 9 | +Crawler → Frontier → Fetcher → Parser → Classification Québec | |
| 10 | +→ Déduplication → Indexer → Index → Ranking → API → Web App | |
| 11 | +``` | |
| 12 | + | |
| 13 | +Le crawler fait partie du produit. Tout vit dans un seul monorepo. | |
| 14 | + | |
| 15 | +--- | |
| 16 | + | |
| 17 | +## 0. Règles non négociables | |
| 18 | + | |
| 19 | +### 0.1 Header obligatoire dans chaque fichier de code | |
| 20 | + | |
| 21 | +**Chaque fichier source** (TS, JS, Python, SQL, config exécutable, scripts) doit commencer par un header d'auteur : | |
| 22 | + | |
| 23 | +```ts | |
| 24 | +/** | |
| 25 | + * Trouve-KA — <nom du module> | |
| 26 | + * Author: Simon-Pierre Boucher | |
| 27 | + * Contact: contact@spboucher.ai | |
| 28 | + */ | |
| 29 | +``` | |
| 30 | + | |
| 31 | +```python | |
| 32 | +# Trouve-KA — <nom du module> | |
| 33 | +# Author: Simon-Pierre Boucher | |
| 34 | +# Contact: contact@spboucher.ai | |
| 35 | +``` | |
| 36 | + | |
| 37 | +Adapter la syntaxe de commentaire au langage. Aucune exception. Un lint/check CI doit vérifier la présence du header. | |
| 38 | + | |
| 39 | +### 0.2 Cible de déploiement | |
| 40 | + | |
| 41 | +- **Node de déploiement : `m2m32`** (32 Go RAM). Tout le stack (Postgres, Redis, backend de recherche, workers, API, web) doit tourner confortablement sur cette machine. | |
| 42 | +- **Exposition publique via ngrok : `www.trouve-ka.com`.** Configurer ngrok avec le domaine réservé; l'app web et l'API doivent fonctionner correctement derrière le tunnel (URLs absolues, cookies, CORS, headers `X-Forwarded-*`). | |
| 43 | +- Docker Compose est l'outil de déploiement. Pas de Kubernetes. | |
| 44 | +- Fournir un script/target `deploy:m2m32` et documenter la procédure complète (compose up + tunnel ngrok) dans le README. | |
| 45 | +- Dimensionner les défauts (concurrence, tailles de heap du moteur de recherche, connexions Postgres) pour 32 Go, avec configuration par variables d'environnement. | |
| 46 | + | |
| 47 | +### 0.3 Principe cardinal | |
| 48 | + | |
| 49 | +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Enrichis en asynchrone.** | |
| 50 | + | |
| 51 | +Le moteur doit être utilisable **dès que le crawl démarre**. Jamais de cycle « crawler tout → construire l'index → lancer la recherche ». Chaque page traitée avec succès devient cherchable en secondes. | |
| 52 | + | |
| 53 | +``` | |
| 54 | +12:00:00 démarrage crawler | |
| 55 | +12:00:04 première page fetchée | |
| 56 | +12:00:06 score Québec calculé | |
| 57 | +12:00:07 document indexé | |
| 58 | +12:00:08 cherchable par l'utilisateur | |
| 59 | +``` | |
| 60 | + | |
| 61 | +Aucune interdiction plus importante que celle-ci : **ne jamais bloquer l'indexation sur l'enrichissement** (embeddings, entités, scoring avancé = asynchrone, mise à jour du document après coup). | |
| 62 | + | |
| 63 | +--- | |
| 64 | + | |
| 65 | +## 1. Philosophie produit | |
| 66 | + | |
| 67 | +Trouve-KA est un vrai moteur, pas : un annuaire, une liste curée, un wrapper ChatGPT, un frontend Google CSE, un dataset statique. | |
| 68 | + | |
| 69 | +Il doit de façon autonome : découvrir des sites → les crawler → comprendre le contenu → juger la pertinence québécoise → indexer → ranker → exposer via recherche → découvrir davantage → rafraîchir en continu. | |
| 70 | + | |
| 71 | +**Interdictions absolues (§ fake) :** pas de données de crawl factices, pas de compteurs simulés présentés comme réels, pas de résultats hard-codés, pas d'API de recherche placeholder. Les fixtures sont réservées aux tests. Le dev tourne sur le vrai crawler et le vrai index local. | |
| 72 | + | |
| 73 | +--- | |
| 74 | + | |
| 75 | +## 2. Architecture du dépôt | |
| 76 | + | |
| 77 | +Monorepo : | |
| 78 | + | |
| 79 | +``` | |
| 80 | +trouve-ka/ | |
| 81 | +├── apps/ | |
| 82 | +│ ├── web/ # moteur public | |
| 83 | +│ ├── api/ # search + APIs internes | |
| 84 | +│ └── admin/ # dashboard crawl/index | |
| 85 | +├── services/ | |
| 86 | +│ ├── crawler/ frontier/ parser/ classifier/ | |
| 87 | +│ ├── indexer/ ranking/ scheduler/ enrichment/ | |
| 88 | +├── packages/ | |
| 89 | +│ ├── database/ shared/ config/ logging/ | |
| 90 | +│ ├── queue/ types/ search-core/ | |
| 91 | +├── infrastructure/ | |
| 92 | +│ ├── docker/ migrations/ monitoring/ deployment/ # inclut config ngrok + m2m32 | |
| 93 | +├── scripts/ | |
| 94 | +│ ├── bootstrap-seeds/ start-crawler/ rebuild-index/ health-check/ | |
| 95 | +├── docs/ | |
| 96 | +├── CLAUDE.md README.md docker-compose.yml | |
| 97 | +``` | |
| 98 | + | |
| 99 | +Claude peut améliorer cette structure sur justification technique solide. | |
| 100 | + | |
| 101 | +--- | |
| 102 | + | |
| 103 | +## 3. Stack technologique | |
| 104 | + | |
| 105 | +| Couche | Choix | | |
| 106 | +|---|---| | |
| 107 | +| Frontend | Next.js, TypeScript, React, Tailwind, shadcn/ui | | |
| 108 | +| API | FastAPI (Python) ou backend TypeScript si ça réduit la complexité; le crawler peut rester Python même si l'API est TS | | |
| 109 | +| BD relationnelle | PostgreSQL (domaines, URLs, état de crawl, métadonnées, entités, scheduler, bookkeeping) | | |
| 110 | +| Queue | Redis + vraie file de tâches/streams (évaluer avant de choisir) | | |
| 111 | +| Recherche | À sélectionner parmi : OpenSearch, Elasticsearch, Typesense, Meilisearch, Vespa, Tantivy, Quickwit | | |
| 112 | + | |
| 113 | +Critères de sélection du backend de recherche : qualité BM25, indexation incrémentale, facettes, tolérance aux typos, ranking custom, recherche hybride, performance, scaling horizontal, **complexité opérationnelle sur un seul node m2m32**. Pour le MVP : qualité de recherche + indexation incrémentale + opérations simples. Ne pas choisir le plus facile par défaut. | |
| 114 | + | |
| 115 | +--- | |
| 116 | + | |
| 117 | +## 4. Qualité de recherche progressive | |
| 118 | + | |
| 119 | +Chaque document s'enrichit par étapes; **la disponibilité en recherche ne dépend jamais des étapes suivantes.** | |
| 120 | + | |
| 121 | +- **Étape 1 (immédiat) :** URL, titre, description, corps, headings, domaine, langue, timestamp, score Québec → index BM25. | |
| 122 | +- **Étape 2 (async) :** embedding, classification thématique, organisations, signaux de localisation, entité canonique. | |
| 123 | +- **Étape 3 (async) :** autorité de domaine, score de graphe de liens / PageRank-like, fraîcheur, qualité, spam, intention commerciale, pertinence locale. | |
| 124 | + | |
| 125 | +--- | |
| 126 | + | |
| 127 | +## 5. Crawler | |
| 128 | + | |
| 129 | +Un vrai crawler : frontier d'URLs, scheduling par domaine, robots.txt, canonicalisation, redirections, retries, cache HTTP, compression, gestion des content-types, budgets de crawl, concurrence par hôte, rate limiting, files de priorité, prévention des doublons, historique, hash de contenu, détection de changements. | |
| 130 | + | |
| 131 | +### 5.1 Identité | |
| 132 | +User-agent identifiable : `Mozilla-compatible / TrouveKABot`. Page publique `/trouveka-bot` : quoi, pourquoi, UA, contact (**contact@spboucher.ai**), comment bloquer, respect de robots.txt. **Pas de stealth, pas de rotation de proxys par défaut.** IPs stables et comportement poli. | |
| 133 | + | |
| 134 | +### 5.2 robots.txt et politesse | |
| 135 | +Parsing conforme aux standards, cache des règles, respect de `noindex` / `nofollow` / `canonical` / `X-Robots-Tag`. Politesse par origine : 1–2 requêtes concurrentes max par hôte, budgets indépendants, politiques configurables. Un gros débit global ne doit jamais agresser un site individuel. | |
| 136 | + | |
| 137 | +### 5.3 Frontier | |
| 138 | +Chaque URL : `url, domain, priority, depth, source_url, discovered_at, last_crawled_at, next_crawl_at, status`. Priorités évolutives selon : pertinence Québec, autorité, source de découverte, profondeur, importance du domaine, fraîcheur, historique de changement, succès, duplication. | |
| 139 | + | |
| 140 | +Fonction de priorité conceptuelle : | |
| 141 | +`P = w_q·Q + w_a·A + w_f·F + w_l·L + w_n·N − w_d·D − w_s·S` | |
| 142 | +(Québec, Autorité, Fraîcheur, Liens, Nouveauté, Doublon, Spam). Poids calibrés par mesures, jamais figés. | |
| 143 | + | |
| 144 | +### 5.4 Canonicalisation d'URL | |
| 145 | +Extrême prudence. Normaliser : fragments, ports par défaut, slashs, paramètres de tracking/UTM, ordre des paramètres quand approprié, http/https, www, trailing slash, tags canonical. **Ne jamais fusionner deux ressources distinctes par accident.** | |
| 146 | + | |
| 147 | +### 5.5 Recrawl adaptatif | |
| 148 | +Homepage de nouvelles → minutes; article → heures puis décroissant; page gouvernementale → quotidien; site statique → mensuel; archive inchangée → rarement. Fréquence de changement mesurée empiriquement : page inchangée → intervalle ↑; page volatile → intervalle ↓. | |
| 149 | + | |
| 150 | +### 5.6 Détection de changement et doublons | |
| 151 | +Stocker `content_hash`, etag, last-modified, `last_changed_at`. Réindexer seulement si changement. Doublons : exacts (hash) d'abord, puis SimHash/MinHash/shingling si justifié. Gérer miroirs, versions imprimables, doublons de paramètres, syndication. Conserver la provenance. | |
| 152 | + | |
| 153 | +### 5.7 Sécurité du crawl (SSRF) | |
| 154 | +Bloquer : localhost, 127.0.0.0/8, plages privées IPv4/IPv6, endpoints de métadonnées cloud, `file://`, schémas dangereux. Revalider DNS/IP. Chaque réponse a des limites : taille max, redirections max, timeout, temps de parse max, liens extraits max, profondeur max. | |
| 155 | + | |
| 156 | +### 5.8 Pièges de crawl | |
| 157 | +Détecter : calendriers infinis, session IDs, explosions de navigation à facettes, pagination infinie, paramètres aléatoires, boucles. Limites par pattern/domaine. | |
| 158 | + | |
| 159 | +### 5.9 Erreurs | |
| 160 | +Codes structurés : DNS, timeout, TLS, 4xx, 5xx, robots refusé, parse échoué, contenu non supporté, trop gros, doublon, spam, non pertinent Québec. L'échec est normal; il est traqué. | |
| 161 | + | |
| 162 | +### 5.10 Rendu navigateur | |
| 163 | +**Jamais** de Chromium par page. Défaut : fetch HTTP. Le navigateur est un fallback spécialisé par domaine — essentiel pour l'échelle et pour tenir sur m2m32. | |
| 164 | + | |
| 165 | +--- | |
| 166 | + | |
| 167 | +## 6. Extraction de contenu | |
| 168 | + | |
| 169 | +Extraire : titre, meta description, corps principal, headings, données structurées (JSON-LD, schema.org, microdata, OpenGraph), URL canonique, langue, liens + anchors, dates de publication/modification, auteur, indices d'organisation et d'adresse. | |
| 170 | + | |
| 171 | +Retirer : navigation, menus, bannières cookies, footers répétitifs, scripts, styles, pub. | |
| 172 | + | |
| 173 | +Types au départ : HTML, texte, PDF. Architecture extensible (DOCX/XLSX/PPTX, RSS/Atom) sans que l'extraction coûteuse bloque le crawl HTML. | |
| 174 | + | |
| 175 | +Traiter chaque page crawlée comme **non fiable** : sanitizer HTML, URLs, métadonnées; ne jamais faire confiance aux MIME types distants. | |
| 176 | + | |
| 177 | +--- | |
| 178 | + | |
| 179 | +## 7. Détection Québec | |
| 180 | + | |
| 181 | +L'innovation clé. Un `.ca` seul ne suffit pas. Calculer `quebec_score ∈ [0,1]` avec **deux scores distincts** : | |
| 182 | + | |
| 183 | +- `domain_quebec_score` | |
| 184 | +- `page_quebec_score` (un article du NYT sur Montréal peut être pertinent sans que le domaine le soit) | |
| 185 | + | |
| 186 | +Signaux : toponymes (Québec, Montréal, Gatineau, Sherbrooke, Trois-Rivières, Saguenay, Laval, Longueuil…), adresses postales QC, province dans les adresses structurées, indicatifs téléphoniques (signal faible), organisations québécoises connues (entreprises, municipalités, universités, médias, gouvernement), langue (le français augmente la probabilité sans la prouver), graphe de domaines (un domaine massivement lié par des domaines québécois gagne du signal), métadonnées structurées, pages contact/footer. | |
| 187 | + | |
| 188 | +--- | |
| 189 | + | |
| 190 | +## 8. Découverte | |
| 191 | + | |
| 192 | +**Qualité des seeds > quantité.** Démarrer avec des nœuds fortement connectés : gouvernement du Québec, municipalités, universités, cégeps, grands médias, annuaires d'affaires, associations professionnelles, chambres de commerce, tourisme régional, grandes entreprises. | |
| 193 | + | |
| 194 | +Boucle : crawl → extraction des liens sortants → scoring Québec des domaines cibles → ajout au frontier → répéter. Détecter automatiquement sitemaps (`/sitemap.xml`, entrées robots.txt, index de sitemaps) et flux RSS/Atom, sans confiance aveugle. | |
| 195 | + | |
| 196 | +Maintenir une base de domaines : `domain, first_seen, last_crawled, page_count, quebec_score, language_distribution, robots_status, authority_score, inlinks, outlinks, content_change_rate…` | |
| 197 | + | |
| 198 | +Construire le graphe de liens dès le crawl (Page→Page, Domain→Domain, Org→Domain…) — d'abord pour le ranking et la découverte, plus tard pour l'écosystème KA. | |
| 199 | + | |
| 200 | +--- | |
| 201 | + | |
| 202 | +## 9. Index et ranking | |
| 203 | + | |
| 204 | +Document indexé (cible) : `id, url, canonical_url, domain, title, description, body, headings, language, page_quebec_score, domain_quebec_score, locations, organizations, people, categories, published_at, crawled_at, authority_score, freshness_score, quality_score, spam_score, embedding`. | |
| 205 | + | |
| 206 | +**BM25 d'abord.** Puis fonction de ranking dédiée : | |
| 207 | + | |
| 208 | +`Score(d,q) = w_b·BM25 + w_s·Sémantique + w_q·Québec + w_a·Autorité + w_f·Fraîcheur + w_l·Localité + w_u·Qualité − w_p·Spam` | |
| 209 | + | |
| 210 | +Le ranking est un package/service indépendant, évolutif, aux poids non figés. | |
| 211 | + | |
| 212 | +**Québec-first :** pour `meilleur programme thermopompe`, Hydro-Québec, un programme gouvernemental ou une entreprise CVC québécoise doivent battre un article international générique, à pertinence égale. | |
| 213 | + | |
| 214 | +**Bilingue dès le départ :** comprendre `thermopompe` ↔ `heat pump`. Pas de traduction à l'ingestion; plus tard : embeddings multilingues, expansion de requête, dictionnaires de synonymes. | |
| 215 | + | |
| 216 | +**Pipeline de requête :** normalisation → détection de langue → correction → intention → extraction d'entités/lieux → retrieval lexical → retrieval sémantique → fusion → reranking. **Chaque composant avancé est optionnel; BM25 fonctionne même si tout le reste tombe.** | |
| 217 | + | |
| 218 | +**Localisation :** `plombier Gatineau`, `subvention Sherbrooke` boostent les documents géographiquement pertinents sur preuve géographique explicite, pas juste du keyword matching. | |
| 219 | + | |
| 220 | +--- | |
| 221 | + | |
| 222 | +## 10. Interface | |
| 223 | + | |
| 224 | +**Homepage = la boîte de recherche.** Rien d'autre d'important. | |
| 225 | + | |
| 226 | +``` | |
| 227 | + Trouve-KA | |
| 228 | + [ Rechercher... ] | |
| 229 | + Chercher | |
| 230 | + Cherche le Québec. | |
| 231 | +``` | |
| 232 | + | |
| 233 | +Résultats : favicon, titre, URL/breadcrumb, snippet, badges optionnels (Québec, Gouvernement, Entreprise…). Sensation : rapide, propre, premium, minimal, fiable. Filtres MVP sobres : Tout / Actualités / Gouvernement / Français / English / Québec seulement. | |
| 234 | + | |
| 235 | +**Compteur d'index visible** (« 18 432 pages indexées » le jour 1, c'est parfait — la croissance fait partie du produit) + page `/status` : pages/domaines indexés, débit horaire, état du crawler et de l'index. | |
| 236 | + | |
| 237 | +**Dashboard admin** : longueur des files, débits fetch/parse/index, distribution HTTP, domaines actifs, blocages robots, retries, latences, stockage, taux de doublons, taux d'acceptation Québec, flux live du crawl. | |
| 238 | + | |
| 239 | +Mobile impeccable, accessibilité complète (navigation clavier, HTML sémantique, contrastes, focus states). Design : minimal, moderne, québécois sans clichés — **pas** de fleurs de lys partout, pas de hero marketing, pas de gradients IA. | |
| 240 | + | |
| 241 | +--- | |
| 242 | + | |
| 243 | +## 11. APIs | |
| 244 | + | |
| 245 | +**Recherche :** `GET /api/search?q=&page=&limit=&language=&location=&category=&freshness=` → résultats structurés (`title, url, display_url, snippet, domain, score`, `took_ms`, `total`). Ne pas exposer le scoring interne brut en prod. | |
| 246 | + | |
| 247 | +**Contrôle du crawler (protégé, jamais public sans auth) :** pause/reprise, ajout de seeds, recrawl URL/domaine, inspection du frontier, priorités, blocage de domaine. | |
| 248 | + | |
| 249 | +**Soumission d'URL** (feature simple) : « Soumettre un site québécois » → frontier. Soumission ≠ inclusion; le crawler valide. | |
| 250 | + | |
| 251 | +--- | |
| 252 | + | |
| 253 | +## 12. Coûts, LLM et embeddings | |
| 254 | + | |
| 255 | +Le crawl doit être **économique** : bande passante, compute, stockage — pas d'API LLM ni d'API de scraping commerciale par page. Le cœur (fetch, parse, canonicalisation, langue, hash, doublons, BM25) est déterministe. Les LLM enrichissent des pages **sélectionnées** en asynchrone (catégorisation, entités complexes, classification géographique ambiguë). Embeddings : toujours en arrière-plan, jamais bloquants. | |
| 256 | + | |
| 257 | +**Stockage brut :** métadonnées → Postgres; contenu cherchable → index; HTML brut compressé optionnel → object storage. Pas de blobs HTML géants dans Postgres. | |
| 258 | + | |
| 259 | +Analytics de recherche : métriques agrégées et respectueuses de la vie privée (requête, latence, position cliquée, zéro-résultat, langue). **Les requêtes zéro-résultat sont de l'or** : les stocker pour piloter le crawl par la demande. Pas de profils utilisateurs invasifs. | |
| 260 | + | |
| 261 | +--- | |
| 262 | + | |
| 263 | +## 13. Observabilité et dégradation | |
| 264 | + | |
| 265 | +Logs structurés avec `crawl_id, url_id, domain_id, worker_id, job_id`. Métriques compatibles Prometheus/Grafana/OpenTelemetry (choix pragmatique). | |
| 266 | + | |
| 267 | +**Dégradation gracieuse obligatoire :** embeddings en panne → lexical continue; enrichissement en retard → nouvelles pages cherchables quand même; un worker crash → frontier continue; frontend redémarre → crawler continue. | |
| 268 | + | |
| 269 | +Workers scalables indépendamment (`crawler-worker × N`, `parser-worker × N`, `indexer-worker × N`) — mais **pas de complexité distribuée prématurée** : Postgres + Redis + backend de recherche + quelques workers suffisent au départ. | |
| 270 | + | |
| 271 | +--- | |
| 272 | + | |
| 273 | +## 14. Provenance, droit d'auteur, avenir | |
| 274 | + | |
| 275 | +- Toujours préserver : URL originale, canonique, timestamp de crawl, domaine source. Trouve-KA renvoie vers les éditeurs originaux — c'est un index, pas un remplacement de contenu. | |
| 276 | +- Snippets raisonnables, attribution, liens sortants. Stockage brut, indexation, cache et affichage conçus séparément. | |
| 277 | +- Futur (non-MVP mais à ne pas bloquer architecturalement) : webmaster tools, verticales (news, entreprises, gouvernement, immobilier…), historique de versions des pages, réponses IA basées **uniquement** sur l'index avec citations, écosystème KA (Person-KA, Service-KA, Entreprise-KA…) alimenté par l'extraction d'entités asynchrone. Pas de collecte de données personnelles invasives. | |
| 278 | + | |
| 279 | +--- | |
| 280 | + | |
| 281 | +## 15. Configuration et DX | |
| 282 | + | |
| 283 | +Variables d'env validées : `DATABASE_URL, REDIS_URL, SEARCH_URL, CRAWLER_USER_AGENT, CRAWLER_CONTACT_URL, MAX_GLOBAL_CONCURRENCY, DEFAULT_HOST_DELAY, MAX_RESPONSE_BYTES, NGROK_DOMAIN=www.trouve-ka.com, PUBLIC_URL=https://www.trouve-ka.com`. Fournir `.env.example` sans secrets. | |
| 284 | + | |
| 285 | +Expérience développeur cible : | |
| 286 | + | |
| 287 | +``` | |
| 288 | +git clone … && cd trouve-ka | |
| 289 | +cp .env.example .env | |
| 290 | +docker compose up -d | |
| 291 | +pnpm install && pnpm dev | |
| 292 | +pnpm crawl:seed | |
| 293 | +# → des résultats apparaissent en quelques instants sur localhost:3000 | |
| 294 | +``` | |
| 295 | + | |
| 296 | +Déploiement m2m32 : même stack via compose + tunnel ngrok vers www.trouve-ka.com, scripté et documenté. | |
| 297 | + | |
| 298 | +--- | |
| 299 | + | |
| 300 | +## 16. Tests et évaluation | |
| 301 | + | |
| 302 | +Tests unitaires sur la logique critique : normalisation d'URL, robots, scoring Québec, canonical, doublons, scheduling, prévention SSRF, extraction HTML, API de recherche. | |
| 303 | + | |
| 304 | +Tests d'intégration sur un **web de fixtures local** (plombier québécois, université montréalaise, entreprise ontarienne, doublon, redirection, chemin bloqué par robots, article FR, article EN québécois) : fixture → crawler → parser → index → résultat de recherche. | |
| 305 | + | |
| 306 | +Dataset d'évaluation du ranking (« université québec », « plombier gatineau », « subvention thermopompe »…) avec domaines attendus. Les expériences de ranking doivent être mesurables. Scripts de benchmark : fetches/s, parses/s, docs indexés/s, latence de recherche, lag de queue, RAM/CPU/disque — l'optimisation est fondée sur des preuves, pas des intuitions. | |
| 307 | + | |
| 308 | +**Cibles :** recherche p50 < 100 ms, p95 < 300 ms à l'échelle MVP; page HTML normale cherchable en secondes; UI quasi instantanée. | |
| 309 | + | |
| 310 | +--- | |
| 311 | + | |
| 312 | +## 17. Ordre d'implémentation | |
| 313 | + | |
| 314 | +1. **Fondation** — monorepo, BD, queue, backend de recherche, web, API, crawler basique. | |
| 315 | +2. **Boucle complète** — seed → fetch → parse → index → search. **Ne pas continuer tant que ça ne marche pas de bout en bout.** | |
| 316 | +3. **Découverte continue** — liens sortants, frontier, domaines, scheduler, robots. | |
| 317 | +4. **Intelligence Québec** — scores, langue, localisation, scoring de domaines. | |
| 318 | +5. **Qualité de recherche** — BM25 custom, snippets, autorité, fraîcheur, doublons, compréhension de requête. | |
| 319 | +6. **Échelle** — plus de workers, meilleures queues, recrawl, monitoring. | |
| 320 | +7. **Couche sémantique** — embeddings, hybride, reranking, entités. | |
| 321 | +8. **Écosystème KA.** | |
| 322 | + | |
| 323 | +--- | |
| 324 | + | |
| 325 | +## 18. Milestone critique (non négociable) | |
| 326 | + | |
| 327 | +La première implémentation n'est réussie que si Claude démontre : | |
| 328 | + | |
| 329 | +1. démarrage du stack; | |
| 330 | +2. seed de sites québécois; | |
| 331 | +3. le crawler découvre des URLs; | |
| 332 | +4. télécharge des pages; | |
| 333 | +5. le parser extrait du texte utile; | |
| 334 | +6. les pages reçoivent un score Québec; | |
| 335 | +7. insertion **immédiate** dans l'index; | |
| 336 | +8. l'app web publique cherche ces pages; | |
| 337 | +9. les nouvelles pages deviennent cherchables **sans rien redémarrer**; | |
| 338 | +10. le frontier continue de découvrir des sites. | |
| 339 | + | |
| 340 | +**Succès Jour 1 :** même avec 10 000 pages, l'architecture se comporte exactement comme elle le fera avec 10 000 000+. Le crawler étend sa connaissance du web québécois pendant que le moteur est déjà opérationnel sur tout ce qui a été découvert. | |
| 341 | + | |
| 342 | +--- | |
| 343 | + | |
| 344 | +## 19. Méthode de travail de Claude | |
| 345 | + | |
| 346 | +Avant tout code substantiel : analyser cette spec → inspecter le dépôt existant → identifier le réutilisable → rechercher les décisions techniques incertaines → planifier → choisir sur preuves → documenter les décisions d'architecture → **implémenter verticalement** (du logiciel qui marche, pas de l'architecture spéculative). | |
| 347 | + | |
| 348 | +**Autonomie :** décisions raisonnables sans demander en permanence. Plusieurs options valables → analyser, choisir la meilleure, documenter, avancer. Ne bloquer que si c'est réellement bloquant. | |
| 349 | + | |
| 350 | +**Standard de qualité :** code typé, documenté, modulaire, observable, testable, sécurisé, efficace, orienté production. Pas de fichiers géants ni de couplage profond. Schémas migrables (concepts propres : `domains, urls, crawl_attempts, documents, document_versions, links, frontier_items, robots_rules, entities, index_jobs`). Et bien sûr : **header auteur dans chaque fichier** (§0.1). | |
| 351 | + | |
| 352 | +**Documentation :** README excellent (quoi, architecture, quick start, crawl, indexation incrémentale, recherche, layout, env, dev, tests, déploiement m2m32 + ngrok) et docs d'architecture avec diagrammes Mermaid. | |
| 353 | + | |
| 354 | +--- | |
| 355 | + | |
| 356 | +## 20. Vision | |
| 357 | + | |
| 358 | +L'objectif n'est pas de battre Google sur « Taylor Swift » ou « iPhone ». C'est de devenir extraordinairement bon sur : entreprises, institutions, gouvernement, municipalités, services, personnes, événements, produits, immobilier, documents, nouvelles et savoir local **du Québec**. | |
| 359 | + | |
| 360 | +Chaque page crawlée doit accroître la connaissance propre de Trouve-KA — jamais générer une requête vers l'API de quelqu'un d'autre. Chaque crawl améliore la couverture, le graphe de liens, la classification Québec, l'autorité, la fraîcheur, les priorités futures. **La valeur du dataset se compose dans le temps.** | |
| 361 | + | |
| 362 | +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.** | |
| 363 | +> Le web ne « finit » jamais. Trouve-KA non plus. | |
added
M2M32crouve-ka/README.md
+166 −0
@@ -0,0 +1,166 @@ | ||
| 1 | +# Trouve-KA — Cherche le Québec. | |
| 2 | + | |
| 3 | +**Moteur de recherche web indépendant, Québec-first.** Son propre crawler, son propre | |
| 4 | +index, son propre ranking, son API et son application web publique. Pas un métamoteur : | |
| 5 | +aucune dépendance à Google, Bing ou Brave pour les résultats. | |
| 6 | + | |
| 7 | +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.** | |
| 8 | + | |
| 9 | +En production : **https://www.trouve-ka.com** (node m2m32 du cluster MacLustr, tunnel ngrok). | |
| 10 | + | |
| 11 | +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 12 | + | |
| 13 | +--- | |
| 14 | + | |
| 15 | +## Ce que c'est | |
| 16 | + | |
| 17 | +``` | |
| 18 | +Crawler → Frontier → Fetcher → Parser → Classification Québec | |
| 19 | +→ Déduplication → Indexer → Index → Ranking → API → Web App | |
| 20 | +``` | |
| 21 | + | |
| 22 | +Le crawler découvre le web québécois à partir de seeds à forte autorité (gouvernement, | |
| 23 | +municipalités, universités, médias), suit les liens sortants, juge la pertinence | |
| 24 | +québécoise de chaque page (`page_quebec_score` **et** `domain_quebec_score`), | |
| 25 | +et indexe **immédiatement** : une page fetchée est cherchable en ~2-4 secondes, | |
| 26 | +pendant que le frontier continue de grandir. L'enrichissement (autorité, entités, | |
| 27 | +embeddings) arrive après, en asynchrone, sans jamais bloquer. | |
| 28 | + | |
| 29 | +Détails : [docs/architecture.md](docs/architecture.md) (diagrammes Mermaid) et | |
| 30 | +[docs/decisions.md](docs/decisions.md) (pourquoi OpenSearch, pourquoi Postgres | |
| 31 | +comme frontier, etc.). | |
| 32 | + | |
| 33 | +## Stack | |
| 34 | + | |
| 35 | +| Couche | Choix | | |
| 36 | +|---|---| | |
| 37 | +| Web | Next.js 15, TypeScript, React 19, Tailwind (composants style shadcn/ui) | | |
| 38 | +| API | FastAPI (Python 3.12) | | |
| 39 | +| Pipeline | Python 3.12 async — httpx, selectolax, Protego | | |
| 40 | +| BD relationnelle | PostgreSQL 16 (frontier, domaines, documents, graphe de liens, analytics) | | |
| 41 | +| Coordination | Redis 7 (politesse par hôte, pause, Redis Streams pour l'enrichissement) | | |
| 42 | +| Recherche | OpenSearch 2.17 (BM25 FR/EN, synonymes bilingues, function_score Québec-first) | | |
| 43 | +| Déploiement | Docker Compose sur m2m32 + ngrok (www.trouve-ka.com) | | |
| 44 | + | |
| 45 | +## Layout du monorepo | |
| 46 | + | |
| 47 | +``` | |
| 48 | +apps/web # moteur public + dashboard /admin (Next.js) | |
| 49 | +apps/api # FastAPI (trouveka.api) | |
| 50 | +services/ # crawler, frontier, parser, classifier, indexer, ranking, scheduler, enrichment | |
| 51 | +packages/ # config, database, logging, queue, search-core, shared, types | |
| 52 | +infrastructure/ # docker/, migrations/, monitoring/, deployment/ (m2m32 + ngrok) | |
| 53 | +scripts/ # bootstrap-seeds/, start-crawler/, health-check/, eval/, check-headers.py | |
| 54 | +tests/ # unitaires : canonicalisation, SSRF, robots, scoring Québec, fetcher, ranking | |
| 55 | +``` | |
| 56 | + | |
| 57 | +Le backend Python est un seul package namespace `trouveka.*` mappé sur ce layout | |
| 58 | +(voir `pyproject.toml`). | |
| 59 | + | |
| 60 | +## Démarrage rapide (dev) | |
| 61 | + | |
| 62 | +```bash | |
| 63 | +git clone … && cd trouve-ka | |
| 64 | +cp .env.example .env | |
| 65 | +docker compose up -d postgres redis opensearch # infra | |
| 66 | +uv venv --python 3.12 .venv && uv pip install -e ".[dev]" --python .venv/bin/python | |
| 67 | +pnpm install | |
| 68 | + | |
| 69 | +pnpm crawl:seed # migrations + 60+ seeds québécoises | |
| 70 | +bash scripts/start-crawler/start.sh & # le crawl démarre | |
| 71 | +.venv/bin/uvicorn trouveka.api.main:app --port 8080 & | |
| 72 | +pnpm dev # → http://localhost:3000 | |
| 73 | +# → des résultats apparaissent en quelques secondes | |
| 74 | +``` | |
| 75 | + | |
| 76 | +Ports occupés sur la machine? Surcharger dans `.env` : `PG_PORT`, `REDIS_PORT`, | |
| 77 | +`SEARCH_PORT`, `API_PORT` (et les URLs correspondantes). | |
| 78 | + | |
| 79 | +Stack complet en containers : `docker compose up -d --build` (le service `migrate` | |
| 80 | +applique les migrations, `crawler-worker` se scale avec | |
| 81 | +`docker compose up -d --scale crawler-worker=3`). | |
| 82 | + | |
| 83 | +## Comment ça marche | |
| 84 | + | |
| 85 | +### Crawl et politesse | |
| 86 | +- **Identité assumée** : UA `Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)`, | |
| 87 | + page publique [/trouveka-bot](https://www.trouve-ka.com/trouveka-bot), pas de stealth. | |
| 88 | +- **robots.txt** parsé avec Protego, cache 24 h en base; `noindex`/`nofollow`/`X-Robots-Tag` respectés. | |
| 89 | +- **Politesse par origine** : verrou Redis par hôte (défaut 2 s entre requêtes, `Crawl-delay` respecté), | |
| 90 | + quel que soit le nombre de workers. | |
| 91 | +- **Sécurité** : garde SSRF (IP privées/loopback/métadonnées cloud bloquées, revalidée à chaque | |
| 92 | + redirection), limites par réponse (3 Mo, 5 redirections, timeout 20 s), détection de pièges | |
| 93 | + (session IDs, calendriers infinis, facettes explosives, pagination sans fin). | |
| 94 | + | |
| 95 | +### Indexation incrémentale | |
| 96 | +Le worker exécute fetch→parse→score→index **inline** : `refresh_interval: 1s` côté | |
| 97 | +OpenSearch → cherchable en secondes. Détection de changement par hash de contenu + | |
| 98 | +ETag/If-Modified-Since; recrawl adaptatif (inchangé → intervalle ×2, volatil → ÷2). | |
| 99 | + | |
| 100 | +### Détection Québec | |
| 101 | +Deux scores distincts (`page` et `domaine`) calculés à partir de : TLD (.qc.ca, .quebec), | |
| 102 | +gazetteer de toponymes (pondération réduite pour les ambigus type Laval/Hull), codes | |
| 103 | +postaux G/H/J, indicatifs (418/514/438/…), organisations connues (Hydro-Québec, RAMQ, | |
| 104 | +UQAM…), mentions structurées de la province (JSON-LD), langue française (indice, pas preuve). | |
| 105 | +Déterministe et gratuit — aucun LLM dans le chemin chaud (§12). | |
| 106 | + | |
| 107 | +### Ranking | |
| 108 | +`function_score` OpenSearch : BM25 bilingue (analyzers FR + EN, synonymes | |
| 109 | +thermopompe↔heat pump à la recherche) + scores Québec + autorité de domaine | |
| 110 | +(inlinks pondérés) + fraîcheur + boost de localité (« plombier Gatineau » → documents | |
| 111 | +avec preuve géographique `locations`). Chaque composant est optionnel; BM25 tient seul. | |
| 112 | + | |
| 113 | +Évaluation mesurable : `python3 scripts/eval/run-eval.py --api http://localhost:8080` | |
| 114 | +(dataset dans `scripts/eval/ranking-eval.yaml`). | |
| 115 | + | |
| 116 | +## API | |
| 117 | + | |
| 118 | +- `GET /api/search?q=&page=&limit=&language=&category=&quebec_only=&freshness=` | |
| 119 | +- `GET /api/status` — compteurs publics (pages, domaines, débit, état du crawler) | |
| 120 | +- `POST /api/submit {"url": …}` — soumettre un site québécois (soumission ≠ inclusion) | |
| 121 | +- `GET|POST /api/admin/*` — protégé par header `X-Admin-Token` : overview, flux live, | |
| 122 | + pause/reprise, seeds, recrawl, blocage de domaine, inspection du frontier | |
| 123 | + | |
| 124 | +## Tests | |
| 125 | + | |
| 126 | +```bash | |
| 127 | +.venv/bin/python -m pytest tests/ # 54 tests : URLs, SSRF, pièges, Québec, frontier, parser, fetcher, ranking | |
| 128 | +python3 scripts/check-headers.py # header auteur obligatoire dans chaque fichier source (CI) | |
| 129 | +bash scripts/health-check/check.sh # santé du stack | |
| 130 | +``` | |
| 131 | + | |
| 132 | +## Déploiement m2m32 + ngrok (www.trouve-ka.com) | |
| 133 | + | |
| 134 | +Prérequis sur m2m32 : colima + docker + docker-compose (brew), ngrok authentifié, | |
| 135 | +domaine `www.trouve-ka.com` réservé dans le compte ngrok. | |
| 136 | + | |
| 137 | +```bash | |
| 138 | +pnpm deploy:m2m32 # = bash infrastructure/deployment/deploy-m2m32.sh | |
| 139 | +``` | |
| 140 | + | |
| 141 | +Le script : rsync du monorepo → `docker compose up -d --build` → migrations + seeds | |
| 142 | +(idempotent) → tunnel `ngrok http --url=www.trouve-ka.com 3000` → health-check. | |
| 143 | +Le web proxifie `/api/*` vers l'API interne : un seul port exposé, pas d'URL absolues | |
| 144 | +côté client, cookies/CORS sans surprise derrière le tunnel. | |
| 145 | + | |
| 146 | +Opérations courantes sur le node : | |
| 147 | + | |
| 148 | +```bash | |
| 149 | +ssh M2M32 'cd trouve-ka && docker compose logs -f crawler-worker' # crawl en direct | |
| 150 | +ssh M2M32 'cd trouve-ka && docker compose up -d --scale crawler-worker=3' | |
| 151 | +``` | |
| 152 | + | |
| 153 | +## Observabilité | |
| 154 | + | |
| 155 | +Logs JSON structurés par service, métriques réelles via `/api/status` et | |
| 156 | +`/api/admin/overview`, dashboard `/admin` (files, débits, distribution HTTP, latences | |
| 157 | +p50/p95, flux live). Voir [infrastructure/monitoring/README.md](infrastructure/monitoring/README.md). | |
| 158 | + | |
| 159 | +## Règles du dépôt | |
| 160 | + | |
| 161 | +- **Header auteur obligatoire** dans chaque fichier source (§0.1) — vérifié par | |
| 162 | + `scripts/check-headers.py`. | |
| 163 | +- **Aucune donnée factice** : pas de compteurs simulés, pas de résultats hard-codés; | |
| 164 | + les fixtures vivent dans `tests/` uniquement. | |
| 165 | +- Provenance préservée : URL originale, canonique, timestamp de crawl, domaine source. | |
| 166 | + Trouve-KA renvoie vers les éditeurs originaux. | |
added
M2M32crouve-ka/apps/api/__init__.py
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +# Trouve-KA — application API | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""API publique (recherche, statut, soumission) et API de contrôle du crawler (protégée).""" | |
added
M2M32crouve-ka/apps/api/main.py
+297 −0
@@ -0,0 +1,297 @@ | ||
| 1 | +# Trouve-KA — API FastAPI | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""API de Trouve-KA. | |
| 6 | + | |
| 7 | +Publique : /api/search, /api/status, /api/submit, /api/health | |
| 8 | +Protégée (X-Admin-Token) : /api/admin/* — contrôle du crawler, jamais public sans auth. | |
| 9 | + | |
| 10 | +Dégradation gracieuse : si OpenSearch tombe, /api/status répond quand même; | |
| 11 | +si Postgres tombe, la recherche répond quand même (analytics sautées). | |
| 12 | +""" | |
| 13 | + | |
| 14 | +import html | |
| 15 | +import re | |
| 16 | +import time | |
| 17 | +from contextlib import asynccontextmanager | |
| 18 | +from typing import Annotated | |
| 19 | + | |
| 20 | +from fastapi import Depends, FastAPI, Header, HTTPException, Query | |
| 21 | +from fastapi.middleware.cors import CORSMiddleware | |
| 22 | +from pydantic import BaseModel | |
| 23 | + | |
| 24 | +from trouveka.config import get_settings | |
| 25 | +from trouveka.database import Database | |
| 26 | +from trouveka.logging import get_logger | |
| 27 | +from trouveka.queue import Coordination | |
| 28 | +from trouveka.ranking import build_search_body | |
| 29 | +from trouveka.search_core import SearchCore | |
| 30 | +from trouveka.shared import canonicalize_url, display_url, extract_domain, is_http_url | |
| 31 | + | |
| 32 | +log = get_logger("api") | |
| 33 | +settings = get_settings() | |
| 34 | + | |
| 35 | +db = Database(settings.database_url, pool_min=settings.pg_pool_min, pool_max=settings.pg_pool_max) | |
| 36 | +coord = Coordination(settings.redis_url) | |
| 37 | +search = SearchCore(settings.search_url, settings.search_index) | |
| 38 | + | |
| 39 | + | |
| 40 | +@asynccontextmanager | |
| 41 | +async def lifespan(_app: FastAPI): | |
| 42 | + await db.connect() | |
| 43 | + await search.ensure_index() | |
| 44 | + yield | |
| 45 | + await search.close() | |
| 46 | + await coord.close() | |
| 47 | + await db.close() | |
| 48 | + | |
| 49 | + | |
| 50 | +app = FastAPI(title="Trouve-KA API", version="0.1.0", lifespan=lifespan) | |
| 51 | + | |
| 52 | +# Derrière ngrok (www.trouve-ka.com), le web app proxifie /api : CORS permissif inutile | |
| 53 | +# en prod, mais pratique en dev local (web sur :3000, API sur :8080). | |
| 54 | +app.add_middleware( | |
| 55 | + CORSMiddleware, | |
| 56 | + allow_origins=["http://localhost:3000", settings.public_url], | |
| 57 | + allow_methods=["GET", "POST"], | |
| 58 | + allow_headers=["*", "X-Admin-Token"], | |
| 59 | +) | |
| 60 | + | |
| 61 | + | |
| 62 | +def require_admin(x_admin_token: Annotated[str | None, Header()] = None) -> None: | |
| 63 | + if not x_admin_token or x_admin_token != settings.admin_token: | |
| 64 | + raise HTTPException(status_code=401, detail="Jeton admin invalide") | |
| 65 | + | |
| 66 | + | |
| 67 | +_TAG_RE = re.compile(r"<(?!/?em>)[^>]*>") | |
| 68 | + | |
| 69 | + | |
| 70 | +def _safe_snippet(fragments: list[str]) -> str: | |
| 71 | + """Ne laisse passer que <em>/</em> (highlight); tout le reste est échappé par OpenSearch.""" | |
| 72 | + return _TAG_RE.sub("", " … ".join(fragments))[:400] | |
| 73 | + | |
| 74 | + | |
| 75 | +BADGE_LABELS = {"government": "Gouvernement", "news": "Actualités", "education": "Éducation"} | |
| 76 | + | |
| 77 | + | |
| 78 | +# ---------------------------------------------------------------------- publique | |
| 79 | + | |
| 80 | +@app.get("/api/health") | |
| 81 | +async def health(): | |
| 82 | + return {"ok": True, "search_ok": await search.ping()} | |
| 83 | + | |
| 84 | + | |
| 85 | +@app.get("/api/search") | |
| 86 | +async def api_search( | |
| 87 | + q: str = Query(..., min_length=1, max_length=200), | |
| 88 | + page: int = Query(1, ge=1, le=100), | |
| 89 | + limit: int = Query(10, ge=1, le=50), | |
| 90 | + language: str | None = Query(None, pattern="^(fr|en)$"), | |
| 91 | + location: str | None = None, | |
| 92 | + category: str | None = Query(None, max_length=40), | |
| 93 | + quebec_only: bool = False, | |
| 94 | + freshness: str | None = Query(None, pattern="^(day|week|month|year)$"), | |
| 95 | +): | |
| 96 | + started = time.monotonic() | |
| 97 | + query_text = f"{q} {location}" if location else q | |
| 98 | + body, analysis = build_search_body( | |
| 99 | + query_text, page=page, limit=limit, language=language, | |
| 100 | + category=category, quebec_only=quebec_only, freshness=freshness, | |
| 101 | + ) | |
| 102 | + try: | |
| 103 | + res = await search.search(body) | |
| 104 | + except Exception: | |
| 105 | + log.exception("recherche échouée", extra={"ctx": {"q": q}}) | |
| 106 | + raise HTTPException(status_code=503, detail="Le moteur de recherche est temporairement indisponible") | |
| 107 | + | |
| 108 | + took_ms = int((time.monotonic() - started) * 1000) | |
| 109 | + results = [] | |
| 110 | + for hit in res["hits"]["hits"]: | |
| 111 | + src = hit["_source"] | |
| 112 | + highlight = hit.get("highlight", {}) | |
| 113 | + fragments = highlight.get("body") or highlight.get("description") or [] | |
| 114 | + snippet = _safe_snippet(fragments) if fragments else html.escape(src.get("description") or "")[:400] | |
| 115 | + badges = [BADGE_LABELS[c] for c in src.get("categories", []) if c in BADGE_LABELS] | |
| 116 | + if src.get("page_quebec_score", 0) >= 0.45 or src.get("domain_quebec_score", 0) >= 0.6: | |
| 117 | + badges.insert(0, "Québec") | |
| 118 | + results.append({ | |
| 119 | + "title": src.get("title") or src["url"], | |
| 120 | + "url": src["url"], | |
| 121 | + "display_url": display_url(src["url"]), | |
| 122 | + "snippet": snippet, | |
| 123 | + "domain": src["domain"], | |
| 124 | + "language": src.get("language"), | |
| 125 | + "quebec_score": src.get("page_quebec_score", 0), | |
| 126 | + "badges": badges, | |
| 127 | + "published_at": src.get("published_at"), | |
| 128 | + }) | |
| 129 | + | |
| 130 | + total = res["hits"]["total"]["value"] | |
| 131 | + # Analytics agrégées, respectueuses de la vie privée — jamais bloquantes | |
| 132 | + try: | |
| 133 | + await db.record_search_query(q, analysis["language"], total, took_ms) | |
| 134 | + except Exception: | |
| 135 | + log.exception("analytics de recherche sautées") | |
| 136 | + | |
| 137 | + return {"query": q, "total": total, "took_ms": took_ms, "page": page, "limit": limit, "results": results} | |
| 138 | + | |
| 139 | + | |
| 140 | +@app.get("/api/status") | |
| 141 | +async def api_status(): | |
| 142 | + snapshot: dict = {} | |
| 143 | + try: | |
| 144 | + snapshot = await db.status_snapshot() | |
| 145 | + except Exception: | |
| 146 | + log.exception("statut PG indisponible") | |
| 147 | + search_ok = await search.ping() | |
| 148 | + try: | |
| 149 | + paused = await coord.is_paused() | |
| 150 | + except Exception: | |
| 151 | + paused = False | |
| 152 | + return { | |
| 153 | + "pages_indexed": snapshot.get("pages_indexed", 0), | |
| 154 | + "domains_count": snapshot.get("domains_count", 0), | |
| 155 | + "indexed_last_hour": snapshot.get("indexed_last_hour", 0), | |
| 156 | + "fetched_last_hour": snapshot.get("fetched_last_hour", 0), | |
| 157 | + "errors_last_hour": snapshot.get("errors_last_hour", 0), | |
| 158 | + "frontier_pending": snapshot.get("frontier_pending", 0), | |
| 159 | + "frontier_in_progress": snapshot.get("frontier_in_progress", 0), | |
| 160 | + "crawler_state": "paused" if paused else "running", | |
| 161 | + "search_ok": search_ok, | |
| 162 | + } | |
| 163 | + | |
| 164 | + | |
| 165 | +@app.get("/api/live") | |
| 166 | +async def api_live(): | |
| 167 | + """Dernière page visitée par TrouveKABot — alimente le flux temps réel du footer. | |
| 168 | + | |
| 169 | + Public mais volontairement minimal : domaine + URL + horodatage, rien d'interne. | |
| 170 | + """ | |
| 171 | + try: | |
| 172 | + events = await db.recent_events(1) | |
| 173 | + except Exception: | |
| 174 | + return {"event": None} | |
| 175 | + if not events: | |
| 176 | + return {"event": None} | |
| 177 | + e = events[0] | |
| 178 | + return { | |
| 179 | + "event": { | |
| 180 | + "at": e["at"], | |
| 181 | + "url": e["url"], | |
| 182 | + "domain": extract_domain(e["url"]), | |
| 183 | + "outcome": e["outcome"], | |
| 184 | + } | |
| 185 | + } | |
| 186 | + | |
| 187 | + | |
| 188 | +class SubmitBody(BaseModel): | |
| 189 | + url: str | |
| 190 | + | |
| 191 | + | |
| 192 | +@app.post("/api/submit") | |
| 193 | +async def api_submit(payload: SubmitBody): | |
| 194 | + """Soumettre un site québécois. Soumission ≠ inclusion : le crawler valide.""" | |
| 195 | + if not is_http_url(payload.url): | |
| 196 | + raise HTTPException(status_code=422, detail="URL invalide (http/https seulement)") | |
| 197 | + url = canonicalize_url(payload.url) | |
| 198 | + domain = extract_domain(url) if url else None | |
| 199 | + if not url or not domain: | |
| 200 | + raise HTTPException(status_code=422, detail="URL invalide") | |
| 201 | + await db.add_submission(url) | |
| 202 | + await db.enqueue_url(url, domain, priority=0.7, depth=0) | |
| 203 | + return { | |
| 204 | + "accepted": True, | |
| 205 | + "message": "Merci! Le site sera visité par TrouveKABot. La soumission ne garantit pas l'inclusion.", | |
| 206 | + } | |
| 207 | + | |
| 208 | + | |
| 209 | +# ---------------------------------------------------------------------- admin | |
| 210 | + | |
| 211 | +class SeedsBody(BaseModel): | |
| 212 | + urls: list[str] | |
| 213 | + | |
| 214 | + | |
| 215 | +class RecrawlBody(BaseModel): | |
| 216 | + url: str | None = None | |
| 217 | + domain: str | None = None | |
| 218 | + | |
| 219 | + | |
| 220 | +class DomainBody(BaseModel): | |
| 221 | + domain: str | |
| 222 | + | |
| 223 | + | |
| 224 | +@app.get("/api/admin/overview", dependencies=[Depends(require_admin)]) | |
| 225 | +async def admin_overview(): | |
| 226 | + overview = await db.admin_overview() | |
| 227 | + overview["paused"] = await coord.is_paused() | |
| 228 | + try: | |
| 229 | + overview["enrich_backlog"] = await coord.enrich_backlog() | |
| 230 | + except Exception: | |
| 231 | + overview["enrich_backlog"] = None | |
| 232 | + return overview | |
| 233 | + | |
| 234 | + | |
| 235 | +@app.get("/api/admin/recent", dependencies=[Depends(require_admin)]) | |
| 236 | +async def admin_recent(limit: int = Query(50, ge=1, le=200)): | |
| 237 | + return {"events": await db.recent_events(limit)} | |
| 238 | + | |
| 239 | + | |
| 240 | +@app.post("/api/admin/pause", dependencies=[Depends(require_admin)]) | |
| 241 | +async def admin_pause(): | |
| 242 | + await coord.pause_crawler() | |
| 243 | + return {"paused": True} | |
| 244 | + | |
| 245 | + | |
| 246 | +@app.post("/api/admin/resume", dependencies=[Depends(require_admin)]) | |
| 247 | +async def admin_resume(): | |
| 248 | + await coord.resume_crawler() | |
| 249 | + return {"paused": False} | |
| 250 | + | |
| 251 | + | |
| 252 | +@app.post("/api/admin/seeds", dependencies=[Depends(require_admin)]) | |
| 253 | +async def admin_seeds(payload: SeedsBody): | |
| 254 | + added = 0 | |
| 255 | + for raw in payload.urls[:500]: | |
| 256 | + url = canonicalize_url(raw.strip()) | |
| 257 | + domain = extract_domain(url) if url else None | |
| 258 | + if url and domain: | |
| 259 | + if await db.enqueue_url(url, domain, priority=1.0, depth=0, is_seed=True): | |
| 260 | + added += 1 | |
| 261 | + return {"added": added} | |
| 262 | + | |
| 263 | + | |
| 264 | +@app.post("/api/admin/recrawl", dependencies=[Depends(require_admin)]) | |
| 265 | +async def admin_recrawl(payload: RecrawlBody): | |
| 266 | + if payload.url: | |
| 267 | + url = canonicalize_url(payload.url) | |
| 268 | + ok = await db.requeue_url(url) if url else False | |
| 269 | + return {"requeued": 1 if ok else 0} | |
| 270 | + if payload.domain: | |
| 271 | + return {"requeued": await db.requeue_domain(payload.domain.lower())} | |
| 272 | + raise HTTPException(status_code=422, detail="url ou domain requis") | |
| 273 | + | |
| 274 | + | |
| 275 | +@app.post("/api/admin/domains/block", dependencies=[Depends(require_admin)]) | |
| 276 | +async def admin_block_domain(payload: DomainBody): | |
| 277 | + await db.block_domain(payload.domain.lower()) | |
| 278 | + return {"blocked": payload.domain.lower()} | |
| 279 | + | |
| 280 | + | |
| 281 | +@app.get("/api/admin/frontier", dependencies=[Depends(require_admin)]) | |
| 282 | +async def admin_frontier( | |
| 283 | + domain: str | None = None, | |
| 284 | + status: str | None = Query(None, pattern="^(pending|in_progress|done|failed|blocked)$"), | |
| 285 | + limit: int = Query(100, ge=1, le=500), | |
| 286 | +): | |
| 287 | + return {"items": await db.frontier_inspect(domain, status, limit)} | |
| 288 | + | |
| 289 | + | |
| 290 | +def main() -> None: | |
| 291 | + import uvicorn | |
| 292 | + | |
| 293 | + uvicorn.run("trouveka.api.main:app", host=settings.api_host, port=settings.api_port, workers=1) | |
| 294 | + | |
| 295 | + | |
| 296 | +if __name__ == "__main__": | |
| 297 | + main() | |
added
M2M32crouve-ka/docker-compose.yml
+149 −0
@@ -0,0 +1,149 @@ | ||
| 1 | +# Trouve-KA — stack Docker Compose (dev local et déploiement m2m32) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | +# | |
| 5 | +# Dimensionné pour m2m32 (32 Go RAM) : OpenSearch 2 Go de heap, Postgres léger, | |
| 6 | +# workers scalables via `docker compose up -d --scale crawler-worker=3`. | |
| 7 | + | |
| 8 | +services: | |
| 9 | + postgres: | |
| 10 | + image: postgres:16-alpine | |
| 11 | + environment: | |
| 12 | + POSTGRES_USER: trouveka | |
| 13 | + POSTGRES_PASSWORD: trouveka | |
| 14 | + POSTGRES_DB: trouveka | |
| 15 | + ports: | |
| 16 | + - "${PG_PORT:-5432}:5432" | |
| 17 | + volumes: | |
| 18 | + - pgdata:/var/lib/postgresql/data | |
| 19 | + healthcheck: | |
| 20 | + test: ["CMD-SHELL", "pg_isready -U trouveka"] | |
| 21 | + interval: 5s | |
| 22 | + timeout: 3s | |
| 23 | + retries: 20 | |
| 24 | + | |
| 25 | + redis: | |
| 26 | + image: redis:7-alpine | |
| 27 | + ports: | |
| 28 | + - "${REDIS_PORT:-6379}:6379" | |
| 29 | + volumes: | |
| 30 | + - redisdata:/data | |
| 31 | + command: ["redis-server", "--appendonly", "yes"] | |
| 32 | + healthcheck: | |
| 33 | + test: ["CMD", "redis-cli", "ping"] | |
| 34 | + interval: 5s | |
| 35 | + timeout: 3s | |
| 36 | + retries: 20 | |
| 37 | + | |
| 38 | + opensearch: | |
| 39 | + image: opensearchproject/opensearch:2.17.1 | |
| 40 | + environment: | |
| 41 | + discovery.type: single-node | |
| 42 | + plugins.security.disabled: "true" | |
| 43 | + OPENSEARCH_JAVA_OPTS: "-Xms2g -Xmx2g" | |
| 44 | + OPENSEARCH_INITIAL_ADMIN_PASSWORD: "TrouveKA!Local1" | |
| 45 | + bootstrap.memory_lock: "true" | |
| 46 | + ulimits: | |
| 47 | + memlock: | |
| 48 | + soft: -1 | |
| 49 | + hard: -1 | |
| 50 | + ports: | |
| 51 | + - "${SEARCH_PORT:-9200}:9200" | |
| 52 | + volumes: | |
| 53 | + - osdata:/usr/share/opensearch/data | |
| 54 | + healthcheck: | |
| 55 | + test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"] | |
| 56 | + interval: 10s | |
| 57 | + timeout: 5s | |
| 58 | + retries: 30 | |
| 59 | + | |
| 60 | + api: | |
| 61 | + build: | |
| 62 | + context: . | |
| 63 | + dockerfile: infrastructure/docker/backend.Dockerfile | |
| 64 | + command: ["uvicorn", "trouveka.api.main:app", "--host", "0.0.0.0", "--port", "8080"] | |
| 65 | + environment: &backend_env | |
| 66 | + DATABASE_URL: postgresql://trouveka:trouveka@postgres:5432/trouveka | |
| 67 | + REDIS_URL: redis://redis:6379/0 | |
| 68 | + SEARCH_URL: http://opensearch:9200 | |
| 69 | + ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me-admin-token} | |
| 70 | + PUBLIC_URL: ${PUBLIC_URL:-https://www.trouve-ka.com} | |
| 71 | + MAX_GLOBAL_CONCURRENCY: ${MAX_GLOBAL_CONCURRENCY:-24} | |
| 72 | + DEFAULT_HOST_DELAY: ${DEFAULT_HOST_DELAY:-2.0} | |
| 73 | + MAX_RESPONSE_BYTES: ${MAX_RESPONSE_BYTES:-3000000} | |
| 74 | + ports: | |
| 75 | + - "8080:8080" | |
| 76 | + depends_on: | |
| 77 | + postgres: | |
| 78 | + condition: service_healthy | |
| 79 | + redis: | |
| 80 | + condition: service_healthy | |
| 81 | + opensearch: | |
| 82 | + condition: service_healthy | |
| 83 | + restart: unless-stopped | |
| 84 | + | |
| 85 | + migrate: | |
| 86 | + build: | |
| 87 | + context: . | |
| 88 | + dockerfile: infrastructure/docker/backend.Dockerfile | |
| 89 | + command: ["python", "-c", "import asyncio; from trouveka.database import run_migrations; from trouveka.config import get_settings; print(asyncio.run(run_migrations(get_settings().database_url)))"] | |
| 90 | + environment: *backend_env | |
| 91 | + depends_on: | |
| 92 | + postgres: | |
| 93 | + condition: service_healthy | |
| 94 | + restart: "no" | |
| 95 | + | |
| 96 | + crawler-worker: | |
| 97 | + build: | |
| 98 | + context: . | |
| 99 | + dockerfile: infrastructure/docker/backend.Dockerfile | |
| 100 | + command: ["python", "-m", "trouveka.crawler.worker"] | |
| 101 | + environment: *backend_env | |
| 102 | + depends_on: | |
| 103 | + migrate: | |
| 104 | + condition: service_completed_successfully | |
| 105 | + opensearch: | |
| 106 | + condition: service_healthy | |
| 107 | + redis: | |
| 108 | + condition: service_healthy | |
| 109 | + restart: unless-stopped | |
| 110 | + | |
| 111 | + enrichment-worker: | |
| 112 | + build: | |
| 113 | + context: . | |
| 114 | + dockerfile: infrastructure/docker/backend.Dockerfile | |
| 115 | + command: ["python", "-m", "trouveka.enrichment.worker"] | |
| 116 | + environment: *backend_env | |
| 117 | + depends_on: | |
| 118 | + migrate: | |
| 119 | + condition: service_completed_successfully | |
| 120 | + restart: unless-stopped | |
| 121 | + | |
| 122 | + scheduler: | |
| 123 | + build: | |
| 124 | + context: . | |
| 125 | + dockerfile: infrastructure/docker/backend.Dockerfile | |
| 126 | + command: ["python", "-m", "trouveka.scheduler.loop"] | |
| 127 | + environment: *backend_env | |
| 128 | + depends_on: | |
| 129 | + migrate: | |
| 130 | + condition: service_completed_successfully | |
| 131 | + restart: unless-stopped | |
| 132 | + | |
| 133 | + web: | |
| 134 | + build: | |
| 135 | + context: . | |
| 136 | + dockerfile: infrastructure/docker/web.Dockerfile | |
| 137 | + environment: | |
| 138 | + API_URL: http://api:8080 | |
| 139 | + PORT: "3000" | |
| 140 | + ports: | |
| 141 | + - "3000:3000" | |
| 142 | + depends_on: | |
| 143 | + - api | |
| 144 | + restart: unless-stopped | |
| 145 | + | |
| 146 | +volumes: | |
| 147 | + pgdata: | |
| 148 | + redisdata: | |
| 149 | + osdata: | |
added
M2M32crouve-ka/docs/architecture.md
+106 −0
@@ -0,0 +1,106 @@ | ||
| 1 | +# Trouve-KA — Architecture | |
| 2 | + | |
| 3 | +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +## Vue d'ensemble | |
| 6 | + | |
| 7 | +```mermaid | |
| 8 | +flowchart LR | |
| 9 | + subgraph Découverte | |
| 10 | + SEEDS[Seeds §8] --> FRONTIER | |
| 11 | + SUBMIT[Soumissions /soumettre] --> FRONTIER | |
| 12 | + LINKS[Liens sortants] --> FRONTIER | |
| 13 | + end | |
| 14 | + FRONTIER[(Frontier<br/>Postgres)] -->|claim SKIP LOCKED| CW[crawler-worker × N] | |
| 15 | + CW -->|politesse SET NX PX| REDIS[(Redis)] | |
| 16 | + CW --> ROBOTS[robots.txt cache] | |
| 17 | + CW --> FETCH[Fetcher HTTP<br/>garde SSRF] | |
| 18 | + FETCH --> PARSE[Parser selectolax] | |
| 19 | + PARSE --> QC[Classification Québec<br/>page_quebec_score] | |
| 20 | + QC --> DEDUP[Hash contenu / doublons] | |
| 21 | + DEDUP -->|indexation IMMÉDIATE| OS[(OpenSearch<br/>trouveka-docs)] | |
| 22 | + DEDUP --> PG[(Postgres<br/>documents, domaines, liens)] | |
| 23 | + CW -->|jamais bloquant| STREAM[Redis Stream<br/>trouveka:enrich] | |
| 24 | + STREAM --> EW[enrichment-worker] | |
| 25 | + EW -->|update partiel| OS | |
| 26 | + SCHED[scheduler] -->|items abandonnés,<br/>autorité de domaine| PG | |
| 27 | + OS --> API[API FastAPI] | |
| 28 | + PG --> API | |
| 29 | + API --> WEB[Next.js web<br/>+ /admin] | |
| 30 | + WEB --> NGROK[ngrok<br/>www.trouve-ka.com] | |
| 31 | +``` | |
| 32 | + | |
| 33 | +## Principe cardinal (§0.3) | |
| 34 | + | |
| 35 | +```mermaid | |
| 36 | +sequenceDiagram | |
| 37 | + participant F as Frontier | |
| 38 | + participant W as crawler-worker | |
| 39 | + participant O as OpenSearch | |
| 40 | + participant U as Utilisateur | |
| 41 | + F->>W: claim URL (t+0s) | |
| 42 | + W->>W: fetch + parse + score Québec (t+2s) | |
| 43 | + W->>O: index_document (t+3s) | |
| 44 | + Note over O: refresh_interval 1s | |
| 45 | + U->>O: recherche (t+4s) — la page est déjà cherchable | |
| 46 | + W--)W: enrichissement async (étapes 2-3, plus tard) | |
| 47 | +``` | |
| 48 | + | |
| 49 | +Jamais de cycle « crawler tout → indexer → chercher ». L'enrichissement met à jour | |
| 50 | +des documents **déjà cherchables** (update partiel), il ne conditionne rien. | |
| 51 | + | |
| 52 | +## Cycle de vie d'une URL | |
| 53 | + | |
| 54 | +```mermaid | |
| 55 | +stateDiagram-v2 | |
| 56 | + [*] --> pending: découverte (seed, lien, soumission) | |
| 57 | + pending --> in_progress: claim (priorité DESC, SKIP LOCKED) | |
| 58 | + in_progress --> pending: politesse (trop tôt pour cet hôte) | |
| 59 | + in_progress --> pending: succès → next_crawl_at adaptatif | |
| 60 | + in_progress --> pending: erreur transitoire (retry backoff ≤3) | |
| 61 | + in_progress --> done: robots refusé / redirection / doublon | |
| 62 | + in_progress --> failed: erreur permanente ou retries épuisés | |
| 63 | + in_progress --> blocked: domaine bloqué | |
| 64 | + pending --> in_progress: recrawl (fréquence mesurée §5.5) | |
| 65 | +``` | |
| 66 | + | |
| 67 | +## Scores (§4, §7, §9) | |
| 68 | + | |
| 69 | +| Étape | Quand | Champs | | |
| 70 | +|---|---|---| | |
| 71 | +| 1 — immédiat | pipeline inline | titre, corps, headings, langue, `page_quebec_score`, `locations`, catégories grossières | | |
| 72 | +| 2 — async | enrichment-worker | `domain_quebec_score` à jour, `authority_score` propagé (plus tard : embeddings, entités) | | |
| 73 | +| 3 — async | scheduler | autorité de domaine depuis `domain_links` (inlinks pondérés Québec) | | |
| 74 | + | |
| 75 | +Ranking à la requête : `function_score = BM25 (multi_match FR/EN + synonymes) + | |
| 76 | +w·page_quebec + w·domain_quebec + w·autorité + gauss(published_at) + boost localité` | |
| 77 | +(désactivable composant par composant — BM25 reste seul debout si tout tombe, §9). | |
| 78 | + | |
| 79 | +## Dégradation gracieuse (§13) | |
| 80 | + | |
| 81 | +- OpenSearch en panne → l'API répond 503 sur /search, /status reste up; le crawler continue d'alimenter Postgres? Non : l'indexation échoue → l'item est relâché en retry; le frontier survit. | |
| 82 | +- Redis en panne → politesse locale impossible : le worker s'arrête proprement; Postgres intact. | |
| 83 | +- Un worker crash → `scheduler.reset_stale_items` relance ses URLs après 30 min. | |
| 84 | +- Enrichissement en retard → aucune conséquence sur la recherche (backlog visible dans /admin). | |
| 85 | + | |
| 86 | +## Déploiement m2m32 (§0.2) | |
| 87 | + | |
| 88 | +```mermaid | |
| 89 | +flowchart LR | |
| 90 | + NG[ngrok www.trouve-ka.com] --> WEB3000[web :3000] | |
| 91 | + WEB3000 -->|rewrite /api/*| API8080[api :8080] | |
| 92 | + subgraph m2m32[Docker Compose sur m2m32 — 32 Go] | |
| 93 | + WEB3000 | |
| 94 | + API8080 | |
| 95 | + PG5432[postgres] | |
| 96 | + RD[redis] | |
| 97 | + OS9200[opensearch 2 Go heap] | |
| 98 | + CWX[crawler-worker × N] | |
| 99 | + EN[enrichment-worker] | |
| 100 | + SC[scheduler] | |
| 101 | + end | |
| 102 | +``` | |
| 103 | + | |
| 104 | +Un seul port exposé publiquement (3000 via ngrok). Budgets mémoire : OpenSearch 2 Go | |
| 105 | +de heap (~3 Go RSS), Postgres < 1 Go, workers Python ~100-200 Mo chacun, web ~150 Mo — | |
| 106 | +large marge sur 32 Go, scalable par `docker compose up -d --scale crawler-worker=3`. | |
added
M2M32crouve-ka/docs/decisions.md
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +# Trouve-KA — Décisions d'architecture | |
| 2 | + | |
| 3 | +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +## D1 — Backend de recherche : OpenSearch 2.x | |
| 6 | + | |
| 7 | +**Candidats évalués :** OpenSearch, Elasticsearch, Typesense, Meilisearch, Vespa, Tantivy, Quickwit (CLAUDE.md §3). | |
| 8 | + | |
| 9 | +**Choix : OpenSearch 2.17**, parce que : | |
| 10 | +- **BM25 réel et paramétrable** par champ (multi_match, boosts), critère n°1 du MVP. | |
| 11 | +- **Indexation incrémentale** near-real-time : `refresh_interval: 1s` → une page indexée est cherchable en ~1 s (principe cardinal §0.3). | |
| 12 | +- **`function_score`** : la fonction de ranking Québec-first custom (§9) s'exprime nativement, poids ajustables sans réindexation. | |
| 13 | +- **Analyzers FR/EN** + `synonym_graph` search-time (bilinguisme sans traduction à l'ingestion). | |
| 14 | +- **Highlighting** natif pour les snippets, **facettes** pour les filtres, **k-NN natif** pour l'hybride sémantique futur (§17.7) sans changer de moteur. | |
| 15 | +- Licence Apache 2.0, image ARM64, tourne en 2 Go de heap sur un seul node m2m32. | |
| 16 | + | |
| 17 | +**Rejetés :** Meilisearch/Typesense (ranking custom trop rigide pour `Score(d,q)` pondéré; c'était le choix « facile » interdit par la spec), Tantivy (librairie Rust, il faudrait construire le serveur), Quickwit (orienté logs append-only, pas de mise à jour de documents), Vespa (excellent mais complexité opérationnelle démesurée pour un node unique), Elasticsearch (équivalent fonctionnel d'OpenSearch, licence moins permissive). | |
| 18 | + | |
| 19 | +## D2 — Backend Python unique (FastAPI + pipeline) | |
| 20 | + | |
| 21 | +Crawler, parser, classifier, indexer, ranking, scheduler, enrichment et API partagent un seul langage (Python 3.12) et un seul packaging (`pyproject.toml` racine, namespace `trouveka.*` mappé sur `packages/` et `services/`). Réduit la complexité (§3 : « le crawler peut rester Python même si l'API est TS » — ici tout le backend est Python, seul le web est TS). | |
| 22 | + | |
| 23 | +## D3 — Frontier dans Postgres, coordination dans Redis | |
| 24 | + | |
| 25 | +- **Frontier = Postgres** : état relationnel (priorités, retries, scheduling), claims multi-workers via `FOR UPDATE SKIP LOCKED` — N workers sans coordinateur. | |
| 26 | +- **Redis** : politesse par hôte (`SET NX PX`, un fetch par hôte par fenêtre, tous workers confondus), drapeau pause, **Redis Streams** pour l'enrichissement asynchrone (consumer groups, ack explicite). | |
| 27 | +- Pas de Celery/RQ : la file d'enrichissement est un stream nu, observable (`XLEN`), sans dépendance lourde. | |
| 28 | + | |
| 29 | +## D4 — Pipeline inline (fetch→parse→score→index) dans le crawler-worker | |
| 30 | + | |
| 31 | +Le chemin rapide est exécuté inline par le worker : c'est la garantie la plus simple du « cherchable en secondes ». La scalabilité passe par le nombre de workers (`--scale crawler-worker=N`), pas par une séparation prématurée fetch/parse/index (§13 : pas de complexité distribuée prématurée). Les étapes 2-3 (§4) passent par le stream d'enrichissement et ne bloquent jamais. | |
| 32 | + | |
| 33 | +## D5 — apps/admin fusionné dans apps/web (route /admin) | |
| 34 | + | |
| 35 | +Un seul runtime Next.js sur m2m32 au lieu de deux (~150 Mo RSS économisés), même design system, même proxy API. La séparation reste possible plus tard (le dashboard est un groupe de composants isolés sous `components/admin/`). Justification prévue par §2 (« Claude peut améliorer cette structure sur justification technique solide »). | |
| 36 | + | |
| 37 | +## D6 — Graphe de liens au niveau domaine d'abord | |
| 38 | + | |
| 39 | +`domain_links` agrégé (from, to, count) plutôt que page→page : suffisant pour l'autorité de domaine et la découverte au MVP, volume contrôlé. Le graphe page→page viendra quand le ranking l'exigera (§8). | |
| 40 | + | |
| 41 | +## D7 — Réseau : le web proxifie /api | |
| 42 | + | |
| 43 | +Next.js rewrite `/api/*` → API interne. Derrière ngrok (www.trouve-ka.com), un seul port exposé (3000), pas d'URL absolues côté client, pas de CORS en prod. | |
added
M2M32crouve-ka/infrastructure/deployment/deploy-m2m32.sh
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Trouve-KA — déploiement sur le node m2m32 (cluster MacLustr) + tunnel ngrok | |
| 3 | +# Author: Simon-Pierre Boucher | |
| 4 | +# Contact: contact@spboucher.ai | |
| 5 | +# | |
| 6 | +# Prérequis sur m2m32 : Docker (ou colima) + docker compose, ngrok authentifié. | |
| 7 | +# Le domaine www.trouve-ka.com doit être réservé dans le compte ngrok. | |
| 8 | +set -euo pipefail | |
| 9 | + | |
| 10 | +NODE="${NODE:-M2M32}" | |
| 11 | +REMOTE_DIR="~/trouve-ka" | |
| 12 | +NGROK_DOMAIN="${NGROK_DOMAIN:-www.trouve-ka.com}" | |
| 13 | + | |
| 14 | +cd "$(dirname "$0")/../.." | |
| 15 | + | |
| 16 | +echo "→ Synchronisation du code vers $NODE:$REMOTE_DIR" | |
| 17 | +rsync -az --delete \ | |
| 18 | + --exclude node_modules --exclude .next --exclude .venv --exclude __pycache__ \ | |
| 19 | + --exclude .git --exclude data --exclude '*.egg-info' \ | |
| 20 | + ./ "$NODE:$REMOTE_DIR/" | |
| 21 | + | |
| 22 | +echo "→ Build et démarrage du stack sur $NODE" | |
| 23 | +ssh "$NODE" "cd $REMOTE_DIR && docker compose up -d --build" | |
| 24 | + | |
| 25 | +echo "→ Chargement des seeds (idempotent)" | |
| 26 | +ssh "$NODE" "cd $REMOTE_DIR && docker compose run --rm migrate && \ | |
| 27 | + docker compose run --rm --entrypoint python crawler-worker -m trouveka.crawler.seed scripts/bootstrap-seeds/seeds.txt" | |
| 28 | + | |
| 29 | +echo "→ Tunnel ngrok ($NGROK_DOMAIN → :3000)" | |
| 30 | +ssh "$NODE" "pgrep -f 'ngrok http.*$NGROK_DOMAIN' > /dev/null || \ | |
| 31 | + nohup ngrok http --url=$NGROK_DOMAIN 3000 > ~/trouve-ka-ngrok.log 2>&1 &" | |
| 32 | + | |
| 33 | +echo "→ Vérification de santé" | |
| 34 | +sleep 5 | |
| 35 | +ssh "$NODE" "cd $REMOTE_DIR && bash scripts/health-check/check.sh" || true | |
| 36 | + | |
| 37 | +echo "✓ Déploiement terminé : https://$NGROK_DOMAIN" | |
| 38 | +echo " N'oublie pas de mettre à jour ~/Desktop/cluster-skill/cluster-deployments.json" | |
added
M2M32crouve-ka/infrastructure/docker/backend.Dockerfile
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +# Trouve-KA — image backend Python (API, crawler, enrichissement, scheduler) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +FROM python:3.12-slim | |
| 6 | + | |
| 7 | +WORKDIR /app | |
| 8 | + | |
| 9 | +COPY pyproject.toml ./ | |
| 10 | +COPY packages ./packages | |
| 11 | +COPY services ./services | |
| 12 | +COPY apps/api ./apps/api | |
| 13 | +COPY infrastructure/migrations ./infrastructure/migrations | |
| 14 | +COPY scripts ./scripts | |
| 15 | + | |
| 16 | +RUN pip install --no-cache-dir . | |
| 17 | + | |
| 18 | +ENV PYTHONUNBUFFERED=1 | |
| 19 | + | |
| 20 | +CMD ["uvicorn", "trouveka.api.main:app", "--host", "0.0.0.0", "--port", "8080"] | |
added
M2M32crouve-ka/infrastructure/docker/web.Dockerfile
+26 −0
@@ -0,0 +1,26 @@ | ||
| 1 | +# Trouve-KA — image web Next.js | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +FROM node:22-alpine AS builder | |
| 6 | +WORKDIR /repo | |
| 7 | +RUN corepack enable | |
| 8 | +COPY package.json pnpm-workspace.yaml ./ | |
| 9 | +COPY apps/web/package.json apps/web/ | |
| 10 | +# sharp et unrs-resolver ont des scripts de build napi légitimes requis par Next | |
| 11 | +RUN pnpm install --frozen-lockfile=false --dangerously-allow-all-builds | |
| 12 | +COPY apps/web apps/web | |
| 13 | +# Les rewrites Next.js sont compilés au build : l'URL interne de l'API doit être fixée ici | |
| 14 | +ARG API_URL=http://api:8080 | |
| 15 | +ENV API_URL=${API_URL} | |
| 16 | +RUN pnpm --filter @trouveka/web build | |
| 17 | + | |
| 18 | +FROM node:22-alpine | |
| 19 | +WORKDIR /repo | |
| 20 | +RUN corepack enable | |
| 21 | +ENV NODE_ENV=production | |
| 22 | +COPY --from=builder /repo/package.json /repo/pnpm-workspace.yaml ./ | |
| 23 | +COPY --from=builder /repo/node_modules ./node_modules | |
| 24 | +COPY --from=builder /repo/apps/web ./apps/web | |
| 25 | +EXPOSE 3000 | |
| 26 | +CMD ["pnpm", "--filter", "@trouveka/web", "start"] | |
added
M2M32crouve-ka/infrastructure/migrations/001_init.sql
+152 −0
@@ -0,0 +1,152 @@ | ||
| 1 | +-- Trouve-KA — migration 001 : schéma initial | |
| 2 | +-- Author: Simon-Pierre Boucher | |
| 3 | +-- Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +BEGIN; | |
| 6 | + | |
| 7 | +CREATE TABLE IF NOT EXISTS schema_migrations ( | |
| 8 | + version INTEGER PRIMARY KEY, | |
| 9 | + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 10 | +); | |
| 11 | + | |
| 12 | +-- --------------------------------------------------------------------------- | |
| 13 | +-- Domaines connus du web québécois (et non québécois, pour mémoire) | |
| 14 | +-- --------------------------------------------------------------------------- | |
| 15 | +CREATE TABLE IF NOT EXISTS domains ( | |
| 16 | + id BIGSERIAL PRIMARY KEY, | |
| 17 | + domain TEXT NOT NULL UNIQUE, | |
| 18 | + first_seen TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 19 | + last_crawled_at TIMESTAMPTZ, | |
| 20 | + robots_fetched_at TIMESTAMPTZ, | |
| 21 | + robots_body TEXT, | |
| 22 | + robots_status TEXT, -- ok | not_found | error | forbidden | |
| 23 | + crawl_delay_ms INTEGER, -- délai imposé par robots.txt (Crawl-delay) | |
| 24 | + quebec_score REAL NOT NULL DEFAULT 0, | |
| 25 | + authority_score REAL NOT NULL DEFAULT 0, | |
| 26 | + page_count INTEGER NOT NULL DEFAULT 0, | |
| 27 | + inlink_domains INTEGER NOT NULL DEFAULT 0, | |
| 28 | + outlink_domains INTEGER NOT NULL DEFAULT 0, | |
| 29 | + language_stats JSONB NOT NULL DEFAULT '{}'::jsonb, | |
| 30 | + content_change_rate REAL, | |
| 31 | + blocked BOOLEAN NOT NULL DEFAULT FALSE, | |
| 32 | + is_seed BOOLEAN NOT NULL DEFAULT FALSE | |
| 33 | +); | |
| 34 | +CREATE INDEX IF NOT EXISTS idx_domains_quebec ON domains (quebec_score DESC); | |
| 35 | + | |
| 36 | +-- --------------------------------------------------------------------------- | |
| 37 | +-- URLs canoniques connues | |
| 38 | +-- --------------------------------------------------------------------------- | |
| 39 | +CREATE TABLE IF NOT EXISTS urls ( | |
| 40 | + id BIGSERIAL PRIMARY KEY, | |
| 41 | + url TEXT NOT NULL UNIQUE, | |
| 42 | + canonical_url TEXT, | |
| 43 | + domain_id BIGINT NOT NULL REFERENCES domains(id), | |
| 44 | + first_seen TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 45 | +); | |
| 46 | +CREATE INDEX IF NOT EXISTS idx_urls_domain ON urls (domain_id); | |
| 47 | + | |
| 48 | +-- --------------------------------------------------------------------------- | |
| 49 | +-- Frontier : la file d'URLs à crawler, avec priorités et scheduling | |
| 50 | +-- --------------------------------------------------------------------------- | |
| 51 | +CREATE TABLE IF NOT EXISTS frontier_items ( | |
| 52 | + id BIGSERIAL PRIMARY KEY, | |
| 53 | + url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id), | |
| 54 | + priority REAL NOT NULL DEFAULT 0.5, | |
| 55 | + depth INTEGER NOT NULL DEFAULT 0, | |
| 56 | + source_url_id BIGINT REFERENCES urls(id), | |
| 57 | + discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 58 | + last_crawled_at TIMESTAMPTZ, | |
| 59 | + next_crawl_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 60 | + status TEXT NOT NULL DEFAULT 'pending', -- pending|in_progress|done|failed|blocked | |
| 61 | + retries INTEGER NOT NULL DEFAULT 0, | |
| 62 | + error_code TEXT, | |
| 63 | + locked_by TEXT, | |
| 64 | + locked_at TIMESTAMPTZ | |
| 65 | +); | |
| 66 | +CREATE INDEX IF NOT EXISTS idx_frontier_ready | |
| 67 | + ON frontier_items (next_crawl_at, priority DESC) | |
| 68 | + WHERE status = 'pending'; | |
| 69 | +CREATE INDEX IF NOT EXISTS idx_frontier_status ON frontier_items (status); | |
| 70 | + | |
| 71 | +-- --------------------------------------------------------------------------- | |
| 72 | +-- Historique des tentatives de crawl (l'échec est normal; il est traqué) | |
| 73 | +-- --------------------------------------------------------------------------- | |
| 74 | +CREATE TABLE IF NOT EXISTS crawl_attempts ( | |
| 75 | + id BIGSERIAL PRIMARY KEY, | |
| 76 | + url_id BIGINT NOT NULL REFERENCES urls(id), | |
| 77 | + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 78 | + status_code INTEGER, | |
| 79 | + error_code TEXT, -- dns|timeout|tls|http_4xx|http_5xx|robots_denied|parse_failed| | |
| 80 | + -- unsupported_content|too_large|duplicate|spam|not_quebec|ssrf_blocked|ok | |
| 81 | + outcome TEXT NOT NULL, -- indexed|duplicate|error|not_quebec|robots_blocked|redirect|unchanged | |
| 82 | + content_hash TEXT, | |
| 83 | + bytes INTEGER, | |
| 84 | + duration_ms INTEGER, | |
| 85 | + redirect_url TEXT, | |
| 86 | + title TEXT, | |
| 87 | + quebec_score REAL | |
| 88 | +); | |
| 89 | +CREATE INDEX IF NOT EXISTS idx_attempts_time ON crawl_attempts (fetched_at DESC); | |
| 90 | +CREATE INDEX IF NOT EXISTS idx_attempts_url ON crawl_attempts (url_id); | |
| 91 | + | |
| 92 | +-- --------------------------------------------------------------------------- | |
| 93 | +-- Documents indexés (métadonnées; le contenu cherchable vit dans l'index) | |
| 94 | +-- --------------------------------------------------------------------------- | |
| 95 | +CREATE TABLE IF NOT EXISTS documents ( | |
| 96 | + id BIGSERIAL PRIMARY KEY, | |
| 97 | + url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id), | |
| 98 | + content_hash TEXT NOT NULL, | |
| 99 | + etag TEXT, | |
| 100 | + last_modified TEXT, | |
| 101 | + title TEXT, | |
| 102 | + description TEXT, | |
| 103 | + language TEXT, | |
| 104 | + page_quebec_score REAL NOT NULL DEFAULT 0, | |
| 105 | + published_at TIMESTAMPTZ, | |
| 106 | + last_changed_at TIMESTAMPTZ, | |
| 107 | + first_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 108 | + last_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 109 | + enrichment_stage INTEGER NOT NULL DEFAULT 1 | |
| 110 | +); | |
| 111 | +CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents (content_hash); | |
| 112 | + | |
| 113 | +-- --------------------------------------------------------------------------- | |
| 114 | +-- Graphe de liens agrégé au niveau domaine (page→page viendra plus tard) | |
| 115 | +-- --------------------------------------------------------------------------- | |
| 116 | +CREATE TABLE IF NOT EXISTS domain_links ( | |
| 117 | + from_domain_id BIGINT NOT NULL REFERENCES domains(id), | |
| 118 | + to_domain_id BIGINT NOT NULL REFERENCES domains(id), | |
| 119 | + link_count INTEGER NOT NULL DEFAULT 1, | |
| 120 | + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 121 | + PRIMARY KEY (from_domain_id, to_domain_id) | |
| 122 | +); | |
| 123 | +CREATE INDEX IF NOT EXISTS idx_domain_links_to ON domain_links (to_domain_id); | |
| 124 | + | |
| 125 | +-- --------------------------------------------------------------------------- | |
| 126 | +-- Analytics de recherche agrégées et respectueuses de la vie privée | |
| 127 | +-- (les requêtes zéro-résultat sont de l'or : elles pilotent le crawl) | |
| 128 | +-- --------------------------------------------------------------------------- | |
| 129 | +CREATE TABLE IF NOT EXISTS search_queries ( | |
| 130 | + id BIGSERIAL PRIMARY KEY, | |
| 131 | + query TEXT NOT NULL, | |
| 132 | + language TEXT, | |
| 133 | + results_total INTEGER NOT NULL, | |
| 134 | + took_ms INTEGER NOT NULL, | |
| 135 | + zero_result BOOLEAN NOT NULL DEFAULT FALSE, | |
| 136 | + created_at TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 137 | +); | |
| 138 | +CREATE INDEX IF NOT EXISTS idx_queries_zero ON search_queries (created_at) WHERE zero_result; | |
| 139 | + | |
| 140 | +-- --------------------------------------------------------------------------- | |
| 141 | +-- Soumissions publiques d'URL (soumission ≠ inclusion) | |
| 142 | +-- --------------------------------------------------------------------------- | |
| 143 | +CREATE TABLE IF NOT EXISTS submissions ( | |
| 144 | + id BIGSERIAL PRIMARY KEY, | |
| 145 | + url TEXT NOT NULL, | |
| 146 | + submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 147 | + status TEXT NOT NULL DEFAULT 'queued' -- queued|accepted|rejected | |
| 148 | +); | |
| 149 | + | |
| 150 | +INSERT INTO schema_migrations (version) VALUES (1) ON CONFLICT DO NOTHING; | |
| 151 | + | |
| 152 | +COMMIT; | |
added
M2M32crouve-ka/infrastructure/migrations/002_frontier_priority_index.sql
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +-- Trouve-KA — migration 002 : index de claim par priorité | |
| 2 | +-- Author: Simon-Pierre Boucher | |
| 3 | +-- Contact: contact@spboucher.ai | |
| 4 | +-- | |
| 5 | +-- Le claim ordonne par priority DESC; l'index 001 (next_crawl_at, priority) | |
| 6 | +-- force un tri de toutes les lignes dues à chaque claim. Cet index sert | |
| 7 | +-- l'ORDER BY directement (la plupart des lignes pending sont dues). | |
| 8 | + | |
| 9 | +BEGIN; | |
| 10 | + | |
| 11 | +CREATE INDEX IF NOT EXISTS idx_frontier_priority | |
| 12 | + ON frontier_items (priority DESC, next_crawl_at) | |
| 13 | + WHERE status = 'pending'; | |
| 14 | + | |
| 15 | +INSERT INTO schema_migrations (version) VALUES (2) ON CONFLICT DO NOTHING; | |
| 16 | + | |
| 17 | +COMMIT; | |
added
M2M32crouve-ka/infrastructure/monitoring/README.md
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +# Trouve-KA — Observabilité | |
| 2 | + | |
| 3 | +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +## En place (MVP) | |
| 6 | + | |
| 7 | +- **Logs structurés JSON** (une ligne par événement) sur stdout de chaque service, | |
| 8 | + avec contexte `worker_id, url_id, domain_id, url, outcome, quebec_score, ms` | |
| 9 | + (`packages/logging`). Consultables via `docker compose logs -f crawler-worker`. | |
| 10 | +- **Métriques opérationnelles** exposées par l'API : | |
| 11 | + - `GET /api/status` (public) : pages indexées, domaines, débits horaires, état du crawler. | |
| 12 | + - `GET /api/admin/overview` (protégé) : files par statut, débits, distribution HTTP, | |
| 13 | + latences fetch p50/p95, top domaines, erreurs récentes, backlog d'enrichissement. | |
| 14 | + - Toutes calculées sur les vraies tables (`crawl_attempts`, `frontier_items`) — | |
| 15 | + aucun compteur simulé (§ fake). | |
| 16 | +- **Dashboard admin** : `/admin` sur le web (flux live du crawl, contrôles pause/seeds/blocage). | |
| 17 | + | |
| 18 | +## Prochaine étape (post-MVP) | |
| 19 | + | |
| 20 | +Exporter ces mêmes agrégats au format Prometheus (`/metrics`) et brancher | |
| 21 | +Grafana — les requêtes SQL de `Database.admin_overview()` sont la source de vérité, | |
| 22 | +l'export est un simple reformatage. Choix pragmatique conforme au CLAUDE.md §13. | |
added
M2M32crouve-ka/package.json
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +{ | |
| 2 | + "name": "trouve-ka", | |
| 3 | + "private": true, | |
| 4 | + "version": "0.1.0", | |
| 5 | + "packageManager": "pnpm@11.1.2", | |
| 6 | + "author": "Simon-Pierre Boucher <contact@spboucher.ai>", | |
| 7 | + "description": "Trouve-KA — Cherche le Québec. Moteur de recherche web indépendant, Québec-first.", | |
| 8 | + "pnpm": { | |
| 9 | + "onlyBuiltDependencies": ["sharp", "unrs-resolver"] | |
| 10 | + }, | |
| 11 | + "scripts": { | |
| 12 | + "dev": "pnpm --filter @trouveka/web dev", | |
| 13 | + "build": "pnpm --filter @trouveka/web build", | |
| 14 | + "start": "pnpm --filter @trouveka/web start", | |
| 15 | + "crawl:seed": "bash scripts/bootstrap-seeds/seed.sh", | |
| 16 | + "check:headers": "python3 scripts/check-headers.py", | |
| 17 | + "deploy:m2m32": "bash infrastructure/deployment/deploy-m2m32.sh" | |
| 18 | + } | |
| 19 | +} | |
added
M2M32crouve-ka/packages/config/__init__.py
+67 −0
@@ -0,0 +1,67 @@ | ||
| 1 | +# Trouve-KA — configuration validée par variables d'environnement | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Configuration centrale de Trouve-KA. | |
| 6 | + | |
| 7 | +Toutes les valeurs sont surchargeables par variable d'environnement et | |
| 8 | +dimensionnées par défaut pour le node de déploiement m2m32 (32 Go RAM). | |
| 9 | +""" | |
| 10 | + | |
| 11 | +from functools import lru_cache | |
| 12 | + | |
| 13 | +from pydantic_settings import BaseSettings, SettingsConfigDict | |
| 14 | + | |
| 15 | + | |
| 16 | +class Settings(BaseSettings): | |
| 17 | + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") | |
| 18 | + | |
| 19 | + # Bases de données | |
| 20 | + database_url: str = "postgresql://trouveka:trouveka@localhost:5432/trouveka" | |
| 21 | + redis_url: str = "redis://localhost:6379/0" | |
| 22 | + search_url: str = "http://localhost:9200" | |
| 23 | + search_index: str = "trouveka-docs" | |
| 24 | + | |
| 25 | + # Crawler — identité et politesse | |
| 26 | + crawler_user_agent: str = ( | |
| 27 | + "Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)" | |
| 28 | + ) | |
| 29 | + crawler_contact_url: str = "https://www.trouve-ka.com/trouveka-bot" | |
| 30 | + max_global_concurrency: int = 24 | |
| 31 | + max_per_host_concurrency: int = 2 | |
| 32 | + default_host_delay: float = 2.0 # secondes entre deux requêtes vers un même hôte | |
| 33 | + | |
| 34 | + # Crawler — limites de sécurité (chaque réponse a des limites) | |
| 35 | + max_response_bytes: int = 3_000_000 | |
| 36 | + max_redirects: int = 5 | |
| 37 | + fetch_timeout: float = 20.0 | |
| 38 | + max_crawl_depth: int = 8 | |
| 39 | + max_links_per_page: int = 300 | |
| 40 | + max_urls_per_domain: int = 5_000 | |
| 41 | + max_query_params: int = 8 | |
| 42 | + max_path_segments: int = 12 | |
| 43 | + | |
| 44 | + # Recrawl adaptatif (bornes, en heures) | |
| 45 | + min_recrawl_hours: float = 1.0 | |
| 46 | + max_recrawl_hours: float = 24 * 30.0 | |
| 47 | + default_recrawl_hours: float = 24.0 | |
| 48 | + | |
| 49 | + # Indexation | |
| 50 | + min_quebec_score_to_index: float = 0.15 | |
| 51 | + min_body_length: int = 80 | |
| 52 | + | |
| 53 | + # API | |
| 54 | + api_host: str = "0.0.0.0" | |
| 55 | + api_port: int = 8080 | |
| 56 | + admin_token: str = "change-me-admin-token" | |
| 57 | + public_url: str = "https://www.trouve-ka.com" | |
| 58 | + ngrok_domain: str = "www.trouve-ka.com" | |
| 59 | + | |
| 60 | + # Postgres pool (dimensionné pour 32 Go / plusieurs workers) | |
| 61 | + pg_pool_min: int = 2 | |
| 62 | + pg_pool_max: int = 10 | |
| 63 | + | |
| 64 | + | |
| 65 | +@lru_cache | |
| 66 | +def get_settings() -> Settings: | |
| 67 | + return Settings() | |
added
M2M32crouve-ka/packages/database/__init__.py
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +# Trouve-KA — accès PostgreSQL (pool asyncpg + dépôts) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Couche d'accès PostgreSQL : pool asyncpg, migrations, dépôts frontier/domaines/documents.""" | |
| 6 | + | |
| 7 | +from .db import Database | |
| 8 | +from .migrate import run_migrations | |
| 9 | + | |
| 10 | +__all__ = ["Database", "run_migrations"] | |
added
M2M32crouve-ka/packages/database/db.py
+522 −0
@@ -0,0 +1,522 @@ | ||
| 1 | +# Trouve-KA — dépôts PostgreSQL | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Dépôts PostgreSQL de Trouve-KA. | |
| 6 | + | |
| 7 | +Le frontier vit dans Postgres : les workers réclament des lots d'URLs via | |
| 8 | +FOR UPDATE SKIP LOCKED, ce qui permet N workers concurrents sans coordination | |
| 9 | +externe (CLAUDE.md §13 : pas de complexité distribuée prématurée). | |
| 10 | +""" | |
| 11 | + | |
| 12 | +from datetime import UTC, datetime, timedelta | |
| 13 | +from typing import Any | |
| 14 | + | |
| 15 | +import asyncpg | |
| 16 | + | |
| 17 | + | |
| 18 | +def _dsn(url: str) -> str: | |
| 19 | + # asyncpg accepte postgresql:// mais pas postgresql+asyncpg:// | |
| 20 | + return url.replace("postgresql+asyncpg://", "postgresql://") | |
| 21 | + | |
| 22 | + | |
| 23 | +class Database: | |
| 24 | + def __init__(self, database_url: str, *, pool_min: int = 2, pool_max: int = 10): | |
| 25 | + self._url = _dsn(database_url) | |
| 26 | + self._pool_min = pool_min | |
| 27 | + self._pool_max = pool_max | |
| 28 | + self.pool: asyncpg.Pool | None = None | |
| 29 | + | |
| 30 | + async def connect(self) -> None: | |
| 31 | + self.pool = await asyncpg.create_pool( | |
| 32 | + self._url, min_size=self._pool_min, max_size=self._pool_max, command_timeout=30 | |
| 33 | + ) | |
| 34 | + | |
| 35 | + async def close(self) -> None: | |
| 36 | + if self.pool: | |
| 37 | + await self.pool.close() | |
| 38 | + self.pool = None | |
| 39 | + | |
| 40 | + # ------------------------------------------------------------------ domaines | |
| 41 | + | |
| 42 | + async def upsert_domain(self, domain: str, *, is_seed: bool = False) -> int: | |
| 43 | + row = await self.pool.fetchrow( | |
| 44 | + """ | |
| 45 | + INSERT INTO domains (domain, is_seed) VALUES ($1, $2) | |
| 46 | + ON CONFLICT (domain) DO UPDATE SET is_seed = domains.is_seed OR EXCLUDED.is_seed | |
| 47 | + RETURNING id | |
| 48 | + """, | |
| 49 | + domain, | |
| 50 | + is_seed, | |
| 51 | + ) | |
| 52 | + return row["id"] | |
| 53 | + | |
| 54 | + async def get_domain(self, domain: str) -> asyncpg.Record | None: | |
| 55 | + return await self.pool.fetchrow("SELECT * FROM domains WHERE domain = $1", domain) | |
| 56 | + | |
| 57 | + async def get_domain_by_id(self, domain_id: int) -> asyncpg.Record | None: | |
| 58 | + return await self.pool.fetchrow("SELECT * FROM domains WHERE id = $1", domain_id) | |
| 59 | + | |
| 60 | + async def save_robots( | |
| 61 | + self, domain_id: int, body: str | None, status: str, crawl_delay_ms: int | None | |
| 62 | + ) -> None: | |
| 63 | + await self.pool.execute( | |
| 64 | + """ | |
| 65 | + UPDATE domains SET robots_body = $2, robots_status = $3, | |
| 66 | + robots_fetched_at = now(), crawl_delay_ms = $4 | |
| 67 | + WHERE id = $1 | |
| 68 | + """, | |
| 69 | + domain_id, | |
| 70 | + body, | |
| 71 | + status, | |
| 72 | + crawl_delay_ms, | |
| 73 | + ) | |
| 74 | + | |
| 75 | + async def block_domain(self, domain: str) -> None: | |
| 76 | + await self.pool.execute( | |
| 77 | + "UPDATE domains SET blocked = TRUE WHERE domain = $1", domain | |
| 78 | + ) | |
| 79 | + await self.pool.execute( | |
| 80 | + """ | |
| 81 | + UPDATE frontier_items SET status = 'blocked' | |
| 82 | + WHERE status IN ('pending', 'in_progress') | |
| 83 | + AND url_id IN (SELECT u.id FROM urls u JOIN domains d ON d.id = u.domain_id | |
| 84 | + WHERE d.domain = $1) | |
| 85 | + """, | |
| 86 | + domain, | |
| 87 | + ) | |
| 88 | + | |
| 89 | + async def update_domain_after_page( | |
| 90 | + self, domain_id: int, page_quebec_score: float, language: str | None | |
| 91 | + ) -> None: | |
| 92 | + """Met à jour le score Québec du domaine (moyenne mobile) et ses stats de langue.""" | |
| 93 | + lang = language or "unknown" | |
| 94 | + await self.pool.execute( | |
| 95 | + """ | |
| 96 | + UPDATE domains SET | |
| 97 | + page_count = page_count + 1, | |
| 98 | + last_crawled_at = now(), | |
| 99 | + quebec_score = (quebec_score * page_count + $2) / (page_count + 1), | |
| 100 | + language_stats = jsonb_set( | |
| 101 | + language_stats, ARRAY[$3], | |
| 102 | + (COALESCE(language_stats->>$3, '0')::int + 1)::text::jsonb) | |
| 103 | + WHERE id = $1 | |
| 104 | + """, | |
| 105 | + domain_id, | |
| 106 | + page_quebec_score, | |
| 107 | + lang, | |
| 108 | + ) | |
| 109 | + | |
| 110 | + async def record_domain_links(self, from_domain_id: int, to_domain_counts: dict[int, int]) -> None: | |
| 111 | + if not to_domain_counts: | |
| 112 | + return | |
| 113 | + await self.pool.executemany( | |
| 114 | + """ | |
| 115 | + INSERT INTO domain_links (from_domain_id, to_domain_id, link_count) | |
| 116 | + VALUES ($1, $2, $3) | |
| 117 | + ON CONFLICT (from_domain_id, to_domain_id) | |
| 118 | + DO UPDATE SET link_count = domain_links.link_count + EXCLUDED.link_count, | |
| 119 | + updated_at = now() | |
| 120 | + """, | |
| 121 | + [(from_domain_id, to_id, n) for to_id, n in to_domain_counts.items() if to_id != from_domain_id], | |
| 122 | + ) | |
| 123 | + | |
| 124 | + # ------------------------------------------------------------------ frontier | |
| 125 | + | |
| 126 | + async def enqueue_url( | |
| 127 | + self, | |
| 128 | + url: str, | |
| 129 | + domain: str, | |
| 130 | + *, | |
| 131 | + priority: float = 0.5, | |
| 132 | + depth: int = 0, | |
| 133 | + source_url_id: int | None = None, | |
| 134 | + is_seed: bool = False, | |
| 135 | + max_urls_per_domain: int = 5000, | |
| 136 | + ) -> int | None: | |
| 137 | + """Ajoute une URL au frontier si inconnue. Retourne url_id si ajoutée, None sinon.""" | |
| 138 | + async with self.pool.acquire() as conn: | |
| 139 | + async with conn.transaction(): | |
| 140 | + domain_id = await conn.fetchval( | |
| 141 | + """ | |
| 142 | + INSERT INTO domains (domain, is_seed) VALUES ($1, $2) | |
| 143 | + ON CONFLICT (domain) DO UPDATE SET is_seed = domains.is_seed OR EXCLUDED.is_seed | |
| 144 | + RETURNING id | |
| 145 | + """, | |
| 146 | + domain, | |
| 147 | + is_seed, | |
| 148 | + ) | |
| 149 | + dom = await conn.fetchrow( | |
| 150 | + "SELECT blocked, page_count FROM domains WHERE id = $1", domain_id | |
| 151 | + ) | |
| 152 | + if dom["blocked"]: | |
| 153 | + return None | |
| 154 | + if not is_seed and dom["page_count"] >= max_urls_per_domain: | |
| 155 | + return None | |
| 156 | + url_id = await conn.fetchval( | |
| 157 | + """ | |
| 158 | + INSERT INTO urls (url, domain_id) VALUES ($1, $2) | |
| 159 | + ON CONFLICT (url) DO NOTHING RETURNING id | |
| 160 | + """, | |
| 161 | + url, | |
| 162 | + domain_id, | |
| 163 | + ) | |
| 164 | + if url_id is None: | |
| 165 | + return None # URL déjà connue : pas de doublon dans le frontier | |
| 166 | + await conn.execute( | |
| 167 | + """ | |
| 168 | + INSERT INTO frontier_items (url_id, priority, depth, source_url_id) | |
| 169 | + VALUES ($1, $2, $3, $4) ON CONFLICT (url_id) DO NOTHING | |
| 170 | + """, | |
| 171 | + url_id, | |
| 172 | + priority, | |
| 173 | + depth, | |
| 174 | + source_url_id, | |
| 175 | + ) | |
| 176 | + return url_id | |
| 177 | + | |
| 178 | + async def claim_batch(self, worker_id: str, limit: int = 20) -> list[asyncpg.Record]: | |
| 179 | + """Réclame un lot d'URLs prêtes à crawler (SKIP LOCKED, multi-workers sûr).""" | |
| 180 | + return await self.pool.fetch( | |
| 181 | + """ | |
| 182 | + WITH claimed AS ( | |
| 183 | + SELECT f.id FROM frontier_items f | |
| 184 | + WHERE f.status = 'pending' AND f.next_crawl_at <= now() | |
| 185 | + ORDER BY f.priority DESC, f.next_crawl_at | |
| 186 | + LIMIT $2 | |
| 187 | + FOR UPDATE SKIP LOCKED | |
| 188 | + ) | |
| 189 | + UPDATE frontier_items f SET status = 'in_progress', locked_by = $1, locked_at = now() | |
| 190 | + FROM claimed | |
| 191 | + WHERE f.id = claimed.id | |
| 192 | + RETURNING f.id, f.url_id, f.priority, f.depth, f.retries, | |
| 193 | + f.last_crawled_at, f.next_crawl_at, | |
| 194 | + (SELECT url FROM urls WHERE id = f.url_id) AS url, | |
| 195 | + (SELECT domain_id FROM urls WHERE id = f.url_id) AS domain_id | |
| 196 | + """, | |
| 197 | + worker_id, | |
| 198 | + limit, | |
| 199 | + ) | |
| 200 | + | |
| 201 | + async def release_item( | |
| 202 | + self, | |
| 203 | + url_id: int, | |
| 204 | + *, | |
| 205 | + status: str, | |
| 206 | + error_code: str | None = None, | |
| 207 | + next_crawl_at: datetime | None = None, | |
| 208 | + priority: float | None = None, | |
| 209 | + increment_retries: bool = False, | |
| 210 | + ) -> None: | |
| 211 | + await self.pool.execute( | |
| 212 | + """ | |
| 213 | + UPDATE frontier_items SET | |
| 214 | + status = $2, | |
| 215 | + error_code = $3, | |
| 216 | + last_crawled_at = now(), | |
| 217 | + next_crawl_at = COALESCE($4, next_crawl_at), | |
| 218 | + priority = COALESCE($5, priority), | |
| 219 | + retries = retries + CASE WHEN $6 THEN 1 ELSE 0 END, | |
| 220 | + locked_by = NULL, locked_at = NULL | |
| 221 | + WHERE url_id = $1 | |
| 222 | + """, | |
| 223 | + url_id, | |
| 224 | + status, | |
| 225 | + error_code, | |
| 226 | + next_crawl_at, | |
| 227 | + priority, | |
| 228 | + increment_retries, | |
| 229 | + ) | |
| 230 | + | |
| 231 | + async def reset_stale_items(self, older_than_minutes: int = 30) -> int: | |
| 232 | + """Relance les items in_progress abandonnés (worker mort). Retourne le nombre.""" | |
| 233 | + result = await self.pool.execute( | |
| 234 | + """ | |
| 235 | + UPDATE frontier_items SET status = 'pending', locked_by = NULL, locked_at = NULL | |
| 236 | + WHERE status = 'in_progress' AND locked_at < now() - ($1 || ' minutes')::interval | |
| 237 | + """, | |
| 238 | + str(older_than_minutes), | |
| 239 | + ) | |
| 240 | + return int(result.split()[-1]) | |
| 241 | + | |
| 242 | + async def requeue_url(self, url: str) -> bool: | |
| 243 | + result = await self.pool.execute( | |
| 244 | + """ | |
| 245 | + UPDATE frontier_items SET status = 'pending', next_crawl_at = now(), retries = 0 | |
| 246 | + WHERE url_id = (SELECT id FROM urls WHERE url = $1) | |
| 247 | + """, | |
| 248 | + url, | |
| 249 | + ) | |
| 250 | + return result.endswith("1") | |
| 251 | + | |
| 252 | + async def requeue_domain(self, domain: str) -> int: | |
| 253 | + result = await self.pool.execute( | |
| 254 | + """ | |
| 255 | + UPDATE frontier_items SET status = 'pending', next_crawl_at = now() | |
| 256 | + WHERE status IN ('done', 'failed') | |
| 257 | + AND url_id IN (SELECT u.id FROM urls u JOIN domains d ON d.id = u.domain_id | |
| 258 | + WHERE d.domain = $1) | |
| 259 | + """, | |
| 260 | + domain, | |
| 261 | + ) | |
| 262 | + return int(result.split()[-1]) | |
| 263 | + | |
| 264 | + # ------------------------------------------------------------------ crawl / documents | |
| 265 | + | |
| 266 | + async def record_attempt( | |
| 267 | + self, | |
| 268 | + url_id: int, | |
| 269 | + *, | |
| 270 | + status_code: int | None, | |
| 271 | + error_code: str, | |
| 272 | + outcome: str, | |
| 273 | + content_hash: str | None = None, | |
| 274 | + num_bytes: int | None = None, | |
| 275 | + duration_ms: int | None = None, | |
| 276 | + redirect_url: str | None = None, | |
| 277 | + title: str | None = None, | |
| 278 | + quebec_score: float | None = None, | |
| 279 | + ) -> None: | |
| 280 | + await self.pool.execute( | |
| 281 | + """ | |
| 282 | + INSERT INTO crawl_attempts | |
| 283 | + (url_id, status_code, error_code, outcome, content_hash, bytes, | |
| 284 | + duration_ms, redirect_url, title, quebec_score) | |
| 285 | + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) | |
| 286 | + """, | |
| 287 | + url_id, | |
| 288 | + status_code, | |
| 289 | + error_code, | |
| 290 | + outcome, | |
| 291 | + content_hash, | |
| 292 | + num_bytes, | |
| 293 | + duration_ms, | |
| 294 | + redirect_url, | |
| 295 | + title, | |
| 296 | + quebec_score, | |
| 297 | + ) | |
| 298 | + | |
| 299 | + async def get_document(self, url_id: int) -> asyncpg.Record | None: | |
| 300 | + return await self.pool.fetchrow("SELECT * FROM documents WHERE url_id = $1", url_id) | |
| 301 | + | |
| 302 | + async def find_duplicate(self, chash: str, url_id: int) -> asyncpg.Record | None: | |
| 303 | + """Doublon exact : même hash de contenu sur une autre URL.""" | |
| 304 | + return await self.pool.fetchrow( | |
| 305 | + "SELECT * FROM documents WHERE content_hash = $1 AND url_id != $2 LIMIT 1", | |
| 306 | + chash, | |
| 307 | + url_id, | |
| 308 | + ) | |
| 309 | + | |
| 310 | + async def upsert_document( | |
| 311 | + self, | |
| 312 | + url_id: int, | |
| 313 | + *, | |
| 314 | + content_hash: str, | |
| 315 | + etag: str | None, | |
| 316 | + last_modified: str | None, | |
| 317 | + title: str, | |
| 318 | + description: str, | |
| 319 | + language: str | None, | |
| 320 | + page_quebec_score: float, | |
| 321 | + published_at: datetime | None, | |
| 322 | + changed: bool, | |
| 323 | + ) -> int: | |
| 324 | + row = await self.pool.fetchrow( | |
| 325 | + """ | |
| 326 | + INSERT INTO documents | |
| 327 | + (url_id, content_hash, etag, last_modified, title, description, language, | |
| 328 | + page_quebec_score, published_at, last_changed_at) | |
| 329 | + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) | |
| 330 | + ON CONFLICT (url_id) DO UPDATE SET | |
| 331 | + content_hash = EXCLUDED.content_hash, | |
| 332 | + etag = EXCLUDED.etag, | |
| 333 | + last_modified = EXCLUDED.last_modified, | |
| 334 | + title = EXCLUDED.title, | |
| 335 | + description = EXCLUDED.description, | |
| 336 | + language = EXCLUDED.language, | |
| 337 | + page_quebec_score = EXCLUDED.page_quebec_score, | |
| 338 | + published_at = EXCLUDED.published_at, | |
| 339 | + last_indexed_at = now(), | |
| 340 | + last_changed_at = CASE WHEN $10 THEN now() ELSE documents.last_changed_at END | |
| 341 | + RETURNING id | |
| 342 | + """, | |
| 343 | + url_id, | |
| 344 | + content_hash, | |
| 345 | + etag, | |
| 346 | + last_modified, | |
| 347 | + title, | |
| 348 | + description, | |
| 349 | + language, | |
| 350 | + page_quebec_score, | |
| 351 | + published_at, | |
| 352 | + changed, | |
| 353 | + ) | |
| 354 | + return row["id"] | |
| 355 | + | |
| 356 | + async def set_canonical(self, url_id: int, canonical_url: str) -> None: | |
| 357 | + await self.pool.execute( | |
| 358 | + "UPDATE urls SET canonical_url = $2 WHERE id = $1", url_id, canonical_url | |
| 359 | + ) | |
| 360 | + | |
| 361 | + # ------------------------------------------------------------------ analytics / statut | |
| 362 | + | |
| 363 | + async def record_search_query( | |
| 364 | + self, query: str, language: str | None, total: int, took_ms: int | |
| 365 | + ) -> None: | |
| 366 | + await self.pool.execute( | |
| 367 | + """ | |
| 368 | + INSERT INTO search_queries (query, language, results_total, took_ms, zero_result) | |
| 369 | + VALUES ($1, $2, $3, $4, $3 = 0) | |
| 370 | + """, | |
| 371 | + query[:500], | |
| 372 | + language, | |
| 373 | + total, | |
| 374 | + took_ms, | |
| 375 | + ) | |
| 376 | + | |
| 377 | + async def add_submission(self, url: str) -> None: | |
| 378 | + await self.pool.execute("INSERT INTO submissions (url) VALUES ($1)", url) | |
| 379 | + | |
| 380 | + async def status_snapshot(self) -> dict[str, Any]: | |
| 381 | + since = datetime.now(UTC) - timedelta(hours=1) | |
| 382 | + frontier = { | |
| 383 | + r["status"]: r["n"] | |
| 384 | + for r in await self.pool.fetch( | |
| 385 | + "SELECT status, count(*)::int AS n FROM frontier_items GROUP BY status" | |
| 386 | + ) | |
| 387 | + } | |
| 388 | + row = await self.pool.fetchrow( | |
| 389 | + """ | |
| 390 | + SELECT | |
| 391 | + (SELECT count(*)::int FROM documents) AS docs, | |
| 392 | + (SELECT count(*)::int FROM domains WHERE page_count > 0) AS domains, | |
| 393 | + (SELECT count(*)::int FROM documents WHERE last_indexed_at >= $1) AS indexed_1h, | |
| 394 | + (SELECT count(*)::int FROM crawl_attempts WHERE fetched_at >= $1) AS fetched_1h, | |
| 395 | + (SELECT count(*)::int FROM crawl_attempts | |
| 396 | + WHERE fetched_at >= $1 AND outcome = 'error') AS errors_1h | |
| 397 | + """, | |
| 398 | + since, | |
| 399 | + ) | |
| 400 | + return { | |
| 401 | + "pages_indexed": row["docs"], | |
| 402 | + "domains_count": row["domains"], | |
| 403 | + "indexed_last_hour": row["indexed_1h"], | |
| 404 | + "fetched_last_hour": row["fetched_1h"], | |
| 405 | + "errors_last_hour": row["errors_1h"], | |
| 406 | + "frontier_pending": frontier.get("pending", 0), | |
| 407 | + "frontier_in_progress": frontier.get("in_progress", 0), | |
| 408 | + "frontier": frontier, | |
| 409 | + } | |
| 410 | + | |
| 411 | + async def admin_overview(self) -> dict[str, Any]: | |
| 412 | + since = datetime.now(UTC) - timedelta(hours=1) | |
| 413 | + base = await self.status_snapshot() | |
| 414 | + http_status = { | |
| 415 | + str(r["status_code"]): r["n"] | |
| 416 | + for r in await self.pool.fetch( | |
| 417 | + """ | |
| 418 | + SELECT status_code, count(*)::int AS n FROM crawl_attempts | |
| 419 | + WHERE fetched_at >= $1 AND status_code IS NOT NULL | |
| 420 | + GROUP BY status_code ORDER BY n DESC | |
| 421 | + """, | |
| 422 | + since, | |
| 423 | + ) | |
| 424 | + } | |
| 425 | + outcomes = { | |
| 426 | + r["outcome"]: r["n"] | |
| 427 | + for r in await self.pool.fetch( | |
| 428 | + "SELECT outcome, count(*)::int AS n FROM crawl_attempts WHERE fetched_at >= $1 GROUP BY outcome", | |
| 429 | + since, | |
| 430 | + ) | |
| 431 | + } | |
| 432 | + top_domains = [ | |
| 433 | + dict(r) | |
| 434 | + for r in await self.pool.fetch( | |
| 435 | + """ | |
| 436 | + SELECT domain, page_count AS pages, round(quebec_score::numeric, 3)::float AS quebec_score | |
| 437 | + FROM domains WHERE page_count > 0 | |
| 438 | + ORDER BY page_count DESC LIMIT 20 | |
| 439 | + """ | |
| 440 | + ) | |
| 441 | + ] | |
| 442 | + recent_errors = [ | |
| 443 | + {"url": r["url"], "error_code": r["error_code"], "at": r["fetched_at"].isoformat()} | |
| 444 | + for r in await self.pool.fetch( | |
| 445 | + """ | |
| 446 | + SELECT u.url, a.error_code, a.fetched_at | |
| 447 | + FROM crawl_attempts a JOIN urls u ON u.id = a.url_id | |
| 448 | + WHERE a.outcome = 'error' ORDER BY a.fetched_at DESC LIMIT 20 | |
| 449 | + """ | |
| 450 | + ) | |
| 451 | + ] | |
| 452 | + latency = await self.pool.fetchrow( | |
| 453 | + """ | |
| 454 | + SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms) AS p50, | |
| 455 | + percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95 | |
| 456 | + FROM crawl_attempts WHERE fetched_at >= $1 AND duration_ms IS NOT NULL | |
| 457 | + """, | |
| 458 | + since, | |
| 459 | + ) | |
| 460 | + return { | |
| 461 | + "frontier": { | |
| 462 | + "pending": base["frontier"].get("pending", 0), | |
| 463 | + "in_progress": base["frontier"].get("in_progress", 0), | |
| 464 | + "done": base["frontier"].get("done", 0), | |
| 465 | + "failed": base["frontier"].get("failed", 0), | |
| 466 | + "blocked": base["frontier"].get("blocked", 0), | |
| 467 | + }, | |
| 468 | + "rates": { | |
| 469 | + "fetched_1h": base["fetched_last_hour"], | |
| 470 | + "indexed_1h": outcomes.get("indexed", 0), | |
| 471 | + "errors_1h": outcomes.get("error", 0), | |
| 472 | + "robots_blocked_1h": outcomes.get("robots_blocked", 0), | |
| 473 | + "duplicates_1h": outcomes.get("duplicate", 0), | |
| 474 | + "parsed_1h": outcomes.get("indexed", 0) + outcomes.get("not_quebec", 0), | |
| 475 | + }, | |
| 476 | + "http_status": http_status, | |
| 477 | + "top_domains": top_domains, | |
| 478 | + "recent_errors": recent_errors, | |
| 479 | + "latency": { | |
| 480 | + "fetch_p50_ms": round(latency["p50"]) if latency and latency["p50"] else None, | |
| 481 | + "fetch_p95_ms": round(latency["p95"]) if latency and latency["p95"] else None, | |
| 482 | + }, | |
| 483 | + } | |
| 484 | + | |
| 485 | + async def recent_events(self, limit: int = 50) -> list[dict[str, Any]]: | |
| 486 | + rows = await self.pool.fetch( | |
| 487 | + """ | |
| 488 | + SELECT a.fetched_at, u.url, a.status_code, a.outcome, a.quebec_score, a.title | |
| 489 | + FROM crawl_attempts a JOIN urls u ON u.id = a.url_id | |
| 490 | + ORDER BY a.fetched_at DESC LIMIT $1 | |
| 491 | + """, | |
| 492 | + min(limit, 200), | |
| 493 | + ) | |
| 494 | + return [ | |
| 495 | + { | |
| 496 | + "at": r["fetched_at"].isoformat(), | |
| 497 | + "url": r["url"], | |
| 498 | + "status": r["status_code"], | |
| 499 | + "outcome": r["outcome"], | |
| 500 | + "quebec_score": r["quebec_score"], | |
| 501 | + "title": r["title"], | |
| 502 | + } | |
| 503 | + for r in rows | |
| 504 | + ] | |
| 505 | + | |
| 506 | + async def frontier_inspect( | |
| 507 | + self, domain: str | None = None, status: str | None = None, limit: int = 100 | |
| 508 | + ) -> list[dict[str, Any]]: | |
| 509 | + rows = await self.pool.fetch( | |
| 510 | + """ | |
| 511 | + SELECT u.url, f.priority, f.depth, f.status, f.retries, f.next_crawl_at, f.error_code | |
| 512 | + FROM frontier_items f JOIN urls u ON u.id = f.url_id | |
| 513 | + JOIN domains d ON d.id = u.domain_id | |
| 514 | + WHERE ($1::text IS NULL OR d.domain = $1) | |
| 515 | + AND ($2::text IS NULL OR f.status = $2) | |
| 516 | + ORDER BY f.priority DESC LIMIT $3 | |
| 517 | + """, | |
| 518 | + domain, | |
| 519 | + status, | |
| 520 | + min(limit, 500), | |
| 521 | + ) | |
| 522 | + return [dict(r) | {"next_crawl_at": r["next_crawl_at"].isoformat()} for r in rows] | |
added
M2M32crouve-ka/packages/database/migrate.py
+46 −0
@@ -0,0 +1,46 @@ | ||
| 1 | +# Trouve-KA — exécution des migrations SQL | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Applique les migrations SQL de infrastructure/migrations dans l'ordre.""" | |
| 6 | + | |
| 7 | +import os | |
| 8 | +import pathlib | |
| 9 | +import re | |
| 10 | + | |
| 11 | +import asyncpg | |
| 12 | + | |
| 13 | + | |
| 14 | +def _find_migrations_dir() -> pathlib.Path: | |
| 15 | + """Résout le dossier de migrations : env, cwd (container /app), ou racine du dépôt (editable).""" | |
| 16 | + candidates = [ | |
| 17 | + os.environ.get("TROUVEKA_MIGRATIONS_DIR"), | |
| 18 | + pathlib.Path.cwd() / "infrastructure" / "migrations", | |
| 19 | + pathlib.Path(__file__).resolve().parents[2] / "infrastructure" / "migrations", | |
| 20 | + ] | |
| 21 | + for cand in candidates: | |
| 22 | + if cand and pathlib.Path(cand).is_dir(): | |
| 23 | + return pathlib.Path(cand) | |
| 24 | + raise FileNotFoundError("dossier infrastructure/migrations introuvable") | |
| 25 | + | |
| 26 | + | |
| 27 | +async def run_migrations(database_url: str) -> list[str]: | |
| 28 | + """Applique les migrations manquantes. Retourne la liste des fichiers appliqués.""" | |
| 29 | + migrations_dir = _find_migrations_dir() | |
| 30 | + conn = await asyncpg.connect(database_url) | |
| 31 | + applied: list[str] = [] | |
| 32 | + try: | |
| 33 | + await conn.execute( | |
| 34 | + "CREATE TABLE IF NOT EXISTS schema_migrations (" | |
| 35 | + " version INTEGER PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())" | |
| 36 | + ) | |
| 37 | + done = {r["version"] for r in await conn.fetch("SELECT version FROM schema_migrations")} | |
| 38 | + for path in sorted(migrations_dir.glob("*.sql")): | |
| 39 | + match = re.match(r"^(\d+)_", path.name) | |
| 40 | + if not match or int(match.group(1)) in done: | |
| 41 | + continue | |
| 42 | + await conn.execute(path.read_text(encoding="utf-8")) | |
| 43 | + applied.append(path.name) | |
| 44 | + finally: | |
| 45 | + await conn.close() | |
| 46 | + return applied | |
added
M2M32crouve-ka/packages/logging/__init__.py
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +# Trouve-KA — logging structuré JSON | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Logs structurés (JSON une ligne) avec contexte crawl_id/url_id/domain_id/worker_id.""" | |
| 6 | + | |
| 7 | +import json | |
| 8 | +import logging | |
| 9 | +import sys | |
| 10 | +import time | |
| 11 | +from typing import Any | |
| 12 | + | |
| 13 | + | |
| 14 | +class JsonFormatter(logging.Formatter): | |
| 15 | + def format(self, record: logging.LogRecord) -> str: | |
| 16 | + payload: dict[str, Any] = { | |
| 17 | + "ts": round(time.time(), 3), | |
| 18 | + "level": record.levelname.lower(), | |
| 19 | + "logger": record.name, | |
| 20 | + "msg": record.getMessage(), | |
| 21 | + } | |
| 22 | + extra = getattr(record, "ctx", None) | |
| 23 | + if isinstance(extra, dict): | |
| 24 | + payload.update(extra) | |
| 25 | + if record.exc_info and record.exc_info[0] is not None: | |
| 26 | + payload["exc"] = self.formatException(record.exc_info) | |
| 27 | + return json.dumps(payload, ensure_ascii=False, default=str) | |
| 28 | + | |
| 29 | + | |
| 30 | +def get_logger(name: str) -> logging.Logger: | |
| 31 | + """Retourne un logger JSON prêt à l'emploi. Utiliser `log.info("msg", extra={"ctx": {...}})`.""" | |
| 32 | + logger = logging.getLogger(name) | |
| 33 | + if not logger.handlers: | |
| 34 | + handler = logging.StreamHandler(sys.stdout) | |
| 35 | + handler.setFormatter(JsonFormatter()) | |
| 36 | + logger.addHandler(handler) | |
| 37 | + logger.setLevel(logging.INFO) | |
| 38 | + logger.propagate = False | |
| 39 | + return logger | |
added
M2M32crouve-ka/packages/queue/__init__.py
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +# Trouve-KA — coordination Redis (politesse, pause, enrichissement) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Coordination inter-workers via Redis. | |
| 6 | + | |
| 7 | +- Politesse par hôte : lock SET NX PX — un seul fetch par hôte par fenêtre, | |
| 8 | + quel que soit le nombre de workers. | |
| 9 | +- Pause globale du crawler : simple clé drapeau. | |
| 10 | +- Enrichissement asynchrone : Redis Stream (jamais bloquant pour l'indexation). | |
| 11 | +""" | |
| 12 | + | |
| 13 | +import json | |
| 14 | +from typing import Any | |
| 15 | + | |
| 16 | +import redis.asyncio as aioredis | |
| 17 | + | |
| 18 | +PAUSE_KEY = "trouveka:crawler:paused" | |
| 19 | +HOST_LOCK_PREFIX = "trouveka:host-lock:" | |
| 20 | +ENRICH_STREAM = "trouveka:enrich" | |
| 21 | + | |
| 22 | + | |
| 23 | +class Coordination: | |
| 24 | + def __init__(self, redis_url: str): | |
| 25 | + self.redis: aioredis.Redis = aioredis.from_url(redis_url, decode_responses=True) | |
| 26 | + | |
| 27 | + async def close(self) -> None: | |
| 28 | + await self.redis.aclose() | |
| 29 | + | |
| 30 | + # ------------------------------------------------------------- politesse | |
| 31 | + async def acquire_host_slot(self, host: str, delay_seconds: float) -> bool: | |
| 32 | + """Réserve le droit de fetcher cet hôte. False = trop tôt, repasser plus tard.""" | |
| 33 | + px = max(int(delay_seconds * 1000), 100) | |
| 34 | + return bool(await self.redis.set(HOST_LOCK_PREFIX + host, "1", nx=True, px=px)) | |
| 35 | + | |
| 36 | + # ------------------------------------------------------------- pause | |
| 37 | + async def pause_crawler(self) -> None: | |
| 38 | + await self.redis.set(PAUSE_KEY, "1") | |
| 39 | + | |
| 40 | + async def resume_crawler(self) -> None: | |
| 41 | + await self.redis.delete(PAUSE_KEY) | |
| 42 | + | |
| 43 | + async def is_paused(self) -> bool: | |
| 44 | + return await self.redis.exists(PAUSE_KEY) == 1 | |
| 45 | + | |
| 46 | + # ------------------------------------------------------------- enrichissement | |
| 47 | + async def enqueue_enrichment(self, payload: dict[str, Any]) -> None: | |
| 48 | + await self.redis.xadd(ENRICH_STREAM, {"data": json.dumps(payload, default=str)}, maxlen=100_000) | |
| 49 | + | |
| 50 | + async def read_enrichment( | |
| 51 | + self, group: str, consumer: str, count: int = 10, block_ms: int = 5000 | |
| 52 | + ) -> list[tuple[str, dict[str, Any]]]: | |
| 53 | + try: | |
| 54 | + await self.redis.xgroup_create(ENRICH_STREAM, group, id="0", mkstream=True) | |
| 55 | + except aioredis.ResponseError as exc: | |
| 56 | + if "BUSYGROUP" not in str(exc): | |
| 57 | + raise | |
| 58 | + entries = await self.redis.xreadgroup( | |
| 59 | + group, consumer, {ENRICH_STREAM: ">"}, count=count, block=block_ms | |
| 60 | + ) | |
| 61 | + out: list[tuple[str, dict[str, Any]]] = [] | |
| 62 | + for _stream, items in entries or []: | |
| 63 | + for msg_id, fields in items: | |
| 64 | + out.append((msg_id, json.loads(fields["data"]))) | |
| 65 | + return out | |
| 66 | + | |
| 67 | + async def ack_enrichment(self, group: str, msg_id: str) -> None: | |
| 68 | + await self.redis.xack(ENRICH_STREAM, group, msg_id) | |
| 69 | + | |
| 70 | + async def enrich_backlog(self) -> int: | |
| 71 | + return await self.redis.xlen(ENRICH_STREAM) | |
added
M2M32crouve-ka/packages/search-core/__init__.py
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +# Trouve-KA — couche OpenSearch (client, mapping, indexation) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Couche OpenSearch : client async, mapping bilingue FR/EN, indexation immédiate.""" | |
| 6 | + | |
| 7 | +from .client import SearchCore | |
| 8 | +from .mapping import INDEX_SETTINGS | |
| 9 | + | |
| 10 | +__all__ = ["SearchCore", "INDEX_SETTINGS"] | |
added
M2M32crouve-ka/packages/search-core/client.py
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +# Trouve-KA — client OpenSearch | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Client OpenSearch async : création d'index, indexation immédiate, recherche, statut.""" | |
| 6 | + | |
| 7 | +import hashlib | |
| 8 | +from typing import Any | |
| 9 | + | |
| 10 | +from opensearchpy import AsyncOpenSearch | |
| 11 | +from opensearchpy.exceptions import NotFoundError | |
| 12 | + | |
| 13 | +from .mapping import INDEX_SETTINGS | |
| 14 | + | |
| 15 | + | |
| 16 | +def doc_id_for_url(url: str) -> str: | |
| 17 | + """ID de document stable dérivé de l'URL canonique.""" | |
| 18 | + return hashlib.sha256(url.encode("utf-8")).hexdigest()[:32] | |
| 19 | + | |
| 20 | + | |
| 21 | +class SearchCore: | |
| 22 | + def __init__(self, search_url: str, index: str): | |
| 23 | + self.index = index | |
| 24 | + self.client = AsyncOpenSearch(hosts=[search_url], timeout=15, max_retries=2, retry_on_timeout=True) | |
| 25 | + | |
| 26 | + async def close(self) -> None: | |
| 27 | + await self.client.close() | |
| 28 | + | |
| 29 | + async def ensure_index(self) -> None: | |
| 30 | + if not await self.client.indices.exists(index=self.index): | |
| 31 | + await self.client.indices.create(index=self.index, body=INDEX_SETTINGS) | |
| 32 | + | |
| 33 | + async def index_document(self, doc: dict[str, Any]) -> str: | |
| 34 | + """Indexation immédiate d'un document (cherchable au prochain refresh, ~1 s).""" | |
| 35 | + _id = doc_id_for_url(doc["canonical_url"] or doc["url"]) | |
| 36 | + await self.client.index(index=self.index, id=_id, body=doc) | |
| 37 | + return _id | |
| 38 | + | |
| 39 | + async def update_document(self, url: str, partial: dict[str, Any]) -> None: | |
| 40 | + """Mise à jour partielle (enrichissement asynchrone, étapes 2-3).""" | |
| 41 | + try: | |
| 42 | + await self.client.update(index=self.index, id=doc_id_for_url(url), body={"doc": partial}) | |
| 43 | + except NotFoundError: | |
| 44 | + pass | |
| 45 | + | |
| 46 | + async def delete_document(self, url: str) -> None: | |
| 47 | + try: | |
| 48 | + await self.client.delete(index=self.index, id=doc_id_for_url(url)) | |
| 49 | + except NotFoundError: | |
| 50 | + pass | |
| 51 | + | |
| 52 | + async def search(self, body: dict[str, Any]) -> dict[str, Any]: | |
| 53 | + return await self.client.search(index=self.index, body=body) | |
| 54 | + | |
| 55 | + async def count(self) -> int: | |
| 56 | + try: | |
| 57 | + res = await self.client.count(index=self.index) | |
| 58 | + return res["count"] | |
| 59 | + except NotFoundError: | |
| 60 | + return 0 | |
| 61 | + | |
| 62 | + async def ping(self) -> bool: | |
| 63 | + try: | |
| 64 | + return await self.client.ping() | |
| 65 | + except Exception: | |
| 66 | + return False | |
added
M2M32crouve-ka/packages/search-core/mapping.py
+124 −0
@@ -0,0 +1,124 @@ | ||
| 1 | +# Trouve-KA — mapping OpenSearch bilingue | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Mapping et settings de l'index documents. | |
| 6 | + | |
| 7 | +- Analyzers français ET anglais sur les champs texte (bilingue dès le départ). | |
| 8 | +- Filtre de synonymes bilingues (thermopompe ↔ heat pump) appliqué à la recherche | |
| 9 | + seulement : pas de traduction à l'ingestion. | |
| 10 | +- Refresh 1 s : une page indexée est cherchable en ~1 seconde (principe cardinal). | |
| 11 | +""" | |
| 12 | + | |
| 13 | +# Synonymes bilingues de départ — extensibles sans réindexation (search-time) | |
| 14 | +BILINGUAL_SYNONYMS = [ | |
| 15 | + "thermopompe, heat pump", | |
| 16 | + "subvention, grant, subsidy", | |
| 17 | + "emploi, job, employment", | |
| 18 | + "entreprise, company, business", | |
| 19 | + "université, university", | |
| 20 | + "cégep, cegep, college", | |
| 21 | + "garderie, daycare, cpe", | |
| 22 | + "impôt, tax, taxes", | |
| 23 | + "santé, health", | |
| 24 | + "plombier, plumber", | |
| 25 | + "électricien, electrician", | |
| 26 | + "déneigement, snow removal", | |
| 27 | + "assurance, insurance", | |
| 28 | + "logement, housing, apartment", | |
| 29 | + "ville, city, municipalité, municipality", | |
| 30 | + "gouvernement, government", | |
| 31 | + "permis, permit, license, licence", | |
| 32 | +] | |
| 33 | + | |
| 34 | +INDEX_SETTINGS = { | |
| 35 | + "settings": { | |
| 36 | + "number_of_shards": 1, | |
| 37 | + "number_of_replicas": 0, | |
| 38 | + "refresh_interval": "1s", | |
| 39 | + "analysis": { | |
| 40 | + "filter": { | |
| 41 | + "french_elision": { | |
| 42 | + "type": "elision", | |
| 43 | + "articles_case": True, | |
| 44 | + "articles": ["l", "m", "t", "qu", "n", "s", "j", "d", "c", "jusqu", "quoiqu", "lorsqu", "puisqu"], | |
| 45 | + }, | |
| 46 | + "french_stop": {"type": "stop", "stopwords": "_french_"}, | |
| 47 | + "french_stemmer": {"type": "stemmer", "language": "light_french"}, | |
| 48 | + "english_stop": {"type": "stop", "stopwords": "_english_"}, | |
| 49 | + "english_stemmer": {"type": "stemmer", "language": "light_english"}, | |
| 50 | + "bilingual_synonyms": { | |
| 51 | + "type": "synonym_graph", | |
| 52 | + "lenient": True, | |
| 53 | + "synonyms": BILINGUAL_SYNONYMS, | |
| 54 | + }, | |
| 55 | + }, | |
| 56 | + "analyzer": { | |
| 57 | + "fr_text": { | |
| 58 | + "tokenizer": "standard", | |
| 59 | + "filter": ["french_elision", "lowercase", "asciifolding", "french_stop", "french_stemmer"], | |
| 60 | + }, | |
| 61 | + "fr_search": { | |
| 62 | + "tokenizer": "standard", | |
| 63 | + "filter": ["french_elision", "lowercase", "asciifolding", "bilingual_synonyms", | |
| 64 | + "french_stop", "french_stemmer"], | |
| 65 | + }, | |
| 66 | + "en_text": { | |
| 67 | + "tokenizer": "standard", | |
| 68 | + "filter": ["lowercase", "asciifolding", "english_stop", "english_stemmer"], | |
| 69 | + }, | |
| 70 | + "en_search": { | |
| 71 | + "tokenizer": "standard", | |
| 72 | + "filter": ["lowercase", "asciifolding", "bilingual_synonyms", | |
| 73 | + "english_stop", "english_stemmer"], | |
| 74 | + }, | |
| 75 | + }, | |
| 76 | + }, | |
| 77 | + }, | |
| 78 | + "mappings": { | |
| 79 | + "properties": { | |
| 80 | + "url": {"type": "keyword"}, | |
| 81 | + "canonical_url": {"type": "keyword"}, | |
| 82 | + "domain": {"type": "keyword"}, | |
| 83 | + "title": { | |
| 84 | + "type": "text", | |
| 85 | + "analyzer": "fr_text", | |
| 86 | + "search_analyzer": "fr_search", | |
| 87 | + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 88 | + }, | |
| 89 | + "description": { | |
| 90 | + "type": "text", | |
| 91 | + "analyzer": "fr_text", | |
| 92 | + "search_analyzer": "fr_search", | |
| 93 | + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 94 | + }, | |
| 95 | + "body": { | |
| 96 | + "type": "text", | |
| 97 | + "analyzer": "fr_text", | |
| 98 | + "search_analyzer": "fr_search", | |
| 99 | + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 100 | + }, | |
| 101 | + "headings": { | |
| 102 | + "type": "text", | |
| 103 | + "analyzer": "fr_text", | |
| 104 | + "search_analyzer": "fr_search", | |
| 105 | + "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 106 | + }, | |
| 107 | + "language": {"type": "keyword"}, | |
| 108 | + "page_quebec_score": {"type": "float"}, | |
| 109 | + "domain_quebec_score": {"type": "float"}, | |
| 110 | + "locations": {"type": "keyword"}, | |
| 111 | + "organizations": {"type": "keyword"}, | |
| 112 | + "people": {"type": "keyword"}, | |
| 113 | + "categories": {"type": "keyword"}, | |
| 114 | + "published_at": {"type": "date"}, | |
| 115 | + "crawled_at": {"type": "date"}, | |
| 116 | + "authority_score": {"type": "float"}, | |
| 117 | + "freshness_score": {"type": "float"}, | |
| 118 | + "quality_score": {"type": "float"}, | |
| 119 | + "spam_score": {"type": "float"}, | |
| 120 | + # embedding (dense_vector/knn) ajouté à l'étape sémantique (§17.7), | |
| 121 | + # le mapping est extensible sans réindexation pour un nouveau champ. | |
| 122 | + } | |
| 123 | + }, | |
| 124 | +} | |
added
M2M32crouve-ka/packages/shared/__init__.py
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +# Trouve-KA — utilitaires partagés | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Utilitaires partagés : canonicalisation d'URL, garde SSRF, hachage de contenu.""" | |
| 6 | + | |
| 7 | +from .urls import canonicalize_url, display_url, extract_domain, is_http_url | |
| 8 | +from .ssrf import is_safe_url, is_safe_ip | |
| 9 | +from .hashing import content_hash, text_fingerprint | |
| 10 | + | |
| 11 | +__all__ = [ | |
| 12 | + "canonicalize_url", | |
| 13 | + "display_url", | |
| 14 | + "extract_domain", | |
| 15 | + "is_http_url", | |
| 16 | + "is_safe_url", | |
| 17 | + "is_safe_ip", | |
| 18 | + "content_hash", | |
| 19 | + "text_fingerprint", | |
| 20 | +] | |
added
M2M32crouve-ka/packages/shared/hashing.py
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +# Trouve-KA — hachage de contenu et détection de changement | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Hachage de contenu pour la déduplication exacte et la détection de changement. | |
| 6 | + | |
| 7 | +Le hash est calculé sur le texte extrait normalisé (pas le HTML brut) pour | |
| 8 | +ignorer le bruit de balisage (nonces, timestamps de rendu, etc.). | |
| 9 | +""" | |
| 10 | + | |
| 11 | +import hashlib | |
| 12 | +import re | |
| 13 | + | |
| 14 | +_WHITESPACE = re.compile(r"\s+") | |
| 15 | + | |
| 16 | + | |
| 17 | +def text_fingerprint(text: str) -> str: | |
| 18 | + """Texte normalisé pour hachage : espaces réduits, minuscules.""" | |
| 19 | + return _WHITESPACE.sub(" ", text).strip().lower() | |
| 20 | + | |
| 21 | + | |
| 22 | +def content_hash(title: str, body: str) -> str: | |
| 23 | + """SHA-256 du contenu textuel normalisé (titre + corps).""" | |
| 24 | + payload = text_fingerprint(title) + "\n" + text_fingerprint(body) | |
| 25 | + return hashlib.sha256(payload.encode("utf-8")).hexdigest() | |
added
M2M32crouve-ka/packages/shared/ssrf.py
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +# Trouve-KA — prévention SSRF | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Garde SSRF du crawler (CLAUDE.md §5.7). | |
| 6 | + | |
| 7 | +Bloque localhost, plages privées IPv4/IPv6, link-local, métadonnées cloud, | |
| 8 | +schémas non-HTTP. La résolution DNS est revalidée à chaque saut de redirection | |
| 9 | +par l'appelant (fetcher). | |
| 10 | +""" | |
| 11 | + | |
| 12 | +import ipaddress | |
| 13 | +import socket | |
| 14 | +from urllib.parse import urlsplit | |
| 15 | + | |
| 16 | +BLOCKED_HOSTS = { | |
| 17 | + "localhost", | |
| 18 | + "metadata.google.internal", | |
| 19 | + "metadata.gke.internal", | |
| 20 | +} | |
| 21 | + | |
| 22 | +# Endpoint de métadonnées AWS/GCP/Azure/OpenStack | |
| 23 | +METADATA_IPS = {"169.254.169.254", "fd00:ec2::254"} | |
| 24 | + | |
| 25 | + | |
| 26 | +def is_safe_ip(ip_str: str) -> bool: | |
| 27 | + try: | |
| 28 | + ip = ipaddress.ip_address(ip_str) | |
| 29 | + except ValueError: | |
| 30 | + return False | |
| 31 | + if ip_str in METADATA_IPS: | |
| 32 | + return False | |
| 33 | + return not ( | |
| 34 | + ip.is_private | |
| 35 | + or ip.is_loopback | |
| 36 | + or ip.is_link_local | |
| 37 | + or ip.is_multicast | |
| 38 | + or ip.is_reserved | |
| 39 | + or ip.is_unspecified | |
| 40 | + ) | |
| 41 | + | |
| 42 | + | |
| 43 | +def resolve_host(host: str) -> list[str]: | |
| 44 | + """Résout un hôte en IPs (IPv4+IPv6). Lève socket.gaierror si introuvable.""" | |
| 45 | + infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP) | |
| 46 | + return list({info[4][0] for info in infos}) | |
| 47 | + | |
| 48 | + | |
| 49 | +def is_safe_url(url: str, *, resolve: bool = True) -> bool: | |
| 50 | + """Vérifie qu'une URL est sûre à fetcher (schéma, hôte, IPs résolues).""" | |
| 51 | + try: | |
| 52 | + parts = urlsplit(url) | |
| 53 | + except ValueError: | |
| 54 | + return False | |
| 55 | + if parts.scheme.lower() not in ("http", "https"): | |
| 56 | + return False | |
| 57 | + host = parts.hostname | |
| 58 | + if not host: | |
| 59 | + return False | |
| 60 | + host = host.lower().strip(".") | |
| 61 | + if host in BLOCKED_HOSTS or host.endswith(".localhost") or host.endswith(".internal"): | |
| 62 | + return False | |
| 63 | + # Hôte littéral IP | |
| 64 | + try: | |
| 65 | + ipaddress.ip_address(host) | |
| 66 | + return is_safe_ip(host) | |
| 67 | + except ValueError: | |
| 68 | + pass | |
| 69 | + if not resolve: | |
| 70 | + return True | |
| 71 | + try: | |
| 72 | + ips = resolve_host(host) | |
| 73 | + except (socket.gaierror, OSError): | |
| 74 | + return False | |
| 75 | + return bool(ips) and all(is_safe_ip(ip) for ip in ips) | |
added
M2M32crouve-ka/packages/shared/urls.py
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +# Trouve-KA — canonicalisation d'URL | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Canonicalisation d'URL prudente. | |
| 6 | + | |
| 7 | +Règle cardinale (CLAUDE.md §5.4) : ne JAMAIS fusionner deux ressources | |
| 8 | +distinctes par accident. On normalise seulement ce qui est sûr : | |
| 9 | +fragments, ports par défaut, casse de l'hôte, paramètres de tracking connus, | |
| 10 | +encodage. On ne touche ni à la casse du chemin ni aux paramètres inconnus. | |
| 11 | +""" | |
| 12 | + | |
| 13 | +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit, quote, unquote | |
| 14 | + | |
| 15 | +# Paramètres de tracking sûrs à retirer (jamais porteurs de contenu) | |
| 16 | +TRACKING_PARAMS = { | |
| 17 | + "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id", | |
| 18 | + "fbclid", "gclid", "gclsrc", "dclid", "msclkid", "twclid", "igshid", | |
| 19 | + "mc_cid", "mc_eid", "_ga", "_gl", "yclid", "wbraid", "gbraid", | |
| 20 | + "ref_src", "cmpid", "s_kwcid", "spm", | |
| 21 | +} | |
| 22 | + | |
| 23 | +DEFAULT_PORTS = {"http": 80, "https": 443} | |
| 24 | + | |
| 25 | + | |
| 26 | +def is_http_url(url: str) -> bool: | |
| 27 | + try: | |
| 28 | + scheme = urlsplit(url).scheme.lower() | |
| 29 | + except ValueError: | |
| 30 | + return False | |
| 31 | + return scheme in ("http", "https") | |
| 32 | + | |
| 33 | + | |
| 34 | +def canonicalize_url(url: str, base: str | None = None) -> str | None: | |
| 35 | + """Normalise une URL. Retourne None si l'URL n'est pas crawlable (schéma non http).""" | |
| 36 | + url = url.strip() | |
| 37 | + if not url: | |
| 38 | + return None | |
| 39 | + if base: | |
| 40 | + from urllib.parse import urljoin | |
| 41 | + | |
| 42 | + url = urljoin(base, url) | |
| 43 | + try: | |
| 44 | + parts = urlsplit(url) | |
| 45 | + except ValueError: | |
| 46 | + return None | |
| 47 | + scheme = parts.scheme.lower() | |
| 48 | + if scheme not in ("http", "https"): | |
| 49 | + return None | |
| 50 | + host = parts.hostname | |
| 51 | + if not host: | |
| 52 | + return None | |
| 53 | + host = host.strip(".").lower() | |
| 54 | + try: | |
| 55 | + host = host.encode("idna").decode("ascii") if any(ord(c) > 127 for c in host) else host | |
| 56 | + except UnicodeError: | |
| 57 | + return None | |
| 58 | + | |
| 59 | + port = parts.port | |
| 60 | + netloc = host | |
| 61 | + if port and port != DEFAULT_PORTS.get(scheme): | |
| 62 | + netloc = f"{host}:{port}" | |
| 63 | + | |
| 64 | + # Chemin : ré-encoder proprement sans changer la sémantique (casse préservée) | |
| 65 | + path = quote(unquote(parts.path or "/"), safe="/%:@!$&'()*+,;=~-._") | |
| 66 | + # Slashs multiples consécutifs → un seul (sûr pour HTTP) | |
| 67 | + while "//" in path: | |
| 68 | + path = path.replace("//", "/") | |
| 69 | + if not path: | |
| 70 | + path = "/" | |
| 71 | + | |
| 72 | + # Query : retirer uniquement les paramètres de tracking connus; préserver l'ordre | |
| 73 | + query = "" | |
| 74 | + if parts.query: | |
| 75 | + kept = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) | |
| 76 | + if k.lower() not in TRACKING_PARAMS] | |
| 77 | + query = urlencode(kept) | |
| 78 | + | |
| 79 | + # Fragment : toujours retiré (jamais envoyé au serveur) | |
| 80 | + return urlunsplit((scheme, netloc, path, query, "")) | |
| 81 | + | |
| 82 | + | |
| 83 | +def extract_domain(url: str) -> str | None: | |
| 84 | + """Domaine enregistrable approximatif : hôte sans le préfixe www.""" | |
| 85 | + try: | |
| 86 | + host = urlsplit(url).hostname | |
| 87 | + except ValueError: | |
| 88 | + return None | |
| 89 | + if not host: | |
| 90 | + return None | |
| 91 | + host = host.lower().strip(".") | |
| 92 | + return host[4:] if host.startswith("www.") else host | |
| 93 | + | |
| 94 | + | |
| 95 | +def display_url(url: str, max_len: int = 80) -> str: | |
| 96 | + """URL d'affichage façon breadcrumb : quebec.ca › services › permis.""" | |
| 97 | + parts = urlsplit(url) | |
| 98 | + host = (parts.hostname or "").removeprefix("www.") | |
| 99 | + segments = [unquote(s) for s in parts.path.split("/") if s] | |
| 100 | + crumb = " › ".join([host, *segments[:3]]) | |
| 101 | + if len(crumb) > max_len: | |
| 102 | + crumb = crumb[: max_len - 1] + "…" | |
| 103 | + return crumb | |
added
M2M32crouve-ka/packages/types/__init__.py
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +# Trouve-KA — types partagés du pipeline | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Modèles Pydantic partagés entre crawler, parser, classifier, indexer et API.""" | |
| 6 | + | |
| 7 | +from datetime import datetime | |
| 8 | +from enum import StrEnum | |
| 9 | + | |
| 10 | +from pydantic import BaseModel, Field | |
| 11 | + | |
| 12 | + | |
| 13 | +class ErrorCode(StrEnum): | |
| 14 | + DNS = "dns" | |
| 15 | + TIMEOUT = "timeout" | |
| 16 | + TLS = "tls" | |
| 17 | + HTTP_4XX = "http_4xx" | |
| 18 | + HTTP_5XX = "http_5xx" | |
| 19 | + ROBOTS_DENIED = "robots_denied" | |
| 20 | + PARSE_FAILED = "parse_failed" | |
| 21 | + UNSUPPORTED_CONTENT = "unsupported_content" | |
| 22 | + TOO_LARGE = "too_large" | |
| 23 | + DUPLICATE = "duplicate" | |
| 24 | + SPAM = "spam" | |
| 25 | + NOT_QUEBEC = "not_quebec" | |
| 26 | + SSRF_BLOCKED = "ssrf_blocked" | |
| 27 | + TOO_MANY_REDIRECTS = "too_many_redirects" | |
| 28 | + CONNECTION = "connection" | |
| 29 | + OK = "ok" | |
| 30 | + | |
| 31 | + | |
| 32 | +class Outcome(StrEnum): | |
| 33 | + INDEXED = "indexed" | |
| 34 | + DUPLICATE = "duplicate" | |
| 35 | + UNCHANGED = "unchanged" | |
| 36 | + ERROR = "error" | |
| 37 | + NOT_QUEBEC = "not_quebec" | |
| 38 | + ROBOTS_BLOCKED = "robots_blocked" | |
| 39 | + REDIRECT = "redirect" | |
| 40 | + | |
| 41 | + | |
| 42 | +class FetchResult(BaseModel): | |
| 43 | + url: str | |
| 44 | + final_url: str | |
| 45 | + status_code: int | None = None | |
| 46 | + error_code: ErrorCode = ErrorCode.OK | |
| 47 | + content_type: str | None = None | |
| 48 | + charset: str | None = None | |
| 49 | + body: bytes | None = None | |
| 50 | + etag: str | None = None | |
| 51 | + last_modified: str | None = None | |
| 52 | + duration_ms: int = 0 | |
| 53 | + redirect_chain: list[str] = Field(default_factory=list) | |
| 54 | + | |
| 55 | + | |
| 56 | +class ExtractedLink(BaseModel): | |
| 57 | + url: str | |
| 58 | + anchor: str = "" | |
| 59 | + nofollow: bool = False | |
| 60 | + | |
| 61 | + | |
| 62 | +class ParsedPage(BaseModel): | |
| 63 | + url: str | |
| 64 | + canonical_url: str | None = None | |
| 65 | + title: str = "" | |
| 66 | + description: str = "" | |
| 67 | + body: str = "" | |
| 68 | + headings: list[str] = Field(default_factory=list) | |
| 69 | + language: str | None = None | |
| 70 | + links: list[ExtractedLink] = Field(default_factory=list) | |
| 71 | + published_at: datetime | None = None | |
| 72 | + modified_at: datetime | None = None | |
| 73 | + author: str | None = None | |
| 74 | + noindex: bool = False | |
| 75 | + nofollow_page: bool = False | |
| 76 | + structured_hints: list[str] = Field(default_factory=list) # textes JSON-LD/OG utiles au scoring | |
| 77 | + | |
| 78 | + | |
| 79 | +class QuebecSignals(BaseModel): | |
| 80 | + score: float = 0.0 | |
| 81 | + locations: list[str] = Field(default_factory=list) | |
| 82 | + reasons: list[str] = Field(default_factory=list) | |
| 83 | + | |
| 84 | + | |
| 85 | +class SearchResult(BaseModel): | |
| 86 | + title: str | |
| 87 | + url: str | |
| 88 | + display_url: str | |
| 89 | + snippet: str | |
| 90 | + domain: str | |
| 91 | + language: str | None = None | |
| 92 | + quebec_score: float = 0.0 | |
| 93 | + badges: list[str] = Field(default_factory=list) | |
| 94 | + published_at: datetime | None = None | |
| 95 | + | |
| 96 | + | |
| 97 | +class SearchResponse(BaseModel): | |
| 98 | + query: str | |
| 99 | + total: int | |
| 100 | + took_ms: int | |
| 101 | + page: int | |
| 102 | + limit: int | |
| 103 | + results: list[SearchResult] | |
added
M2M32crouve-ka/pnpm-lock.yaml
+3967 −0
@@ -0,0 +1,3967 @@ | ||
| 1 | +lockfileVersion: '9.0' | |
| 2 | + | |
| 3 | +settings: | |
| 4 | + autoInstallPeers: true | |
| 5 | + excludeLinksFromLockfile: false | |
| 6 | + | |
| 7 | +importers: | |
| 8 | + | |
| 9 | + .: {} | |
| 10 | + | |
| 11 | + apps/web: | |
| 12 | + dependencies: | |
| 13 | + class-variance-authority: | |
| 14 | + specifier: ^0.7.1 | |
| 15 | + version: 0.7.1 | |
| 16 | + clsx: | |
| 17 | + specifier: ^2.1.1 | |
| 18 | + version: 2.1.1 | |
| 19 | + next: | |
| 20 | + specifier: ^15.3.5 | |
| 21 | + version: 15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8) | |
| 22 | + react: | |
| 23 | + specifier: ^19.1.0 | |
| 24 | + version: 19.2.8 | |
| 25 | + react-dom: | |
| 26 | + specifier: ^19.1.0 | |
| 27 | + version: 19.2.8(react@19.2.8) | |
| 28 | + tailwind-merge: | |
| 29 | + specifier: ^2.6.0 | |
| 30 | + version: 2.6.1 | |
| 31 | + devDependencies: | |
| 32 | + '@types/node': | |
| 33 | + specifier: ^22.15.0 | |
| 34 | + version: 22.20.1 | |
| 35 | + '@types/react': | |
| 36 | + specifier: ^19.1.0 | |
| 37 | + version: 19.2.18 | |
| 38 | + '@types/react-dom': | |
| 39 | + specifier: ^19.1.0 | |
| 40 | + version: 19.2.4(@types/react@19.2.18) | |
| 41 | + autoprefixer: | |
| 42 | + specifier: ^10.4.21 | |
| 43 | + version: 10.5.4(postcss@8.5.26) | |
| 44 | + eslint: | |
| 45 | + specifier: ^8.57.1 | |
| 46 | + version: 8.57.1 | |
| 47 | + eslint-config-next: | |
| 48 | + specifier: ^15.3.5 | |
| 49 | + version: 15.5.23(eslint@8.57.1)(typescript@5.9.3) | |
| 50 | + postcss: | |
| 51 | + specifier: ^8.5.3 | |
| 52 | + version: 8.5.26 | |
| 53 | + tailwindcss: | |
| 54 | + specifier: ^3.4.17 | |
| 55 | + version: 3.4.19 | |
| 56 | + typescript: | |
| 57 | + specifier: ^5.8.3 | |
| 58 | + version: 5.9.3 | |
| 59 | + | |
| 60 | +packages: | |
| 61 | + | |
| 62 | + '@alloc/quick-lru@5.2.0': | |
| 63 | + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} | |
| 64 | + engines: {node: '>=10'} | |
| 65 | + | |
| 66 | + '@emnapi/core@1.10.0': | |
| 67 | + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} | |
| 68 | + | |
| 69 | + '@emnapi/runtime@1.10.0': | |
| 70 | + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} | |
| 71 | + | |
| 72 | + '@emnapi/runtime@1.11.3': | |
| 73 | + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} | |
| 74 | + | |
| 75 | + '@emnapi/wasi-threads@1.2.1': | |
| 76 | + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} | |
| 77 | + | |
| 78 | + '@eslint-community/eslint-utils@4.10.1': | |
| 79 | + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} | |
| 80 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 81 | + peerDependencies: | |
| 82 | + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 | |
| 83 | + | |
| 84 | + '@eslint-community/regexpp@4.12.2': | |
| 85 | + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} | |
| 86 | + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} | |
| 87 | + | |
| 88 | + '@eslint/eslintrc@2.1.4': | |
| 89 | + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} | |
| 90 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 91 | + | |
| 92 | + '@eslint/js@8.57.1': | |
| 93 | + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} | |
| 94 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 95 | + | |
| 96 | + '@humanwhocodes/config-array@0.13.0': | |
| 97 | + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} | |
| 98 | + engines: {node: '>=10.10.0'} | |
| 99 | + deprecated: Use @eslint/config-array instead | |
| 100 | + | |
| 101 | + '@humanwhocodes/module-importer@1.0.1': | |
| 102 | + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} | |
| 103 | + engines: {node: '>=12.22'} | |
| 104 | + | |
| 105 | + '@humanwhocodes/object-schema@2.0.3': | |
| 106 | + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} | |
| 107 | + deprecated: Use @eslint/object-schema instead | |
| 108 | + | |
| 109 | + '@img/colour@1.1.0': | |
| 110 | + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} | |
| 111 | + engines: {node: '>=18'} | |
| 112 | + | |
| 113 | + '@img/sharp-darwin-arm64@0.34.5': | |
| 114 | + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} | |
| 115 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 116 | + cpu: [arm64] | |
| 117 | + os: [darwin] | |
| 118 | + | |
| 119 | + '@img/sharp-darwin-x64@0.34.5': | |
| 120 | + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} | |
| 121 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 122 | + cpu: [x64] | |
| 123 | + os: [darwin] | |
| 124 | + | |
| 125 | + '@img/sharp-libvips-darwin-arm64@1.2.4': | |
| 126 | + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} | |
| 127 | + cpu: [arm64] | |
| 128 | + os: [darwin] | |
| 129 | + | |
| 130 | + '@img/sharp-libvips-darwin-x64@1.2.4': | |
| 131 | + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} | |
| 132 | + cpu: [x64] | |
| 133 | + os: [darwin] | |
| 134 | + | |
| 135 | + '@img/sharp-libvips-linux-arm64@1.2.4': | |
| 136 | + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} | |
| 137 | + cpu: [arm64] | |
| 138 | + os: [linux] | |
| 139 | + libc: [glibc] | |
| 140 | + | |
| 141 | + '@img/sharp-libvips-linux-arm@1.2.4': | |
| 142 | + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} | |
| 143 | + cpu: [arm] | |
| 144 | + os: [linux] | |
| 145 | + libc: [glibc] | |
| 146 | + | |
| 147 | + '@img/sharp-libvips-linux-ppc64@1.2.4': | |
| 148 | + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} | |
| 149 | + cpu: [ppc64] | |
| 150 | + os: [linux] | |
| 151 | + libc: [glibc] | |
| 152 | + | |
| 153 | + '@img/sharp-libvips-linux-riscv64@1.2.4': | |
| 154 | + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} | |
| 155 | + cpu: [riscv64] | |
| 156 | + os: [linux] | |
| 157 | + libc: [glibc] | |
| 158 | + | |
| 159 | + '@img/sharp-libvips-linux-s390x@1.2.4': | |
| 160 | + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} | |
| 161 | + cpu: [s390x] | |
| 162 | + os: [linux] | |
| 163 | + libc: [glibc] | |
| 164 | + | |
| 165 | + '@img/sharp-libvips-linux-x64@1.2.4': | |
| 166 | + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} | |
| 167 | + cpu: [x64] | |
| 168 | + os: [linux] | |
| 169 | + libc: [glibc] | |
| 170 | + | |
| 171 | + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': | |
| 172 | + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} | |
| 173 | + cpu: [arm64] | |
| 174 | + os: [linux] | |
| 175 | + libc: [musl] | |
| 176 | + | |
| 177 | + '@img/sharp-libvips-linuxmusl-x64@1.2.4': | |
| 178 | + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} | |
| 179 | + cpu: [x64] | |
| 180 | + os: [linux] | |
| 181 | + libc: [musl] | |
| 182 | + | |
| 183 | + '@img/sharp-linux-arm64@0.34.5': | |
| 184 | + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} | |
| 185 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 186 | + cpu: [arm64] | |
| 187 | + os: [linux] | |
| 188 | + libc: [glibc] | |
| 189 | + | |
| 190 | + '@img/sharp-linux-arm@0.34.5': | |
| 191 | + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} | |
| 192 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 193 | + cpu: [arm] | |
| 194 | + os: [linux] | |
| 195 | + libc: [glibc] | |
| 196 | + | |
| 197 | + '@img/sharp-linux-ppc64@0.34.5': | |
| 198 | + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} | |
| 199 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 200 | + cpu: [ppc64] | |
| 201 | + os: [linux] | |
| 202 | + libc: [glibc] | |
| 203 | + | |
| 204 | + '@img/sharp-linux-riscv64@0.34.5': | |
| 205 | + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} | |
| 206 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 207 | + cpu: [riscv64] | |
| 208 | + os: [linux] | |
| 209 | + libc: [glibc] | |
| 210 | + | |
| 211 | + '@img/sharp-linux-s390x@0.34.5': | |
| 212 | + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} | |
| 213 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 214 | + cpu: [s390x] | |
| 215 | + os: [linux] | |
| 216 | + libc: [glibc] | |
| 217 | + | |
| 218 | + '@img/sharp-linux-x64@0.34.5': | |
| 219 | + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} | |
| 220 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 221 | + cpu: [x64] | |
| 222 | + os: [linux] | |
| 223 | + libc: [glibc] | |
| 224 | + | |
| 225 | + '@img/sharp-linuxmusl-arm64@0.34.5': | |
| 226 | + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} | |
| 227 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 228 | + cpu: [arm64] | |
| 229 | + os: [linux] | |
| 230 | + libc: [musl] | |
| 231 | + | |
| 232 | + '@img/sharp-linuxmusl-x64@0.34.5': | |
| 233 | + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} | |
| 234 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 235 | + cpu: [x64] | |
| 236 | + os: [linux] | |
| 237 | + libc: [musl] | |
| 238 | + | |
| 239 | + '@img/sharp-wasm32@0.34.5': | |
| 240 | + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} | |
| 241 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 242 | + cpu: [wasm32] | |
| 243 | + | |
| 244 | + '@img/sharp-win32-arm64@0.34.5': | |
| 245 | + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} | |
| 246 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 247 | + cpu: [arm64] | |
| 248 | + os: [win32] | |
| 249 | + | |
| 250 | + '@img/sharp-win32-ia32@0.34.5': | |
| 251 | + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} | |
| 252 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 253 | + cpu: [ia32] | |
| 254 | + os: [win32] | |
| 255 | + | |
| 256 | + '@img/sharp-win32-x64@0.34.5': | |
| 257 | + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} | |
| 258 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 259 | + cpu: [x64] | |
| 260 | + os: [win32] | |
| 261 | + | |
| 262 | + '@jridgewell/gen-mapping@0.3.13': | |
| 263 | + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} | |
| 264 | + | |
| 265 | + '@jridgewell/resolve-uri@3.1.2': | |
| 266 | + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} | |
| 267 | + engines: {node: '>=6.0.0'} | |
| 268 | + | |
| 269 | + '@jridgewell/sourcemap-codec@1.5.5': | |
| 270 | + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} | |
| 271 | + | |
| 272 | + '@jridgewell/trace-mapping@0.3.31': | |
| 273 | + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} | |
| 274 | + | |
| 275 | + '@napi-rs/wasm-runtime@1.2.3': | |
| 276 | + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} | |
| 277 | + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} | |
| 278 | + peerDependencies: | |
| 279 | + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 | |
| 280 | + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 | |
| 281 | + | |
| 282 | + '@next/env@15.5.23': | |
| 283 | + resolution: {integrity: sha512-Mv3Z9hVbFcPnoLevsZ6rnX1TBtyHb5E17yN7HTPDXSXxeNsGBjUFrdbjRXKKXIOhfth7/cg6Ay7PZ2UFawaWsQ==} | |
| 284 | + | |
| 285 | + '@next/eslint-plugin-next@15.5.23': | |
| 286 | + resolution: {integrity: sha512-0KnCFpiWVIsbwBhByZ0uIcjYM5xqGrFzN2eOPbwru/wuy5Z1dmA+3gP+PRbi4gl1Ny7an66BqAM/NHkX/50rbw==} | |
| 287 | + | |
| 288 | + '@next/swc-darwin-arm64@15.5.23': | |
| 289 | + resolution: {integrity: sha512-SrEwOROH/rhA03F59hHtdhgtfZMWGzr5duDBWgRQt2rS3mJhqMKOcnNx6txOd0/i3E3D3uFKYFvyHsEiwQxzag==} | |
| 290 | + engines: {node: '>= 10'} | |
| 291 | + cpu: [arm64] | |
| 292 | + os: [darwin] | |
| 293 | + | |
| 294 | + '@next/swc-darwin-x64@15.5.23': | |
| 295 | + resolution: {integrity: sha512-f0FpFbG2EhDCuptBGcfrLcYMDuQAhe6m1QA4VVfXFrIBoFXvXt/olGbBkYkloKlXQtmhuzvtdYyuu/6zf07GIg==} | |
| 296 | + engines: {node: '>= 10'} | |
| 297 | + cpu: [x64] | |
| 298 | + os: [darwin] | |
| 299 | + | |
| 300 | + '@next/swc-linux-arm64-gnu@15.5.23': | |
| 301 | + resolution: {integrity: sha512-WlNtfepUXKX2u2ZsJZ8c3c8+tJSRZqsYzoMwLOY72A8ucKCCgxgNhiePA3qzFYahVWrwcQd8jOeJmBinc+VFVQ==} | |
| 302 | + engines: {node: '>= 10'} | |
| 303 | + cpu: [arm64] | |
| 304 | + os: [linux] | |
| 305 | + libc: [glibc] | |
| 306 | + | |
| 307 | + '@next/swc-linux-arm64-musl@15.5.23': | |
| 308 | + resolution: {integrity: sha512-W/6qKk7UG93mg14PmQC+2urt69MIdwTBLNQ6MJyeC4wOCIHCjz+VfgssvS1pK7mgYBtLC1g6VKNoHD9xB0WWGg==} | |
| 309 | + engines: {node: '>= 10'} | |
| 310 | + cpu: [arm64] | |
| 311 | + os: [linux] | |
| 312 | + libc: [musl] | |
| 313 | + | |
| 314 | + '@next/swc-linux-x64-gnu@15.5.23': | |
| 315 | + resolution: {integrity: sha512-vzefI32mi6VMk96RaTAyxApgfGbiFzQBXVsekEjsDv1fr48mlABTWx0sUYhaYCBHWqCalxmz3DxbxFcbFvzNtw==} | |
| 316 | + engines: {node: '>= 10'} | |
| 317 | + cpu: [x64] | |
| 318 | + os: [linux] | |
| 319 | + libc: [glibc] | |
| 320 | + | |
| 321 | + '@next/swc-linux-x64-musl@15.5.23': | |
| 322 | + resolution: {integrity: sha512-qppK/3dTGOTI+aoWWBZc3DshFIhrzgL8guATlaN9V6M1QJxbkP/rhEZ22tdICsQ/2WWXopMZ2Jokzj2u3uKY3Q==} | |
| 323 | + engines: {node: '>= 10'} | |
| 324 | + cpu: [x64] | |
| 325 | + os: [linux] | |
| 326 | + libc: [musl] | |
| 327 | + | |
| 328 | + '@next/swc-win32-arm64-msvc@15.5.23': | |
| 329 | + resolution: {integrity: sha512-Wc29KFOdT7XBcII3Vtmw7aoU8Uk3Mes/FNJfhFeSHdYBFJWMcR/DsI8U9BCPUhq/uycsUVuqSKGthW15tLsigA==} | |
| 330 | + engines: {node: '>= 10'} | |
| 331 | + cpu: [arm64] | |
| 332 | + os: [win32] | |
| 333 | + | |
| 334 | + '@next/swc-win32-x64-msvc@15.5.23': | |
| 335 | + resolution: {integrity: sha512-/C7wRW4fa9s/PKA18zGPPpVmx8ycgVpP8yOxro4gzGTzjPJdscbAP3ODeFvgiIovxD176Z2J/SXO9t8PJKHLeQ==} | |
| 336 | + engines: {node: '>= 10'} | |
| 337 | + cpu: [x64] | |
| 338 | + os: [win32] | |
| 339 | + | |
| 340 | + '@nodelib/fs.scandir@2.1.5': | |
| 341 | + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} | |
| 342 | + engines: {node: '>= 8'} | |
| 343 | + | |
| 344 | + '@nodelib/fs.stat@2.0.5': | |
| 345 | + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} | |
| 346 | + engines: {node: '>= 8'} | |
| 347 | + | |
| 348 | + '@nodelib/fs.walk@1.2.8': | |
| 349 | + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} | |
| 350 | + engines: {node: '>= 8'} | |
| 351 | + | |
| 352 | + '@nolyfill/is-core-module@1.0.39': | |
| 353 | + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} | |
| 354 | + engines: {node: '>=12.4.0'} | |
| 355 | + | |
| 356 | + '@rtsao/scc@1.1.0': | |
| 357 | + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} | |
| 358 | + | |
| 359 | + '@rushstack/eslint-patch@1.16.1': | |
| 360 | + resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} | |
| 361 | + | |
| 362 | + '@swc/helpers@0.5.15': | |
| 363 | + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} | |
| 364 | + | |
| 365 | + '@tybys/wasm-util@0.10.3': | |
| 366 | + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} | |
| 367 | + | |
| 368 | + '@types/json5@0.0.29': | |
| 369 | + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} | |
| 370 | + | |
| 371 | + '@types/node@22.20.1': | |
| 372 | + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} | |
| 373 | + | |
| 374 | + '@types/react-dom@19.2.4': | |
| 375 | + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} | |
| 376 | + peerDependencies: | |
| 377 | + '@types/react': ^19.2.0 | |
| 378 | + | |
| 379 | + '@types/react@19.2.18': | |
| 380 | + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} | |
| 381 | + | |
| 382 | + '@typescript-eslint/eslint-plugin@8.67.0': | |
| 383 | + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} | |
| 384 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 385 | + peerDependencies: | |
| 386 | + '@typescript-eslint/parser': ^8.67.0 | |
| 387 | + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 388 | + typescript: '>=4.8.4 <6.1.0' | |
| 389 | + | |
| 390 | + '@typescript-eslint/parser@8.67.0': | |
| 391 | + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} | |
| 392 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 393 | + peerDependencies: | |
| 394 | + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 395 | + typescript: '>=4.8.4 <6.1.0' | |
| 396 | + | |
| 397 | + '@typescript-eslint/project-service@8.67.0': | |
| 398 | + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} | |
| 399 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 400 | + peerDependencies: | |
| 401 | + typescript: '>=4.8.4 <6.1.0' | |
| 402 | + | |
| 403 | + '@typescript-eslint/scope-manager@8.67.0': | |
| 404 | + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} | |
| 405 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 406 | + | |
| 407 | + '@typescript-eslint/tsconfig-utils@8.67.0': | |
| 408 | + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} | |
| 409 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 410 | + peerDependencies: | |
| 411 | + typescript: '>=4.8.4 <6.1.0' | |
| 412 | + | |
| 413 | + '@typescript-eslint/type-utils@8.67.0': | |
| 414 | + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} | |
| 415 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 416 | + peerDependencies: | |
| 417 | + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 418 | + typescript: '>=4.8.4 <6.1.0' | |
| 419 | + | |
| 420 | + '@typescript-eslint/types@8.67.0': | |
| 421 | + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} | |
| 422 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 423 | + | |
| 424 | + '@typescript-eslint/typescript-estree@8.67.0': | |
| 425 | + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} | |
| 426 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 427 | + peerDependencies: | |
| 428 | + typescript: '>=4.8.4 <6.1.0' | |
| 429 | + | |
| 430 | + '@typescript-eslint/utils@8.67.0': | |
| 431 | + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} | |
| 432 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 433 | + peerDependencies: | |
| 434 | + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 435 | + typescript: '>=4.8.4 <6.1.0' | |
| 436 | + | |
| 437 | + '@typescript-eslint/visitor-keys@8.67.0': | |
| 438 | + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} | |
| 439 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 440 | + | |
| 441 | + '@ungap/structured-clone@1.3.3': | |
| 442 | + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} | |
| 443 | + | |
| 444 | + '@unrs/resolver-binding-android-arm-eabi@1.12.2': | |
| 445 | + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} | |
| 446 | + cpu: [arm] | |
| 447 | + os: [android] | |
| 448 | + | |
| 449 | + '@unrs/resolver-binding-android-arm64@1.12.2': | |
| 450 | + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} | |
| 451 | + cpu: [arm64] | |
| 452 | + os: [android] | |
| 453 | + | |
| 454 | + '@unrs/resolver-binding-darwin-arm64@1.12.2': | |
| 455 | + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} | |
| 456 | + cpu: [arm64] | |
| 457 | + os: [darwin] | |
| 458 | + | |
| 459 | + '@unrs/resolver-binding-darwin-x64@1.12.2': | |
| 460 | + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} | |
| 461 | + cpu: [x64] | |
| 462 | + os: [darwin] | |
| 463 | + | |
| 464 | + '@unrs/resolver-binding-freebsd-x64@1.12.2': | |
| 465 | + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} | |
| 466 | + cpu: [x64] | |
| 467 | + os: [freebsd] | |
| 468 | + | |
| 469 | + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': | |
| 470 | + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} | |
| 471 | + cpu: [arm] | |
| 472 | + os: [linux] | |
| 473 | + | |
| 474 | + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': | |
| 475 | + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} | |
| 476 | + cpu: [arm] | |
| 477 | + os: [linux] | |
| 478 | + | |
| 479 | + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': | |
| 480 | + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} | |
| 481 | + cpu: [arm64] | |
| 482 | + os: [linux] | |
| 483 | + libc: [glibc] | |
| 484 | + | |
| 485 | + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': | |
| 486 | + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} | |
| 487 | + cpu: [arm64] | |
| 488 | + os: [linux] | |
| 489 | + libc: [musl] | |
| 490 | + | |
| 491 | + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': | |
| 492 | + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} | |
| 493 | + cpu: [loong64] | |
| 494 | + os: [linux] | |
| 495 | + libc: [glibc] | |
| 496 | + | |
| 497 | + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': | |
| 498 | + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} | |
| 499 | + cpu: [loong64] | |
| 500 | + os: [linux] | |
| 501 | + libc: [musl] | |
| 502 | + | |
| 503 | + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': | |
| 504 | + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} | |
| 505 | + cpu: [ppc64] | |
| 506 | + os: [linux] | |
| 507 | + libc: [glibc] | |
| 508 | + | |
| 509 | + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': | |
| 510 | + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} | |
| 511 | + cpu: [riscv64] | |
| 512 | + os: [linux] | |
| 513 | + libc: [glibc] | |
| 514 | + | |
| 515 | + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': | |
| 516 | + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} | |
| 517 | + cpu: [riscv64] | |
| 518 | + os: [linux] | |
| 519 | + libc: [musl] | |
| 520 | + | |
| 521 | + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': | |
| 522 | + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} | |
| 523 | + cpu: [s390x] | |
| 524 | + os: [linux] | |
| 525 | + libc: [glibc] | |
| 526 | + | |
| 527 | + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': | |
| 528 | + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} | |
| 529 | + cpu: [x64] | |
| 530 | + os: [linux] | |
| 531 | + libc: [glibc] | |
| 532 | + | |
| 533 | + '@unrs/resolver-binding-linux-x64-musl@1.12.2': | |
| 534 | + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} | |
| 535 | + cpu: [x64] | |
| 536 | + os: [linux] | |
| 537 | + libc: [musl] | |
| 538 | + | |
| 539 | + '@unrs/resolver-binding-openharmony-arm64@1.12.2': | |
| 540 | + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} | |
| 541 | + cpu: [arm64] | |
| 542 | + os: [openharmony] | |
| 543 | + | |
| 544 | + '@unrs/resolver-binding-wasm32-wasi@1.12.2': | |
| 545 | + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} | |
| 546 | + engines: {node: '>=14.0.0'} | |
| 547 | + cpu: [wasm32] | |
| 548 | + | |
| 549 | + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': | |
| 550 | + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} | |
| 551 | + cpu: [arm64] | |
| 552 | + os: [win32] | |
| 553 | + | |
| 554 | + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': | |
| 555 | + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} | |
| 556 | + cpu: [ia32] | |
| 557 | + os: [win32] | |
| 558 | + | |
| 559 | + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': | |
| 560 | + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} | |
| 561 | + cpu: [x64] | |
| 562 | + os: [win32] | |
| 563 | + | |
| 564 | + acorn-jsx@5.3.2: | |
| 565 | + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} | |
| 566 | + peerDependencies: | |
| 567 | + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 | |
| 568 | + | |
| 569 | + acorn@8.18.0: | |
| 570 | + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} | |
| 571 | + engines: {node: '>=0.4.0'} | |
| 572 | + hasBin: true | |
| 573 | + | |
| 574 | + ajv@6.15.0: | |
| 575 | + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} | |
| 576 | + | |
| 577 | + ansi-regex@5.0.1: | |
| 578 | + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} | |
| 579 | + engines: {node: '>=8'} | |
| 580 | + | |
| 581 | + ansi-styles@4.3.0: | |
| 582 | + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} | |
| 583 | + engines: {node: '>=8'} | |
| 584 | + | |
| 585 | + any-promise@1.3.0: | |
| 586 | + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} | |
| 587 | + | |
| 588 | + anymatch@3.1.3: | |
| 589 | + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} | |
| 590 | + engines: {node: '>= 8'} | |
| 591 | + | |
| 592 | + arg@5.0.2: | |
| 593 | + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} | |
| 594 | + | |
| 595 | + argparse@2.0.1: | |
| 596 | + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} | |
| 597 | + | |
| 598 | + aria-query@5.3.2: | |
| 599 | + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} | |
| 600 | + engines: {node: '>= 0.4'} | |
| 601 | + | |
| 602 | + array-buffer-byte-length@1.0.2: | |
| 603 | + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} | |
| 604 | + engines: {node: '>= 0.4'} | |
| 605 | + | |
| 606 | + array-includes@3.1.9: | |
| 607 | + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} | |
| 608 | + engines: {node: '>= 0.4'} | |
| 609 | + | |
| 610 | + array.prototype.findlast@1.2.5: | |
| 611 | + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} | |
| 612 | + engines: {node: '>= 0.4'} | |
| 613 | + | |
| 614 | + array.prototype.findlastindex@1.2.6: | |
| 615 | + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} | |
| 616 | + engines: {node: '>= 0.4'} | |
| 617 | + | |
| 618 | + array.prototype.flat@1.3.3: | |
| 619 | + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} | |
| 620 | + engines: {node: '>= 0.4'} | |
| 621 | + | |
| 622 | + array.prototype.flatmap@1.3.3: | |
| 623 | + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} | |
| 624 | + engines: {node: '>= 0.4'} | |
| 625 | + | |
| 626 | + array.prototype.tosorted@1.1.4: | |
| 627 | + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} | |
| 628 | + engines: {node: '>= 0.4'} | |
| 629 | + | |
| 630 | + arraybuffer.prototype.slice@1.0.4: | |
| 631 | + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} | |
| 632 | + engines: {node: '>= 0.4'} | |
| 633 | + | |
| 634 | + ast-types-flow@0.0.8: | |
| 635 | + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} | |
| 636 | + | |
| 637 | + async-function@1.0.0: | |
| 638 | + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} | |
| 639 | + engines: {node: '>= 0.4'} | |
| 640 | + | |
| 641 | + autoprefixer@10.5.4: | |
| 642 | + resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} | |
| 643 | + engines: {node: ^10 || ^12 || >=14} | |
| 644 | + hasBin: true | |
| 645 | + peerDependencies: | |
| 646 | + postcss: ^8.1.0 | |
| 647 | + | |
| 648 | + available-typed-arrays@1.0.7: | |
| 649 | + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} | |
| 650 | + engines: {node: '>= 0.4'} | |
| 651 | + | |
| 652 | + axe-core@4.13.0: | |
| 653 | + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} | |
| 654 | + engines: {node: '>=4'} | |
| 655 | + | |
| 656 | + axobject-query@4.1.0: | |
| 657 | + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} | |
| 658 | + engines: {node: '>= 0.4'} | |
| 659 | + | |
| 660 | + balanced-match@1.0.2: | |
| 661 | + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} | |
| 662 | + | |
| 663 | + balanced-match@4.0.4: | |
| 664 | + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} | |
| 665 | + engines: {node: 18 || 20 || >=22} | |
| 666 | + | |
| 667 | + baseline-browser-mapping@2.11.13: | |
| 668 | + resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} | |
| 669 | + engines: {node: '>=6.0.0'} | |
| 670 | + hasBin: true | |
| 671 | + | |
| 672 | + binary-extensions@2.3.0: | |
| 673 | + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} | |
| 674 | + engines: {node: '>=8'} | |
| 675 | + | |
| 676 | + brace-expansion@1.1.18: | |
| 677 | + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} | |
| 678 | + | |
| 679 | + brace-expansion@5.0.9: | |
| 680 | + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} | |
| 681 | + engines: {node: 20 || >=22} | |
| 682 | + | |
| 683 | + braces@3.0.3: | |
| 684 | + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} | |
| 685 | + engines: {node: '>=8'} | |
| 686 | + | |
| 687 | + browserslist@4.28.8: | |
| 688 | + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} | |
| 689 | + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} | |
| 690 | + hasBin: true | |
| 691 | + | |
| 692 | + call-bind-apply-helpers@1.0.2: | |
| 693 | + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} | |
| 694 | + engines: {node: '>= 0.4'} | |
| 695 | + | |
| 696 | + call-bind@1.0.9: | |
| 697 | + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} | |
| 698 | + engines: {node: '>= 0.4'} | |
| 699 | + | |
| 700 | + call-bound@1.0.4: | |
| 701 | + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} | |
| 702 | + engines: {node: '>= 0.4'} | |
| 703 | + | |
| 704 | + callsites@3.1.0: | |
| 705 | + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} | |
| 706 | + engines: {node: '>=6'} | |
| 707 | + | |
| 708 | + camelcase-css@2.0.1: | |
| 709 | + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} | |
| 710 | + engines: {node: '>= 6'} | |
| 711 | + | |
| 712 | + caniuse-lite@1.0.30001809: | |
| 713 | + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} | |
| 714 | + | |
| 715 | + chalk@4.1.2: | |
| 716 | + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} | |
| 717 | + engines: {node: '>=10'} | |
| 718 | + | |
| 719 | + chokidar@3.6.0: | |
| 720 | + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} | |
| 721 | + engines: {node: '>= 8.10.0'} | |
| 722 | + | |
| 723 | + class-variance-authority@0.7.1: | |
| 724 | + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} | |
| 725 | + | |
| 726 | + client-only@0.0.1: | |
| 727 | + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} | |
| 728 | + | |
| 729 | + clsx@2.1.1: | |
| 730 | + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} | |
| 731 | + engines: {node: '>=6'} | |
| 732 | + | |
| 733 | + color-convert@2.0.1: | |
| 734 | + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} | |
| 735 | + engines: {node: '>=7.0.0'} | |
| 736 | + | |
| 737 | + color-name@1.1.4: | |
| 738 | + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} | |
| 739 | + | |
| 740 | + commander@4.1.1: | |
| 741 | + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} | |
| 742 | + engines: {node: '>= 6'} | |
| 743 | + | |
| 744 | + concat-map@0.0.1: | |
| 745 | + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} | |
| 746 | + | |
| 747 | + cross-spawn@7.0.6: | |
| 748 | + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} | |
| 749 | + engines: {node: '>= 8'} | |
| 750 | + | |
| 751 | + cssesc@3.0.0: | |
| 752 | + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} | |
| 753 | + engines: {node: '>=4'} | |
| 754 | + hasBin: true | |
| 755 | + | |
| 756 | + csstype@3.2.3: | |
| 757 | + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} | |
| 758 | + | |
| 759 | + damerau-levenshtein@1.0.8: | |
| 760 | + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} | |
| 761 | + | |
| 762 | + data-view-buffer@1.0.2: | |
| 763 | + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} | |
| 764 | + engines: {node: '>= 0.4'} | |
| 765 | + | |
| 766 | + data-view-byte-length@1.0.2: | |
| 767 | + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} | |
| 768 | + engines: {node: '>= 0.4'} | |
| 769 | + | |
| 770 | + data-view-byte-offset@1.0.1: | |
| 771 | + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} | |
| 772 | + engines: {node: '>= 0.4'} | |
| 773 | + | |
| 774 | + debug@3.2.7: | |
| 775 | + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} | |
| 776 | + peerDependencies: | |
| 777 | + supports-color: '*' | |
| 778 | + peerDependenciesMeta: | |
| 779 | + supports-color: | |
| 780 | + optional: true | |
| 781 | + | |
| 782 | + debug@4.4.3: | |
| 783 | + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} | |
| 784 | + engines: {node: '>=6.0'} | |
| 785 | + peerDependencies: | |
| 786 | + supports-color: '*' | |
| 787 | + peerDependenciesMeta: | |
| 788 | + supports-color: | |
| 789 | + optional: true | |
| 790 | + | |
| 791 | + deep-is@0.1.4: | |
| 792 | + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} | |
| 793 | + | |
| 794 | + define-data-property@1.1.4: | |
| 795 | + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} | |
| 796 | + engines: {node: '>= 0.4'} | |
| 797 | + | |
| 798 | + define-properties@1.2.1: | |
| 799 | + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} | |
| 800 | + engines: {node: '>= 0.4'} | |
| 801 | + | |
| 802 | + detect-libc@2.1.2: | |
| 803 | + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} | |
| 804 | + engines: {node: '>=8'} | |
| 805 | + | |
| 806 | + didyoumean@1.2.2: | |
| 807 | + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} | |
| 808 | + | |
| 809 | + dlv@1.1.3: | |
| 810 | + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} | |
| 811 | + | |
| 812 | + doctrine@2.1.0: | |
| 813 | + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} | |
| 814 | + engines: {node: '>=0.10.0'} | |
| 815 | + | |
| 816 | + doctrine@3.0.0: | |
| 817 | + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} | |
| 818 | + engines: {node: '>=6.0.0'} | |
| 819 | + | |
| 820 | + dunder-proto@1.0.1: | |
| 821 | + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} | |
| 822 | + engines: {node: '>= 0.4'} | |
| 823 | + | |
| 824 | + electron-to-chromium@1.5.405: | |
| 825 | + resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==} | |
| 826 | + | |
| 827 | + emoji-regex@9.2.2: | |
| 828 | + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} | |
| 829 | + | |
| 830 | + es-abstract-get@1.0.0: | |
| 831 | + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} | |
| 832 | + engines: {node: '>= 0.4'} | |
| 833 | + | |
| 834 | + es-abstract@1.24.2: | |
| 835 | + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} | |
| 836 | + engines: {node: '>= 0.4'} | |
| 837 | + | |
| 838 | + es-define-property@1.0.1: | |
| 839 | + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} | |
| 840 | + engines: {node: '>= 0.4'} | |
| 841 | + | |
| 842 | + es-errors@1.3.0: | |
| 843 | + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} | |
| 844 | + engines: {node: '>= 0.4'} | |
| 845 | + | |
| 846 | + es-iterator-helpers@1.4.0: | |
| 847 | + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} | |
| 848 | + engines: {node: '>= 0.4'} | |
| 849 | + | |
| 850 | + es-object-atoms@1.1.2: | |
| 851 | + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} | |
| 852 | + engines: {node: '>= 0.4'} | |
| 853 | + | |
| 854 | + es-set-tostringtag@2.1.0: | |
| 855 | + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} | |
| 856 | + engines: {node: '>= 0.4'} | |
| 857 | + | |
| 858 | + es-shim-unscopables@1.1.0: | |
| 859 | + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} | |
| 860 | + engines: {node: '>= 0.4'} | |
| 861 | + | |
| 862 | + es-to-primitive@1.3.4: | |
| 863 | + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} | |
| 864 | + engines: {node: '>= 0.4'} | |
| 865 | + | |
| 866 | + escalade@3.2.0: | |
| 867 | + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} | |
| 868 | + engines: {node: '>=6'} | |
| 869 | + | |
| 870 | + escape-string-regexp@4.0.0: | |
| 871 | + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} | |
| 872 | + engines: {node: '>=10'} | |
| 873 | + | |
| 874 | + eslint-config-next@15.5.23: | |
| 875 | + resolution: {integrity: sha512-z4WcTXNqFHwMG4V8WHb2xrlEPJvwarZa+H/6CR28vxr53icRnQzGXviO11p748BwrMZGl52itdPRzZfzYo0SKw==} | |
| 876 | + peerDependencies: | |
| 877 | + eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 | |
| 878 | + typescript: '>=3.3.1' | |
| 879 | + peerDependenciesMeta: | |
| 880 | + typescript: | |
| 881 | + optional: true | |
| 882 | + | |
| 883 | + eslint-import-resolver-node@0.3.10: | |
| 884 | + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} | |
| 885 | + | |
| 886 | + eslint-import-resolver-typescript@3.10.1: | |
| 887 | + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} | |
| 888 | + engines: {node: ^14.18.0 || >=16.0.0} | |
| 889 | + peerDependencies: | |
| 890 | + eslint: '*' | |
| 891 | + eslint-plugin-import: '*' | |
| 892 | + eslint-plugin-import-x: '*' | |
| 893 | + peerDependenciesMeta: | |
| 894 | + eslint-plugin-import: | |
| 895 | + optional: true | |
| 896 | + eslint-plugin-import-x: | |
| 897 | + optional: true | |
| 898 | + | |
| 899 | + eslint-module-utils@2.14.0: | |
| 900 | + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} | |
| 901 | + engines: {node: '>=4'} | |
| 902 | + peerDependencies: | |
| 903 | + '@typescript-eslint/parser': '*' | |
| 904 | + eslint: '*' | |
| 905 | + eslint-import-resolver-node: '*' | |
| 906 | + eslint-import-resolver-typescript: '*' | |
| 907 | + eslint-import-resolver-webpack: '*' | |
| 908 | + peerDependenciesMeta: | |
| 909 | + '@typescript-eslint/parser': | |
| 910 | + optional: true | |
| 911 | + eslint: | |
| 912 | + optional: true | |
| 913 | + eslint-import-resolver-node: | |
| 914 | + optional: true | |
| 915 | + eslint-import-resolver-typescript: | |
| 916 | + optional: true | |
| 917 | + eslint-import-resolver-webpack: | |
| 918 | + optional: true | |
| 919 | + | |
| 920 | + eslint-plugin-import@2.32.0: | |
| 921 | + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} | |
| 922 | + engines: {node: '>=4'} | |
| 923 | + peerDependencies: | |
| 924 | + '@typescript-eslint/parser': '*' | |
| 925 | + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 | |
| 926 | + peerDependenciesMeta: | |
| 927 | + '@typescript-eslint/parser': | |
| 928 | + optional: true | |
| 929 | + | |
| 930 | + eslint-plugin-jsx-a11y@6.10.2: | |
| 931 | + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} | |
| 932 | + engines: {node: '>=4.0'} | |
| 933 | + peerDependencies: | |
| 934 | + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 | |
| 935 | + | |
| 936 | + eslint-plugin-react-hooks@5.2.0: | |
| 937 | + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} | |
| 938 | + engines: {node: '>=10'} | |
| 939 | + peerDependencies: | |
| 940 | + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 | |
| 941 | + | |
| 942 | + eslint-plugin-react@7.37.5: | |
| 943 | + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} | |
| 944 | + engines: {node: '>=4'} | |
| 945 | + peerDependencies: | |
| 946 | + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 | |
| 947 | + | |
| 948 | + eslint-scope@7.2.2: | |
| 949 | + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} | |
| 950 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 951 | + | |
| 952 | + eslint-visitor-keys@3.4.3: | |
| 953 | + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} | |
| 954 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 955 | + | |
| 956 | + eslint-visitor-keys@5.0.1: | |
| 957 | + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} | |
| 958 | + engines: {node: ^20.19.0 || ^22.13.0 || >=24} | |
| 959 | + | |
| 960 | + eslint@8.57.1: | |
| 961 | + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} | |
| 962 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 963 | + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. | |
| 964 | + hasBin: true | |
| 965 | + | |
| 966 | + espree@9.6.1: | |
| 967 | + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} | |
| 968 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 969 | + | |
| 970 | + esquery@1.7.0: | |
| 971 | + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} | |
| 972 | + engines: {node: '>=0.10'} | |
| 973 | + | |
| 974 | + esrecurse@4.3.0: | |
| 975 | + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} | |
| 976 | + engines: {node: '>=4.0'} | |
| 977 | + | |
| 978 | + estraverse@5.3.0: | |
| 979 | + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} | |
| 980 | + engines: {node: '>=4.0'} | |
| 981 | + | |
| 982 | + esutils@2.0.3: | |
| 983 | + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} | |
| 984 | + engines: {node: '>=0.10.0'} | |
| 985 | + | |
| 986 | + fast-deep-equal@3.1.3: | |
| 987 | + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} | |
| 988 | + | |
| 989 | + fast-glob@3.3.1: | |
| 990 | + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} | |
| 991 | + engines: {node: '>=8.6.0'} | |
| 992 | + | |
| 993 | + fast-glob@3.3.3: | |
| 994 | + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} | |
| 995 | + engines: {node: '>=8.6.0'} | |
| 996 | + | |
| 997 | + fast-json-stable-stringify@2.1.0: | |
| 998 | + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} | |
| 999 | + | |
| 1000 | + fast-levenshtein@2.0.6: | |
| 1001 | + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} | |
| 1002 | + | |
| 1003 | + fastq@1.20.1: | |
| 1004 | + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} | |
| 1005 | + | |
| 1006 | + fdir@6.5.0: | |
| 1007 | + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} | |
| 1008 | + engines: {node: '>=12.0.0'} | |
| 1009 | + peerDependencies: | |
| 1010 | + picomatch: ^3 || ^4 | |
| 1011 | + peerDependenciesMeta: | |
| 1012 | + picomatch: | |
| 1013 | + optional: true | |
| 1014 | + | |
| 1015 | + file-entry-cache@6.0.1: | |
| 1016 | + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} | |
| 1017 | + engines: {node: ^10.12.0 || >=12.0.0} | |
| 1018 | + | |
| 1019 | + fill-range@7.1.1: | |
| 1020 | + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} | |
| 1021 | + engines: {node: '>=8'} | |
| 1022 | + | |
| 1023 | + find-up@5.0.0: | |
| 1024 | + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} | |
| 1025 | + engines: {node: '>=10'} | |
| 1026 | + | |
| 1027 | + flat-cache@3.2.0: | |
| 1028 | + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} | |
| 1029 | + engines: {node: ^10.12.0 || >=12.0.0} | |
| 1030 | + | |
| 1031 | + flatted@3.4.4: | |
| 1032 | + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} | |
| 1033 | + | |
| 1034 | + for-each@0.3.5: | |
| 1035 | + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} | |
| 1036 | + engines: {node: '>= 0.4'} | |
| 1037 | + | |
| 1038 | + fraction.js@5.3.4: | |
| 1039 | + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} | |
| 1040 | + | |
| 1041 | + fs.realpath@1.0.0: | |
| 1042 | + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} | |
| 1043 | + | |
| 1044 | + fsevents@2.3.3: | |
| 1045 | + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} | |
| 1046 | + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} | |
| 1047 | + os: [darwin] | |
| 1048 | + | |
| 1049 | + function-bind@1.1.2: | |
| 1050 | + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} | |
| 1051 | + | |
| 1052 | + function.prototype.name@1.2.0: | |
| 1053 | + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} | |
| 1054 | + engines: {node: '>= 0.4'} | |
| 1055 | + | |
| 1056 | + functions-have-names@1.2.3: | |
| 1057 | + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} | |
| 1058 | + | |
| 1059 | + generator-function@2.0.1: | |
| 1060 | + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} | |
| 1061 | + engines: {node: '>= 0.4'} | |
| 1062 | + | |
| 1063 | + get-intrinsic@1.3.0: | |
| 1064 | + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} | |
| 1065 | + engines: {node: '>= 0.4'} | |
| 1066 | + | |
| 1067 | + get-proto@1.0.1: | |
| 1068 | + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} | |
| 1069 | + engines: {node: '>= 0.4'} | |
| 1070 | + | |
| 1071 | + get-symbol-description@1.1.0: | |
| 1072 | + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} | |
| 1073 | + engines: {node: '>= 0.4'} | |
| 1074 | + | |
| 1075 | + get-tsconfig@4.14.2: | |
| 1076 | + resolution: {integrity: sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==} | |
| 1077 | + | |
| 1078 | + glob-parent@5.1.2: | |
| 1079 | + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} | |
| 1080 | + engines: {node: '>= 6'} | |
| 1081 | + | |
| 1082 | + glob-parent@6.0.2: | |
| 1083 | + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} | |
| 1084 | + engines: {node: '>=10.13.0'} | |
| 1085 | + | |
| 1086 | + glob@7.2.3: | |
| 1087 | + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} | |
| 1088 | + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me | |
| 1089 | + | |
| 1090 | + globals@13.24.0: | |
| 1091 | + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} | |
| 1092 | + engines: {node: '>=8'} | |
| 1093 | + | |
| 1094 | + globalthis@1.0.4: | |
| 1095 | + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} | |
| 1096 | + engines: {node: '>= 0.4'} | |
| 1097 | + | |
| 1098 | + gopd@1.2.0: | |
| 1099 | + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} | |
| 1100 | + engines: {node: '>= 0.4'} | |
| 1101 | + | |
| 1102 | + graphemer@1.4.0: | |
| 1103 | + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} | |
| 1104 | + | |
| 1105 | + has-bigints@1.1.0: | |
| 1106 | + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} | |
| 1107 | + engines: {node: '>= 0.4'} | |
| 1108 | + | |
| 1109 | + has-flag@4.0.0: | |
| 1110 | + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} | |
| 1111 | + engines: {node: '>=8'} | |
| 1112 | + | |
| 1113 | + has-property-descriptors@1.0.2: | |
| 1114 | + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} | |
| 1115 | + | |
| 1116 | + has-proto@1.2.0: | |
| 1117 | + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} | |
| 1118 | + engines: {node: '>= 0.4'} | |
| 1119 | + | |
| 1120 | + has-symbols@1.1.0: | |
| 1121 | + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} | |
| 1122 | + engines: {node: '>= 0.4'} | |
| 1123 | + | |
| 1124 | + has-tostringtag@1.0.2: | |
| 1125 | + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} | |
| 1126 | + engines: {node: '>= 0.4'} | |
| 1127 | + | |
| 1128 | + hasown@2.0.4: | |
| 1129 | + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} | |
| 1130 | + engines: {node: '>= 0.4'} | |
| 1131 | + | |
| 1132 | + ignore@5.3.2: | |
| 1133 | + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} | |
| 1134 | + engines: {node: '>= 4'} | |
| 1135 | + | |
| 1136 | + ignore@7.0.6: | |
| 1137 | + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} | |
| 1138 | + engines: {node: '>= 4'} | |
| 1139 | + | |
| 1140 | + import-fresh@3.3.1: | |
| 1141 | + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} | |
| 1142 | + engines: {node: '>=6'} | |
| 1143 | + | |
| 1144 | + imurmurhash@0.1.4: | |
| 1145 | + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} | |
| 1146 | + engines: {node: '>=0.8.19'} | |
| 1147 | + | |
| 1148 | + inflight@1.0.6: | |
| 1149 | + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} | |
| 1150 | + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. | |
| 1151 | + | |
| 1152 | + inherits@2.0.4: | |
| 1153 | + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} | |
| 1154 | + | |
| 1155 | + internal-slot@1.1.0: | |
| 1156 | + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} | |
| 1157 | + engines: {node: '>= 0.4'} | |
| 1158 | + | |
| 1159 | + is-array-buffer@3.0.5: | |
| 1160 | + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} | |
| 1161 | + engines: {node: '>= 0.4'} | |
| 1162 | + | |
| 1163 | + is-async-function@2.1.1: | |
| 1164 | + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} | |
| 1165 | + engines: {node: '>= 0.4'} | |
| 1166 | + | |
| 1167 | + is-bigint@1.1.0: | |
| 1168 | + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} | |
| 1169 | + engines: {node: '>= 0.4'} | |
| 1170 | + | |
| 1171 | + is-binary-path@2.1.0: | |
| 1172 | + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} | |
| 1173 | + engines: {node: '>=8'} | |
| 1174 | + | |
| 1175 | + is-boolean-object@1.2.2: | |
| 1176 | + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} | |
| 1177 | + engines: {node: '>= 0.4'} | |
| 1178 | + | |
| 1179 | + is-bun-module@2.0.0: | |
| 1180 | + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} | |
| 1181 | + | |
| 1182 | + is-callable@1.2.7: | |
| 1183 | + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} | |
| 1184 | + engines: {node: '>= 0.4'} | |
| 1185 | + | |
| 1186 | + is-core-module@2.16.2: | |
| 1187 | + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} | |
| 1188 | + engines: {node: '>= 0.4'} | |
| 1189 | + | |
| 1190 | + is-data-view@1.0.2: | |
| 1191 | + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} | |
| 1192 | + engines: {node: '>= 0.4'} | |
| 1193 | + | |
| 1194 | + is-date-object@1.1.0: | |
| 1195 | + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} | |
| 1196 | + engines: {node: '>= 0.4'} | |
| 1197 | + | |
| 1198 | + is-document.all@1.0.0: | |
| 1199 | + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} | |
| 1200 | + engines: {node: '>= 0.4'} | |
| 1201 | + | |
| 1202 | + is-extglob@2.1.1: | |
| 1203 | + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} | |
| 1204 | + engines: {node: '>=0.10.0'} | |
| 1205 | + | |
| 1206 | + is-finalizationregistry@1.1.1: | |
| 1207 | + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} | |
| 1208 | + engines: {node: '>= 0.4'} | |
| 1209 | + | |
| 1210 | + is-generator-function@1.1.2: | |
| 1211 | + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} | |
| 1212 | + engines: {node: '>= 0.4'} | |
| 1213 | + | |
| 1214 | + is-glob@4.0.3: | |
| 1215 | + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} | |
| 1216 | + engines: {node: '>=0.10.0'} | |
| 1217 | + | |
| 1218 | + is-map@2.0.3: | |
| 1219 | + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} | |
| 1220 | + engines: {node: '>= 0.4'} | |
| 1221 | + | |
| 1222 | + is-negative-zero@2.0.3: | |
| 1223 | + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} | |
| 1224 | + engines: {node: '>= 0.4'} | |
| 1225 | + | |
| 1226 | + is-number-object@1.1.1: | |
| 1227 | + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} | |
| 1228 | + engines: {node: '>= 0.4'} | |
| 1229 | + | |
| 1230 | + is-number@7.0.0: | |
| 1231 | + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} | |
| 1232 | + engines: {node: '>=0.12.0'} | |
| 1233 | + | |
| 1234 | + is-path-inside@3.0.3: | |
| 1235 | + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} | |
| 1236 | + engines: {node: '>=8'} | |
| 1237 | + | |
| 1238 | + is-regex@1.2.1: | |
| 1239 | + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} | |
| 1240 | + engines: {node: '>= 0.4'} | |
| 1241 | + | |
| 1242 | + is-set@2.0.3: | |
| 1243 | + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} | |
| 1244 | + engines: {node: '>= 0.4'} | |
| 1245 | + | |
| 1246 | + is-shared-array-buffer@1.0.4: | |
| 1247 | + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} | |
| 1248 | + engines: {node: '>= 0.4'} | |
| 1249 | + | |
| 1250 | + is-string@1.1.1: | |
| 1251 | + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} | |
| 1252 | + engines: {node: '>= 0.4'} | |
| 1253 | + | |
| 1254 | + is-symbol@1.1.1: | |
| 1255 | + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} | |
| 1256 | + engines: {node: '>= 0.4'} | |
| 1257 | + | |
| 1258 | + is-typed-array@1.1.15: | |
| 1259 | + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} | |
| 1260 | + engines: {node: '>= 0.4'} | |
| 1261 | + | |
| 1262 | + is-weakmap@2.0.2: | |
| 1263 | + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} | |
| 1264 | + engines: {node: '>= 0.4'} | |
| 1265 | + | |
| 1266 | + is-weakref@1.1.1: | |
| 1267 | + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} | |
| 1268 | + engines: {node: '>= 0.4'} | |
| 1269 | + | |
| 1270 | + is-weakset@2.0.4: | |
| 1271 | + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} | |
| 1272 | + engines: {node: '>= 0.4'} | |
| 1273 | + | |
| 1274 | + isarray@2.0.5: | |
| 1275 | + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} | |
| 1276 | + | |
| 1277 | + isexe@2.0.0: | |
| 1278 | + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} | |
| 1279 | + | |
| 1280 | + iterator.prototype@1.1.5: | |
| 1281 | + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} | |
| 1282 | + engines: {node: '>= 0.4'} | |
| 1283 | + | |
| 1284 | + jiti@1.21.7: | |
| 1285 | + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} | |
| 1286 | + hasBin: true | |
| 1287 | + | |
| 1288 | + js-tokens@4.0.0: | |
| 1289 | + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} | |
| 1290 | + | |
| 1291 | + js-yaml@4.3.1: | |
| 1292 | + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} | |
| 1293 | + hasBin: true | |
| 1294 | + | |
| 1295 | + json-buffer@3.0.1: | |
| 1296 | + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} | |
| 1297 | + | |
| 1298 | + json-schema-traverse@0.4.1: | |
| 1299 | + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} | |
| 1300 | + | |
| 1301 | + json-stable-stringify-without-jsonify@1.0.1: | |
| 1302 | + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} | |
| 1303 | + | |
| 1304 | + json5@1.0.2: | |
| 1305 | + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} | |
| 1306 | + hasBin: true | |
| 1307 | + | |
| 1308 | + jsx-ast-utils@3.3.5: | |
| 1309 | + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} | |
| 1310 | + engines: {node: '>=4.0'} | |
| 1311 | + | |
| 1312 | + keyv@4.5.4: | |
| 1313 | + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} | |
| 1314 | + | |
| 1315 | + language-subtag-registry@0.3.23: | |
| 1316 | + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} | |
| 1317 | + | |
| 1318 | + language-tags@1.0.9: | |
| 1319 | + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} | |
| 1320 | + engines: {node: '>=0.10'} | |
| 1321 | + | |
| 1322 | + levn@0.4.1: | |
| 1323 | + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} | |
| 1324 | + engines: {node: '>= 0.8.0'} | |
| 1325 | + | |
| 1326 | + lilconfig@3.1.3: | |
| 1327 | + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} | |
| 1328 | + engines: {node: '>=14'} | |
| 1329 | + | |
| 1330 | + lines-and-columns@1.2.4: | |
| 1331 | + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} | |
| 1332 | + | |
| 1333 | + locate-path@6.0.0: | |
| 1334 | + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} | |
| 1335 | + engines: {node: '>=10'} | |
| 1336 | + | |
| 1337 | + lodash.merge@4.6.2: | |
| 1338 | + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} | |
| 1339 | + | |
| 1340 | + loose-envify@1.4.0: | |
| 1341 | + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} | |
| 1342 | + hasBin: true | |
| 1343 | + | |
| 1344 | + math-intrinsics@1.1.0: | |
| 1345 | + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} | |
| 1346 | + engines: {node: '>= 0.4'} | |
| 1347 | + | |
| 1348 | + merge2@1.4.1: | |
| 1349 | + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} | |
| 1350 | + engines: {node: '>= 8'} | |
| 1351 | + | |
| 1352 | + micromatch@4.0.8: | |
| 1353 | + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} | |
| 1354 | + engines: {node: '>=8.6'} | |
| 1355 | + | |
| 1356 | + minimatch@10.2.6: | |
| 1357 | + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} | |
| 1358 | + engines: {node: 18 || 20 || >=22} | |
| 1359 | + | |
| 1360 | + minimatch@3.1.5: | |
| 1361 | + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} | |
| 1362 | + | |
| 1363 | + minimist@1.2.8: | |
| 1364 | + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} | |
| 1365 | + | |
| 1366 | + ms@2.1.3: | |
| 1367 | + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} | |
| 1368 | + | |
| 1369 | + mz@2.7.0: | |
| 1370 | + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} | |
| 1371 | + | |
| 1372 | + nanoid@3.3.18: | |
| 1373 | + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} | |
| 1374 | + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} | |
| 1375 | + hasBin: true | |
| 1376 | + | |
| 1377 | + napi-postinstall@0.3.4: | |
| 1378 | + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} | |
| 1379 | + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} | |
| 1380 | + hasBin: true | |
| 1381 | + | |
| 1382 | + natural-compare@1.4.0: | |
| 1383 | + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} | |
| 1384 | + | |
| 1385 | + next@15.5.23: | |
| 1386 | + resolution: {integrity: sha512-Gvd2WKgvxIXCGotxcI1im/Uf3rS3J3oZGw0g/uskg6AVBZhyE3aAbujkYWzS3xLmEPEtTLfkaVQUKK0KMTSIkA==} | |
| 1387 | + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} | |
| 1388 | + hasBin: true | |
| 1389 | + peerDependencies: | |
| 1390 | + '@opentelemetry/api': ^1.1.0 | |
| 1391 | + '@playwright/test': ^1.51.1 | |
| 1392 | + babel-plugin-react-compiler: '*' | |
| 1393 | + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 | |
| 1394 | + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 | |
| 1395 | + sass: ^1.3.0 | |
| 1396 | + peerDependenciesMeta: | |
| 1397 | + '@opentelemetry/api': | |
| 1398 | + optional: true | |
| 1399 | + '@playwright/test': | |
| 1400 | + optional: true | |
| 1401 | + babel-plugin-react-compiler: | |
| 1402 | + optional: true | |
| 1403 | + sass: | |
| 1404 | + optional: true | |
| 1405 | + | |
| 1406 | + node-exports-info@1.6.2: | |
| 1407 | + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} | |
| 1408 | + engines: {node: '>= 0.4'} | |
| 1409 | + | |
| 1410 | + node-releases@2.0.53: | |
| 1411 | + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} | |
| 1412 | + engines: {node: '>=18'} | |
| 1413 | + | |
| 1414 | + normalize-path@3.0.0: | |
| 1415 | + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} | |
| 1416 | + engines: {node: '>=0.10.0'} | |
| 1417 | + | |
| 1418 | + object-assign@4.1.1: | |
| 1419 | + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} | |
| 1420 | + engines: {node: '>=0.10.0'} | |
| 1421 | + | |
| 1422 | + object-hash@3.0.0: | |
| 1423 | + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} | |
| 1424 | + engines: {node: '>= 6'} | |
| 1425 | + | |
| 1426 | + object-inspect@1.13.4: | |
| 1427 | + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} | |
| 1428 | + engines: {node: '>= 0.4'} | |
| 1429 | + | |
| 1430 | + object-keys@1.1.1: | |
| 1431 | + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} | |
| 1432 | + engines: {node: '>= 0.4'} | |
| 1433 | + | |
| 1434 | + object.assign@4.1.7: | |
| 1435 | + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} | |
| 1436 | + engines: {node: '>= 0.4'} | |
| 1437 | + | |
| 1438 | + object.entries@1.1.9: | |
| 1439 | + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} | |
| 1440 | + engines: {node: '>= 0.4'} | |
| 1441 | + | |
| 1442 | + object.fromentries@2.0.8: | |
| 1443 | + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} | |
| 1444 | + engines: {node: '>= 0.4'} | |
| 1445 | + | |
| 1446 | + object.groupby@1.0.3: | |
| 1447 | + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} | |
| 1448 | + engines: {node: '>= 0.4'} | |
| 1449 | + | |
| 1450 | + object.values@1.2.1: | |
| 1451 | + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} | |
| 1452 | + engines: {node: '>= 0.4'} | |
| 1453 | + | |
| 1454 | + once@1.4.0: | |
| 1455 | + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} | |
| 1456 | + | |
| 1457 | + optionator@0.9.4: | |
| 1458 | + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} | |
| 1459 | + engines: {node: '>= 0.8.0'} | |
| 1460 | + | |
| 1461 | + own-keys@1.0.2: | |
| 1462 | + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} | |
| 1463 | + engines: {node: '>= 0.4'} | |
| 1464 | + | |
| 1465 | + p-limit@3.1.0: | |
| 1466 | + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} | |
| 1467 | + engines: {node: '>=10'} | |
| 1468 | + | |
| 1469 | + p-locate@5.0.0: | |
| 1470 | + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} | |
| 1471 | + engines: {node: '>=10'} | |
| 1472 | + | |
| 1473 | + parent-module@1.0.1: | |
| 1474 | + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} | |
| 1475 | + engines: {node: '>=6'} | |
| 1476 | + | |
| 1477 | + path-exists@4.0.0: | |
| 1478 | + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} | |
| 1479 | + engines: {node: '>=8'} | |
| 1480 | + | |
| 1481 | + path-is-absolute@1.0.1: | |
| 1482 | + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} | |
| 1483 | + engines: {node: '>=0.10.0'} | |
| 1484 | + | |
| 1485 | + path-key@3.1.1: | |
| 1486 | + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} | |
| 1487 | + engines: {node: '>=8'} | |
| 1488 | + | |
| 1489 | + path-parse@1.0.7: | |
| 1490 | + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} | |
| 1491 | + | |
| 1492 | + picocolors@1.1.1: | |
| 1493 | + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} | |
| 1494 | + | |
| 1495 | + picomatch@2.3.2: | |
| 1496 | + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} | |
| 1497 | + engines: {node: '>=8.6'} | |
| 1498 | + | |
| 1499 | + picomatch@4.0.5: | |
| 1500 | + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} | |
| 1501 | + engines: {node: '>=12'} | |
| 1502 | + | |
| 1503 | + pify@2.3.0: | |
| 1504 | + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} | |
| 1505 | + engines: {node: '>=0.10.0'} | |
| 1506 | + | |
| 1507 | + pirates@4.0.7: | |
| 1508 | + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} | |
| 1509 | + engines: {node: '>= 6'} | |
| 1510 | + | |
| 1511 | + possible-typed-array-names@1.1.0: | |
| 1512 | + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} | |
| 1513 | + engines: {node: '>= 0.4'} | |
| 1514 | + | |
| 1515 | + postcss-import@15.1.0: | |
| 1516 | + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} | |
| 1517 | + engines: {node: '>=14.0.0'} | |
| 1518 | + peerDependencies: | |
| 1519 | + postcss: ^8.0.0 | |
| 1520 | + | |
| 1521 | + postcss-js@4.1.0: | |
| 1522 | + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} | |
| 1523 | + engines: {node: ^12 || ^14 || >= 16} | |
| 1524 | + peerDependencies: | |
| 1525 | + postcss: ^8.4.21 | |
| 1526 | + | |
| 1527 | + postcss-load-config@6.0.1: | |
| 1528 | + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} | |
| 1529 | + engines: {node: '>= 18'} | |
| 1530 | + peerDependencies: | |
| 1531 | + jiti: '>=1.21.0' | |
| 1532 | + postcss: '>=8.0.9' | |
| 1533 | + tsx: ^4.8.1 | |
| 1534 | + yaml: ^2.4.2 | |
| 1535 | + peerDependenciesMeta: | |
| 1536 | + jiti: | |
| 1537 | + optional: true | |
| 1538 | + postcss: | |
| 1539 | + optional: true | |
| 1540 | + tsx: | |
| 1541 | + optional: true | |
| 1542 | + yaml: | |
| 1543 | + optional: true | |
| 1544 | + | |
| 1545 | + postcss-nested@6.2.0: | |
| 1546 | + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} | |
| 1547 | + engines: {node: '>=12.0'} | |
| 1548 | + peerDependencies: | |
| 1549 | + postcss: ^8.2.14 | |
| 1550 | + | |
| 1551 | + postcss-selector-parser@6.1.4: | |
| 1552 | + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} | |
| 1553 | + engines: {node: '>=4'} | |
| 1554 | + | |
| 1555 | + postcss-value-parser@4.2.0: | |
| 1556 | + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} | |
| 1557 | + | |
| 1558 | + postcss@8.4.31: | |
| 1559 | + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} | |
| 1560 | + engines: {node: ^10 || ^12 || >=14} | |
| 1561 | + | |
| 1562 | + postcss@8.5.26: | |
| 1563 | + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} | |
| 1564 | + engines: {node: ^10 || ^12 || >=14} | |
| 1565 | + | |
| 1566 | + prelude-ls@1.2.1: | |
| 1567 | + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} | |
| 1568 | + engines: {node: '>= 0.8.0'} | |
| 1569 | + | |
| 1570 | + prop-types@15.8.1: | |
| 1571 | + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} | |
| 1572 | + | |
| 1573 | + punycode@2.3.1: | |
| 1574 | + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} | |
| 1575 | + engines: {node: '>=6'} | |
| 1576 | + | |
| 1577 | + queue-microtask@1.2.3: | |
| 1578 | + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} | |
| 1579 | + | |
| 1580 | + react-dom@19.2.8: | |
| 1581 | + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} | |
| 1582 | + peerDependencies: | |
| 1583 | + react: ^19.2.8 | |
| 1584 | + | |
| 1585 | + react-is@16.13.1: | |
| 1586 | + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} | |
| 1587 | + | |
| 1588 | + react@19.2.8: | |
| 1589 | + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} | |
| 1590 | + engines: {node: '>=0.10.0'} | |
| 1591 | + | |
| 1592 | + read-cache@1.0.0: | |
| 1593 | + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} | |
| 1594 | + | |
| 1595 | + readdirp@3.6.0: | |
| 1596 | + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} | |
| 1597 | + engines: {node: '>=8.10.0'} | |
| 1598 | + | |
| 1599 | + reflect.getprototypeof@1.0.10: | |
| 1600 | + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} | |
| 1601 | + engines: {node: '>= 0.4'} | |
| 1602 | + | |
| 1603 | + regexp.prototype.flags@1.5.4: | |
| 1604 | + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} | |
| 1605 | + engines: {node: '>= 0.4'} | |
| 1606 | + | |
| 1607 | + resolve-from@4.0.0: | |
| 1608 | + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} | |
| 1609 | + engines: {node: '>=4'} | |
| 1610 | + | |
| 1611 | + resolve-pkg-maps@1.0.0: | |
| 1612 | + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} | |
| 1613 | + | |
| 1614 | + resolve@1.22.12: | |
| 1615 | + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} | |
| 1616 | + engines: {node: '>= 0.4'} | |
| 1617 | + hasBin: true | |
| 1618 | + | |
| 1619 | + resolve@2.0.0-next.7: | |
| 1620 | + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} | |
| 1621 | + engines: {node: '>= 0.4'} | |
| 1622 | + hasBin: true | |
| 1623 | + | |
| 1624 | + reusify@1.1.0: | |
| 1625 | + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} | |
| 1626 | + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} | |
| 1627 | + | |
| 1628 | + rimraf@3.0.2: | |
| 1629 | + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} | |
| 1630 | + deprecated: Rimraf versions prior to v4 are no longer supported | |
| 1631 | + hasBin: true | |
| 1632 | + | |
| 1633 | + run-parallel@1.2.0: | |
| 1634 | + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} | |
| 1635 | + | |
| 1636 | + safe-array-concat@1.1.4: | |
| 1637 | + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} | |
| 1638 | + engines: {node: '>=0.4'} | |
| 1639 | + | |
| 1640 | + safe-push-apply@1.0.0: | |
| 1641 | + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} | |
| 1642 | + engines: {node: '>= 0.4'} | |
| 1643 | + | |
| 1644 | + safe-regex-test@1.1.0: | |
| 1645 | + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} | |
| 1646 | + engines: {node: '>= 0.4'} | |
| 1647 | + | |
| 1648 | + scheduler@0.27.0: | |
| 1649 | + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} | |
| 1650 | + | |
| 1651 | + semver@6.3.1: | |
| 1652 | + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} | |
| 1653 | + hasBin: true | |
| 1654 | + | |
| 1655 | + semver@7.8.5: | |
| 1656 | + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} | |
| 1657 | + engines: {node: '>=10'} | |
| 1658 | + hasBin: true | |
| 1659 | + | |
| 1660 | + set-function-length@1.2.2: | |
| 1661 | + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} | |
| 1662 | + engines: {node: '>= 0.4'} | |
| 1663 | + | |
| 1664 | + set-function-name@2.0.2: | |
| 1665 | + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} | |
| 1666 | + engines: {node: '>= 0.4'} | |
| 1667 | + | |
| 1668 | + set-proto@1.0.0: | |
| 1669 | + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} | |
| 1670 | + engines: {node: '>= 0.4'} | |
| 1671 | + | |
| 1672 | + sharp@0.34.5: | |
| 1673 | + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} | |
| 1674 | + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 1675 | + | |
| 1676 | + shebang-command@2.0.0: | |
| 1677 | + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} | |
| 1678 | + engines: {node: '>=8'} | |
| 1679 | + | |
| 1680 | + shebang-regex@3.0.0: | |
| 1681 | + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} | |
| 1682 | + engines: {node: '>=8'} | |
| 1683 | + | |
| 1684 | + side-channel-list@1.0.1: | |
| 1685 | + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} | |
| 1686 | + engines: {node: '>= 0.4'} | |
| 1687 | + | |
| 1688 | + side-channel-map@1.0.1: | |
| 1689 | + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} | |
| 1690 | + engines: {node: '>= 0.4'} | |
| 1691 | + | |
| 1692 | + side-channel-weakmap@1.0.2: | |
| 1693 | + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} | |
| 1694 | + engines: {node: '>= 0.4'} | |
| 1695 | + | |
| 1696 | + side-channel@1.1.1: | |
| 1697 | + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} | |
| 1698 | + engines: {node: '>= 0.4'} | |
| 1699 | + | |
| 1700 | + source-map-js@1.2.1: | |
| 1701 | + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} | |
| 1702 | + engines: {node: '>=0.10.0'} | |
| 1703 | + | |
| 1704 | + stable-hash@0.0.5: | |
| 1705 | + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} | |
| 1706 | + | |
| 1707 | + stop-iteration-iterator@1.1.0: | |
| 1708 | + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} | |
| 1709 | + engines: {node: '>= 0.4'} | |
| 1710 | + | |
| 1711 | + string.prototype.includes@2.0.1: | |
| 1712 | + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} | |
| 1713 | + engines: {node: '>= 0.4'} | |
| 1714 | + | |
| 1715 | + string.prototype.matchall@4.0.12: | |
| 1716 | + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} | |
| 1717 | + engines: {node: '>= 0.4'} | |
| 1718 | + | |
| 1719 | + string.prototype.repeat@1.0.0: | |
| 1720 | + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} | |
| 1721 | + | |
| 1722 | + string.prototype.trim@1.2.11: | |
| 1723 | + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} | |
| 1724 | + engines: {node: '>= 0.4'} | |
| 1725 | + | |
| 1726 | + string.prototype.trimend@1.0.10: | |
| 1727 | + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} | |
| 1728 | + engines: {node: '>= 0.4'} | |
| 1729 | + | |
| 1730 | + string.prototype.trimstart@1.0.8: | |
| 1731 | + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} | |
| 1732 | + engines: {node: '>= 0.4'} | |
| 1733 | + | |
| 1734 | + strip-ansi@6.0.1: | |
| 1735 | + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} | |
| 1736 | + engines: {node: '>=8'} | |
| 1737 | + | |
| 1738 | + strip-bom@3.0.0: | |
| 1739 | + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} | |
| 1740 | + engines: {node: '>=4'} | |
| 1741 | + | |
| 1742 | + strip-json-comments@3.1.1: | |
| 1743 | + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} | |
| 1744 | + engines: {node: '>=8'} | |
| 1745 | + | |
| 1746 | + styled-jsx@5.1.6: | |
| 1747 | + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} | |
| 1748 | + engines: {node: '>= 12.0.0'} | |
| 1749 | + peerDependencies: | |
| 1750 | + '@babel/core': '*' | |
| 1751 | + babel-plugin-macros: '*' | |
| 1752 | + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' | |
| 1753 | + peerDependenciesMeta: | |
| 1754 | + '@babel/core': | |
| 1755 | + optional: true | |
| 1756 | + babel-plugin-macros: | |
| 1757 | + optional: true | |
| 1758 | + | |
| 1759 | + sucrase@3.35.1: | |
| 1760 | + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} | |
| 1761 | + engines: {node: '>=16 || 14 >=14.17'} | |
| 1762 | + hasBin: true | |
| 1763 | + | |
| 1764 | + supports-color@7.2.0: | |
| 1765 | + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} | |
| 1766 | + engines: {node: '>=8'} | |
| 1767 | + | |
| 1768 | + supports-preserve-symlinks-flag@1.0.0: | |
| 1769 | + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} | |
| 1770 | + engines: {node: '>= 0.4'} | |
| 1771 | + | |
| 1772 | + tailwind-merge@2.6.1: | |
| 1773 | + resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} | |
| 1774 | + | |
| 1775 | + tailwindcss@3.4.19: | |
| 1776 | + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} | |
| 1777 | + engines: {node: '>=14.0.0'} | |
| 1778 | + hasBin: true | |
| 1779 | + | |
| 1780 | + text-table@0.2.0: | |
| 1781 | + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} | |
| 1782 | + | |
| 1783 | + thenify-all@1.6.0: | |
| 1784 | + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} | |
| 1785 | + engines: {node: '>=0.8'} | |
| 1786 | + | |
| 1787 | + thenify@3.3.1: | |
| 1788 | + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} | |
| 1789 | + | |
| 1790 | + tinyglobby@0.2.17: | |
| 1791 | + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} | |
| 1792 | + engines: {node: '>=12.0.0'} | |
| 1793 | + | |
| 1794 | + to-regex-range@5.0.1: | |
| 1795 | + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} | |
| 1796 | + engines: {node: '>=8.0'} | |
| 1797 | + | |
| 1798 | + ts-api-utils@2.5.0: | |
| 1799 | + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} | |
| 1800 | + engines: {node: '>=18.12'} | |
| 1801 | + peerDependencies: | |
| 1802 | + typescript: '>=4.8.4' | |
| 1803 | + | |
| 1804 | + ts-interface-checker@0.1.13: | |
| 1805 | + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} | |
| 1806 | + | |
| 1807 | + tsconfig-paths@3.15.0: | |
| 1808 | + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} | |
| 1809 | + | |
| 1810 | + tslib@2.8.1: | |
| 1811 | + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} | |
| 1812 | + | |
| 1813 | + type-check@0.4.0: | |
| 1814 | + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} | |
| 1815 | + engines: {node: '>= 0.8.0'} | |
| 1816 | + | |
| 1817 | + type-fest@0.20.2: | |
| 1818 | + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} | |
| 1819 | + engines: {node: '>=10'} | |
| 1820 | + | |
| 1821 | + typed-array-buffer@1.0.3: | |
| 1822 | + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} | |
| 1823 | + engines: {node: '>= 0.4'} | |
| 1824 | + | |
| 1825 | + typed-array-byte-length@1.0.3: | |
| 1826 | + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} | |
| 1827 | + engines: {node: '>= 0.4'} | |
| 1828 | + | |
| 1829 | + typed-array-byte-offset@1.0.4: | |
| 1830 | + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} | |
| 1831 | + engines: {node: '>= 0.4'} | |
| 1832 | + | |
| 1833 | + typed-array-length@1.0.8: | |
| 1834 | + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} | |
| 1835 | + engines: {node: '>= 0.4'} | |
| 1836 | + | |
| 1837 | + typescript@5.9.3: | |
| 1838 | + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} | |
| 1839 | + engines: {node: '>=14.17'} | |
| 1840 | + hasBin: true | |
| 1841 | + | |
| 1842 | + unbox-primitive@1.1.0: | |
| 1843 | + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} | |
| 1844 | + engines: {node: '>= 0.4'} | |
| 1845 | + | |
| 1846 | + undici-types@6.21.0: | |
| 1847 | + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} | |
| 1848 | + | |
| 1849 | + unrs-resolver@1.12.2: | |
| 1850 | + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} | |
| 1851 | + | |
| 1852 | + update-browserslist-db@1.3.1: | |
| 1853 | + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} | |
| 1854 | + hasBin: true | |
| 1855 | + peerDependencies: | |
| 1856 | + browserslist: '>= 4.21.0' | |
| 1857 | + | |
| 1858 | + uri-js@4.4.1: | |
| 1859 | + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} | |
| 1860 | + | |
| 1861 | + util-deprecate@1.0.2: | |
| 1862 | + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} | |
| 1863 | + | |
| 1864 | + which-boxed-primitive@1.1.1: | |
| 1865 | + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} | |
| 1866 | + engines: {node: '>= 0.4'} | |
| 1867 | + | |
| 1868 | + which-builtin-type@1.2.1: | |
| 1869 | + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} | |
| 1870 | + engines: {node: '>= 0.4'} | |
| 1871 | + | |
| 1872 | + which-collection@1.0.2: | |
| 1873 | + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} | |
| 1874 | + engines: {node: '>= 0.4'} | |
| 1875 | + | |
| 1876 | + which-typed-array@1.1.22: | |
| 1877 | + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} | |
| 1878 | + engines: {node: '>= 0.4'} | |
| 1879 | + | |
| 1880 | + which@2.0.2: | |
| 1881 | + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} | |
| 1882 | + engines: {node: '>= 8'} | |
| 1883 | + hasBin: true | |
| 1884 | + | |
| 1885 | + word-wrap@1.2.5: | |
| 1886 | + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} | |
| 1887 | + engines: {node: '>=0.10.0'} | |
| 1888 | + | |
| 1889 | + wrappy@1.0.2: | |
| 1890 | + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} | |
| 1891 | + | |
| 1892 | + yocto-queue@0.1.0: | |
| 1893 | + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} | |
| 1894 | + engines: {node: '>=10'} | |
| 1895 | + | |
| 1896 | +snapshots: | |
| 1897 | + | |
| 1898 | + '@alloc/quick-lru@5.2.0': {} | |
| 1899 | + | |
| 1900 | + '@emnapi/core@1.10.0': | |
| 1901 | + dependencies: | |
| 1902 | + '@emnapi/wasi-threads': 1.2.1 | |
| 1903 | + tslib: 2.8.1 | |
| 1904 | + optional: true | |
| 1905 | + | |
| 1906 | + '@emnapi/runtime@1.10.0': | |
| 1907 | + dependencies: | |
| 1908 | + tslib: 2.8.1 | |
| 1909 | + optional: true | |
| 1910 | + | |
| 1911 | + '@emnapi/runtime@1.11.3': | |
| 1912 | + dependencies: | |
| 1913 | + tslib: 2.8.1 | |
| 1914 | + optional: true | |
| 1915 | + | |
| 1916 | + '@emnapi/wasi-threads@1.2.1': | |
| 1917 | + dependencies: | |
| 1918 | + tslib: 2.8.1 | |
| 1919 | + optional: true | |
| 1920 | + | |
| 1921 | + '@eslint-community/eslint-utils@4.10.1(eslint@8.57.1)': | |
| 1922 | + dependencies: | |
| 1923 | + eslint: 8.57.1 | |
| 1924 | + eslint-visitor-keys: 3.4.3 | |
| 1925 | + | |
| 1926 | + '@eslint-community/regexpp@4.12.2': {} | |
| 1927 | + | |
| 1928 | + '@eslint/eslintrc@2.1.4': | |
| 1929 | + dependencies: | |
| 1930 | + ajv: 6.15.0 | |
| 1931 | + debug: 4.4.3 | |
| 1932 | + espree: 9.6.1 | |
| 1933 | + globals: 13.24.0 | |
| 1934 | + ignore: 5.3.2 | |
| 1935 | + import-fresh: 3.3.1 | |
| 1936 | + js-yaml: 4.3.1 | |
| 1937 | + minimatch: 3.1.5 | |
| 1938 | + strip-json-comments: 3.1.1 | |
| 1939 | + transitivePeerDependencies: | |
| 1940 | + - supports-color | |
| 1941 | + | |
| 1942 | + '@eslint/js@8.57.1': {} | |
| 1943 | + | |
| 1944 | + '@humanwhocodes/config-array@0.13.0': | |
| 1945 | + dependencies: | |
| 1946 | + '@humanwhocodes/object-schema': 2.0.3 | |
| 1947 | + debug: 4.4.3 | |
| 1948 | + minimatch: 3.1.5 | |
| 1949 | + transitivePeerDependencies: | |
| 1950 | + - supports-color | |
| 1951 | + | |
| 1952 | + '@humanwhocodes/module-importer@1.0.1': {} | |
| 1953 | + | |
| 1954 | + '@humanwhocodes/object-schema@2.0.3': {} | |
| 1955 | + | |
| 1956 | + '@img/colour@1.1.0': | |
| 1957 | + optional: true | |
| 1958 | + | |
| 1959 | + '@img/sharp-darwin-arm64@0.34.5': | |
| 1960 | + optionalDependencies: | |
| 1961 | + '@img/sharp-libvips-darwin-arm64': 1.2.4 | |
| 1962 | + optional: true | |
| 1963 | + | |
| 1964 | + '@img/sharp-darwin-x64@0.34.5': | |
| 1965 | + optionalDependencies: | |
| 1966 | + '@img/sharp-libvips-darwin-x64': 1.2.4 | |
| 1967 | + optional: true | |
| 1968 | + | |
| 1969 | + '@img/sharp-libvips-darwin-arm64@1.2.4': | |
| 1970 | + optional: true | |
| 1971 | + | |
| 1972 | + '@img/sharp-libvips-darwin-x64@1.2.4': | |
| 1973 | + optional: true | |
| 1974 | + | |
| 1975 | + '@img/sharp-libvips-linux-arm64@1.2.4': | |
| 1976 | + optional: true | |
| 1977 | + | |
| 1978 | + '@img/sharp-libvips-linux-arm@1.2.4': | |
| 1979 | + optional: true | |
| 1980 | + | |
| 1981 | + '@img/sharp-libvips-linux-ppc64@1.2.4': | |
| 1982 | + optional: true | |
| 1983 | + | |
| 1984 | + '@img/sharp-libvips-linux-riscv64@1.2.4': | |
| 1985 | + optional: true | |
| 1986 | + | |
| 1987 | + '@img/sharp-libvips-linux-s390x@1.2.4': | |
| 1988 | + optional: true | |
| 1989 | + | |
| 1990 | + '@img/sharp-libvips-linux-x64@1.2.4': | |
| 1991 | + optional: true | |
| 1992 | + | |
| 1993 | + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': | |
| 1994 | + optional: true | |
| 1995 | + | |
| 1996 | + '@img/sharp-libvips-linuxmusl-x64@1.2.4': | |
| 1997 | + optional: true | |
| 1998 | + | |
| 1999 | + '@img/sharp-linux-arm64@0.34.5': | |
| 2000 | + optionalDependencies: | |
| 2001 | + '@img/sharp-libvips-linux-arm64': 1.2.4 | |
| 2002 | + optional: true | |
| 2003 | + | |
| 2004 | + '@img/sharp-linux-arm@0.34.5': | |
| 2005 | + optionalDependencies: | |
| 2006 | + '@img/sharp-libvips-linux-arm': 1.2.4 | |
| 2007 | + optional: true | |
| 2008 | + | |
| 2009 | + '@img/sharp-linux-ppc64@0.34.5': | |
| 2010 | + optionalDependencies: | |
| 2011 | + '@img/sharp-libvips-linux-ppc64': 1.2.4 | |
| 2012 | + optional: true | |
| 2013 | + | |
| 2014 | + '@img/sharp-linux-riscv64@0.34.5': | |
| 2015 | + optionalDependencies: | |
| 2016 | + '@img/sharp-libvips-linux-riscv64': 1.2.4 | |
| 2017 | + optional: true | |
| 2018 | + | |
| 2019 | + '@img/sharp-linux-s390x@0.34.5': | |
| 2020 | + optionalDependencies: | |
| 2021 | + '@img/sharp-libvips-linux-s390x': 1.2.4 | |
| 2022 | + optional: true | |
| 2023 | + | |
| 2024 | + '@img/sharp-linux-x64@0.34.5': | |
| 2025 | + optionalDependencies: | |
| 2026 | + '@img/sharp-libvips-linux-x64': 1.2.4 | |
| 2027 | + optional: true | |
| 2028 | + | |
| 2029 | + '@img/sharp-linuxmusl-arm64@0.34.5': | |
| 2030 | + optionalDependencies: | |
| 2031 | + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 | |
| 2032 | + optional: true | |
| 2033 | + | |
| 2034 | + '@img/sharp-linuxmusl-x64@0.34.5': | |
| 2035 | + optionalDependencies: | |
| 2036 | + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 | |
| 2037 | + optional: true | |
| 2038 | + | |
| 2039 | + '@img/sharp-wasm32@0.34.5': | |
| 2040 | + dependencies: | |
| 2041 | + '@emnapi/runtime': 1.11.3 | |
| 2042 | + optional: true | |
| 2043 | + | |
| 2044 | + '@img/sharp-win32-arm64@0.34.5': | |
| 2045 | + optional: true | |
| 2046 | + | |
| 2047 | + '@img/sharp-win32-ia32@0.34.5': | |
| 2048 | + optional: true | |
| 2049 | + | |
| 2050 | + '@img/sharp-win32-x64@0.34.5': | |
| 2051 | + optional: true | |
| 2052 | + | |
| 2053 | + '@jridgewell/gen-mapping@0.3.13': | |
| 2054 | + dependencies: | |
| 2055 | + '@jridgewell/sourcemap-codec': 1.5.5 | |
| 2056 | + '@jridgewell/trace-mapping': 0.3.31 | |
| 2057 | + | |
| 2058 | + '@jridgewell/resolve-uri@3.1.2': {} | |
| 2059 | + | |
| 2060 | + '@jridgewell/sourcemap-codec@1.5.5': {} | |
| 2061 | + | |
| 2062 | + '@jridgewell/trace-mapping@0.3.31': | |
| 2063 | + dependencies: | |
| 2064 | + '@jridgewell/resolve-uri': 3.1.2 | |
| 2065 | + '@jridgewell/sourcemap-codec': 1.5.5 | |
| 2066 | + | |
| 2067 | + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': | |
| 2068 | + dependencies: | |
| 2069 | + '@emnapi/core': 1.10.0 | |
| 2070 | + '@emnapi/runtime': 1.10.0 | |
| 2071 | + '@tybys/wasm-util': 0.10.3 | |
| 2072 | + optional: true | |
| 2073 | + | |
| 2074 | + '@next/env@15.5.23': {} | |
| 2075 | + | |
| 2076 | + '@next/eslint-plugin-next@15.5.23': | |
| 2077 | + dependencies: | |
| 2078 | + fast-glob: 3.3.1 | |
| 2079 | + | |
| 2080 | + '@next/swc-darwin-arm64@15.5.23': | |
| 2081 | + optional: true | |
| 2082 | + | |
| 2083 | + '@next/swc-darwin-x64@15.5.23': | |
| 2084 | + optional: true | |
| 2085 | + | |
| 2086 | + '@next/swc-linux-arm64-gnu@15.5.23': | |
| 2087 | + optional: true | |
| 2088 | + | |
| 2089 | + '@next/swc-linux-arm64-musl@15.5.23': | |
| 2090 | + optional: true | |
| 2091 | + | |
| 2092 | + '@next/swc-linux-x64-gnu@15.5.23': | |
| 2093 | + optional: true | |
| 2094 | + | |
| 2095 | + '@next/swc-linux-x64-musl@15.5.23': | |
| 2096 | + optional: true | |
| 2097 | + | |
| 2098 | + '@next/swc-win32-arm64-msvc@15.5.23': | |
| 2099 | + optional: true | |
| 2100 | + | |
| 2101 | + '@next/swc-win32-x64-msvc@15.5.23': | |
| 2102 | + optional: true | |
| 2103 | + | |
| 2104 | + '@nodelib/fs.scandir@2.1.5': | |
| 2105 | + dependencies: | |
| 2106 | + '@nodelib/fs.stat': 2.0.5 | |
| 2107 | + run-parallel: 1.2.0 | |
| 2108 | + | |
| 2109 | + '@nodelib/fs.stat@2.0.5': {} | |
| 2110 | + | |
| 2111 | + '@nodelib/fs.walk@1.2.8': | |
| 2112 | + dependencies: | |
| 2113 | + '@nodelib/fs.scandir': 2.1.5 | |
| 2114 | + fastq: 1.20.1 | |
| 2115 | + | |
| 2116 | + '@nolyfill/is-core-module@1.0.39': {} | |
| 2117 | + | |
| 2118 | + '@rtsao/scc@1.1.0': {} | |
| 2119 | + | |
| 2120 | + '@rushstack/eslint-patch@1.16.1': {} | |
| 2121 | + | |
| 2122 | + '@swc/helpers@0.5.15': | |
| 2123 | + dependencies: | |
| 2124 | + tslib: 2.8.1 | |
| 2125 | + | |
| 2126 | + '@tybys/wasm-util@0.10.3': | |
| 2127 | + dependencies: | |
| 2128 | + tslib: 2.8.1 | |
| 2129 | + optional: true | |
| 2130 | + | |
| 2131 | + '@types/json5@0.0.29': {} | |
| 2132 | + | |
| 2133 | + '@types/node@22.20.1': | |
| 2134 | + dependencies: | |
| 2135 | + undici-types: 6.21.0 | |
| 2136 | + | |
| 2137 | + '@types/react-dom@19.2.4(@types/react@19.2.18)': | |
| 2138 | + dependencies: | |
| 2139 | + '@types/react': 19.2.18 | |
| 2140 | + | |
| 2141 | + '@types/react@19.2.18': | |
| 2142 | + dependencies: | |
| 2143 | + csstype: 3.2.3 | |
| 2144 | + | |
| 2145 | + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': | |
| 2146 | + dependencies: | |
| 2147 | + '@eslint-community/regexpp': 4.12.2 | |
| 2148 | + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2149 | + '@typescript-eslint/scope-manager': 8.67.0 | |
| 2150 | + '@typescript-eslint/type-utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2151 | + '@typescript-eslint/utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2152 | + '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2153 | + eslint: 8.57.1 | |
| 2154 | + ignore: 7.0.6 | |
| 2155 | + natural-compare: 1.4.0 | |
| 2156 | + ts-api-utils: 2.5.0(typescript@5.9.3) | |
| 2157 | + typescript: 5.9.3 | |
| 2158 | + transitivePeerDependencies: | |
| 2159 | + - supports-color | |
| 2160 | + | |
| 2161 | + '@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3)': | |
| 2162 | + dependencies: | |
| 2163 | + '@typescript-eslint/scope-manager': 8.67.0 | |
| 2164 | + '@typescript-eslint/types': 8.67.0 | |
| 2165 | + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) | |
| 2166 | + '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2167 | + debug: 4.4.3 | |
| 2168 | + eslint: 8.57.1 | |
| 2169 | + typescript: 5.9.3 | |
| 2170 | + transitivePeerDependencies: | |
| 2171 | + - supports-color | |
| 2172 | + | |
| 2173 | + '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': | |
| 2174 | + dependencies: | |
| 2175 | + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) | |
| 2176 | + '@typescript-eslint/types': 8.67.0 | |
| 2177 | + debug: 4.4.3 | |
| 2178 | + typescript: 5.9.3 | |
| 2179 | + transitivePeerDependencies: | |
| 2180 | + - supports-color | |
| 2181 | + | |
| 2182 | + '@typescript-eslint/scope-manager@8.67.0': | |
| 2183 | + dependencies: | |
| 2184 | + '@typescript-eslint/types': 8.67.0 | |
| 2185 | + '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2186 | + | |
| 2187 | + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)': | |
| 2188 | + dependencies: | |
| 2189 | + typescript: 5.9.3 | |
| 2190 | + | |
| 2191 | + '@typescript-eslint/type-utils@8.67.0(eslint@8.57.1)(typescript@5.9.3)': | |
| 2192 | + dependencies: | |
| 2193 | + '@typescript-eslint/types': 8.67.0 | |
| 2194 | + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) | |
| 2195 | + '@typescript-eslint/utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2196 | + debug: 4.4.3 | |
| 2197 | + eslint: 8.57.1 | |
| 2198 | + ts-api-utils: 2.5.0(typescript@5.9.3) | |
| 2199 | + typescript: 5.9.3 | |
| 2200 | + transitivePeerDependencies: | |
| 2201 | + - supports-color | |
| 2202 | + | |
| 2203 | + '@typescript-eslint/types@8.67.0': {} | |
| 2204 | + | |
| 2205 | + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': | |
| 2206 | + dependencies: | |
| 2207 | + '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3) | |
| 2208 | + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) | |
| 2209 | + '@typescript-eslint/types': 8.67.0 | |
| 2210 | + '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2211 | + debug: 4.4.3 | |
| 2212 | + minimatch: 10.2.6 | |
| 2213 | + semver: 7.8.5 | |
| 2214 | + tinyglobby: 0.2.17 | |
| 2215 | + ts-api-utils: 2.5.0(typescript@5.9.3) | |
| 2216 | + typescript: 5.9.3 | |
| 2217 | + transitivePeerDependencies: | |
| 2218 | + - supports-color | |
| 2219 | + | |
| 2220 | + '@typescript-eslint/utils@8.67.0(eslint@8.57.1)(typescript@5.9.3)': | |
| 2221 | + dependencies: | |
| 2222 | + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1) | |
| 2223 | + '@typescript-eslint/scope-manager': 8.67.0 | |
| 2224 | + '@typescript-eslint/types': 8.67.0 | |
| 2225 | + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) | |
| 2226 | + eslint: 8.57.1 | |
| 2227 | + typescript: 5.9.3 | |
| 2228 | + transitivePeerDependencies: | |
| 2229 | + - supports-color | |
| 2230 | + | |
| 2231 | + '@typescript-eslint/visitor-keys@8.67.0': | |
| 2232 | + dependencies: | |
| 2233 | + '@typescript-eslint/types': 8.67.0 | |
| 2234 | + eslint-visitor-keys: 5.0.1 | |
| 2235 | + | |
| 2236 | + '@ungap/structured-clone@1.3.3': {} | |
| 2237 | + | |
| 2238 | + '@unrs/resolver-binding-android-arm-eabi@1.12.2': | |
| 2239 | + optional: true | |
| 2240 | + | |
| 2241 | + '@unrs/resolver-binding-android-arm64@1.12.2': | |
| 2242 | + optional: true | |
| 2243 | + | |
| 2244 | + '@unrs/resolver-binding-darwin-arm64@1.12.2': | |
| 2245 | + optional: true | |
| 2246 | + | |
| 2247 | + '@unrs/resolver-binding-darwin-x64@1.12.2': | |
| 2248 | + optional: true | |
| 2249 | + | |
| 2250 | + '@unrs/resolver-binding-freebsd-x64@1.12.2': | |
| 2251 | + optional: true | |
| 2252 | + | |
| 2253 | + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': | |
| 2254 | + optional: true | |
| 2255 | + | |
| 2256 | + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': | |
| 2257 | + optional: true | |
| 2258 | + | |
| 2259 | + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': | |
| 2260 | + optional: true | |
| 2261 | + | |
| 2262 | + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': | |
| 2263 | + optional: true | |
| 2264 | + | |
| 2265 | + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': | |
| 2266 | + optional: true | |
| 2267 | + | |
| 2268 | + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': | |
| 2269 | + optional: true | |
| 2270 | + | |
| 2271 | + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': | |
| 2272 | + optional: true | |
| 2273 | + | |
| 2274 | + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': | |
| 2275 | + optional: true | |
| 2276 | + | |
| 2277 | + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': | |
| 2278 | + optional: true | |
| 2279 | + | |
| 2280 | + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': | |
| 2281 | + optional: true | |
| 2282 | + | |
| 2283 | + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': | |
| 2284 | + optional: true | |
| 2285 | + | |
| 2286 | + '@unrs/resolver-binding-linux-x64-musl@1.12.2': | |
| 2287 | + optional: true | |
| 2288 | + | |
| 2289 | + '@unrs/resolver-binding-openharmony-arm64@1.12.2': | |
| 2290 | + optional: true | |
| 2291 | + | |
| 2292 | + '@unrs/resolver-binding-wasm32-wasi@1.12.2': | |
| 2293 | + dependencies: | |
| 2294 | + '@emnapi/core': 1.10.0 | |
| 2295 | + '@emnapi/runtime': 1.10.0 | |
| 2296 | + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) | |
| 2297 | + optional: true | |
| 2298 | + | |
| 2299 | + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': | |
| 2300 | + optional: true | |
| 2301 | + | |
| 2302 | + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': | |
| 2303 | + optional: true | |
| 2304 | + | |
| 2305 | + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': | |
| 2306 | + optional: true | |
| 2307 | + | |
| 2308 | + acorn-jsx@5.3.2(acorn@8.18.0): | |
| 2309 | + dependencies: | |
| 2310 | + acorn: 8.18.0 | |
| 2311 | + | |
| 2312 | + acorn@8.18.0: {} | |
| 2313 | + | |
| 2314 | + ajv@6.15.0: | |
| 2315 | + dependencies: | |
| 2316 | + fast-deep-equal: 3.1.3 | |
| 2317 | + fast-json-stable-stringify: 2.1.0 | |
| 2318 | + json-schema-traverse: 0.4.1 | |
| 2319 | + uri-js: 4.4.1 | |
| 2320 | + | |
| 2321 | + ansi-regex@5.0.1: {} | |
| 2322 | + | |
| 2323 | + ansi-styles@4.3.0: | |
| 2324 | + dependencies: | |
| 2325 | + color-convert: 2.0.1 | |
| 2326 | + | |
| 2327 | + any-promise@1.3.0: {} | |
| 2328 | + | |
| 2329 | + anymatch@3.1.3: | |
| 2330 | + dependencies: | |
| 2331 | + normalize-path: 3.0.0 | |
| 2332 | + picomatch: 2.3.2 | |
| 2333 | + | |
| 2334 | + arg@5.0.2: {} | |
| 2335 | + | |
| 2336 | + argparse@2.0.1: {} | |
| 2337 | + | |
| 2338 | + aria-query@5.3.2: {} | |
| 2339 | + | |
| 2340 | + array-buffer-byte-length@1.0.2: | |
| 2341 | + dependencies: | |
| 2342 | + call-bound: 1.0.4 | |
| 2343 | + is-array-buffer: 3.0.5 | |
| 2344 | + | |
| 2345 | + array-includes@3.1.9: | |
| 2346 | + dependencies: | |
| 2347 | + call-bind: 1.0.9 | |
| 2348 | + call-bound: 1.0.4 | |
| 2349 | + define-properties: 1.2.1 | |
| 2350 | + es-abstract: 1.24.2 | |
| 2351 | + es-object-atoms: 1.1.2 | |
| 2352 | + get-intrinsic: 1.3.0 | |
| 2353 | + is-string: 1.1.1 | |
| 2354 | + math-intrinsics: 1.1.0 | |
| 2355 | + | |
| 2356 | + array.prototype.findlast@1.2.5: | |
| 2357 | + dependencies: | |
| 2358 | + call-bind: 1.0.9 | |
| 2359 | + define-properties: 1.2.1 | |
| 2360 | + es-abstract: 1.24.2 | |
| 2361 | + es-errors: 1.3.0 | |
| 2362 | + es-object-atoms: 1.1.2 | |
| 2363 | + es-shim-unscopables: 1.1.0 | |
| 2364 | + | |
| 2365 | + array.prototype.findlastindex@1.2.6: | |
| 2366 | + dependencies: | |
| 2367 | + call-bind: 1.0.9 | |
| 2368 | + call-bound: 1.0.4 | |
| 2369 | + define-properties: 1.2.1 | |
| 2370 | + es-abstract: 1.24.2 | |
| 2371 | + es-errors: 1.3.0 | |
| 2372 | + es-object-atoms: 1.1.2 | |
| 2373 | + es-shim-unscopables: 1.1.0 | |
| 2374 | + | |
| 2375 | + array.prototype.flat@1.3.3: | |
| 2376 | + dependencies: | |
| 2377 | + call-bind: 1.0.9 | |
| 2378 | + define-properties: 1.2.1 | |
| 2379 | + es-abstract: 1.24.2 | |
| 2380 | + es-shim-unscopables: 1.1.0 | |
| 2381 | + | |
| 2382 | + array.prototype.flatmap@1.3.3: | |
| 2383 | + dependencies: | |
| 2384 | + call-bind: 1.0.9 | |
| 2385 | + define-properties: 1.2.1 | |
| 2386 | + es-abstract: 1.24.2 | |
| 2387 | + es-shim-unscopables: 1.1.0 | |
| 2388 | + | |
| 2389 | + array.prototype.tosorted@1.1.4: | |
| 2390 | + dependencies: | |
| 2391 | + call-bind: 1.0.9 | |
| 2392 | + define-properties: 1.2.1 | |
| 2393 | + es-abstract: 1.24.2 | |
| 2394 | + es-errors: 1.3.0 | |
| 2395 | + es-shim-unscopables: 1.1.0 | |
| 2396 | + | |
| 2397 | + arraybuffer.prototype.slice@1.0.4: | |
| 2398 | + dependencies: | |
| 2399 | + array-buffer-byte-length: 1.0.2 | |
| 2400 | + call-bind: 1.0.9 | |
| 2401 | + define-properties: 1.2.1 | |
| 2402 | + es-abstract: 1.24.2 | |
| 2403 | + es-errors: 1.3.0 | |
| 2404 | + get-intrinsic: 1.3.0 | |
| 2405 | + is-array-buffer: 3.0.5 | |
| 2406 | + | |
| 2407 | + ast-types-flow@0.0.8: {} | |
| 2408 | + | |
| 2409 | + async-function@1.0.0: {} | |
| 2410 | + | |
| 2411 | + autoprefixer@10.5.4(postcss@8.5.26): | |
| 2412 | + dependencies: | |
| 2413 | + browserslist: 4.28.8 | |
| 2414 | + caniuse-lite: 1.0.30001809 | |
| 2415 | + fraction.js: 5.3.4 | |
| 2416 | + picocolors: 1.1.1 | |
| 2417 | + postcss: 8.5.26 | |
| 2418 | + postcss-value-parser: 4.2.0 | |
| 2419 | + | |
| 2420 | + available-typed-arrays@1.0.7: | |
| 2421 | + dependencies: | |
| 2422 | + possible-typed-array-names: 1.1.0 | |
| 2423 | + | |
| 2424 | + axe-core@4.13.0: {} | |
| 2425 | + | |
| 2426 | + axobject-query@4.1.0: {} | |
| 2427 | + | |
| 2428 | + balanced-match@1.0.2: {} | |
| 2429 | + | |
| 2430 | + balanced-match@4.0.4: {} | |
| 2431 | + | |
| 2432 | + baseline-browser-mapping@2.11.13: {} | |
| 2433 | + | |
| 2434 | + binary-extensions@2.3.0: {} | |
| 2435 | + | |
| 2436 | + brace-expansion@1.1.18: | |
| 2437 | + dependencies: | |
| 2438 | + balanced-match: 1.0.2 | |
| 2439 | + concat-map: 0.0.1 | |
| 2440 | + | |
| 2441 | + brace-expansion@5.0.9: | |
| 2442 | + dependencies: | |
| 2443 | + balanced-match: 4.0.4 | |
| 2444 | + | |
| 2445 | + braces@3.0.3: | |
| 2446 | + dependencies: | |
| 2447 | + fill-range: 7.1.1 | |
| 2448 | + | |
| 2449 | + browserslist@4.28.8: | |
| 2450 | + dependencies: | |
| 2451 | + baseline-browser-mapping: 2.11.13 | |
| 2452 | + caniuse-lite: 1.0.30001809 | |
| 2453 | + electron-to-chromium: 1.5.405 | |
| 2454 | + node-releases: 2.0.53 | |
| 2455 | + update-browserslist-db: 1.3.1(browserslist@4.28.8) | |
| 2456 | + | |
| 2457 | + call-bind-apply-helpers@1.0.2: | |
| 2458 | + dependencies: | |
| 2459 | + es-errors: 1.3.0 | |
| 2460 | + function-bind: 1.1.2 | |
| 2461 | + | |
| 2462 | + call-bind@1.0.9: | |
| 2463 | + dependencies: | |
| 2464 | + call-bind-apply-helpers: 1.0.2 | |
| 2465 | + es-define-property: 1.0.1 | |
| 2466 | + get-intrinsic: 1.3.0 | |
| 2467 | + set-function-length: 1.2.2 | |
| 2468 | + | |
| 2469 | + call-bound@1.0.4: | |
| 2470 | + dependencies: | |
| 2471 | + call-bind-apply-helpers: 1.0.2 | |
| 2472 | + get-intrinsic: 1.3.0 | |
| 2473 | + | |
| 2474 | + callsites@3.1.0: {} | |
| 2475 | + | |
| 2476 | + camelcase-css@2.0.1: {} | |
| 2477 | + | |
| 2478 | + caniuse-lite@1.0.30001809: {} | |
| 2479 | + | |
| 2480 | + chalk@4.1.2: | |
| 2481 | + dependencies: | |
| 2482 | + ansi-styles: 4.3.0 | |
| 2483 | + supports-color: 7.2.0 | |
| 2484 | + | |
| 2485 | + chokidar@3.6.0: | |
| 2486 | + dependencies: | |
| 2487 | + anymatch: 3.1.3 | |
| 2488 | + braces: 3.0.3 | |
| 2489 | + glob-parent: 5.1.2 | |
| 2490 | + is-binary-path: 2.1.0 | |
| 2491 | + is-glob: 4.0.3 | |
| 2492 | + normalize-path: 3.0.0 | |
| 2493 | + readdirp: 3.6.0 | |
| 2494 | + optionalDependencies: | |
| 2495 | + fsevents: 2.3.3 | |
| 2496 | + | |
| 2497 | + class-variance-authority@0.7.1: | |
| 2498 | + dependencies: | |
| 2499 | + clsx: 2.1.1 | |
| 2500 | + | |
| 2501 | + client-only@0.0.1: {} | |
| 2502 | + | |
| 2503 | + clsx@2.1.1: {} | |
| 2504 | + | |
| 2505 | + color-convert@2.0.1: | |
| 2506 | + dependencies: | |
| 2507 | + color-name: 1.1.4 | |
| 2508 | + | |
| 2509 | + color-name@1.1.4: {} | |
| 2510 | + | |
| 2511 | + commander@4.1.1: {} | |
| 2512 | + | |
| 2513 | + concat-map@0.0.1: {} | |
| 2514 | + | |
| 2515 | + cross-spawn@7.0.6: | |
| 2516 | + dependencies: | |
| 2517 | + path-key: 3.1.1 | |
| 2518 | + shebang-command: 2.0.0 | |
| 2519 | + which: 2.0.2 | |
| 2520 | + | |
| 2521 | + cssesc@3.0.0: {} | |
| 2522 | + | |
| 2523 | + csstype@3.2.3: {} | |
| 2524 | + | |
| 2525 | + damerau-levenshtein@1.0.8: {} | |
| 2526 | + | |
| 2527 | + data-view-buffer@1.0.2: | |
| 2528 | + dependencies: | |
| 2529 | + call-bound: 1.0.4 | |
| 2530 | + es-errors: 1.3.0 | |
| 2531 | + is-data-view: 1.0.2 | |
| 2532 | + | |
| 2533 | + data-view-byte-length@1.0.2: | |
| 2534 | + dependencies: | |
| 2535 | + call-bound: 1.0.4 | |
| 2536 | + es-errors: 1.3.0 | |
| 2537 | + is-data-view: 1.0.2 | |
| 2538 | + | |
| 2539 | + data-view-byte-offset@1.0.1: | |
| 2540 | + dependencies: | |
| 2541 | + call-bound: 1.0.4 | |
| 2542 | + es-errors: 1.3.0 | |
| 2543 | + is-data-view: 1.0.2 | |
| 2544 | + | |
| 2545 | + debug@3.2.7: | |
| 2546 | + dependencies: | |
| 2547 | + ms: 2.1.3 | |
| 2548 | + | |
| 2549 | + debug@4.4.3: | |
| 2550 | + dependencies: | |
| 2551 | + ms: 2.1.3 | |
| 2552 | + | |
| 2553 | + deep-is@0.1.4: {} | |
| 2554 | + | |
| 2555 | + define-data-property@1.1.4: | |
| 2556 | + dependencies: | |
| 2557 | + es-define-property: 1.0.1 | |
| 2558 | + es-errors: 1.3.0 | |
| 2559 | + gopd: 1.2.0 | |
| 2560 | + | |
| 2561 | + define-properties@1.2.1: | |
| 2562 | + dependencies: | |
| 2563 | + define-data-property: 1.1.4 | |
| 2564 | + has-property-descriptors: 1.0.2 | |
| 2565 | + object-keys: 1.1.1 | |
| 2566 | + | |
| 2567 | + detect-libc@2.1.2: | |
| 2568 | + optional: true | |
| 2569 | + | |
| 2570 | + didyoumean@1.2.2: {} | |
| 2571 | + | |
| 2572 | + dlv@1.1.3: {} | |
| 2573 | + | |
| 2574 | + doctrine@2.1.0: | |
| 2575 | + dependencies: | |
| 2576 | + esutils: 2.0.3 | |
| 2577 | + | |
| 2578 | + doctrine@3.0.0: | |
| 2579 | + dependencies: | |
| 2580 | + esutils: 2.0.3 | |
| 2581 | + | |
| 2582 | + dunder-proto@1.0.1: | |
| 2583 | + dependencies: | |
| 2584 | + call-bind-apply-helpers: 1.0.2 | |
| 2585 | + es-errors: 1.3.0 | |
| 2586 | + gopd: 1.2.0 | |
| 2587 | + | |
| 2588 | + electron-to-chromium@1.5.405: {} | |
| 2589 | + | |
| 2590 | + emoji-regex@9.2.2: {} | |
| 2591 | + | |
| 2592 | + es-abstract-get@1.0.0: | |
| 2593 | + dependencies: | |
| 2594 | + es-errors: 1.3.0 | |
| 2595 | + es-object-atoms: 1.1.2 | |
| 2596 | + is-callable: 1.2.7 | |
| 2597 | + object-inspect: 1.13.4 | |
| 2598 | + | |
| 2599 | + es-abstract@1.24.2: | |
| 2600 | + dependencies: | |
| 2601 | + array-buffer-byte-length: 1.0.2 | |
| 2602 | + arraybuffer.prototype.slice: 1.0.4 | |
| 2603 | + available-typed-arrays: 1.0.7 | |
| 2604 | + call-bind: 1.0.9 | |
| 2605 | + call-bound: 1.0.4 | |
| 2606 | + data-view-buffer: 1.0.2 | |
| 2607 | + data-view-byte-length: 1.0.2 | |
| 2608 | + data-view-byte-offset: 1.0.1 | |
| 2609 | + es-define-property: 1.0.1 | |
| 2610 | + es-errors: 1.3.0 | |
| 2611 | + es-object-atoms: 1.1.2 | |
| 2612 | + es-set-tostringtag: 2.1.0 | |
| 2613 | + es-to-primitive: 1.3.4 | |
| 2614 | + function.prototype.name: 1.2.0 | |
| 2615 | + get-intrinsic: 1.3.0 | |
| 2616 | + get-proto: 1.0.1 | |
| 2617 | + get-symbol-description: 1.1.0 | |
| 2618 | + globalthis: 1.0.4 | |
| 2619 | + gopd: 1.2.0 | |
| 2620 | + has-property-descriptors: 1.0.2 | |
| 2621 | + has-proto: 1.2.0 | |
| 2622 | + has-symbols: 1.1.0 | |
| 2623 | + hasown: 2.0.4 | |
| 2624 | + internal-slot: 1.1.0 | |
| 2625 | + is-array-buffer: 3.0.5 | |
| 2626 | + is-callable: 1.2.7 | |
| 2627 | + is-data-view: 1.0.2 | |
| 2628 | + is-negative-zero: 2.0.3 | |
| 2629 | + is-regex: 1.2.1 | |
| 2630 | + is-set: 2.0.3 | |
| 2631 | + is-shared-array-buffer: 1.0.4 | |
| 2632 | + is-string: 1.1.1 | |
| 2633 | + is-typed-array: 1.1.15 | |
| 2634 | + is-weakref: 1.1.1 | |
| 2635 | + math-intrinsics: 1.1.0 | |
| 2636 | + object-inspect: 1.13.4 | |
| 2637 | + object-keys: 1.1.1 | |
| 2638 | + object.assign: 4.1.7 | |
| 2639 | + own-keys: 1.0.2 | |
| 2640 | + regexp.prototype.flags: 1.5.4 | |
| 2641 | + safe-array-concat: 1.1.4 | |
| 2642 | + safe-push-apply: 1.0.0 | |
| 2643 | + safe-regex-test: 1.1.0 | |
| 2644 | + set-proto: 1.0.0 | |
| 2645 | + stop-iteration-iterator: 1.1.0 | |
| 2646 | + string.prototype.trim: 1.2.11 | |
| 2647 | + string.prototype.trimend: 1.0.10 | |
| 2648 | + string.prototype.trimstart: 1.0.8 | |
| 2649 | + typed-array-buffer: 1.0.3 | |
| 2650 | + typed-array-byte-length: 1.0.3 | |
| 2651 | + typed-array-byte-offset: 1.0.4 | |
| 2652 | + typed-array-length: 1.0.8 | |
| 2653 | + unbox-primitive: 1.1.0 | |
| 2654 | + which-typed-array: 1.1.22 | |
| 2655 | + | |
| 2656 | + es-define-property@1.0.1: {} | |
| 2657 | + | |
| 2658 | + es-errors@1.3.0: {} | |
| 2659 | + | |
| 2660 | + es-iterator-helpers@1.4.0: | |
| 2661 | + dependencies: | |
| 2662 | + call-bind: 1.0.9 | |
| 2663 | + call-bound: 1.0.4 | |
| 2664 | + define-properties: 1.2.1 | |
| 2665 | + es-abstract: 1.24.2 | |
| 2666 | + es-errors: 1.3.0 | |
| 2667 | + es-set-tostringtag: 2.1.0 | |
| 2668 | + function-bind: 1.1.2 | |
| 2669 | + get-intrinsic: 1.3.0 | |
| 2670 | + globalthis: 1.0.4 | |
| 2671 | + gopd: 1.2.0 | |
| 2672 | + has-property-descriptors: 1.0.2 | |
| 2673 | + has-proto: 1.2.0 | |
| 2674 | + has-symbols: 1.1.0 | |
| 2675 | + internal-slot: 1.1.0 | |
| 2676 | + iterator.prototype: 1.1.5 | |
| 2677 | + math-intrinsics: 1.1.0 | |
| 2678 | + | |
| 2679 | + es-object-atoms@1.1.2: | |
| 2680 | + dependencies: | |
| 2681 | + es-errors: 1.3.0 | |
| 2682 | + | |
| 2683 | + es-set-tostringtag@2.1.0: | |
| 2684 | + dependencies: | |
| 2685 | + es-errors: 1.3.0 | |
| 2686 | + get-intrinsic: 1.3.0 | |
| 2687 | + has-tostringtag: 1.0.2 | |
| 2688 | + hasown: 2.0.4 | |
| 2689 | + | |
| 2690 | + es-shim-unscopables@1.1.0: | |
| 2691 | + dependencies: | |
| 2692 | + hasown: 2.0.4 | |
| 2693 | + | |
| 2694 | + es-to-primitive@1.3.4: | |
| 2695 | + dependencies: | |
| 2696 | + es-abstract-get: 1.0.0 | |
| 2697 | + es-define-property: 1.0.1 | |
| 2698 | + es-errors: 1.3.0 | |
| 2699 | + is-callable: 1.2.7 | |
| 2700 | + is-date-object: 1.1.0 | |
| 2701 | + is-symbol: 1.1.1 | |
| 2702 | + | |
| 2703 | + escalade@3.2.0: {} | |
| 2704 | + | |
| 2705 | + escape-string-regexp@4.0.0: {} | |
| 2706 | + | |
| 2707 | + eslint-config-next@15.5.23(eslint@8.57.1)(typescript@5.9.3): | |
| 2708 | + dependencies: | |
| 2709 | + '@next/eslint-plugin-next': 15.5.23 | |
| 2710 | + '@rushstack/eslint-patch': 1.16.1 | |
| 2711 | + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) | |
| 2712 | + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2713 | + eslint: 8.57.1 | |
| 2714 | + eslint-import-resolver-node: 0.3.10 | |
| 2715 | + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) | |
| 2716 | + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) | |
| 2717 | + eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) | |
| 2718 | + eslint-plugin-react: 7.37.5(eslint@8.57.1) | |
| 2719 | + eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) | |
| 2720 | + optionalDependencies: | |
| 2721 | + typescript: 5.9.3 | |
| 2722 | + transitivePeerDependencies: | |
| 2723 | + - eslint-import-resolver-webpack | |
| 2724 | + - eslint-plugin-import-x | |
| 2725 | + - supports-color | |
| 2726 | + | |
| 2727 | + eslint-import-resolver-node@0.3.10: | |
| 2728 | + dependencies: | |
| 2729 | + debug: 3.2.7 | |
| 2730 | + is-core-module: 2.16.2 | |
| 2731 | + resolve: 2.0.0-next.7 | |
| 2732 | + transitivePeerDependencies: | |
| 2733 | + - supports-color | |
| 2734 | + | |
| 2735 | + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): | |
| 2736 | + dependencies: | |
| 2737 | + '@nolyfill/is-core-module': 1.0.39 | |
| 2738 | + debug: 4.4.3 | |
| 2739 | + eslint: 8.57.1 | |
| 2740 | + get-tsconfig: 4.14.2 | |
| 2741 | + is-bun-module: 2.0.0 | |
| 2742 | + stable-hash: 0.0.5 | |
| 2743 | + tinyglobby: 0.2.17 | |
| 2744 | + unrs-resolver: 1.12.2 | |
| 2745 | + optionalDependencies: | |
| 2746 | + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) | |
| 2747 | + transitivePeerDependencies: | |
| 2748 | + - supports-color | |
| 2749 | + | |
| 2750 | + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): | |
| 2751 | + dependencies: | |
| 2752 | + debug: 3.2.7 | |
| 2753 | + optionalDependencies: | |
| 2754 | + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2755 | + eslint: 8.57.1 | |
| 2756 | + eslint-import-resolver-node: 0.3.10 | |
| 2757 | + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) | |
| 2758 | + transitivePeerDependencies: | |
| 2759 | + - supports-color | |
| 2760 | + | |
| 2761 | + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): | |
| 2762 | + dependencies: | |
| 2763 | + '@rtsao/scc': 1.1.0 | |
| 2764 | + array-includes: 3.1.9 | |
| 2765 | + array.prototype.findlastindex: 1.2.6 | |
| 2766 | + array.prototype.flat: 1.3.3 | |
| 2767 | + array.prototype.flatmap: 1.3.3 | |
| 2768 | + debug: 3.2.7 | |
| 2769 | + doctrine: 2.1.0 | |
| 2770 | + eslint: 8.57.1 | |
| 2771 | + eslint-import-resolver-node: 0.3.10 | |
| 2772 | + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) | |
| 2773 | + hasown: 2.0.4 | |
| 2774 | + is-core-module: 2.16.2 | |
| 2775 | + is-glob: 4.0.3 | |
| 2776 | + minimatch: 3.1.5 | |
| 2777 | + object.fromentries: 2.0.8 | |
| 2778 | + object.groupby: 1.0.3 | |
| 2779 | + object.values: 1.2.1 | |
| 2780 | + semver: 6.3.1 | |
| 2781 | + string.prototype.trimend: 1.0.10 | |
| 2782 | + tsconfig-paths: 3.15.0 | |
| 2783 | + optionalDependencies: | |
| 2784 | + '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2785 | + transitivePeerDependencies: | |
| 2786 | + - eslint-import-resolver-typescript | |
| 2787 | + - eslint-import-resolver-webpack | |
| 2788 | + - supports-color | |
| 2789 | + | |
| 2790 | + eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1): | |
| 2791 | + dependencies: | |
| 2792 | + aria-query: 5.3.2 | |
| 2793 | + array-includes: 3.1.9 | |
| 2794 | + array.prototype.flatmap: 1.3.3 | |
| 2795 | + ast-types-flow: 0.0.8 | |
| 2796 | + axe-core: 4.13.0 | |
| 2797 | + axobject-query: 4.1.0 | |
| 2798 | + damerau-levenshtein: 1.0.8 | |
| 2799 | + emoji-regex: 9.2.2 | |
| 2800 | + eslint: 8.57.1 | |
| 2801 | + hasown: 2.0.4 | |
| 2802 | + jsx-ast-utils: 3.3.5 | |
| 2803 | + language-tags: 1.0.9 | |
| 2804 | + minimatch: 3.1.5 | |
| 2805 | + object.fromentries: 2.0.8 | |
| 2806 | + safe-regex-test: 1.1.0 | |
| 2807 | + string.prototype.includes: 2.0.1 | |
| 2808 | + | |
| 2809 | + eslint-plugin-react-hooks@5.2.0(eslint@8.57.1): | |
| 2810 | + dependencies: | |
| 2811 | + eslint: 8.57.1 | |
| 2812 | + | |
| 2813 | + eslint-plugin-react@7.37.5(eslint@8.57.1): | |
| 2814 | + dependencies: | |
| 2815 | + array-includes: 3.1.9 | |
| 2816 | + array.prototype.findlast: 1.2.5 | |
| 2817 | + array.prototype.flatmap: 1.3.3 | |
| 2818 | + array.prototype.tosorted: 1.1.4 | |
| 2819 | + doctrine: 2.1.0 | |
| 2820 | + es-iterator-helpers: 1.4.0 | |
| 2821 | + eslint: 8.57.1 | |
| 2822 | + estraverse: 5.3.0 | |
| 2823 | + hasown: 2.0.4 | |
| 2824 | + jsx-ast-utils: 3.3.5 | |
| 2825 | + minimatch: 3.1.5 | |
| 2826 | + object.entries: 1.1.9 | |
| 2827 | + object.fromentries: 2.0.8 | |
| 2828 | + object.values: 1.2.1 | |
| 2829 | + prop-types: 15.8.1 | |
| 2830 | + resolve: 2.0.0-next.7 | |
| 2831 | + semver: 6.3.1 | |
| 2832 | + string.prototype.matchall: 4.0.12 | |
| 2833 | + string.prototype.repeat: 1.0.0 | |
| 2834 | + | |
| 2835 | + eslint-scope@7.2.2: | |
| 2836 | + dependencies: | |
| 2837 | + esrecurse: 4.3.0 | |
| 2838 | + estraverse: 5.3.0 | |
| 2839 | + | |
| 2840 | + eslint-visitor-keys@3.4.3: {} | |
| 2841 | + | |
| 2842 | + eslint-visitor-keys@5.0.1: {} | |
| 2843 | + | |
| 2844 | + eslint@8.57.1: | |
| 2845 | + dependencies: | |
| 2846 | + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1) | |
| 2847 | + '@eslint-community/regexpp': 4.12.2 | |
| 2848 | + '@eslint/eslintrc': 2.1.4 | |
| 2849 | + '@eslint/js': 8.57.1 | |
| 2850 | + '@humanwhocodes/config-array': 0.13.0 | |
| 2851 | + '@humanwhocodes/module-importer': 1.0.1 | |
| 2852 | + '@nodelib/fs.walk': 1.2.8 | |
| 2853 | + '@ungap/structured-clone': 1.3.3 | |
| 2854 | + ajv: 6.15.0 | |
| 2855 | + chalk: 4.1.2 | |
| 2856 | + cross-spawn: 7.0.6 | |
| 2857 | + debug: 4.4.3 | |
| 2858 | + doctrine: 3.0.0 | |
| 2859 | + escape-string-regexp: 4.0.0 | |
| 2860 | + eslint-scope: 7.2.2 | |
| 2861 | + eslint-visitor-keys: 3.4.3 | |
| 2862 | + espree: 9.6.1 | |
| 2863 | + esquery: 1.7.0 | |
| 2864 | + esutils: 2.0.3 | |
| 2865 | + fast-deep-equal: 3.1.3 | |
| 2866 | + file-entry-cache: 6.0.1 | |
| 2867 | + find-up: 5.0.0 | |
| 2868 | + glob-parent: 6.0.2 | |
| 2869 | + globals: 13.24.0 | |
| 2870 | + graphemer: 1.4.0 | |
| 2871 | + ignore: 5.3.2 | |
| 2872 | + imurmurhash: 0.1.4 | |
| 2873 | + is-glob: 4.0.3 | |
| 2874 | + is-path-inside: 3.0.3 | |
| 2875 | + js-yaml: 4.3.1 | |
| 2876 | + json-stable-stringify-without-jsonify: 1.0.1 | |
| 2877 | + levn: 0.4.1 | |
| 2878 | + lodash.merge: 4.6.2 | |
| 2879 | + minimatch: 3.1.5 | |
| 2880 | + natural-compare: 1.4.0 | |
| 2881 | + optionator: 0.9.4 | |
| 2882 | + strip-ansi: 6.0.1 | |
| 2883 | + text-table: 0.2.0 | |
| 2884 | + transitivePeerDependencies: | |
| 2885 | + - supports-color | |
| 2886 | + | |
| 2887 | + espree@9.6.1: | |
| 2888 | + dependencies: | |
| 2889 | + acorn: 8.18.0 | |
| 2890 | + acorn-jsx: 5.3.2(acorn@8.18.0) | |
| 2891 | + eslint-visitor-keys: 3.4.3 | |
| 2892 | + | |
| 2893 | + esquery@1.7.0: | |
| 2894 | + dependencies: | |
| 2895 | + estraverse: 5.3.0 | |
| 2896 | + | |
| 2897 | + esrecurse@4.3.0: | |
| 2898 | + dependencies: | |
| 2899 | + estraverse: 5.3.0 | |
| 2900 | + | |
| 2901 | + estraverse@5.3.0: {} | |
| 2902 | + | |
| 2903 | + esutils@2.0.3: {} | |
| 2904 | + | |
| 2905 | + fast-deep-equal@3.1.3: {} | |
| 2906 | + | |
| 2907 | + fast-glob@3.3.1: | |
| 2908 | + dependencies: | |
| 2909 | + '@nodelib/fs.stat': 2.0.5 | |
| 2910 | + '@nodelib/fs.walk': 1.2.8 | |
| 2911 | + glob-parent: 5.1.2 | |
| 2912 | + merge2: 1.4.1 | |
| 2913 | + micromatch: 4.0.8 | |
| 2914 | + | |
| 2915 | + fast-glob@3.3.3: | |
| 2916 | + dependencies: | |
| 2917 | + '@nodelib/fs.stat': 2.0.5 | |
| 2918 | + '@nodelib/fs.walk': 1.2.8 | |
| 2919 | + glob-parent: 5.1.2 | |
| 2920 | + merge2: 1.4.1 | |
| 2921 | + micromatch: 4.0.8 | |
| 2922 | + | |
| 2923 | + fast-json-stable-stringify@2.1.0: {} | |
| 2924 | + | |
| 2925 | + fast-levenshtein@2.0.6: {} | |
| 2926 | + | |
| 2927 | + fastq@1.20.1: | |
| 2928 | + dependencies: | |
| 2929 | + reusify: 1.1.0 | |
| 2930 | + | |
| 2931 | + fdir@6.5.0(picomatch@4.0.5): | |
| 2932 | + optionalDependencies: | |
| 2933 | + picomatch: 4.0.5 | |
| 2934 | + | |
| 2935 | + file-entry-cache@6.0.1: | |
| 2936 | + dependencies: | |
| 2937 | + flat-cache: 3.2.0 | |
| 2938 | + | |
| 2939 | + fill-range@7.1.1: | |
| 2940 | + dependencies: | |
| 2941 | + to-regex-range: 5.0.1 | |
| 2942 | + | |
| 2943 | + find-up@5.0.0: | |
| 2944 | + dependencies: | |
| 2945 | + locate-path: 6.0.0 | |
| 2946 | + path-exists: 4.0.0 | |
| 2947 | + | |
| 2948 | + flat-cache@3.2.0: | |
| 2949 | + dependencies: | |
| 2950 | + flatted: 3.4.4 | |
| 2951 | + keyv: 4.5.4 | |
| 2952 | + rimraf: 3.0.2 | |
| 2953 | + | |
| 2954 | + flatted@3.4.4: {} | |
| 2955 | + | |
| 2956 | + for-each@0.3.5: | |
| 2957 | + dependencies: | |
| 2958 | + is-callable: 1.2.7 | |
| 2959 | + | |
| 2960 | + fraction.js@5.3.4: {} | |
| 2961 | + | |
| 2962 | + fs.realpath@1.0.0: {} | |
| 2963 | + | |
| 2964 | + fsevents@2.3.3: | |
| 2965 | + optional: true | |
| 2966 | + | |
| 2967 | + function-bind@1.1.2: {} | |
| 2968 | + | |
| 2969 | + function.prototype.name@1.2.0: | |
| 2970 | + dependencies: | |
| 2971 | + call-bind: 1.0.9 | |
| 2972 | + call-bound: 1.0.4 | |
| 2973 | + es-define-property: 1.0.1 | |
| 2974 | + es-errors: 1.3.0 | |
| 2975 | + functions-have-names: 1.2.3 | |
| 2976 | + has-property-descriptors: 1.0.2 | |
| 2977 | + hasown: 2.0.4 | |
| 2978 | + is-callable: 1.2.7 | |
| 2979 | + is-document.all: 1.0.0 | |
| 2980 | + | |
| 2981 | + functions-have-names@1.2.3: {} | |
| 2982 | + | |
| 2983 | + generator-function@2.0.1: {} | |
| 2984 | + | |
| 2985 | + get-intrinsic@1.3.0: | |
| 2986 | + dependencies: | |
| 2987 | + call-bind-apply-helpers: 1.0.2 | |
| 2988 | + es-define-property: 1.0.1 | |
| 2989 | + es-errors: 1.3.0 | |
| 2990 | + es-object-atoms: 1.1.2 | |
| 2991 | + function-bind: 1.1.2 | |
| 2992 | + get-proto: 1.0.1 | |
| 2993 | + gopd: 1.2.0 | |
| 2994 | + has-symbols: 1.1.0 | |
| 2995 | + hasown: 2.0.4 | |
| 2996 | + math-intrinsics: 1.1.0 | |
| 2997 | + | |
| 2998 | + get-proto@1.0.1: | |
| 2999 | + dependencies: | |
| 3000 | + dunder-proto: 1.0.1 | |
| 3001 | + es-object-atoms: 1.1.2 | |
| 3002 | + | |
| 3003 | + get-symbol-description@1.1.0: | |
| 3004 | + dependencies: | |
| 3005 | + call-bound: 1.0.4 | |
| 3006 | + es-errors: 1.3.0 | |
| 3007 | + get-intrinsic: 1.3.0 | |
| 3008 | + | |
| 3009 | + get-tsconfig@4.14.2: | |
| 3010 | + dependencies: | |
| 3011 | + resolve-pkg-maps: 1.0.0 | |
| 3012 | + | |
| 3013 | + glob-parent@5.1.2: | |
| 3014 | + dependencies: | |
| 3015 | + is-glob: 4.0.3 | |
| 3016 | + | |
| 3017 | + glob-parent@6.0.2: | |
| 3018 | + dependencies: | |
| 3019 | + is-glob: 4.0.3 | |
| 3020 | + | |
| 3021 | + glob@7.2.3: | |
| 3022 | + dependencies: | |
| 3023 | + fs.realpath: 1.0.0 | |
| 3024 | + inflight: 1.0.6 | |
| 3025 | + inherits: 2.0.4 | |
| 3026 | + minimatch: 3.1.5 | |
| 3027 | + once: 1.4.0 | |
| 3028 | + path-is-absolute: 1.0.1 | |
| 3029 | + | |
| 3030 | + globals@13.24.0: | |
| 3031 | + dependencies: | |
| 3032 | + type-fest: 0.20.2 | |
| 3033 | + | |
| 3034 | + globalthis@1.0.4: | |
| 3035 | + dependencies: | |
| 3036 | + define-properties: 1.2.1 | |
| 3037 | + gopd: 1.2.0 | |
| 3038 | + | |
| 3039 | + gopd@1.2.0: {} | |
| 3040 | + | |
| 3041 | + graphemer@1.4.0: {} | |
| 3042 | + | |
| 3043 | + has-bigints@1.1.0: {} | |
| 3044 | + | |
| 3045 | + has-flag@4.0.0: {} | |
| 3046 | + | |
| 3047 | + has-property-descriptors@1.0.2: | |
| 3048 | + dependencies: | |
| 3049 | + es-define-property: 1.0.1 | |
| 3050 | + | |
| 3051 | + has-proto@1.2.0: | |
| 3052 | + dependencies: | |
| 3053 | + dunder-proto: 1.0.1 | |
| 3054 | + | |
| 3055 | + has-symbols@1.1.0: {} | |
| 3056 | + | |
| 3057 | + has-tostringtag@1.0.2: | |
| 3058 | + dependencies: | |
| 3059 | + has-symbols: 1.1.0 | |
| 3060 | + | |
| 3061 | + hasown@2.0.4: | |
| 3062 | + dependencies: | |
| 3063 | + function-bind: 1.1.2 | |
| 3064 | + | |
| 3065 | + ignore@5.3.2: {} | |
| 3066 | + | |
| 3067 | + ignore@7.0.6: {} | |
| 3068 | + | |
| 3069 | + import-fresh@3.3.1: | |
| 3070 | + dependencies: | |
| 3071 | + parent-module: 1.0.1 | |
| 3072 | + resolve-from: 4.0.0 | |
| 3073 | + | |
| 3074 | + imurmurhash@0.1.4: {} | |
| 3075 | + | |
| 3076 | + inflight@1.0.6: | |
| 3077 | + dependencies: | |
| 3078 | + once: 1.4.0 | |
| 3079 | + wrappy: 1.0.2 | |
| 3080 | + | |
| 3081 | + inherits@2.0.4: {} | |
| 3082 | + | |
| 3083 | + internal-slot@1.1.0: | |
| 3084 | + dependencies: | |
| 3085 | + es-errors: 1.3.0 | |
| 3086 | + hasown: 2.0.4 | |
| 3087 | + side-channel: 1.1.1 | |
| 3088 | + | |
| 3089 | + is-array-buffer@3.0.5: | |
| 3090 | + dependencies: | |
| 3091 | + call-bind: 1.0.9 | |
| 3092 | + call-bound: 1.0.4 | |
| 3093 | + get-intrinsic: 1.3.0 | |
| 3094 | + | |
| 3095 | + is-async-function@2.1.1: | |
| 3096 | + dependencies: | |
| 3097 | + async-function: 1.0.0 | |
| 3098 | + call-bound: 1.0.4 | |
| 3099 | + get-proto: 1.0.1 | |
| 3100 | + has-tostringtag: 1.0.2 | |
| 3101 | + safe-regex-test: 1.1.0 | |
| 3102 | + | |
| 3103 | + is-bigint@1.1.0: | |
| 3104 | + dependencies: | |
| 3105 | + has-bigints: 1.1.0 | |
| 3106 | + | |
| 3107 | + is-binary-path@2.1.0: | |
| 3108 | + dependencies: | |
| 3109 | + binary-extensions: 2.3.0 | |
| 3110 | + | |
| 3111 | + is-boolean-object@1.2.2: | |
| 3112 | + dependencies: | |
| 3113 | + call-bound: 1.0.4 | |
| 3114 | + has-tostringtag: 1.0.2 | |
| 3115 | + | |
| 3116 | + is-bun-module@2.0.0: | |
| 3117 | + dependencies: | |
| 3118 | + semver: 7.8.5 | |
| 3119 | + | |
| 3120 | + is-callable@1.2.7: {} | |
| 3121 | + | |
| 3122 | + is-core-module@2.16.2: | |
| 3123 | + dependencies: | |
| 3124 | + hasown: 2.0.4 | |
| 3125 | + | |
| 3126 | + is-data-view@1.0.2: | |
| 3127 | + dependencies: | |
| 3128 | + call-bound: 1.0.4 | |
| 3129 | + get-intrinsic: 1.3.0 | |
| 3130 | + is-typed-array: 1.1.15 | |
| 3131 | + | |
| 3132 | + is-date-object@1.1.0: | |
| 3133 | + dependencies: | |
| 3134 | + call-bound: 1.0.4 | |
| 3135 | + has-tostringtag: 1.0.2 | |
| 3136 | + | |
| 3137 | + is-document.all@1.0.0: | |
| 3138 | + dependencies: | |
| 3139 | + call-bound: 1.0.4 | |
| 3140 | + | |
| 3141 | + is-extglob@2.1.1: {} | |
| 3142 | + | |
| 3143 | + is-finalizationregistry@1.1.1: | |
| 3144 | + dependencies: | |
| 3145 | + call-bound: 1.0.4 | |
| 3146 | + | |
| 3147 | + is-generator-function@1.1.2: | |
| 3148 | + dependencies: | |
| 3149 | + call-bound: 1.0.4 | |
| 3150 | + generator-function: 2.0.1 | |
| 3151 | + get-proto: 1.0.1 | |
| 3152 | + has-tostringtag: 1.0.2 | |
| 3153 | + safe-regex-test: 1.1.0 | |
| 3154 | + | |
| 3155 | + is-glob@4.0.3: | |
| 3156 | + dependencies: | |
| 3157 | + is-extglob: 2.1.1 | |
| 3158 | + | |
| 3159 | + is-map@2.0.3: {} | |
| 3160 | + | |
| 3161 | + is-negative-zero@2.0.3: {} | |
| 3162 | + | |
| 3163 | + is-number-object@1.1.1: | |
| 3164 | + dependencies: | |
| 3165 | + call-bound: 1.0.4 | |
| 3166 | + has-tostringtag: 1.0.2 | |
| 3167 | + | |
| 3168 | + is-number@7.0.0: {} | |
| 3169 | + | |
| 3170 | + is-path-inside@3.0.3: {} | |
| 3171 | + | |
| 3172 | + is-regex@1.2.1: | |
| 3173 | + dependencies: | |
| 3174 | + call-bound: 1.0.4 | |
| 3175 | + gopd: 1.2.0 | |
| 3176 | + has-tostringtag: 1.0.2 | |
| 3177 | + hasown: 2.0.4 | |
| 3178 | + | |
| 3179 | + is-set@2.0.3: {} | |
| 3180 | + | |
| 3181 | + is-shared-array-buffer@1.0.4: | |
| 3182 | + dependencies: | |
| 3183 | + call-bound: 1.0.4 | |
| 3184 | + | |
| 3185 | + is-string@1.1.1: | |
| 3186 | + dependencies: | |
| 3187 | + call-bound: 1.0.4 | |
| 3188 | + has-tostringtag: 1.0.2 | |
| 3189 | + | |
| 3190 | + is-symbol@1.1.1: | |
| 3191 | + dependencies: | |
| 3192 | + call-bound: 1.0.4 | |
| 3193 | + has-symbols: 1.1.0 | |
| 3194 | + safe-regex-test: 1.1.0 | |
| 3195 | + | |
| 3196 | + is-typed-array@1.1.15: | |
| 3197 | + dependencies: | |
| 3198 | + which-typed-array: 1.1.22 | |
| 3199 | + | |
| 3200 | + is-weakmap@2.0.2: {} | |
| 3201 | + | |
| 3202 | + is-weakref@1.1.1: | |
| 3203 | + dependencies: | |
| 3204 | + call-bound: 1.0.4 | |
| 3205 | + | |
| 3206 | + is-weakset@2.0.4: | |
| 3207 | + dependencies: | |
| 3208 | + call-bound: 1.0.4 | |
| 3209 | + get-intrinsic: 1.3.0 | |
| 3210 | + | |
| 3211 | + isarray@2.0.5: {} | |
| 3212 | + | |
| 3213 | + isexe@2.0.0: {} | |
| 3214 | + | |
| 3215 | + iterator.prototype@1.1.5: | |
| 3216 | + dependencies: | |
| 3217 | + define-data-property: 1.1.4 | |
| 3218 | + es-object-atoms: 1.1.2 | |
| 3219 | + get-intrinsic: 1.3.0 | |
| 3220 | + get-proto: 1.0.1 | |
| 3221 | + has-symbols: 1.1.0 | |
| 3222 | + set-function-name: 2.0.2 | |
| 3223 | + | |
| 3224 | + jiti@1.21.7: {} | |
| 3225 | + | |
| 3226 | + js-tokens@4.0.0: {} | |
| 3227 | + | |
| 3228 | + js-yaml@4.3.1: | |
| 3229 | + dependencies: | |
| 3230 | + argparse: 2.0.1 | |
| 3231 | + | |
| 3232 | + json-buffer@3.0.1: {} | |
| 3233 | + | |
| 3234 | + json-schema-traverse@0.4.1: {} | |
| 3235 | + | |
| 3236 | + json-stable-stringify-without-jsonify@1.0.1: {} | |
| 3237 | + | |
| 3238 | + json5@1.0.2: | |
| 3239 | + dependencies: | |
| 3240 | + minimist: 1.2.8 | |
| 3241 | + | |
| 3242 | + jsx-ast-utils@3.3.5: | |
| 3243 | + dependencies: | |
| 3244 | + array-includes: 3.1.9 | |
| 3245 | + array.prototype.flat: 1.3.3 | |
| 3246 | + object.assign: 4.1.7 | |
| 3247 | + object.values: 1.2.1 | |
| 3248 | + | |
| 3249 | + keyv@4.5.4: | |
| 3250 | + dependencies: | |
| 3251 | + json-buffer: 3.0.1 | |
| 3252 | + | |
| 3253 | + language-subtag-registry@0.3.23: {} | |
| 3254 | + | |
| 3255 | + language-tags@1.0.9: | |
| 3256 | + dependencies: | |
| 3257 | + language-subtag-registry: 0.3.23 | |
| 3258 | + | |
| 3259 | + levn@0.4.1: | |
| 3260 | + dependencies: | |
| 3261 | + prelude-ls: 1.2.1 | |
| 3262 | + type-check: 0.4.0 | |
| 3263 | + | |
| 3264 | + lilconfig@3.1.3: {} | |
| 3265 | + | |
| 3266 | + lines-and-columns@1.2.4: {} | |
| 3267 | + | |
| 3268 | + locate-path@6.0.0: | |
| 3269 | + dependencies: | |
| 3270 | + p-locate: 5.0.0 | |
| 3271 | + | |
| 3272 | + lodash.merge@4.6.2: {} | |
| 3273 | + | |
| 3274 | + loose-envify@1.4.0: | |
| 3275 | + dependencies: | |
| 3276 | + js-tokens: 4.0.0 | |
| 3277 | + | |
| 3278 | + math-intrinsics@1.1.0: {} | |
| 3279 | + | |
| 3280 | + merge2@1.4.1: {} | |
| 3281 | + | |
| 3282 | + micromatch@4.0.8: | |
| 3283 | + dependencies: | |
| 3284 | + braces: 3.0.3 | |
| 3285 | + picomatch: 2.3.2 | |
| 3286 | + | |
| 3287 | + minimatch@10.2.6: | |
| 3288 | + dependencies: | |
| 3289 | + brace-expansion: 5.0.9 | |
| 3290 | + | |
| 3291 | + minimatch@3.1.5: | |
| 3292 | + dependencies: | |
| 3293 | + brace-expansion: 1.1.18 | |
| 3294 | + | |
| 3295 | + minimist@1.2.8: {} | |
| 3296 | + | |
| 3297 | + ms@2.1.3: {} | |
| 3298 | + | |
| 3299 | + mz@2.7.0: | |
| 3300 | + dependencies: | |
| 3301 | + any-promise: 1.3.0 | |
| 3302 | + object-assign: 4.1.1 | |
| 3303 | + thenify-all: 1.6.0 | |
| 3304 | + | |
| 3305 | + nanoid@3.3.18: {} | |
| 3306 | + | |
| 3307 | + napi-postinstall@0.3.4: {} | |
| 3308 | + | |
| 3309 | + natural-compare@1.4.0: {} | |
| 3310 | + | |
| 3311 | + next@15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8): | |
| 3312 | + dependencies: | |
| 3313 | + '@next/env': 15.5.23 | |
| 3314 | + '@swc/helpers': 0.5.15 | |
| 3315 | + caniuse-lite: 1.0.30001809 | |
| 3316 | + postcss: 8.4.31 | |
| 3317 | + react: 19.2.8 | |
| 3318 | + react-dom: 19.2.8(react@19.2.8) | |
| 3319 | + styled-jsx: 5.1.6(react@19.2.8) | |
| 3320 | + optionalDependencies: | |
| 3321 | + '@next/swc-darwin-arm64': 15.5.23 | |
| 3322 | + '@next/swc-darwin-x64': 15.5.23 | |
| 3323 | + '@next/swc-linux-arm64-gnu': 15.5.23 | |
| 3324 | + '@next/swc-linux-arm64-musl': 15.5.23 | |
| 3325 | + '@next/swc-linux-x64-gnu': 15.5.23 | |
| 3326 | + '@next/swc-linux-x64-musl': 15.5.23 | |
| 3327 | + '@next/swc-win32-arm64-msvc': 15.5.23 | |
| 3328 | + '@next/swc-win32-x64-msvc': 15.5.23 | |
| 3329 | + sharp: 0.34.5 | |
| 3330 | + transitivePeerDependencies: | |
| 3331 | + - '@babel/core' | |
| 3332 | + - babel-plugin-macros | |
| 3333 | + | |
| 3334 | + node-exports-info@1.6.2: | |
| 3335 | + dependencies: | |
| 3336 | + array.prototype.flatmap: 1.3.3 | |
| 3337 | + es-errors: 1.3.0 | |
| 3338 | + object.entries: 1.1.9 | |
| 3339 | + semver: 6.3.1 | |
| 3340 | + | |
| 3341 | + node-releases@2.0.53: {} | |
| 3342 | + | |
| 3343 | + normalize-path@3.0.0: {} | |
| 3344 | + | |
| 3345 | + object-assign@4.1.1: {} | |
| 3346 | + | |
| 3347 | + object-hash@3.0.0: {} | |
| 3348 | + | |
| 3349 | + object-inspect@1.13.4: {} | |
| 3350 | + | |
| 3351 | + object-keys@1.1.1: {} | |
| 3352 | + | |
| 3353 | + object.assign@4.1.7: | |
| 3354 | + dependencies: | |
| 3355 | + call-bind: 1.0.9 | |
| 3356 | + call-bound: 1.0.4 | |
| 3357 | + define-properties: 1.2.1 | |
| 3358 | + es-object-atoms: 1.1.2 | |
| 3359 | + has-symbols: 1.1.0 | |
| 3360 | + object-keys: 1.1.1 | |
| 3361 | + | |
| 3362 | + object.entries@1.1.9: | |
| 3363 | + dependencies: | |
| 3364 | + call-bind: 1.0.9 | |
| 3365 | + call-bound: 1.0.4 | |
| 3366 | + define-properties: 1.2.1 | |
| 3367 | + es-object-atoms: 1.1.2 | |
| 3368 | + | |
| 3369 | + object.fromentries@2.0.8: | |
| 3370 | + dependencies: | |
| 3371 | + call-bind: 1.0.9 | |
| 3372 | + define-properties: 1.2.1 | |
| 3373 | + es-abstract: 1.24.2 | |
| 3374 | + es-object-atoms: 1.1.2 | |
| 3375 | + | |
| 3376 | + object.groupby@1.0.3: | |
| 3377 | + dependencies: | |
| 3378 | + call-bind: 1.0.9 | |
| 3379 | + define-properties: 1.2.1 | |
| 3380 | + es-abstract: 1.24.2 | |
| 3381 | + | |
| 3382 | + object.values@1.2.1: | |
| 3383 | + dependencies: | |
| 3384 | + call-bind: 1.0.9 | |
| 3385 | + call-bound: 1.0.4 | |
| 3386 | + define-properties: 1.2.1 | |
| 3387 | + es-object-atoms: 1.1.2 | |
| 3388 | + | |
| 3389 | + once@1.4.0: | |
| 3390 | + dependencies: | |
| 3391 | + wrappy: 1.0.2 | |
| 3392 | + | |
| 3393 | + optionator@0.9.4: | |
| 3394 | + dependencies: | |
| 3395 | + deep-is: 0.1.4 | |
| 3396 | + fast-levenshtein: 2.0.6 | |
| 3397 | + levn: 0.4.1 | |
| 3398 | + prelude-ls: 1.2.1 | |
| 3399 | + type-check: 0.4.0 | |
| 3400 | + word-wrap: 1.2.5 | |
| 3401 | + | |
| 3402 | + own-keys@1.0.2: | |
| 3403 | + dependencies: | |
| 3404 | + call-bound: 1.0.4 | |
| 3405 | + get-intrinsic: 1.3.0 | |
| 3406 | + object-keys: 1.1.1 | |
| 3407 | + safe-push-apply: 1.0.0 | |
| 3408 | + | |
| 3409 | + p-limit@3.1.0: | |
| 3410 | + dependencies: | |
| 3411 | + yocto-queue: 0.1.0 | |
| 3412 | + | |
| 3413 | + p-locate@5.0.0: | |
| 3414 | + dependencies: | |
| 3415 | + p-limit: 3.1.0 | |
| 3416 | + | |
| 3417 | + parent-module@1.0.1: | |
| 3418 | + dependencies: | |
| 3419 | + callsites: 3.1.0 | |
| 3420 | + | |
| 3421 | + path-exists@4.0.0: {} | |
| 3422 | + | |
| 3423 | + path-is-absolute@1.0.1: {} | |
| 3424 | + | |
| 3425 | + path-key@3.1.1: {} | |
| 3426 | + | |
| 3427 | + path-parse@1.0.7: {} | |
| 3428 | + | |
| 3429 | + picocolors@1.1.1: {} | |
| 3430 | + | |
| 3431 | + picomatch@2.3.2: {} | |
| 3432 | + | |
| 3433 | + picomatch@4.0.5: {} | |
| 3434 | + | |
| 3435 | + pify@2.3.0: {} | |
| 3436 | + | |
| 3437 | + pirates@4.0.7: {} | |
| 3438 | + | |
| 3439 | + possible-typed-array-names@1.1.0: {} | |
| 3440 | + | |
| 3441 | + postcss-import@15.1.0(postcss@8.5.26): | |
| 3442 | + dependencies: | |
| 3443 | + postcss: 8.5.26 | |
| 3444 | + postcss-value-parser: 4.2.0 | |
| 3445 | + read-cache: 1.0.0 | |
| 3446 | + resolve: 1.22.12 | |
| 3447 | + | |
| 3448 | + postcss-js@4.1.0(postcss@8.5.26): | |
| 3449 | + dependencies: | |
| 3450 | + camelcase-css: 2.0.1 | |
| 3451 | + postcss: 8.5.26 | |
| 3452 | + | |
| 3453 | + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26): | |
| 3454 | + dependencies: | |
| 3455 | + lilconfig: 3.1.3 | |
| 3456 | + optionalDependencies: | |
| 3457 | + jiti: 1.21.7 | |
| 3458 | + postcss: 8.5.26 | |
| 3459 | + | |
| 3460 | + postcss-nested@6.2.0(postcss@8.5.26): | |
| 3461 | + dependencies: | |
| 3462 | + postcss: 8.5.26 | |
| 3463 | + postcss-selector-parser: 6.1.4 | |
| 3464 | + | |
| 3465 | + postcss-selector-parser@6.1.4: | |
| 3466 | + dependencies: | |
| 3467 | + cssesc: 3.0.0 | |
| 3468 | + util-deprecate: 1.0.2 | |
| 3469 | + | |
| 3470 | + postcss-value-parser@4.2.0: {} | |
| 3471 | + | |
| 3472 | + postcss@8.4.31: | |
| 3473 | + dependencies: | |
| 3474 | + nanoid: 3.3.18 | |
| 3475 | + picocolors: 1.1.1 | |
| 3476 | + source-map-js: 1.2.1 | |
| 3477 | + | |
| 3478 | + postcss@8.5.26: | |
| 3479 | + dependencies: | |
| 3480 | + nanoid: 3.3.18 | |
| 3481 | + picocolors: 1.1.1 | |
| 3482 | + source-map-js: 1.2.1 | |
| 3483 | + | |
| 3484 | + prelude-ls@1.2.1: {} | |
| 3485 | + | |
| 3486 | + prop-types@15.8.1: | |
| 3487 | + dependencies: | |
| 3488 | + loose-envify: 1.4.0 | |
| 3489 | + object-assign: 4.1.1 | |
| 3490 | + react-is: 16.13.1 | |
| 3491 | + | |
| 3492 | + punycode@2.3.1: {} | |
| 3493 | + | |
| 3494 | + queue-microtask@1.2.3: {} | |
| 3495 | + | |
| 3496 | + react-dom@19.2.8(react@19.2.8): | |
| 3497 | + dependencies: | |
| 3498 | + react: 19.2.8 | |
| 3499 | + scheduler: 0.27.0 | |
| 3500 | + | |
| 3501 | + react-is@16.13.1: {} | |
| 3502 | + | |
| 3503 | + react@19.2.8: {} | |
| 3504 | + | |
| 3505 | + read-cache@1.0.0: | |
| 3506 | + dependencies: | |
| 3507 | + pify: 2.3.0 | |
| 3508 | + | |
| 3509 | + readdirp@3.6.0: | |
| 3510 | + dependencies: | |
| 3511 | + picomatch: 2.3.2 | |
| 3512 | + | |
| 3513 | + reflect.getprototypeof@1.0.10: | |
| 3514 | + dependencies: | |
| 3515 | + call-bind: 1.0.9 | |
| 3516 | + define-properties: 1.2.1 | |
| 3517 | + es-abstract: 1.24.2 | |
| 3518 | + es-errors: 1.3.0 | |
| 3519 | + es-object-atoms: 1.1.2 | |
| 3520 | + get-intrinsic: 1.3.0 | |
| 3521 | + get-proto: 1.0.1 | |
| 3522 | + which-builtin-type: 1.2.1 | |
| 3523 | + | |
| 3524 | + regexp.prototype.flags@1.5.4: | |
| 3525 | + dependencies: | |
| 3526 | + call-bind: 1.0.9 | |
| 3527 | + define-properties: 1.2.1 | |
| 3528 | + es-errors: 1.3.0 | |
| 3529 | + get-proto: 1.0.1 | |
| 3530 | + gopd: 1.2.0 | |
| 3531 | + set-function-name: 2.0.2 | |
| 3532 | + | |
| 3533 | + resolve-from@4.0.0: {} | |
| 3534 | + | |
| 3535 | + resolve-pkg-maps@1.0.0: {} | |
| 3536 | + | |
| 3537 | + resolve@1.22.12: | |
| 3538 | + dependencies: | |
| 3539 | + es-errors: 1.3.0 | |
| 3540 | + is-core-module: 2.16.2 | |
| 3541 | + path-parse: 1.0.7 | |
| 3542 | + supports-preserve-symlinks-flag: 1.0.0 | |
| 3543 | + | |
| 3544 | + resolve@2.0.0-next.7: | |
| 3545 | + dependencies: | |
| 3546 | + es-errors: 1.3.0 | |
| 3547 | + is-core-module: 2.16.2 | |
| 3548 | + node-exports-info: 1.6.2 | |
| 3549 | + object-keys: 1.1.1 | |
| 3550 | + path-parse: 1.0.7 | |
| 3551 | + supports-preserve-symlinks-flag: 1.0.0 | |
| 3552 | + | |
| 3553 | + reusify@1.1.0: {} | |
| 3554 | + | |
| 3555 | + rimraf@3.0.2: | |
| 3556 | + dependencies: | |
| 3557 | + glob: 7.2.3 | |
| 3558 | + | |
| 3559 | + run-parallel@1.2.0: | |
| 3560 | + dependencies: | |
| 3561 | + queue-microtask: 1.2.3 | |
| 3562 | + | |
| 3563 | + safe-array-concat@1.1.4: | |
| 3564 | + dependencies: | |
| 3565 | + call-bind: 1.0.9 | |
| 3566 | + call-bound: 1.0.4 | |
| 3567 | + get-intrinsic: 1.3.0 | |
| 3568 | + has-symbols: 1.1.0 | |
| 3569 | + isarray: 2.0.5 | |
| 3570 | + | |
| 3571 | + safe-push-apply@1.0.0: | |
| 3572 | + dependencies: | |
| 3573 | + es-errors: 1.3.0 | |
| 3574 | + isarray: 2.0.5 | |
| 3575 | + | |
| 3576 | + safe-regex-test@1.1.0: | |
| 3577 | + dependencies: | |
| 3578 | + call-bound: 1.0.4 | |
| 3579 | + es-errors: 1.3.0 | |
| 3580 | + is-regex: 1.2.1 | |
| 3581 | + | |
| 3582 | + scheduler@0.27.0: {} | |
| 3583 | + | |
| 3584 | + semver@6.3.1: {} | |
| 3585 | + | |
| 3586 | + semver@7.8.5: {} | |
| 3587 | + | |
| 3588 | + set-function-length@1.2.2: | |
| 3589 | + dependencies: | |
| 3590 | + define-data-property: 1.1.4 | |
| 3591 | + es-errors: 1.3.0 | |
| 3592 | + function-bind: 1.1.2 | |
| 3593 | + get-intrinsic: 1.3.0 | |
| 3594 | + gopd: 1.2.0 | |
| 3595 | + has-property-descriptors: 1.0.2 | |
| 3596 | + | |
| 3597 | + set-function-name@2.0.2: | |
| 3598 | + dependencies: | |
| 3599 | + define-data-property: 1.1.4 | |
| 3600 | + es-errors: 1.3.0 | |
| 3601 | + functions-have-names: 1.2.3 | |
| 3602 | + has-property-descriptors: 1.0.2 | |
| 3603 | + | |
| 3604 | + set-proto@1.0.0: | |
| 3605 | + dependencies: | |
| 3606 | + dunder-proto: 1.0.1 | |
| 3607 | + es-errors: 1.3.0 | |
| 3608 | + es-object-atoms: 1.1.2 | |
| 3609 | + | |
| 3610 | + sharp@0.34.5: | |
| 3611 | + dependencies: | |
| 3612 | + '@img/colour': 1.1.0 | |
| 3613 | + detect-libc: 2.1.2 | |
| 3614 | + semver: 7.8.5 | |
| 3615 | + optionalDependencies: | |
| 3616 | + '@img/sharp-darwin-arm64': 0.34.5 | |
| 3617 | + '@img/sharp-darwin-x64': 0.34.5 | |
| 3618 | + '@img/sharp-libvips-darwin-arm64': 1.2.4 | |
| 3619 | + '@img/sharp-libvips-darwin-x64': 1.2.4 | |
| 3620 | + '@img/sharp-libvips-linux-arm': 1.2.4 | |
| 3621 | + '@img/sharp-libvips-linux-arm64': 1.2.4 | |
| 3622 | + '@img/sharp-libvips-linux-ppc64': 1.2.4 | |
| 3623 | + '@img/sharp-libvips-linux-riscv64': 1.2.4 | |
| 3624 | + '@img/sharp-libvips-linux-s390x': 1.2.4 | |
| 3625 | + '@img/sharp-libvips-linux-x64': 1.2.4 | |
| 3626 | + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 | |
| 3627 | + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 | |
| 3628 | + '@img/sharp-linux-arm': 0.34.5 | |
| 3629 | + '@img/sharp-linux-arm64': 0.34.5 | |
| 3630 | + '@img/sharp-linux-ppc64': 0.34.5 | |
| 3631 | + '@img/sharp-linux-riscv64': 0.34.5 | |
| 3632 | + '@img/sharp-linux-s390x': 0.34.5 | |
| 3633 | + '@img/sharp-linux-x64': 0.34.5 | |
| 3634 | + '@img/sharp-linuxmusl-arm64': 0.34.5 | |
| 3635 | + '@img/sharp-linuxmusl-x64': 0.34.5 | |
| 3636 | + '@img/sharp-wasm32': 0.34.5 | |
| 3637 | + '@img/sharp-win32-arm64': 0.34.5 | |
| 3638 | + '@img/sharp-win32-ia32': 0.34.5 | |
| 3639 | + '@img/sharp-win32-x64': 0.34.5 | |
| 3640 | + optional: true | |
| 3641 | + | |
| 3642 | + shebang-command@2.0.0: | |
| 3643 | + dependencies: | |
| 3644 | + shebang-regex: 3.0.0 | |
| 3645 | + | |
| 3646 | + shebang-regex@3.0.0: {} | |
| 3647 | + | |
| 3648 | + side-channel-list@1.0.1: | |
| 3649 | + dependencies: | |
| 3650 | + es-errors: 1.3.0 | |
| 3651 | + object-inspect: 1.13.4 | |
| 3652 | + | |
| 3653 | + side-channel-map@1.0.1: | |
| 3654 | + dependencies: | |
| 3655 | + call-bound: 1.0.4 | |
| 3656 | + es-errors: 1.3.0 | |
| 3657 | + get-intrinsic: 1.3.0 | |
| 3658 | + object-inspect: 1.13.4 | |
| 3659 | + | |
| 3660 | + side-channel-weakmap@1.0.2: | |
| 3661 | + dependencies: | |
| 3662 | + call-bound: 1.0.4 | |
| 3663 | + es-errors: 1.3.0 | |
| 3664 | + get-intrinsic: 1.3.0 | |
| 3665 | + object-inspect: 1.13.4 | |
| 3666 | + side-channel-map: 1.0.1 | |
| 3667 | + | |
| 3668 | + side-channel@1.1.1: | |
| 3669 | + dependencies: | |
| 3670 | + es-errors: 1.3.0 | |
| 3671 | + object-inspect: 1.13.4 | |
| 3672 | + side-channel-list: 1.0.1 | |
| 3673 | + side-channel-map: 1.0.1 | |
| 3674 | + side-channel-weakmap: 1.0.2 | |
| 3675 | + | |
| 3676 | + source-map-js@1.2.1: {} | |
| 3677 | + | |
| 3678 | + stable-hash@0.0.5: {} | |
| 3679 | + | |
| 3680 | + stop-iteration-iterator@1.1.0: | |
| 3681 | + dependencies: | |
| 3682 | + es-errors: 1.3.0 | |
| 3683 | + internal-slot: 1.1.0 | |
| 3684 | + | |
| 3685 | + string.prototype.includes@2.0.1: | |
| 3686 | + dependencies: | |
| 3687 | + call-bind: 1.0.9 | |
| 3688 | + define-properties: 1.2.1 | |
| 3689 | + es-abstract: 1.24.2 | |
| 3690 | + | |
| 3691 | + string.prototype.matchall@4.0.12: | |
| 3692 | + dependencies: | |
| 3693 | + call-bind: 1.0.9 | |
| 3694 | + call-bound: 1.0.4 | |
| 3695 | + define-properties: 1.2.1 | |
| 3696 | + es-abstract: 1.24.2 | |
| 3697 | + es-errors: 1.3.0 | |
| 3698 | + es-object-atoms: 1.1.2 | |
| 3699 | + get-intrinsic: 1.3.0 | |
| 3700 | + gopd: 1.2.0 | |
| 3701 | + has-symbols: 1.1.0 | |
| 3702 | + internal-slot: 1.1.0 | |
| 3703 | + regexp.prototype.flags: 1.5.4 | |
| 3704 | + set-function-name: 2.0.2 | |
| 3705 | + side-channel: 1.1.1 | |
| 3706 | + | |
| 3707 | + string.prototype.repeat@1.0.0: | |
| 3708 | + dependencies: | |
| 3709 | + define-properties: 1.2.1 | |
| 3710 | + es-abstract: 1.24.2 | |
| 3711 | + | |
| 3712 | + string.prototype.trim@1.2.11: | |
| 3713 | + dependencies: | |
| 3714 | + call-bind: 1.0.9 | |
| 3715 | + call-bound: 1.0.4 | |
| 3716 | + define-data-property: 1.1.4 | |
| 3717 | + define-properties: 1.2.1 | |
| 3718 | + es-abstract: 1.24.2 | |
| 3719 | + es-object-atoms: 1.1.2 | |
| 3720 | + has-property-descriptors: 1.0.2 | |
| 3721 | + safe-regex-test: 1.1.0 | |
| 3722 | + | |
| 3723 | + string.prototype.trimend@1.0.10: | |
| 3724 | + dependencies: | |
| 3725 | + call-bind: 1.0.9 | |
| 3726 | + call-bound: 1.0.4 | |
| 3727 | + define-properties: 1.2.1 | |
| 3728 | + es-object-atoms: 1.1.2 | |
| 3729 | + | |
| 3730 | + string.prototype.trimstart@1.0.8: | |
| 3731 | + dependencies: | |
| 3732 | + call-bind: 1.0.9 | |
| 3733 | + define-properties: 1.2.1 | |
| 3734 | + es-object-atoms: 1.1.2 | |
| 3735 | + | |
| 3736 | + strip-ansi@6.0.1: | |
| 3737 | + dependencies: | |
| 3738 | + ansi-regex: 5.0.1 | |
| 3739 | + | |
| 3740 | + strip-bom@3.0.0: {} | |
| 3741 | + | |
| 3742 | + strip-json-comments@3.1.1: {} | |
| 3743 | + | |
| 3744 | + styled-jsx@5.1.6(react@19.2.8): | |
| 3745 | + dependencies: | |
| 3746 | + client-only: 0.0.1 | |
| 3747 | + react: 19.2.8 | |
| 3748 | + | |
| 3749 | + sucrase@3.35.1: | |
| 3750 | + dependencies: | |
| 3751 | + '@jridgewell/gen-mapping': 0.3.13 | |
| 3752 | + commander: 4.1.1 | |
| 3753 | + lines-and-columns: 1.2.4 | |
| 3754 | + mz: 2.7.0 | |
| 3755 | + pirates: 4.0.7 | |
| 3756 | + tinyglobby: 0.2.17 | |
| 3757 | + ts-interface-checker: 0.1.13 | |
| 3758 | + | |
| 3759 | + supports-color@7.2.0: | |
| 3760 | + dependencies: | |
| 3761 | + has-flag: 4.0.0 | |
| 3762 | + | |
| 3763 | + supports-preserve-symlinks-flag@1.0.0: {} | |
| 3764 | + | |
| 3765 | + tailwind-merge@2.6.1: {} | |
| 3766 | + | |
| 3767 | + tailwindcss@3.4.19: | |
| 3768 | + dependencies: | |
| 3769 | + '@alloc/quick-lru': 5.2.0 | |
| 3770 | + arg: 5.0.2 | |
| 3771 | + chokidar: 3.6.0 | |
| 3772 | + didyoumean: 1.2.2 | |
| 3773 | + dlv: 1.1.3 | |
| 3774 | + fast-glob: 3.3.3 | |
| 3775 | + glob-parent: 6.0.2 | |
| 3776 | + is-glob: 4.0.3 | |
| 3777 | + jiti: 1.21.7 | |
| 3778 | + lilconfig: 3.1.3 | |
| 3779 | + micromatch: 4.0.8 | |
| 3780 | + normalize-path: 3.0.0 | |
| 3781 | + object-hash: 3.0.0 | |
| 3782 | + picocolors: 1.1.1 | |
| 3783 | + postcss: 8.5.26 | |
| 3784 | + postcss-import: 15.1.0(postcss@8.5.26) | |
| 3785 | + postcss-js: 4.1.0(postcss@8.5.26) | |
| 3786 | + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26) | |
| 3787 | + postcss-nested: 6.2.0(postcss@8.5.26) | |
| 3788 | + postcss-selector-parser: 6.1.4 | |
| 3789 | + resolve: 1.22.12 | |
| 3790 | + sucrase: 3.35.1 | |
| 3791 | + transitivePeerDependencies: | |
| 3792 | + - tsx | |
| 3793 | + - yaml | |
| 3794 | + | |
| 3795 | + text-table@0.2.0: {} | |
| 3796 | + | |
| 3797 | + thenify-all@1.6.0: | |
| 3798 | + dependencies: | |
| 3799 | + thenify: 3.3.1 | |
| 3800 | + | |
| 3801 | + thenify@3.3.1: | |
| 3802 | + dependencies: | |
| 3803 | + any-promise: 1.3.0 | |
| 3804 | + | |
| 3805 | + tinyglobby@0.2.17: | |
| 3806 | + dependencies: | |
| 3807 | + fdir: 6.5.0(picomatch@4.0.5) | |
| 3808 | + picomatch: 4.0.5 | |
| 3809 | + | |
| 3810 | + to-regex-range@5.0.1: | |
| 3811 | + dependencies: | |
| 3812 | + is-number: 7.0.0 | |
| 3813 | + | |
| 3814 | + ts-api-utils@2.5.0(typescript@5.9.3): | |
| 3815 | + dependencies: | |
| 3816 | + typescript: 5.9.3 | |
| 3817 | + | |
| 3818 | + ts-interface-checker@0.1.13: {} | |
| 3819 | + | |
| 3820 | + tsconfig-paths@3.15.0: | |
| 3821 | + dependencies: | |
| 3822 | + '@types/json5': 0.0.29 | |
| 3823 | + json5: 1.0.2 | |
| 3824 | + minimist: 1.2.8 | |
| 3825 | + strip-bom: 3.0.0 | |
| 3826 | + | |
| 3827 | + tslib@2.8.1: {} | |
| 3828 | + | |
| 3829 | + type-check@0.4.0: | |
| 3830 | + dependencies: | |
| 3831 | + prelude-ls: 1.2.1 | |
| 3832 | + | |
| 3833 | + type-fest@0.20.2: {} | |
| 3834 | + | |
| 3835 | + typed-array-buffer@1.0.3: | |
| 3836 | + dependencies: | |
| 3837 | + call-bound: 1.0.4 | |
| 3838 | + es-errors: 1.3.0 | |
| 3839 | + is-typed-array: 1.1.15 | |
| 3840 | + | |
| 3841 | + typed-array-byte-length@1.0.3: | |
| 3842 | + dependencies: | |
| 3843 | + call-bind: 1.0.9 | |
| 3844 | + for-each: 0.3.5 | |
| 3845 | + gopd: 1.2.0 | |
| 3846 | + has-proto: 1.2.0 | |
| 3847 | + is-typed-array: 1.1.15 | |
| 3848 | + | |
| 3849 | + typed-array-byte-offset@1.0.4: | |
| 3850 | + dependencies: | |
| 3851 | + available-typed-arrays: 1.0.7 | |
| 3852 | + call-bind: 1.0.9 | |
| 3853 | + for-each: 0.3.5 | |
| 3854 | + gopd: 1.2.0 | |
| 3855 | + has-proto: 1.2.0 | |
| 3856 | + is-typed-array: 1.1.15 | |
| 3857 | + reflect.getprototypeof: 1.0.10 | |
| 3858 | + | |
| 3859 | + typed-array-length@1.0.8: | |
| 3860 | + dependencies: | |
| 3861 | + call-bind: 1.0.9 | |
| 3862 | + for-each: 0.3.5 | |
| 3863 | + gopd: 1.2.0 | |
| 3864 | + is-typed-array: 1.1.15 | |
| 3865 | + possible-typed-array-names: 1.1.0 | |
| 3866 | + reflect.getprototypeof: 1.0.10 | |
| 3867 | + | |
| 3868 | + typescript@5.9.3: {} | |
| 3869 | + | |
| 3870 | + unbox-primitive@1.1.0: | |
| 3871 | + dependencies: | |
| 3872 | + call-bound: 1.0.4 | |
| 3873 | + has-bigints: 1.1.0 | |
| 3874 | + has-symbols: 1.1.0 | |
| 3875 | + which-boxed-primitive: 1.1.1 | |
| 3876 | + | |
| 3877 | + undici-types@6.21.0: {} | |
| 3878 | + | |
| 3879 | + unrs-resolver@1.12.2: | |
| 3880 | + dependencies: | |
| 3881 | + napi-postinstall: 0.3.4 | |
| 3882 | + optionalDependencies: | |
| 3883 | + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 | |
| 3884 | + '@unrs/resolver-binding-android-arm64': 1.12.2 | |
| 3885 | + '@unrs/resolver-binding-darwin-arm64': 1.12.2 | |
| 3886 | + '@unrs/resolver-binding-darwin-x64': 1.12.2 | |
| 3887 | + '@unrs/resolver-binding-freebsd-x64': 1.12.2 | |
| 3888 | + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 | |
| 3889 | + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 | |
| 3890 | + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 | |
| 3891 | + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 | |
| 3892 | + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 | |
| 3893 | + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 | |
| 3894 | + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 | |
| 3895 | + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 | |
| 3896 | + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 | |
| 3897 | + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 | |
| 3898 | + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 | |
| 3899 | + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 | |
| 3900 | + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 | |
| 3901 | + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 | |
| 3902 | + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 | |
| 3903 | + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 | |
| 3904 | + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 | |
| 3905 | + | |
| 3906 | + update-browserslist-db@1.3.1(browserslist@4.28.8): | |
| 3907 | + dependencies: | |
| 3908 | + browserslist: 4.28.8 | |
| 3909 | + escalade: 3.2.0 | |
| 3910 | + picocolors: 1.1.1 | |
| 3911 | + | |
| 3912 | + uri-js@4.4.1: | |
| 3913 | + dependencies: | |
| 3914 | + punycode: 2.3.1 | |
| 3915 | + | |
| 3916 | + util-deprecate@1.0.2: {} | |
| 3917 | + | |
| 3918 | + which-boxed-primitive@1.1.1: | |
| 3919 | + dependencies: | |
| 3920 | + is-bigint: 1.1.0 | |
| 3921 | + is-boolean-object: 1.2.2 | |
| 3922 | + is-number-object: 1.1.1 | |
| 3923 | + is-string: 1.1.1 | |
| 3924 | + is-symbol: 1.1.1 | |
| 3925 | + | |
| 3926 | + which-builtin-type@1.2.1: | |
| 3927 | + dependencies: | |
| 3928 | + call-bound: 1.0.4 | |
| 3929 | + function.prototype.name: 1.2.0 | |
| 3930 | + has-tostringtag: 1.0.2 | |
| 3931 | + is-async-function: 2.1.1 | |
| 3932 | + is-date-object: 1.1.0 | |
| 3933 | + is-finalizationregistry: 1.1.1 | |
| 3934 | + is-generator-function: 1.1.2 | |
| 3935 | + is-regex: 1.2.1 | |
| 3936 | + is-weakref: 1.1.1 | |
| 3937 | + isarray: 2.0.5 | |
| 3938 | + which-boxed-primitive: 1.1.1 | |
| 3939 | + which-collection: 1.0.2 | |
| 3940 | + which-typed-array: 1.1.22 | |
| 3941 | + | |
| 3942 | + which-collection@1.0.2: | |
| 3943 | + dependencies: | |
| 3944 | + is-map: 2.0.3 | |
| 3945 | + is-set: 2.0.3 | |
| 3946 | + is-weakmap: 2.0.2 | |
| 3947 | + is-weakset: 2.0.4 | |
| 3948 | + | |
| 3949 | + which-typed-array@1.1.22: | |
| 3950 | + dependencies: | |
| 3951 | + available-typed-arrays: 1.0.7 | |
| 3952 | + call-bind: 1.0.9 | |
| 3953 | + call-bound: 1.0.4 | |
| 3954 | + for-each: 0.3.5 | |
| 3955 | + get-proto: 1.0.1 | |
| 3956 | + gopd: 1.2.0 | |
| 3957 | + has-tostringtag: 1.0.2 | |
| 3958 | + | |
| 3959 | + which@2.0.2: | |
| 3960 | + dependencies: | |
| 3961 | + isexe: 2.0.0 | |
| 3962 | + | |
| 3963 | + word-wrap@1.2.5: {} | |
| 3964 | + | |
| 3965 | + wrappy@1.0.2: {} | |
| 3966 | + | |
| 3967 | + yocto-queue@0.1.0: {} | |
added
M2M32crouve-ka/pnpm-workspace.yaml
+8 −0
@@ -0,0 +1,8 @@ | ||
| 1 | +# Trouve-KA — pnpm workspace | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | +packages: | |
| 5 | + - "apps/web" | |
| 6 | +onlyBuiltDependencies: | |
| 7 | + - sharp | |
| 8 | + - unrs-resolver | |
added
M2M32crouve-ka/pyproject.toml
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +# Trouve-KA — Python monorepo build configuration | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +[project] | |
| 6 | +name = "trouveka" | |
| 7 | +version = "0.1.0" | |
| 8 | +description = "Trouve-KA — moteur de recherche web indépendant, Québec-first" | |
| 9 | +requires-python = ">=3.12" | |
| 10 | +authors = [{ name = "Simon-Pierre Boucher", email = "contact@spboucher.ai" }] | |
| 11 | +dependencies = [ | |
| 12 | + "fastapi>=0.115", | |
| 13 | + "uvicorn[standard]>=0.30", | |
| 14 | + "httpx>=0.27", | |
| 15 | + "brotli>=1.1", | |
| 16 | + "selectolax>=0.3.21", | |
| 17 | + "protego>=0.3.1", | |
| 18 | + "asyncpg>=0.29", | |
| 19 | + "redis>=5.0", | |
| 20 | + "opensearch-py[async]>=2.6", | |
| 21 | + "pydantic>=2.7", | |
| 22 | + "pydantic-settings>=2.3", | |
| 23 | + "langdetect>=1.0.9", | |
| 24 | + "python-dateutil>=2.9", | |
| 25 | +] | |
| 26 | + | |
| 27 | +[project.optional-dependencies] | |
| 28 | +dev = [ | |
| 29 | + "pytest>=8", | |
| 30 | + "pytest-asyncio>=0.23", | |
| 31 | + "ruff>=0.5", | |
| 32 | +] | |
| 33 | + | |
| 34 | +[build-system] | |
| 35 | +requires = ["setuptools>=69"] | |
| 36 | +build-backend = "setuptools.build_meta" | |
| 37 | + | |
| 38 | +# The "trouveka" namespace package is assembled from the monorepo layout: | |
| 39 | +# packages/* hold shared libraries, services/* hold pipeline services, | |
| 40 | +# apps/api holds the FastAPI application (see CLAUDE.md §2). | |
| 41 | +[tool.setuptools] | |
| 42 | +packages = [ | |
| 43 | + "trouveka.config", | |
| 44 | + "trouveka.logging", | |
| 45 | + "trouveka.types", | |
| 46 | + "trouveka.shared", | |
| 47 | + "trouveka.database", | |
| 48 | + "trouveka.queue", | |
| 49 | + "trouveka.search_core", | |
| 50 | + "trouveka.crawler", | |
| 51 | + "trouveka.frontier", | |
| 52 | + "trouveka.parser", | |
| 53 | + "trouveka.classifier", | |
| 54 | + "trouveka.indexer", | |
| 55 | + "trouveka.ranking", | |
| 56 | + "trouveka.scheduler", | |
| 57 | + "trouveka.enrichment", | |
| 58 | + "trouveka.api", | |
| 59 | +] | |
| 60 | + | |
| 61 | +[tool.setuptools.package-dir] | |
| 62 | +"trouveka.config" = "packages/config" | |
| 63 | +"trouveka.logging" = "packages/logging" | |
| 64 | +"trouveka.types" = "packages/types" | |
| 65 | +"trouveka.shared" = "packages/shared" | |
| 66 | +"trouveka.database" = "packages/database" | |
| 67 | +"trouveka.queue" = "packages/queue" | |
| 68 | +"trouveka.search_core" = "packages/search-core" | |
| 69 | +"trouveka.crawler" = "services/crawler" | |
| 70 | +"trouveka.frontier" = "services/frontier" | |
| 71 | +"trouveka.parser" = "services/parser" | |
| 72 | +"trouveka.classifier" = "services/classifier" | |
| 73 | +"trouveka.indexer" = "services/indexer" | |
| 74 | +"trouveka.ranking" = "services/ranking" | |
| 75 | +"trouveka.scheduler" = "services/scheduler" | |
| 76 | +"trouveka.enrichment" = "services/enrichment" | |
| 77 | +"trouveka.api" = "apps/api" | |
| 78 | + | |
| 79 | +[tool.pytest.ini_options] | |
| 80 | +asyncio_mode = "auto" | |
| 81 | +testpaths = ["tests"] | |
| 82 | + | |
| 83 | +[tool.ruff] | |
| 84 | +line-length = 110 | |
| 85 | +target-version = "py312" | |
added
M2M32crouve-ka/scripts/bootstrap-seeds/seed.sh
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Trouve-KA — chargement des seeds dans le frontier | |
| 3 | +# Author: Simon-Pierre Boucher | |
| 4 | +# Contact: contact@spboucher.ai | |
| 5 | +set -euo pipefail | |
| 6 | +cd "$(dirname "$0")/../.." | |
| 7 | + | |
| 8 | +if [ -x .venv/bin/python ]; then | |
| 9 | + PY=.venv/bin/python | |
| 10 | +else | |
| 11 | + PY=python3 | |
| 12 | +fi | |
| 13 | + | |
| 14 | +exec "$PY" -m trouveka.crawler.seed "${1:-scripts/bootstrap-seeds/seeds.txt}" | |
added
M2M32crouve-ka/scripts/bootstrap-seeds/seeds.txt
+80 −0
@@ -0,0 +1,80 @@ | ||
| 1 | +# Trouve-KA — seeds de démarrage (qualité > quantité, CLAUDE.md §8) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | +# Nœuds fortement connectés du web québécois. Une URL par ligne, # = commentaire. | |
| 5 | + | |
| 6 | +# --- Gouvernement du Québec --- | |
| 7 | +https://www.quebec.ca/ | |
| 8 | +https://www.assnat.qc.ca/ | |
| 9 | +https://www.revenuquebec.ca/ | |
| 10 | +https://www.ramq.gouv.qc.ca/ | |
| 11 | +https://saaq.gouv.qc.ca/ | |
| 12 | +https://www.cnesst.gouv.qc.ca/ | |
| 13 | +https://www.hydroquebec.com/ | |
| 14 | +https://www.investquebec.com/ | |
| 15 | +https://www.transitionenergetique.gouv.qc.ca/ | |
| 16 | + | |
| 17 | +# --- Municipalités --- | |
| 18 | +https://montreal.ca/ | |
| 19 | +https://www.ville.quebec.qc.ca/ | |
| 20 | +https://www.laval.ca/ | |
| 21 | +https://www.gatineau.ca/ | |
| 22 | +https://www.sherbrooke.ca/ | |
| 23 | +https://www.longueuil.quebec/ | |
| 24 | +https://www.trois-rivieres.ca/ | |
| 25 | +https://ville.saguenay.ca/ | |
| 26 | +https://www.levis.ca/ | |
| 27 | +https://www.terrebonne.ca/ | |
| 28 | +https://www.drummondville.ca/ | |
| 29 | +https://www.rimouski.ca/ | |
| 30 | + | |
| 31 | +# --- Universités et cégeps --- | |
| 32 | +https://www.ulaval.ca/ | |
| 33 | +https://www.umontreal.ca/ | |
| 34 | +https://www.mcgill.ca/ | |
| 35 | +https://uqam.ca/ | |
| 36 | +https://www.usherbrooke.ca/ | |
| 37 | +https://www.concordia.ca/ | |
| 38 | +https://www.polymtl.ca/ | |
| 39 | +https://www.etsmtl.ca/ | |
| 40 | +https://www.hec.ca/ | |
| 41 | +https://www.uqac.ca/ | |
| 42 | +https://www.uqtr.ca/ | |
| 43 | +https://www.uqar.ca/ | |
| 44 | +https://uqo.ca/ | |
| 45 | +https://www.inrs.ca/ | |
| 46 | +https://www.cegepsquebec.ca/ | |
| 47 | + | |
| 48 | +# --- Médias --- | |
| 49 | +https://www.lapresse.ca/ | |
| 50 | +https://www.ledevoir.com/ | |
| 51 | +https://www.journaldemontreal.com/ | |
| 52 | +https://www.journaldequebec.com/ | |
| 53 | +https://ici.radio-canada.ca/ | |
| 54 | +https://www.tvanouvelles.ca/ | |
| 55 | +https://www.lesoleil.com/ | |
| 56 | +https://www.ledroit.com/ | |
| 57 | +https://www.latribune.ca/ | |
| 58 | +https://www.lenouvelliste.ca/ | |
| 59 | +https://www.noovo.info/ | |
| 60 | +https://www.lactualite.com/ | |
| 61 | + | |
| 62 | +# --- Affaires, annuaires, associations --- | |
| 63 | +https://www.registreentreprises.gouv.qc.ca/ | |
| 64 | +https://www.desjardins.com/ | |
| 65 | +https://www.fccq.ca/ | |
| 66 | +https://www.ccmm.ca/ | |
| 67 | +https://www.pagesjaunes.ca/ | |
| 68 | +https://quebec.craigslist.org/ | |
| 69 | +https://www.cqcd.org/ | |
| 70 | +https://www.manufacturiersquebec.ca/ | |
| 71 | + | |
| 72 | +# --- Tourisme et culture --- | |
| 73 | +https://www.bonjourquebec.com/ | |
| 74 | +https://www.mtl.org/ | |
| 75 | +https://www.quebec-cite.com/ | |
| 76 | +https://www.tourisme-charlevoix.com/ | |
| 77 | +https://www.tourismegaspesie.com/ | |
| 78 | +https://www.sepaq.com/ | |
| 79 | +https://www.lavitrine.com/ | |
| 80 | +https://www.banq.qc.ca/ | |
added
M2M32crouve-ka/scripts/check-headers.py
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# Trouve-KA — vérification CI des headers d'auteur (CLAUDE.md §0.1) | |
| 3 | +# Author: Simon-Pierre Boucher | |
| 4 | +# Contact: contact@spboucher.ai | |
| 5 | + | |
| 6 | +"""Vérifie que chaque fichier source porte le header auteur obligatoire.""" | |
| 7 | + | |
| 8 | +import pathlib | |
| 9 | +import sys | |
| 10 | + | |
| 11 | +ROOT = pathlib.Path(__file__).resolve().parents[1] | |
| 12 | +EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".mjs", ".cjs", ".sql", ".sh", ".css", ".yml", ".yaml", ".toml"} | |
| 13 | +SKIP_DIRS = {"node_modules", ".next", ".venv", "__pycache__", ".git", "dist", ".pytest_cache", ".ruff_cache"} | |
| 14 | +SKIP_FILES = {"pnpm-lock.yaml", "next-env.d.ts"} | |
| 15 | +REQUIRED = "Author: Simon-Pierre Boucher" | |
| 16 | + | |
| 17 | + | |
| 18 | +def main() -> int: | |
| 19 | + missing: list[pathlib.Path] = [] | |
| 20 | + for path in ROOT.rglob("*"): | |
| 21 | + if not path.is_file() or path.suffix not in EXTENSIONS or path.name in SKIP_FILES: | |
| 22 | + continue | |
| 23 | + if any(part in SKIP_DIRS for part in path.parts): | |
| 24 | + continue | |
| 25 | + head = path.read_text(encoding="utf-8", errors="replace")[:600] | |
| 26 | + if REQUIRED not in head: | |
| 27 | + missing.append(path.relative_to(ROOT)) | |
| 28 | + if missing: | |
| 29 | + print(f"✗ {len(missing)} fichier(s) sans header auteur :") | |
| 30 | + for p in missing: | |
| 31 | + print(f" - {p}") | |
| 32 | + return 1 | |
| 33 | + print("✓ Tous les fichiers source portent le header auteur.") | |
| 34 | + return 0 | |
| 35 | + | |
| 36 | + | |
| 37 | +if __name__ == "__main__": | |
| 38 | + sys.exit(main()) | |
added
M2M32crouve-ka/scripts/cleanup-garbage.py
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# Trouve-KA — purge des documents mal décodés de l'index | |
| 3 | +# Author: Simon-Pierre Boucher | |
| 4 | +# Contact: contact@spboucher.ai | |
| 5 | + | |
| 6 | +"""Scanne l'index, supprime les documents au contenu charabia (binaire mal décodé) | |
| 7 | +et remet leurs URLs en recrawl (le fetcher corrigé les réindexera proprement). | |
| 8 | + | |
| 9 | +Usage : python -m scripts... non — exécuter depuis la racine : | |
| 10 | + .venv/bin/python scripts/cleanup-garbage.py # dev local | |
| 11 | + (sur m2m32 : docker compose run --rm --entrypoint python api /app/scripts/cleanup-garbage.py) | |
| 12 | +""" | |
| 13 | + | |
| 14 | +import asyncio | |
| 15 | + | |
| 16 | +from trouveka.config import get_settings | |
| 17 | +from trouveka.database import Database | |
| 18 | +from trouveka.parser import looks_like_garbage | |
| 19 | +from trouveka.search_core import SearchCore | |
| 20 | + | |
| 21 | + | |
| 22 | +async def main() -> None: | |
| 23 | + s = get_settings() | |
| 24 | + db = Database(s.database_url, pool_min=1, pool_max=3) | |
| 25 | + await db.connect() | |
| 26 | + search = SearchCore(s.search_url, s.search_index) | |
| 27 | + | |
| 28 | + removed = 0 | |
| 29 | + scanned = 0 | |
| 30 | + # Scroll de tout l'index par pages de 200 | |
| 31 | + body = {"query": {"match_all": {}}, "size": 200, "_source": ["url", "title", "description", "body"]} | |
| 32 | + resp = await search.client.search(index=s.search_index, body=body, scroll="2m") | |
| 33 | + scroll_id = resp.get("_scroll_id") | |
| 34 | + try: | |
| 35 | + while True: | |
| 36 | + hits = resp["hits"]["hits"] | |
| 37 | + if not hits: | |
| 38 | + break | |
| 39 | + for hit in hits: | |
| 40 | + scanned += 1 | |
| 41 | + src = hit["_source"] | |
| 42 | + text = " ".join([src.get("title") or "", src.get("description") or "", | |
| 43 | + (src.get("body") or "")[:20_000]]) | |
| 44 | + if looks_like_garbage(text, threshold=0.02): | |
| 45 | + await search.client.delete(index=s.search_index, id=hit["_id"], ignore=[404]) | |
| 46 | + await db.requeue_url(src["url"]) | |
| 47 | + await db.pool.execute( | |
| 48 | + "DELETE FROM documents WHERE url_id = (SELECT id FROM urls WHERE url = $1)", | |
| 49 | + src["url"], | |
| 50 | + ) | |
| 51 | + removed += 1 | |
| 52 | + print(f"purgé : {src['url']}") | |
| 53 | + resp = await search.client.scroll(scroll_id=scroll_id, scroll="2m") | |
| 54 | + finally: | |
| 55 | + if scroll_id: | |
| 56 | + await search.client.clear_scroll(scroll_id=scroll_id) | |
| 57 | + await search.close() | |
| 58 | + await db.close() | |
| 59 | + print(f"\n{scanned} documents scannés, {removed} purgés et remis en recrawl.") | |
| 60 | + | |
| 61 | + | |
| 62 | +if __name__ == "__main__": | |
| 63 | + asyncio.run(main()) | |
added
M2M32crouve-ka/scripts/eval/ranking-eval.yaml
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +# Trouve-KA — dataset d'évaluation du ranking (§16) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | +# Chaque cas : requête + domaines attendus dans le top-k. Mesurable, reproductible. | |
| 5 | +cases: | |
| 6 | + - query: "université québec" | |
| 7 | + expect_any_of: [uqam.ca, ulaval.ca, umontreal.ca, uqtr.ca, uqac.ca, uqar.ca, uquebec.ca, usherbrooke.ca] | |
| 8 | + top_k: 5 | |
| 9 | + - query: "plombier gatineau" | |
| 10 | + expect_any_of: [gatineau.ca, pagesjaunes.ca] | |
| 11 | + top_k: 10 | |
| 12 | + - query: "subvention thermopompe" | |
| 13 | + expect_any_of: [quebec.ca, hydroquebec.com, transitionenergetique.gouv.qc.ca] | |
| 14 | + top_k: 10 | |
| 15 | + - query: "heat pump rebate" | |
| 16 | + expect_any_of: [quebec.ca, hydroquebec.com, transitionenergetique.gouv.qc.ca] | |
| 17 | + top_k: 10 | |
| 18 | + - query: "impôt revenu" | |
| 19 | + expect_any_of: [revenuquebec.ca, quebec.ca] | |
| 20 | + top_k: 5 | |
| 21 | + - query: "tourisme gaspésie" | |
| 22 | + expect_any_of: [tourismegaspesie.com, bonjourquebec.com, quebecmaritime.ca, sepaq.com] | |
| 23 | + top_k: 5 | |
| 24 | + - query: "nouvelles montréal" | |
| 25 | + expect_any_of: [lapresse.ca, journaldemontreal.com, radio-canada.ca, ledevoir.com, tvanouvelles.ca, montreal.ca, mtl.org] | |
| 26 | + top_k: 10 | |
| 27 | + - query: "permis de conduire" | |
| 28 | + expect_any_of: [saaq.gouv.qc.ca, quebec.ca] | |
| 29 | + top_k: 5 | |
added
M2M32crouve-ka/scripts/eval/run-eval.py
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# Trouve-KA — évaluation du ranking contre le dataset (§16) | |
| 3 | +# Author: Simon-Pierre Boucher | |
| 4 | +# Contact: contact@spboucher.ai | |
| 5 | + | |
| 6 | +"""Exécute le dataset d'évaluation contre l'API de recherche. | |
| 7 | + | |
| 8 | +Usage : python3 scripts/eval/run-eval.py [--api http://localhost:8080] | |
| 9 | +Un cas passe si au moins un domaine attendu apparaît dans le top-k. | |
| 10 | +L'index grandit en continu : les scores s'améliorent avec la couverture. | |
| 11 | +""" | |
| 12 | + | |
| 13 | +import argparse | |
| 14 | +import json | |
| 15 | +import pathlib | |
| 16 | +import re | |
| 17 | +import urllib.parse | |
| 18 | +import urllib.request | |
| 19 | + | |
| 20 | +HERE = pathlib.Path(__file__).parent | |
| 21 | + | |
| 22 | + | |
| 23 | +def load_cases() -> list[dict]: | |
| 24 | + """Parse minimaliste du YAML du dataset (pas de dépendance PyYAML).""" | |
| 25 | + cases, current = [], None | |
| 26 | + for line in (HERE / "ranking-eval.yaml").read_text(encoding="utf-8").splitlines(): | |
| 27 | + if m := re.match(r'\s*- query: "(.*)"', line): | |
| 28 | + current = {"query": m.group(1)} | |
| 29 | + cases.append(current) | |
| 30 | + elif m := re.match(r"\s*expect_any_of: \[(.*)\]", line): | |
| 31 | + current["expect_any_of"] = [d.strip() for d in m.group(1).split(",")] | |
| 32 | + elif m := re.match(r"\s*top_k: (\d+)", line): | |
| 33 | + current["top_k"] = int(m.group(1)) | |
| 34 | + return cases | |
| 35 | + | |
| 36 | + | |
| 37 | +def main() -> int: | |
| 38 | + ap = argparse.ArgumentParser() | |
| 39 | + ap.add_argument("--api", default="http://localhost:8080") | |
| 40 | + args = ap.parse_args() | |
| 41 | + | |
| 42 | + passed = 0 | |
| 43 | + cases = load_cases() | |
| 44 | + for case in cases: | |
| 45 | + q = urllib.parse.quote(case["query"]) | |
| 46 | + url = f"{args.api}/api/search?q={q}&limit={case['top_k']}" | |
| 47 | + try: | |
| 48 | + with urllib.request.urlopen(url, timeout=10) as resp: | |
| 49 | + data = json.load(resp) | |
| 50 | + except Exception as exc: | |
| 51 | + print(f"✗ {case['query']!r} — API injoignable : {exc}") | |
| 52 | + continue | |
| 53 | + domains = [r["domain"] for r in data["results"]] | |
| 54 | + hit = next((d for d in domains if any(d == e or d.endswith("." + e) for e in case["expect_any_of"])), None) | |
| 55 | + if hit: | |
| 56 | + passed += 1 | |
| 57 | + print(f"✓ {case['query']!r} → {hit} (rang {domains.index(hit) + 1}, {data['took_ms']} ms)") | |
| 58 | + else: | |
| 59 | + print(f"✗ {case['query']!r} → top-{case['top_k']}: {domains[:5]}") | |
| 60 | + print(f"\n{passed}/{len(cases)} cas réussis") | |
| 61 | + return 0 if passed == len(cases) else 1 | |
| 62 | + | |
| 63 | + | |
| 64 | +if __name__ == "__main__": | |
| 65 | + raise SystemExit(main()) | |
added
M2M32crouve-ka/scripts/health-check/check.sh
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Trouve-KA — vérification de santé du stack | |
| 3 | +# Author: Simon-Pierre Boucher | |
| 4 | +# Contact: contact@spboucher.ai | |
| 5 | +set -uo pipefail | |
| 6 | + | |
| 7 | +API_URL="${API_URL:-http://localhost:8080}" | |
| 8 | +SEARCH_URL="${SEARCH_URL:-http://localhost:9200}" | |
| 9 | +WEB_URL="${WEB_URL:-http://localhost:3000}" | |
| 10 | +FAIL=0 | |
| 11 | + | |
| 12 | +check() { | |
| 13 | + local name="$1" url="$2" | |
| 14 | + if curl -sf --max-time 5 "$url" > /dev/null; then | |
| 15 | + echo "✓ $name ($url)" | |
| 16 | + else | |
| 17 | + echo "✗ $name ($url)" | |
| 18 | + FAIL=1 | |
| 19 | + fi | |
| 20 | +} | |
| 21 | + | |
| 22 | +check "API santé" "$API_URL/api/health" | |
| 23 | +check "API statut" "$API_URL/api/status" | |
| 24 | +check "OpenSearch" "$SEARCH_URL/_cluster/health" | |
| 25 | +check "Web" "$WEB_URL/" | |
| 26 | + | |
| 27 | +exit $FAIL | |
added
M2M32crouve-ka/scripts/start-crawler/start.sh
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Trouve-KA — démarrage local d'un worker de crawl | |
| 3 | +# Author: Simon-Pierre Boucher | |
| 4 | +# Contact: contact@spboucher.ai | |
| 5 | +set -euo pipefail | |
| 6 | +cd "$(dirname "$0")/../.." | |
| 7 | + | |
| 8 | +if [ -x .venv/bin/python ]; then | |
| 9 | + PY=.venv/bin/python | |
| 10 | +else | |
| 11 | + PY=python3 | |
| 12 | +fi | |
| 13 | + | |
| 14 | +exec "$PY" -m trouveka.crawler.worker | |
added
M2M32crouve-ka/services/classifier/__init__.py
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +# Trouve-KA — classification Québec | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Détection de pertinence québécoise (CLAUDE.md §7). | |
| 6 | + | |
| 7 | +Deux scores distincts : page_quebec_score (calculé ici) et domain_quebec_score | |
| 8 | +(agrégé en base au fil des pages). Un article du NYT sur Montréal peut être | |
| 9 | +pertinent sans que le domaine le soit. | |
| 10 | +""" | |
| 11 | + | |
| 12 | +from .scorer import score_page | |
| 13 | +from .gazetteer import QUEBEC_TOPONYMS, QUEBEC_REGIONS | |
| 14 | + | |
| 15 | +__all__ = ["score_page", "QUEBEC_TOPONYMS", "QUEBEC_REGIONS"] | |
added
M2M32crouve-ka/services/classifier/gazetteer.py
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +# Trouve-KA — gazetteer québécois | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Toponymes, régions et signaux institutionnels du Québec. | |
| 6 | + | |
| 7 | +Liste de départ volontairement conservatrice : des noms non ambigus d'abord. | |
| 8 | +Les toponymes ambigus (Laval existe en France, Hull au Royaume-Uni…) portent | |
| 9 | +un poids réduit dans le scorer. | |
| 10 | +""" | |
| 11 | + | |
| 12 | +# Municipalités et lieux à fort signal (poids plein) | |
| 13 | +QUEBEC_TOPONYMS: set[str] = { | |
| 14 | + "québec", "quebec", "montréal", "montreal", "gatineau", "sherbrooke", | |
| 15 | + "trois-rivières", "trois-rivieres", "saguenay", "lévis", "levis", | |
| 16 | + "longueuil", "terrebonne", "brossard", "repentigny", "drummondville", | |
| 17 | + "saint-jérôme", "saint-jerome", "granby", "blainville", "shawinigan", | |
| 18 | + "dollard-des-ormeaux", "rimouski", "victoriaville", "saint-hyacinthe", | |
| 19 | + "boucherville", "salaberry-de-valleyfield", "vaudreuil-dorion", "châteauguay", | |
| 20 | + "chateauguay", "rouyn-noranda", "joliette", "sorel-tracy", "val-d'or", | |
| 21 | + "alma", "sept-îles", "sept-iles", "baie-comeau", "chicoutimi", "jonquière", | |
| 22 | + "jonquiere", "rivière-du-loup", "riviere-du-loup", "matane", "gaspé", "gaspe", | |
| 23 | + "magog", "thetford mines", "sainte-thérèse", "sainte-therese", "mirabel", | |
| 24 | + "beloeil", "mascouche", "varennes", "chambly", "saint-eustache", | |
| 25 | + "l'assomption", "lachute", "mont-tremblant", "sainte-adèle", "sainte-adele", | |
| 26 | + "kuujjuaq", "chibougamau", "amos", "la tuque", "cowansville", "beauharnois", | |
| 27 | + "outaouais", "estrie", "montérégie", "monteregie", "mauricie", "lanaudière", | |
| 28 | + "lanaudiere", "laurentides", "abitibi-témiscamingue", "abitibi-temiscamingue", | |
| 29 | + "bas-saint-laurent", "côte-nord", "cote-nord", "nord-du-québec", "nord-du-quebec", | |
| 30 | + "gaspésie", "gaspesie", "chaudière-appalaches", "chaudiere-appalaches", | |
| 31 | + "centre-du-québec", "centre-du-quebec", "saguenay-lac-saint-jean", | |
| 32 | + "nunavik", "îles-de-la-madeleine", "iles-de-la-madeleine", | |
| 33 | + "hochelaga", "rosemont", "ahuntsic", "verdun", "outremont", "westmount", | |
| 34 | + "plateau-mont-royal", "villeray", "anjou", "lasalle", "lachine", "dorval", | |
| 35 | + "pointe-claire", "kirkland", "beaconsfield", "côte-des-neiges", "cote-des-neiges", | |
| 36 | +} | |
| 37 | + | |
| 38 | +# Toponymes ambigus (existent ailleurs) — poids réduit | |
| 39 | +AMBIGUOUS_TOPONYMS: set[str] = { | |
| 40 | + "laval", "hull", "aylmer", "candiac", "delson", "bromont", "sutton", | |
| 41 | + "stanstead", "asbestos", "windsor", "richmond", "warwick", "kingsey", | |
| 42 | +} | |
| 43 | + | |
| 44 | +QUEBEC_REGIONS: set[str] = { | |
| 45 | + "outaouais", "estrie", "montérégie", "mauricie", "lanaudière", "laurentides", | |
| 46 | + "abitibi-témiscamingue", "bas-saint-laurent", "côte-nord", "gaspésie", | |
| 47 | + "chaudière-appalaches", "centre-du-québec", "saguenay-lac-saint-jean", "nunavik", | |
| 48 | +} | |
| 49 | + | |
| 50 | +# Institutions et organisations québécoises fortement identifiantes | |
| 51 | +QUEBEC_ORGS: set[str] = { | |
| 52 | + "hydro-québec", "hydro-quebec", "hydroquébec", "hydroquebec", | |
| 53 | + "desjardins", "gouvernement du québec", "gouvernement du quebec", | |
| 54 | + "assemblée nationale", "assemblee nationale", "revenu québec", "revenu quebec", | |
| 55 | + "saq", "sqdc", "caq", "société de transport de montréal", "stm", | |
| 56 | + "réseau de transport de la capitale", "rtc", "exo", "via rail", | |
| 57 | + "université laval", "universite laval", "université de montréal", | |
| 58 | + "universite de montreal", "mcgill", "uqam", "uqac", "uqtr", "uqar", "uqo", "uqat", | |
| 59 | + "université de sherbrooke", "universite de sherbrooke", "polytechnique montréal", | |
| 60 | + "polytechnique montreal", "hec montréal", "hec montreal", "éts", "ets", | |
| 61 | + "concordia", "inrs", "cégep", "cegep", "clsc", "chum", "chu de québec", | |
| 62 | + "chu sainte-justine", "ramq", "saaq", "cnesst", "sûreté du québec", | |
| 63 | + "surete du quebec", "loto-québec", "loto-quebec", "investissement québec", | |
| 64 | + "investissement quebec", "caisse de dépôt", "caisse de depot", "cdpq", | |
| 65 | + "radio-canada", "télé-québec", "tele-quebec", "la presse", "le devoir", | |
| 66 | + "journal de montréal", "journal de montreal", "journal de québec", | |
| 67 | + "journal de quebec", "tva", "noovo", "bonjour québec", "bonjour quebec", | |
| 68 | + "tourisme québec", "tourisme quebec", | |
| 69 | +} | |
| 70 | + | |
| 71 | +# Indicatifs téléphoniques du Québec (signal faible) | |
| 72 | +QUEBEC_AREA_CODES: set[str] = {"418", "514", "438", "450", "579", "819", "873", "367", "263", "354", "468"} | |
| 73 | + | |
| 74 | +# TLD / suffixes de domaine à signal fort | |
| 75 | +STRONG_DOMAIN_SUFFIXES: tuple[str, ...] = (".qc.ca", ".quebec", ".gouv.qc.ca", ".montreal.ca") | |
added
M2M32crouve-ka/services/classifier/scorer.py
+115 −0
@@ -0,0 +1,115 @@ | ||
| 1 | +# Trouve-KA — scoreur Québec | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Calcul de page_quebec_score ∈ [0, 1]. | |
| 6 | + | |
| 7 | +Combinaison de signaux hétérogènes (CLAUDE.md §7) : TLD, toponymes, codes | |
| 8 | +postaux QC, indicatifs téléphoniques, organisations connues, langue française, | |
| 9 | +mentions structurées de la province. Aucun signal seul ne suffit (un .ca seul | |
| 10 | +ne prouve rien); le score sature progressivement via une somme amortie. | |
| 11 | +""" | |
| 12 | + | |
| 13 | +import re | |
| 14 | + | |
| 15 | +from trouveka.types import ParsedPage, QuebecSignals | |
| 16 | + | |
| 17 | +from .gazetteer import ( | |
| 18 | + AMBIGUOUS_TOPONYMS, | |
| 19 | + QUEBEC_AREA_CODES, | |
| 20 | + QUEBEC_ORGS, | |
| 21 | + QUEBEC_TOPONYMS, | |
| 22 | + STRONG_DOMAIN_SUFFIXES, | |
| 23 | +) | |
| 24 | + | |
| 25 | +# Codes postaux du Québec : G, H, J en première lettre (format A1A 1A1) | |
| 26 | +_POSTAL_RE = re.compile(r"\b[GHJ]\d[A-Z]\s?\d[A-Z]\d\b", re.IGNORECASE) | |
| 27 | +_PHONE_RE = re.compile(r"(?:\+?1[\s.-]?)?\(?(\d{3})\)?[\s.-]?\d{3}[\s.-]?\d{4}\b") | |
| 28 | +_PROVINCE_RE = re.compile( | |
| 29 | + r"\b(?:province\s+(?:de\s+|du\s+)?qu[ée]bec|qu[ée]bec\s*\(qc\)|,\s*(?:qc|qu[ée]bec)\b)", | |
| 30 | + re.IGNORECASE, | |
| 31 | +) | |
| 32 | +_WORD_BOUNDARY = r"(?<![\w-]){}(?![\w-])" | |
| 33 | + | |
| 34 | + | |
| 35 | +def _count_terms(text: str, terms: set[str], cap: int = 10) -> tuple[int, list[str]]: | |
| 36 | + found: list[str] = [] | |
| 37 | + total = 0 | |
| 38 | + for term in terms: | |
| 39 | + pattern = re.compile(_WORD_BOUNDARY.format(re.escape(term)), re.IGNORECASE) | |
| 40 | + n = len(pattern.findall(text)) | |
| 41 | + if n: | |
| 42 | + found.append(term) | |
| 43 | + total += min(n, 4) # une page qui répète 200× « Montréal » n'est pas 200× plus québécoise | |
| 44 | + if total >= cap: | |
| 45 | + break | |
| 46 | + return min(total, cap), found | |
| 47 | + | |
| 48 | + | |
| 49 | +def score_page(page: ParsedPage, domain: str) -> QuebecSignals: | |
| 50 | + """Score Québec d'une page. Déterministe, sans LLM, économique (§12).""" | |
| 51 | + signals = QuebecSignals() | |
| 52 | + reasons: list[str] = [] | |
| 53 | + points = 0.0 | |
| 54 | + | |
| 55 | + text = " ".join([page.title, page.description, " ".join(page.headings), page.body[:20_000]]) | |
| 56 | + text_with_hints = text + " " + " ".join(page.structured_hints) | |
| 57 | + lower = text_with_hints.lower() | |
| 58 | + | |
| 59 | + # 1. Domaine (signal fort mais pas suffisant seul) | |
| 60 | + host = domain.lower() | |
| 61 | + if any(host.endswith(suffix) or host == suffix.lstrip(".") for suffix in STRONG_DOMAIN_SUFFIXES): | |
| 62 | + points += 4.0 | |
| 63 | + reasons.append("tld_quebec") | |
| 64 | + | |
| 65 | + # 2. Toponymes non ambigus (titre/headings pèsent plus que le corps) | |
| 66 | + head_text = " ".join([page.title, page.description, " ".join(page.headings)]) | |
| 67 | + head_hits, head_names = _count_terms(head_text, QUEBEC_TOPONYMS, cap=6) | |
| 68 | + body_hits, body_names = _count_terms(page.body[:20_000], QUEBEC_TOPONYMS, cap=8) | |
| 69 | + if head_hits: | |
| 70 | + points += 1.2 * head_hits | |
| 71 | + reasons.append("toponymes_titre") | |
| 72 | + if body_hits: | |
| 73 | + points += 0.4 * body_hits | |
| 74 | + reasons.append("toponymes_corps") | |
| 75 | + signals.locations = sorted({*head_names, *body_names})[:12] | |
| 76 | + | |
| 77 | + # 3. Toponymes ambigus — poids réduit | |
| 78 | + amb_hits, amb_names = _count_terms(lower, AMBIGUOUS_TOPONYMS, cap=3) | |
| 79 | + if amb_hits: | |
| 80 | + points += 0.15 * amb_hits | |
| 81 | + reasons.append("toponymes_ambigus") | |
| 82 | + signals.locations = sorted({*signals.locations, *amb_names})[:12] | |
| 83 | + | |
| 84 | + # 4. Codes postaux QC (signal fort : preuve d'adresse physique) | |
| 85 | + postal_hits = len(set(_POSTAL_RE.findall(text_with_hints))) | |
| 86 | + if postal_hits: | |
| 87 | + points += min(postal_hits, 3) * 1.5 | |
| 88 | + reasons.append("code_postal_qc") | |
| 89 | + | |
| 90 | + # 5. Mention structurée de la province (adresses, footers) | |
| 91 | + if _PROVINCE_RE.search(text_with_hints): | |
| 92 | + points += 1.5 | |
| 93 | + reasons.append("province_quebec") | |
| 94 | + | |
| 95 | + # 6. Indicatifs téléphoniques (signal faible) | |
| 96 | + area_codes = {m for m in _PHONE_RE.findall(text_with_hints) if m in QUEBEC_AREA_CODES} | |
| 97 | + if area_codes: | |
| 98 | + points += min(len(area_codes), 2) * 0.6 | |
| 99 | + reasons.append("indicatif_qc") | |
| 100 | + | |
| 101 | + # 7. Organisations québécoises connues | |
| 102 | + org_hits, _ = _count_terms(lower, QUEBEC_ORGS, cap=6) | |
| 103 | + if org_hits: | |
| 104 | + points += 0.8 * org_hits | |
| 105 | + reasons.append("organisations_qc") | |
| 106 | + | |
| 107 | + # 8. Langue : le français augmente la probabilité sans la prouver | |
| 108 | + if page.language == "fr": | |
| 109 | + points += 0.8 | |
| 110 | + reasons.append("francais") | |
| 111 | + | |
| 112 | + # Saturation douce : 0 pt → 0, ~3 pts → 0.5, ≥9 pts → ~0.95 | |
| 113 | + signals.score = round(points / (points + 3.0), 4) if points > 0 else 0.0 | |
| 114 | + signals.reasons = reasons | |
| 115 | + return signals | |
added
M2M32crouve-ka/services/crawler/__init__.py
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +# Trouve-KA — crawler | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Le crawler de Trouve-KA : frontier Postgres, politesse par hôte, robots.txt, | |
| 6 | +fetch HTTP sécurisé (SSRF), pièges de crawl, pipeline inline fetch→parse→score→index.""" | |
| 7 | + | |
| 8 | +from .robots import RobotsCache | |
| 9 | +from .fetcher import Fetcher | |
| 10 | +from .traps import looks_like_trap | |
| 11 | + | |
| 12 | +__all__ = ["RobotsCache", "Fetcher", "looks_like_trap"] | |
added
M2M32crouve-ka/services/crawler/fetcher.py
+148 −0
@@ -0,0 +1,148 @@ | ||
| 1 | +# Trouve-KA — fetcher HTTP sécurisé | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Fetch HTTP avec garde SSRF revalidée à chaque redirection, limites de taille, | |
| 6 | +gestion des content-types et cache HTTP conditionnel (ETag / Last-Modified). | |
| 7 | + | |
| 8 | +Jamais de Chromium ici (CLAUDE.md §5.10) : fetch HTTP pur. | |
| 9 | +""" | |
| 10 | + | |
| 11 | +import time | |
| 12 | +from urllib.parse import urlsplit | |
| 13 | + | |
| 14 | +import httpx | |
| 15 | + | |
| 16 | +from trouveka.config import Settings | |
| 17 | +from trouveka.shared import canonicalize_url, is_safe_url | |
| 18 | +from trouveka.types import ErrorCode, FetchResult | |
| 19 | + | |
| 20 | +ACCEPTED_CONTENT_TYPES = ("text/html", "application/xhtml+xml", "text/plain") | |
| 21 | + | |
| 22 | + | |
| 23 | +class Fetcher: | |
| 24 | + def __init__(self, client: httpx.AsyncClient, settings: Settings): | |
| 25 | + self._client = client | |
| 26 | + self._s = settings | |
| 27 | + | |
| 28 | + async def fetch( | |
| 29 | + self, url: str, *, etag: str | None = None, last_modified: str | None = None | |
| 30 | + ) -> FetchResult: | |
| 31 | + """Fetch une URL en suivant manuellement les redirections (revalidation SSRF à chaque saut).""" | |
| 32 | + start = time.monotonic() | |
| 33 | + chain: list[str] = [] | |
| 34 | + current = url | |
| 35 | + | |
| 36 | + for _hop in range(self._s.max_redirects + 1): | |
| 37 | + if not is_safe_url(current): | |
| 38 | + return self._fail(url, current, ErrorCode.SSRF_BLOCKED, start, chain) | |
| 39 | + | |
| 40 | + # Accept-Encoding volontairement absent : httpx annonce lui-même les | |
| 41 | + # encodages qu'il sait décoder (br seulement si brotli est installé). | |
| 42 | + # L'annoncer à la main fait indexer du binaire compressé (bug itum.qc.ca). | |
| 43 | + headers = { | |
| 44 | + "User-Agent": self._s.crawler_user_agent, | |
| 45 | + "Accept": "text/html,application/xhtml+xml;q=0.9,text/plain;q=0.5,*/*;q=0.1", | |
| 46 | + "Accept-Language": "fr-CA,fr;q=0.9,en-CA;q=0.8,en;q=0.7", | |
| 47 | + } | |
| 48 | + if etag and current == url: | |
| 49 | + headers["If-None-Match"] = etag | |
| 50 | + if last_modified and current == url: | |
| 51 | + headers["If-Modified-Since"] = last_modified | |
| 52 | + | |
| 53 | + try: | |
| 54 | + async with self._client.stream( | |
| 55 | + "GET", current, headers=headers, timeout=self._s.fetch_timeout, follow_redirects=False | |
| 56 | + ) as resp: | |
| 57 | + if resp.status_code in (301, 302, 303, 307, 308): | |
| 58 | + location = resp.headers.get("location") | |
| 59 | + if not location: | |
| 60 | + return self._fail(url, current, ErrorCode.HTTP_4XX, start, chain, resp.status_code) | |
| 61 | + next_url = canonicalize_url(location, base=current) | |
| 62 | + if not next_url: | |
| 63 | + return self._fail(url, current, ErrorCode.SSRF_BLOCKED, start, chain, resp.status_code) | |
| 64 | + chain.append(next_url) | |
| 65 | + current = next_url | |
| 66 | + continue | |
| 67 | + | |
| 68 | + if resp.status_code == 304: | |
| 69 | + return FetchResult( | |
| 70 | + url=url, final_url=current, status_code=304, | |
| 71 | + duration_ms=self._ms(start), redirect_chain=chain, | |
| 72 | + ) | |
| 73 | + if 400 <= resp.status_code < 500: | |
| 74 | + return self._fail(url, current, ErrorCode.HTTP_4XX, start, chain, resp.status_code) | |
| 75 | + if resp.status_code >= 500: | |
| 76 | + return self._fail(url, current, ErrorCode.HTTP_5XX, start, chain, resp.status_code) | |
| 77 | + | |
| 78 | + # X-Robots-Tag : noindex au niveau HTTP | |
| 79 | + x_robots = (resp.headers.get("x-robots-tag") or "").lower() | |
| 80 | + | |
| 81 | + raw_ct = resp.headers.get("content-type") or "" | |
| 82 | + content_type = raw_ct.split(";")[0].strip().lower() | |
| 83 | + charset = None | |
| 84 | + if "charset=" in raw_ct.lower(): | |
| 85 | + charset = raw_ct.lower().split("charset=")[-1].split(";")[0].strip(' "\'') or None | |
| 86 | + if content_type and not any(content_type.startswith(t) for t in ACCEPTED_CONTENT_TYPES): | |
| 87 | + return self._fail( | |
| 88 | + url, current, ErrorCode.UNSUPPORTED_CONTENT, start, chain, resp.status_code | |
| 89 | + ) | |
| 90 | + | |
| 91 | + declared = resp.headers.get("content-length") | |
| 92 | + if declared and int(declared) > self._s.max_response_bytes: | |
| 93 | + return self._fail(url, current, ErrorCode.TOO_LARGE, start, chain, resp.status_code) | |
| 94 | + | |
| 95 | + body = b"" | |
| 96 | + async for part in resp.aiter_bytes(): | |
| 97 | + body += part | |
| 98 | + if len(body) > self._s.max_response_bytes: | |
| 99 | + return self._fail(url, current, ErrorCode.TOO_LARGE, start, chain, resp.status_code) | |
| 100 | + | |
| 101 | + result = FetchResult( | |
| 102 | + url=url, | |
| 103 | + final_url=current, | |
| 104 | + status_code=resp.status_code, | |
| 105 | + content_type=content_type or None, | |
| 106 | + charset=charset, | |
| 107 | + body=body, | |
| 108 | + etag=resp.headers.get("etag"), | |
| 109 | + last_modified=resp.headers.get("last-modified"), | |
| 110 | + duration_ms=self._ms(start), | |
| 111 | + redirect_chain=chain, | |
| 112 | + ) | |
| 113 | + if "noindex" in x_robots: | |
| 114 | + result.error_code = ErrorCode.ROBOTS_DENIED | |
| 115 | + return result | |
| 116 | + | |
| 117 | + except httpx.TimeoutException: | |
| 118 | + return self._fail(url, current, ErrorCode.TIMEOUT, start, chain) | |
| 119 | + except httpx.ConnectError as exc: | |
| 120 | + code = ErrorCode.TLS if "SSL" in str(exc) or "certificate" in str(exc).lower() else ErrorCode.DNS | |
| 121 | + return self._fail(url, current, code, start, chain) | |
| 122 | + except httpx.HTTPError: | |
| 123 | + return self._fail(url, current, ErrorCode.CONNECTION, start, chain) | |
| 124 | + | |
| 125 | + return self._fail(url, current, ErrorCode.TOO_MANY_REDIRECTS, start, chain) | |
| 126 | + | |
| 127 | + @staticmethod | |
| 128 | + def _ms(start: float) -> int: | |
| 129 | + return int((time.monotonic() - start) * 1000) | |
| 130 | + | |
| 131 | + def _fail( | |
| 132 | + self, | |
| 133 | + url: str, | |
| 134 | + final_url: str, | |
| 135 | + code: ErrorCode, | |
| 136 | + start: float, | |
| 137 | + chain: list[str], | |
| 138 | + status: int | None = None, | |
| 139 | + ) -> FetchResult: | |
| 140 | + return FetchResult( | |
| 141 | + url=url, final_url=final_url, status_code=status, error_code=code, | |
| 142 | + duration_ms=self._ms(start), redirect_chain=chain, | |
| 143 | + ) | |
| 144 | + | |
| 145 | + | |
| 146 | +def scheme_host(url: str) -> str: | |
| 147 | + parts = urlsplit(url) | |
| 148 | + return f"{parts.scheme}://{parts.netloc}" | |
added
M2M32crouve-ka/services/crawler/robots.py
+84 −0
@@ -0,0 +1,84 @@ | ||
| 1 | +# Trouve-KA — gestion robots.txt | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Cache robots.txt conforme aux standards (Protego, le parseur de Scrapy). | |
| 6 | + | |
| 7 | +Les règles sont persistées en base (domains.robots_body) et rafraîchies | |
| 8 | +après 24 h. Un robots.txt introuvable (404) = tout permis; une erreur | |
| 9 | +serveur = prudence, on refuse temporairement. | |
| 10 | +""" | |
| 11 | + | |
| 12 | +from datetime import UTC, datetime, timedelta | |
| 13 | + | |
| 14 | +import httpx | |
| 15 | +from protego import Protego | |
| 16 | + | |
| 17 | +from trouveka.logging import get_logger | |
| 18 | + | |
| 19 | +log = get_logger("crawler.robots") | |
| 20 | + | |
| 21 | +ROBOTS_TTL = timedelta(hours=24) | |
| 22 | +_MAX_ROBOTS_BYTES = 512_000 | |
| 23 | + | |
| 24 | + | |
| 25 | +class RobotsCache: | |
| 26 | + def __init__(self, db, client: httpx.AsyncClient, user_agent: str): | |
| 27 | + self._db = db | |
| 28 | + self._client = client | |
| 29 | + self._ua = user_agent | |
| 30 | + self._parsers: dict[int, tuple[Protego | None, datetime]] = {} | |
| 31 | + | |
| 32 | + async def _fetch_robots(self, scheme_host: str) -> tuple[str | None, str]: | |
| 33 | + """Retourne (body, status) : ok | not_found | error.""" | |
| 34 | + try: | |
| 35 | + resp = await self._client.get( | |
| 36 | + f"{scheme_host}/robots.txt", | |
| 37 | + headers={"User-Agent": self._ua}, | |
| 38 | + timeout=15, | |
| 39 | + follow_redirects=True, | |
| 40 | + ) | |
| 41 | + except httpx.HTTPError: | |
| 42 | + return None, "error" | |
| 43 | + if resp.status_code == 200: | |
| 44 | + return resp.text[:_MAX_ROBOTS_BYTES], "ok" | |
| 45 | + if 400 <= resp.status_code < 500: | |
| 46 | + return None, "not_found" # 4xx = pas de robots → tout permis (standard) | |
| 47 | + return None, "error" | |
| 48 | + | |
| 49 | + async def get_parser(self, domain_id: int, scheme_host: str) -> tuple[Protego | None, str, int | None]: | |
| 50 | + """Parseur robots pour un domaine. Retourne (parser|None, status, crawl_delay_ms).""" | |
| 51 | + cached = self._parsers.get(domain_id) | |
| 52 | + now = datetime.now(UTC) | |
| 53 | + if cached and now - cached[1] < ROBOTS_TTL: | |
| 54 | + parser = cached[0] | |
| 55 | + return parser, "ok" if parser else "not_found", None | |
| 56 | + | |
| 57 | + row = await self._db.get_domain_by_id(domain_id) | |
| 58 | + if row and row["robots_fetched_at"] and now - row["robots_fetched_at"] < ROBOTS_TTL: | |
| 59 | + body, status = row["robots_body"], row["robots_status"] | |
| 60 | + else: | |
| 61 | + body, status = await self._fetch_robots(scheme_host) | |
| 62 | + delay_ms = None | |
| 63 | + if body: | |
| 64 | + delay = Protego.parse(body).crawl_delay(self._ua) | |
| 65 | + delay_ms = int(delay * 1000) if delay else None | |
| 66 | + await self._db.save_robots(domain_id, body, status, delay_ms) | |
| 67 | + | |
| 68 | + parser = Protego.parse(body) if body else None | |
| 69 | + self._parsers[domain_id] = (parser, now) | |
| 70 | + if len(self._parsers) > 5000: # borne mémoire du cache local | |
| 71 | + self._parsers.pop(next(iter(self._parsers))) | |
| 72 | + return parser, status, row["crawl_delay_ms"] if row else None | |
| 73 | + | |
| 74 | + async def allowed(self, domain_id: int, url: str, scheme_host: str) -> tuple[bool, float | None]: | |
| 75 | + """(autorisé, crawl_delay_secondes). Erreur robots = refus temporaire (prudence).""" | |
| 76 | + parser, status, delay_ms = await self.get_parser(domain_id, scheme_host) | |
| 77 | + if status == "error": | |
| 78 | + return False, None | |
| 79 | + if parser is None: | |
| 80 | + return True, None | |
| 81 | + delay = parser.crawl_delay(self._ua) | |
| 82 | + if delay is None and delay_ms: | |
| 83 | + delay = delay_ms / 1000 | |
| 84 | + return parser.can_fetch(url, self._ua), delay | |
added
M2M32crouve-ka/services/crawler/seed.py
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +# Trouve-KA — chargeur de seeds | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Charge les seeds dans le frontier avec priorité maximale. | |
| 6 | + | |
| 7 | +Usage : python -m trouveka.crawler.seed [chemin/vers/seeds.txt] | |
| 8 | +""" | |
| 9 | + | |
| 10 | +import asyncio | |
| 11 | +import pathlib | |
| 12 | +import sys | |
| 13 | + | |
| 14 | +from trouveka.config import get_settings | |
| 15 | +from trouveka.database import Database, run_migrations | |
| 16 | +from trouveka.logging import get_logger | |
| 17 | +from trouveka.shared import canonicalize_url, extract_domain | |
| 18 | + | |
| 19 | +log = get_logger("crawler.seed") | |
| 20 | + | |
| 21 | +DEFAULT_SEEDS = pathlib.Path(__file__).resolve().parents[2] / "scripts" / "bootstrap-seeds" / "seeds.txt" | |
| 22 | + | |
| 23 | + | |
| 24 | +async def load_seeds(path: pathlib.Path) -> int: | |
| 25 | + settings = get_settings() | |
| 26 | + applied = await run_migrations(settings.database_url) | |
| 27 | + if applied: | |
| 28 | + log.info("migrations appliquées", extra={"ctx": {"files": applied}}) | |
| 29 | + | |
| 30 | + db = Database(settings.database_url, pool_min=1, pool_max=3) | |
| 31 | + await db.connect() | |
| 32 | + added = 0 | |
| 33 | + try: | |
| 34 | + for line in path.read_text(encoding="utf-8").splitlines(): | |
| 35 | + line = line.strip() | |
| 36 | + if not line or line.startswith("#"): | |
| 37 | + continue | |
| 38 | + url = canonicalize_url(line) | |
| 39 | + domain = extract_domain(url) if url else None | |
| 40 | + if not url or not domain: | |
| 41 | + log.info("seed ignorée (URL invalide)", extra={"ctx": {"line": line}}) | |
| 42 | + continue | |
| 43 | + if await db.enqueue_url(url, domain, priority=1.0, depth=0, is_seed=True): | |
| 44 | + added += 1 | |
| 45 | + finally: | |
| 46 | + await db.close() | |
| 47 | + log.info("seeds chargées", extra={"ctx": {"added": added, "file": str(path)}}) | |
| 48 | + return added | |
| 49 | + | |
| 50 | + | |
| 51 | +def main() -> None: | |
| 52 | + path = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_SEEDS | |
| 53 | + asyncio.run(load_seeds(path)) | |
| 54 | + | |
| 55 | + | |
| 56 | +if __name__ == "__main__": | |
| 57 | + main() | |
added
M2M32crouve-ka/services/crawler/traps.py
+59 −0
@@ -0,0 +1,59 @@ | ||
| 1 | +# Trouve-KA — détection de pièges de crawl | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Heuristiques anti-pièges (CLAUDE.md §5.8) : calendriers infinis, session IDs, | |
| 6 | +explosions de facettes, pagination infinie, chemins répétitifs.""" | |
| 7 | + | |
| 8 | +import re | |
| 9 | +from urllib.parse import parse_qsl, urlsplit | |
| 10 | + | |
| 11 | +_SESSION_PARAMS = {"sid", "sessionid", "session_id", "phpsessid", "jsessionid", "aspsessionid", "cfid", "cftoken"} | |
| 12 | +_CALENDAR_RE = re.compile(r"/(?:19|20)\d{2}[-/](?:0?\d|1[0-2])(?:[-/](?:0?\d|[12]\d|3[01]))?/?$") | |
| 13 | +_LONG_NUMBER_RE = re.compile(r"\d{10,}") | |
| 14 | + | |
| 15 | + | |
| 16 | +def looks_like_trap( | |
| 17 | + url: str, | |
| 18 | + *, | |
| 19 | + max_query_params: int = 8, | |
| 20 | + max_path_segments: int = 12, | |
| 21 | + max_url_length: int = 1024, | |
| 22 | +) -> bool: | |
| 23 | + """True si l'URL ressemble à un piège de crawl et ne doit pas entrer au frontier.""" | |
| 24 | + if len(url) > max_url_length: | |
| 25 | + return True | |
| 26 | + try: | |
| 27 | + parts = urlsplit(url) | |
| 28 | + except ValueError: | |
| 29 | + return True | |
| 30 | + | |
| 31 | + params = parse_qsl(parts.query, keep_blank_values=True) | |
| 32 | + if len(params) > max_query_params: | |
| 33 | + return True | |
| 34 | + keys = {k.lower() for k, _ in params} | |
| 35 | + if keys & _SESSION_PARAMS: | |
| 36 | + return True | |
| 37 | + # Même paramètre répété (facettes qui explosent : ?filter=a&filter=b&filter=c…) | |
| 38 | + raw_keys = [k.lower() for k, _ in params] | |
| 39 | + if any(raw_keys.count(k) > 3 for k in set(raw_keys)): | |
| 40 | + return True | |
| 41 | + | |
| 42 | + segments = [s for s in parts.path.split("/") if s] | |
| 43 | + if len(segments) > max_path_segments: | |
| 44 | + return True | |
| 45 | + # Segment répété (boucles : /a/b/a/b/a/b) | |
| 46 | + if any(segments.count(s) > 3 for s in set(segments)): | |
| 47 | + return True | |
| 48 | + # Calendriers profonds (au-delà de l'année-mois raisonnable) : /events/2031/05/17 | |
| 49 | + if _CALENDAR_RE.search(parts.path): | |
| 50 | + year_match = re.search(r"/((?:19|20)\d{2})[-/]", parts.path) | |
| 51 | + if year_match and not (1995 <= int(year_match.group(1)) <= 2027): | |
| 52 | + return True | |
| 53 | + # Pagination excessive | |
| 54 | + for key, value in params: | |
| 55 | + if key.lower() in ("page", "p", "offset", "start") and value.isdigit() and int(value) > 500: | |
| 56 | + return True | |
| 57 | + if _LONG_NUMBER_RE.search(parts.query): | |
| 58 | + return True | |
| 59 | + return False | |
added
M2M32crouve-ka/services/crawler/worker.py
+447 −0
@@ -0,0 +1,447 @@ | ||
| 1 | +# Trouve-KA — worker de crawl (pipeline complet) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Worker de crawl : réclame des URLs au frontier et exécute le pipeline inline | |
| 6 | +fetch → parse → score Québec → indexation IMMÉDIATE (principe cardinal §0.3). | |
| 7 | + | |
| 8 | +Chaque page traitée avec succès est cherchable en quelques secondes. | |
| 9 | +L'enrichissement (étapes 2-3) part dans Redis Streams et ne bloque jamais. | |
| 10 | +N workers peuvent tourner en parallèle (frontier SKIP LOCKED + locks Redis par hôte). | |
| 11 | +""" | |
| 12 | + | |
| 13 | +import asyncio | |
| 14 | +import os | |
| 15 | +import random | |
| 16 | +import signal | |
| 17 | +import uuid | |
| 18 | +from datetime import UTC, datetime, timedelta | |
| 19 | + | |
| 20 | +import httpx | |
| 21 | + | |
| 22 | +from trouveka.classifier import score_page | |
| 23 | +from trouveka.config import get_settings | |
| 24 | +from trouveka.database import Database | |
| 25 | +from trouveka.frontier import compute_priority, next_recrawl_delay, retry_delay | |
| 26 | +from trouveka.indexer import build_search_document | |
| 27 | +from trouveka.logging import get_logger | |
| 28 | +from trouveka.parser import looks_like_garbage, parse_html | |
| 29 | +from trouveka.queue import Coordination | |
| 30 | +from trouveka.search_core import SearchCore | |
| 31 | +from trouveka.shared import canonicalize_url, content_hash, extract_domain | |
| 32 | +from trouveka.types import ErrorCode, Outcome | |
| 33 | + | |
| 34 | +from .fetcher import Fetcher, scheme_host | |
| 35 | +from .robots import RobotsCache | |
| 36 | +from .traps import looks_like_trap | |
| 37 | + | |
| 38 | +log = get_logger("crawler.worker") | |
| 39 | + | |
| 40 | +TRANSIENT_ERRORS = {ErrorCode.TIMEOUT, ErrorCode.HTTP_5XX, ErrorCode.CONNECTION, ErrorCode.DNS} | |
| 41 | +MAX_RETRIES = 3 | |
| 42 | + | |
| 43 | + | |
| 44 | +class CrawlerWorker: | |
| 45 | + def __init__(self) -> None: | |
| 46 | + self.s = get_settings() | |
| 47 | + self.worker_id = f"crawler-{os.getpid()}-{uuid.uuid4().hex[:6]}" | |
| 48 | + self.db = Database(self.s.database_url, pool_min=self.s.pg_pool_min, pool_max=self.s.pg_pool_max) | |
| 49 | + self.coord = Coordination(self.s.redis_url) | |
| 50 | + self.search = SearchCore(self.s.search_url, self.s.search_index) | |
| 51 | + self.client = httpx.AsyncClient( | |
| 52 | + limits=httpx.Limits( | |
| 53 | + max_connections=self.s.max_global_concurrency, | |
| 54 | + max_keepalive_connections=self.s.max_global_concurrency, | |
| 55 | + ), | |
| 56 | + http2=False, | |
| 57 | + ) | |
| 58 | + self.fetcher = Fetcher(self.client, self.s) | |
| 59 | + self.robots: RobotsCache | None = None | |
| 60 | + self.stop_event = asyncio.Event() | |
| 61 | + self._domain_cache: dict[int, dict] = {} | |
| 62 | + # Cache domaine→row pour la découverte : évite un SELECT par lien sortant | |
| 63 | + # (une page peut avoir 300 liens, souvent vers les mêmes domaines). | |
| 64 | + self._domain_by_name: dict[str, tuple[object, datetime]] = {} | |
| 65 | + | |
| 66 | + # ------------------------------------------------------------------ cycle de vie | |
| 67 | + | |
| 68 | + async def start(self) -> None: | |
| 69 | + await self.db.connect() | |
| 70 | + self.robots = RobotsCache(self.db, self.client, self.s.crawler_user_agent) | |
| 71 | + await self.search.ensure_index() | |
| 72 | + log.info("worker démarré", extra={"ctx": {"worker_id": self.worker_id}}) | |
| 73 | + | |
| 74 | + loop = asyncio.get_running_loop() | |
| 75 | + for sig in (signal.SIGINT, signal.SIGTERM): | |
| 76 | + loop.add_signal_handler(sig, self.stop_event.set) | |
| 77 | + | |
| 78 | + # Pool continu : on réclame de nouvelles URLs dès qu'un slot se libère. | |
| 79 | + # (Un gather par lot créait du head-of-line blocking : une page lente | |
| 80 | + # bloquait tout le lot avant la réclamation suivante.) | |
| 81 | + in_flight: set[asyncio.Task] = set() | |
| 82 | + idle_sleep = 1.0 | |
| 83 | + | |
| 84 | + async def run_one(item) -> None: | |
| 85 | + try: | |
| 86 | + await self.process_item(item) | |
| 87 | + except Exception: | |
| 88 | + log.exception( | |
| 89 | + "échec inattendu du pipeline", | |
| 90 | + extra={"ctx": {"url": item["url"], "worker_id": self.worker_id}}, | |
| 91 | + ) | |
| 92 | + await self.db.release_item( | |
| 93 | + item["url_id"], status="failed", error_code="parse_failed" | |
| 94 | + ) | |
| 95 | + | |
| 96 | + while not self.stop_event.is_set(): | |
| 97 | + if await self.coord.is_paused(): | |
| 98 | + await asyncio.sleep(3) | |
| 99 | + continue | |
| 100 | + free = self.s.max_global_concurrency - len(in_flight) | |
| 101 | + if free <= 0: | |
| 102 | + await asyncio.wait(in_flight, return_when=asyncio.FIRST_COMPLETED) | |
| 103 | + continue | |
| 104 | + batch = await self.db.claim_batch(self.worker_id, limit=free) | |
| 105 | + if not batch: | |
| 106 | + if in_flight: | |
| 107 | + await asyncio.wait( | |
| 108 | + in_flight, return_when=asyncio.FIRST_COMPLETED, timeout=idle_sleep | |
| 109 | + ) | |
| 110 | + else: | |
| 111 | + await asyncio.sleep(idle_sleep) | |
| 112 | + idle_sleep = min(idle_sleep * 1.5, 15) | |
| 113 | + continue | |
| 114 | + idle_sleep = 1.0 | |
| 115 | + for item in batch: | |
| 116 | + task = asyncio.create_task(run_one(item)) | |
| 117 | + in_flight.add(task) | |
| 118 | + task.add_done_callback(in_flight.discard) | |
| 119 | + | |
| 120 | + if in_flight: | |
| 121 | + await asyncio.gather(*in_flight, return_exceptions=True) | |
| 122 | + await self.shutdown() | |
| 123 | + | |
| 124 | + async def shutdown(self) -> None: | |
| 125 | + await self.client.aclose() | |
| 126 | + await self.search.close() | |
| 127 | + await self.coord.close() | |
| 128 | + await self.db.close() | |
| 129 | + log.info("worker arrêté", extra={"ctx": {"worker_id": self.worker_id}}) | |
| 130 | + | |
| 131 | + # ------------------------------------------------------------------ helpers | |
| 132 | + | |
| 133 | + async def _domain(self, domain_id: int) -> dict: | |
| 134 | + cached = self._domain_cache.get(domain_id) | |
| 135 | + if cached and (datetime.now(UTC) - cached["_at"]).total_seconds() < 60: | |
| 136 | + return cached | |
| 137 | + row = await self.db.get_domain_by_id(domain_id) | |
| 138 | + entry = dict(row) | {"_at": datetime.now(UTC)} | |
| 139 | + self._domain_cache[domain_id] = entry | |
| 140 | + if len(self._domain_cache) > 5000: | |
| 141 | + self._domain_cache.pop(next(iter(self._domain_cache))) | |
| 142 | + return entry | |
| 143 | + | |
| 144 | + async def _defer(self, url_id: int, seconds: float) -> None: | |
| 145 | + """Repousse un item sans compter d'erreur (politesse : trop tôt pour cet hôte). | |
| 146 | + | |
| 147 | + Jitter aléatoire pour désynchroniser les grappes d'URLs d'un même hôte | |
| 148 | + (sinon elles reviennent toutes en tête de file au même instant et churnent). | |
| 149 | + """ | |
| 150 | + await self.db.release_item( | |
| 151 | + url_id, | |
| 152 | + status="pending", | |
| 153 | + next_crawl_at=datetime.now(UTC) + timedelta(seconds=seconds + random.uniform(0.2, 3.0)), | |
| 154 | + ) | |
| 155 | + | |
| 156 | + async def _domain_row_by_name(self, domain: str): | |
| 157 | + """Row domaine (ou None si inconnu), avec cache TTL 60 s.""" | |
| 158 | + cached = self._domain_by_name.get(domain) | |
| 159 | + now = datetime.now(UTC) | |
| 160 | + if cached and (now - cached[1]).total_seconds() < 60: | |
| 161 | + return cached[0] | |
| 162 | + row = await self.db.get_domain(domain) | |
| 163 | + self._domain_by_name[domain] = (row, now) | |
| 164 | + if len(self._domain_by_name) > 20_000: | |
| 165 | + self._domain_by_name.pop(next(iter(self._domain_by_name))) | |
| 166 | + return row | |
| 167 | + | |
| 168 | + def _previous_delay_hours(self, item) -> float | None: | |
| 169 | + if item["last_crawled_at"] and item["next_crawl_at"]: | |
| 170 | + delta = (item["next_crawl_at"] - item["last_crawled_at"]).total_seconds() / 3600 | |
| 171 | + return max(delta, 0.1) | |
| 172 | + return None | |
| 173 | + | |
| 174 | + # ------------------------------------------------------------------ pipeline | |
| 175 | + | |
| 176 | + async def process_item(self, item) -> None: | |
| 177 | + url: str = item["url"] | |
| 178 | + url_id: int = item["url_id"] | |
| 179 | + domain_row = await self._domain(item["domain_id"]) | |
| 180 | + domain: str = domain_row["domain"] | |
| 181 | + | |
| 182 | + if domain_row["blocked"]: | |
| 183 | + await self.db.release_item(url_id, status="blocked") | |
| 184 | + return | |
| 185 | + | |
| 186 | + host = scheme_host(url) | |
| 187 | + | |
| 188 | + # robots.txt d'abord (le fetch de robots ne compte pas dans la politesse) | |
| 189 | + allowed, robots_delay = await self.robots.allowed(item["domain_id"], url, host) | |
| 190 | + if not allowed: | |
| 191 | + await self.db.record_attempt( | |
| 192 | + url_id, status_code=None, error_code=ErrorCode.ROBOTS_DENIED, | |
| 193 | + outcome=Outcome.ROBOTS_BLOCKED, | |
| 194 | + ) | |
| 195 | + await self.db.release_item(url_id, status="done", error_code=ErrorCode.ROBOTS_DENIED) | |
| 196 | + return | |
| 197 | + | |
| 198 | + # Politesse par hôte, tous workers confondus | |
| 199 | + delay = max(robots_delay or 0, self.s.default_host_delay) | |
| 200 | + if not await self.coord.acquire_host_slot(domain, delay): | |
| 201 | + await self._defer(url_id, delay + 0.5) | |
| 202 | + return | |
| 203 | + | |
| 204 | + # Cache HTTP conditionnel | |
| 205 | + existing = await self.db.get_document(url_id) | |
| 206 | + result = await self.fetcher.fetch( | |
| 207 | + url, | |
| 208 | + etag=existing["etag"] if existing else None, | |
| 209 | + last_modified=existing["last_modified"] if existing else None, | |
| 210 | + ) | |
| 211 | + | |
| 212 | + # --- 304 : inchangé | |
| 213 | + if result.status_code == 304: | |
| 214 | + await self._finish_unchanged(item, existing) | |
| 215 | + return | |
| 216 | + | |
| 217 | + # --- erreurs | |
| 218 | + if result.error_code not in (ErrorCode.OK, ErrorCode.ROBOTS_DENIED) or result.body is None: | |
| 219 | + await self._finish_error(item, result) | |
| 220 | + return | |
| 221 | + | |
| 222 | + # --- redirection vers une autre URL canonique : suivre la cible | |
| 223 | + final = canonicalize_url(result.final_url) or result.final_url | |
| 224 | + if final != url: | |
| 225 | + final_domain = extract_domain(final) | |
| 226 | + if final_domain and not looks_like_trap(final): | |
| 227 | + await self.db.enqueue_url( | |
| 228 | + final, final_domain, priority=item["priority"], depth=item["depth"], | |
| 229 | + source_url_id=url_id, max_urls_per_domain=self.s.max_urls_per_domain, | |
| 230 | + ) | |
| 231 | + await self.db.set_canonical(url_id, final) | |
| 232 | + await self.db.record_attempt( | |
| 233 | + url_id, status_code=result.status_code, error_code=ErrorCode.OK, | |
| 234 | + outcome=Outcome.REDIRECT, redirect_url=final, duration_ms=result.duration_ms, | |
| 235 | + ) | |
| 236 | + await self.db.release_item(url_id, status="done") | |
| 237 | + return | |
| 238 | + | |
| 239 | + # --- parse | |
| 240 | + try: | |
| 241 | + page = parse_html( | |
| 242 | + url, result.body, max_links=self.s.max_links_per_page, charset=result.charset | |
| 243 | + ) | |
| 244 | + except Exception: | |
| 245 | + await self.db.record_attempt( | |
| 246 | + url_id, status_code=result.status_code, error_code=ErrorCode.PARSE_FAILED, | |
| 247 | + outcome=Outcome.ERROR, duration_ms=result.duration_ms, | |
| 248 | + ) | |
| 249 | + await self.db.release_item(url_id, status="failed", error_code=ErrorCode.PARSE_FAILED) | |
| 250 | + return | |
| 251 | + | |
| 252 | + # --- garde anti-charabia : du binaire/mal décodé ne doit JAMAIS être indexé | |
| 253 | + if looks_like_garbage(page.title) or looks_like_garbage(page.body): | |
| 254 | + await self.db.record_attempt( | |
| 255 | + url_id, status_code=result.status_code, error_code=ErrorCode.PARSE_FAILED, | |
| 256 | + outcome=Outcome.ERROR, duration_ms=result.duration_ms, title=page.title[:80], | |
| 257 | + ) | |
| 258 | + await self.db.release_item(url_id, status="failed", error_code=ErrorCode.PARSE_FAILED) | |
| 259 | + return | |
| 260 | + | |
| 261 | + # --- score Québec (immédiat, déterministe) | |
| 262 | + signals = score_page(page, domain) | |
| 263 | + | |
| 264 | + # --- détection de changement | |
| 265 | + chash = content_hash(page.title, page.body) | |
| 266 | + if existing and existing["content_hash"] == chash: | |
| 267 | + await self._finish_unchanged(item, existing, status_code=result.status_code) | |
| 268 | + await self._discover_links(item, page, signals) # les liens peuvent avoir changé de priorité | |
| 269 | + return | |
| 270 | + | |
| 271 | + # --- doublon exact sur une autre URL | |
| 272 | + duplicate = await self.db.find_duplicate(chash, url_id) | |
| 273 | + if duplicate: | |
| 274 | + await self.db.record_attempt( | |
| 275 | + url_id, status_code=result.status_code, error_code=ErrorCode.DUPLICATE, | |
| 276 | + outcome=Outcome.DUPLICATE, content_hash=chash, duration_ms=result.duration_ms, | |
| 277 | + title=page.title, quebec_score=signals.score, | |
| 278 | + ) | |
| 279 | + await self.db.release_item( | |
| 280 | + url_id, status="done", | |
| 281 | + next_crawl_at=datetime.now(UTC) + timedelta(hours=self.s.max_recrawl_hours), | |
| 282 | + ) | |
| 283 | + return | |
| 284 | + | |
| 285 | + indexable = ( | |
| 286 | + not page.noindex | |
| 287 | + and result.error_code != ErrorCode.ROBOTS_DENIED # X-Robots-Tag: noindex | |
| 288 | + and signals.score >= self.s.min_quebec_score_to_index | |
| 289 | + and len(page.body) >= self.s.min_body_length | |
| 290 | + ) | |
| 291 | + | |
| 292 | + if indexable: | |
| 293 | + # ------- INDEXATION IMMÉDIATE : la page est cherchable en ~1 s ------- | |
| 294 | + canonical_target = page.canonical_url or url | |
| 295 | + doc = build_search_document( | |
| 296 | + page, signals, | |
| 297 | + domain=domain, | |
| 298 | + domain_quebec_score=float(domain_row["quebec_score"]), | |
| 299 | + authority_score=float(domain_row["authority_score"]), | |
| 300 | + ) | |
| 301 | + await self.search.index_document(doc) | |
| 302 | + await self.db.upsert_document( | |
| 303 | + url_id, | |
| 304 | + content_hash=chash, | |
| 305 | + etag=result.etag, | |
| 306 | + last_modified=result.last_modified, | |
| 307 | + title=page.title, | |
| 308 | + description=page.description, | |
| 309 | + language=page.language, | |
| 310 | + page_quebec_score=signals.score, | |
| 311 | + published_at=page.published_at, | |
| 312 | + changed=existing is not None, | |
| 313 | + ) | |
| 314 | + if page.canonical_url and page.canonical_url != url: | |
| 315 | + await self.db.set_canonical(url_id, canonical_target) | |
| 316 | + # Enrichissement asynchrone (jamais bloquant) | |
| 317 | + await self.coord.enqueue_enrichment( | |
| 318 | + {"url": canonical_target, "url_id": url_id, "domain_id": item["domain_id"], "stage": 2} | |
| 319 | + ) | |
| 320 | + outcome = Outcome.INDEXED | |
| 321 | + else: | |
| 322 | + outcome = Outcome.NOT_QUEBEC if signals.score < self.s.min_quebec_score_to_index else Outcome.UNCHANGED | |
| 323 | + | |
| 324 | + await self.db.update_domain_after_page(item["domain_id"], signals.score, page.language) | |
| 325 | + self._domain_cache.pop(item["domain_id"], None) | |
| 326 | + | |
| 327 | + await self.db.record_attempt( | |
| 328 | + url_id, status_code=result.status_code, error_code=ErrorCode.OK, | |
| 329 | + outcome=outcome, content_hash=chash, num_bytes=len(result.body), | |
| 330 | + duration_ms=result.duration_ms, title=page.title, quebec_score=signals.score, | |
| 331 | + ) | |
| 332 | + recrawl = next_recrawl_delay( | |
| 333 | + changed=True, previous_delay_hours=self._previous_delay_hours(item), | |
| 334 | + min_hours=self.s.min_recrawl_hours, max_hours=self.s.max_recrawl_hours, | |
| 335 | + default_hours=self.s.default_recrawl_hours, | |
| 336 | + ) | |
| 337 | + await self.db.release_item( | |
| 338 | + url_id, status="pending", next_crawl_at=datetime.now(UTC) + recrawl, | |
| 339 | + ) | |
| 340 | + | |
| 341 | + await self._discover_links(item, page, signals) | |
| 342 | + log.info( | |
| 343 | + "page traitée", | |
| 344 | + extra={"ctx": { | |
| 345 | + "worker_id": self.worker_id, "url_id": url_id, "domain_id": item["domain_id"], | |
| 346 | + "url": url, "outcome": str(outcome), "quebec_score": signals.score, | |
| 347 | + "links": len(page.links), "ms": result.duration_ms, | |
| 348 | + }}, | |
| 349 | + ) | |
| 350 | + | |
| 351 | + # ------------------------------------------------------------------ issues terminales | |
| 352 | + | |
| 353 | + async def _finish_unchanged(self, item, existing, status_code: int | None = 304) -> None: | |
| 354 | + recrawl = next_recrawl_delay( | |
| 355 | + changed=False, previous_delay_hours=self._previous_delay_hours(item), | |
| 356 | + min_hours=self.s.min_recrawl_hours, max_hours=self.s.max_recrawl_hours, | |
| 357 | + default_hours=self.s.default_recrawl_hours, | |
| 358 | + ) | |
| 359 | + await self.db.record_attempt( | |
| 360 | + item["url_id"], status_code=status_code, error_code=ErrorCode.OK, | |
| 361 | + outcome=Outcome.UNCHANGED, | |
| 362 | + content_hash=existing["content_hash"] if existing else None, | |
| 363 | + ) | |
| 364 | + await self.db.release_item( | |
| 365 | + item["url_id"], status="pending", | |
| 366 | + next_crawl_at=datetime.now(UTC) + recrawl, | |
| 367 | + ) | |
| 368 | + | |
| 369 | + async def _finish_error(self, item, result) -> None: | |
| 370 | + url_id = item["url_id"] | |
| 371 | + await self.db.record_attempt( | |
| 372 | + url_id, status_code=result.status_code, error_code=result.error_code, | |
| 373 | + outcome=Outcome.ERROR, duration_ms=result.duration_ms, | |
| 374 | + ) | |
| 375 | + transient = result.error_code in TRANSIENT_ERRORS | |
| 376 | + if transient and item["retries"] < MAX_RETRIES: | |
| 377 | + await self.db.release_item( | |
| 378 | + url_id, status="pending", error_code=result.error_code, | |
| 379 | + next_crawl_at=datetime.now(UTC) + retry_delay(item["retries"]), | |
| 380 | + increment_retries=True, | |
| 381 | + ) | |
| 382 | + else: | |
| 383 | + await self.db.release_item(url_id, status="failed", error_code=result.error_code) | |
| 384 | + | |
| 385 | + # ------------------------------------------------------------------ découverte | |
| 386 | + | |
| 387 | + async def _discover_links(self, item, page, signals) -> None: | |
| 388 | + """Boucle de découverte (§8) : liens sortants → scoring → frontier.""" | |
| 389 | + if item["depth"] >= self.s.max_crawl_depth: | |
| 390 | + return | |
| 391 | + domain_row = await self._domain(item["domain_id"]) | |
| 392 | + source_domain = domain_row["domain"] | |
| 393 | + source_quebec = max(float(domain_row["quebec_score"]), signals.score) | |
| 394 | + | |
| 395 | + outlink_domains: dict[int, int] = {} | |
| 396 | + enqueued = 0 | |
| 397 | + for link in page.links: | |
| 398 | + if link.nofollow or looks_like_trap( | |
| 399 | + link.url, | |
| 400 | + max_query_params=self.s.max_query_params, | |
| 401 | + max_path_segments=self.s.max_path_segments, | |
| 402 | + ): | |
| 403 | + continue | |
| 404 | + target_domain = extract_domain(link.url) | |
| 405 | + if not target_domain: | |
| 406 | + continue | |
| 407 | + same_domain = target_domain == source_domain | |
| 408 | + target_row = await self._domain_row_by_name(target_domain) | |
| 409 | + is_new = target_row is None | |
| 410 | + target_quebec = float(target_row["quebec_score"]) if target_row else 0.0 | |
| 411 | + # Un domaine découvert depuis une page québécoise hérite d'un a priori Québec | |
| 412 | + effective_quebec = target_quebec if not is_new else source_quebec * 0.7 | |
| 413 | + if same_domain: | |
| 414 | + effective_quebec = max(effective_quebec, source_quebec) | |
| 415 | + | |
| 416 | + priority = compute_priority( | |
| 417 | + domain_quebec_score=effective_quebec, | |
| 418 | + authority_score=float(target_row["authority_score"]) if target_row else 0.0, | |
| 419 | + link_signal=0.5 if not same_domain else 0.2, | |
| 420 | + is_new_domain=is_new, | |
| 421 | + depth=item["depth"] + 1, | |
| 422 | + ) | |
| 423 | + # Économie de crawl : ignorer les cibles au signal Québec quasi nul | |
| 424 | + if priority < 0.1: | |
| 425 | + continue | |
| 426 | + url_id = await self.db.enqueue_url( | |
| 427 | + link.url, target_domain, | |
| 428 | + priority=priority, depth=item["depth"] + 1, source_url_id=item["url_id"], | |
| 429 | + max_urls_per_domain=self.s.max_urls_per_domain, | |
| 430 | + ) | |
| 431 | + if url_id: | |
| 432 | + enqueued += 1 | |
| 433 | + if not same_domain: | |
| 434 | + target_id = (target_row["id"] if target_row | |
| 435 | + else (await self.db.upsert_domain(target_domain))) | |
| 436 | + outlink_domains[target_id] = outlink_domains.get(target_id, 0) + 1 | |
| 437 | + | |
| 438 | + if outlink_domains: | |
| 439 | + await self.db.record_domain_links(item["domain_id"], outlink_domains) | |
| 440 | + | |
| 441 | + | |
| 442 | +def main() -> None: | |
| 443 | + asyncio.run(CrawlerWorker().start()) | |
| 444 | + | |
| 445 | + | |
| 446 | +if __name__ == "__main__": | |
| 447 | + main() | |
added
M2M32crouve-ka/services/enrichment/__init__.py
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +# Trouve-KA — enrichissement asynchrone | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Enrichissement asynchrone des documents (étapes 2-3, CLAUDE.md §4). | |
| 6 | + | |
| 7 | +Ne bloque JAMAIS l'indexation : consomme Redis Streams et met à jour | |
| 8 | +les documents déjà cherchables (scores de domaine, fraîcheur, plus tard | |
| 9 | +embeddings et entités). | |
| 10 | +""" | |
| 11 | + | |
| 12 | +from .worker import EnrichmentWorker | |
| 13 | + | |
| 14 | +__all__ = ["EnrichmentWorker"] | |
added
M2M32crouve-ka/services/enrichment/worker.py
+82 −0
@@ -0,0 +1,82 @@ | ||
| 1 | +# Trouve-KA — worker d'enrichissement | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Worker d'enrichissement : consomme trouveka:enrich (Redis Streams). | |
| 6 | + | |
| 7 | +Étape 2 (implémentée) : propage domain_quebec_score et authority_score à jour | |
| 8 | +dans le document indexé (le domaine apprend au fil du crawl, les documents déjà | |
| 9 | +indexés en profitent rétroactivement). | |
| 10 | +Étapes futures : embeddings, entités, classification thématique — même canal, | |
| 11 | +même contrat : mise à jour partielle du document, jamais bloquante. | |
| 12 | +""" | |
| 13 | + | |
| 14 | +import asyncio | |
| 15 | +import os | |
| 16 | +import signal | |
| 17 | +import uuid | |
| 18 | + | |
| 19 | +from trouveka.config import get_settings | |
| 20 | +from trouveka.database import Database | |
| 21 | +from trouveka.logging import get_logger | |
| 22 | +from trouveka.queue import Coordination | |
| 23 | +from trouveka.search_core import SearchCore | |
| 24 | + | |
| 25 | +log = get_logger("enrichment") | |
| 26 | + | |
| 27 | +GROUP = "enrichers" | |
| 28 | + | |
| 29 | + | |
| 30 | +class EnrichmentWorker: | |
| 31 | + def __init__(self) -> None: | |
| 32 | + self.s = get_settings() | |
| 33 | + self.consumer = f"enrich-{os.getpid()}-{uuid.uuid4().hex[:6]}" | |
| 34 | + self.db = Database(self.s.database_url, pool_min=1, pool_max=3) | |
| 35 | + self.coord = Coordination(self.s.redis_url) | |
| 36 | + self.search = SearchCore(self.s.search_url, self.s.search_index) | |
| 37 | + self.stop_event = asyncio.Event() | |
| 38 | + | |
| 39 | + async def enrich(self, payload: dict) -> None: | |
| 40 | + domain_row = await self.db.get_domain_by_id(int(payload["domain_id"])) | |
| 41 | + if not domain_row: | |
| 42 | + return | |
| 43 | + await self.search.update_document( | |
| 44 | + payload["url"], | |
| 45 | + { | |
| 46 | + "domain_quebec_score": round(float(domain_row["quebec_score"]), 4), | |
| 47 | + "authority_score": round(float(domain_row["authority_score"]), 4), | |
| 48 | + }, | |
| 49 | + ) | |
| 50 | + | |
| 51 | + async def start(self) -> None: | |
| 52 | + await self.db.connect() | |
| 53 | + loop = asyncio.get_running_loop() | |
| 54 | + for sig in (signal.SIGINT, signal.SIGTERM): | |
| 55 | + loop.add_signal_handler(sig, self.stop_event.set) | |
| 56 | + log.info("worker d'enrichissement démarré", extra={"ctx": {"consumer": self.consumer}}) | |
| 57 | + | |
| 58 | + while not self.stop_event.is_set(): | |
| 59 | + try: | |
| 60 | + messages = await self.coord.read_enrichment(GROUP, self.consumer, count=20, block_ms=5000) | |
| 61 | + for msg_id, payload in messages: | |
| 62 | + try: | |
| 63 | + await self.enrich(payload) | |
| 64 | + except Exception: | |
| 65 | + log.exception("échec enrichissement", extra={"ctx": payload}) | |
| 66 | + finally: | |
| 67 | + await self.coord.ack_enrichment(GROUP, msg_id) | |
| 68 | + except Exception: | |
| 69 | + log.exception("erreur boucle enrichissement (on continue)") | |
| 70 | + await asyncio.sleep(2) | |
| 71 | + | |
| 72 | + await self.search.close() | |
| 73 | + await self.coord.close() | |
| 74 | + await self.db.close() | |
| 75 | + | |
| 76 | + | |
| 77 | +def main() -> None: | |
| 78 | + asyncio.run(EnrichmentWorker().start()) | |
| 79 | + | |
| 80 | + | |
| 81 | +if __name__ == "__main__": | |
| 82 | + main() | |
added
M2M32crouve-ka/services/frontier/__init__.py
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +# Trouve-KA — politique du frontier | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Politique du frontier : fonction de priorité et recrawl adaptatif (CLAUDE.md §5.3, §5.5).""" | |
| 6 | + | |
| 7 | +from .policy import compute_priority, next_recrawl_delay, retry_delay | |
| 8 | + | |
| 9 | +__all__ = ["compute_priority", "next_recrawl_delay", "retry_delay"] | |
added
M2M32crouve-ka/services/frontier/policy.py
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +# Trouve-KA — priorités et recrawl adaptatif | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Fonction de priorité du frontier et scheduling de recrawl. | |
| 6 | + | |
| 7 | +P = w_q·Q + w_a·A + w_f·F + w_l·L + w_n·N − w_d·D − w_s·S (CLAUDE.md §5.3) | |
| 8 | +Les poids sont des constantes nommées, destinées à être calibrées par mesures. | |
| 9 | +""" | |
| 10 | + | |
| 11 | +from datetime import timedelta | |
| 12 | + | |
| 13 | +# Poids de la fonction de priorité — à calibrer empiriquement, jamais figés | |
| 14 | +W_QUEBEC = 0.40 | |
| 15 | +W_AUTHORITY = 0.15 | |
| 16 | +W_FRESHNESS = 0.10 | |
| 17 | +W_LINKS = 0.10 | |
| 18 | +W_NOVELTY = 0.15 | |
| 19 | +W_DEPTH = 0.05 | |
| 20 | +W_SPAM = 0.30 | |
| 21 | + | |
| 22 | + | |
| 23 | +def compute_priority( | |
| 24 | + *, | |
| 25 | + domain_quebec_score: float, | |
| 26 | + authority_score: float = 0.0, | |
| 27 | + freshness_hint: float = 0.0, | |
| 28 | + link_signal: float = 0.0, | |
| 29 | + is_new_domain: bool = False, | |
| 30 | + depth: int = 0, | |
| 31 | + spam_signal: float = 0.0, | |
| 32 | + is_seed: bool = False, | |
| 33 | +) -> float: | |
| 34 | + """Priorité ∈ [0, 1]. Les seeds démarrent au maximum.""" | |
| 35 | + if is_seed: | |
| 36 | + return 1.0 | |
| 37 | + p = ( | |
| 38 | + W_QUEBEC * domain_quebec_score | |
| 39 | + + W_AUTHORITY * min(authority_score, 1.0) | |
| 40 | + + W_FRESHNESS * min(freshness_hint, 1.0) | |
| 41 | + + W_LINKS * min(link_signal, 1.0) | |
| 42 | + + W_NOVELTY * (1.0 if is_new_domain else 0.3) | |
| 43 | + - W_DEPTH * min(depth, 10) / 10.0 | |
| 44 | + - W_SPAM * min(spam_signal, 1.0) | |
| 45 | + ) | |
| 46 | + return max(0.0, min(1.0, round(p, 4))) | |
| 47 | + | |
| 48 | + | |
| 49 | +def next_recrawl_delay( | |
| 50 | + *, | |
| 51 | + changed: bool, | |
| 52 | + previous_delay_hours: float | None, | |
| 53 | + min_hours: float = 1.0, | |
| 54 | + max_hours: float = 24 * 30.0, | |
| 55 | + default_hours: float = 24.0, | |
| 56 | +) -> timedelta: | |
| 57 | + """Recrawl adaptatif (§5.5) : page inchangée → intervalle ×2; page volatile → intervalle ÷2.""" | |
| 58 | + prev = previous_delay_hours or default_hours | |
| 59 | + hours = max(min_hours, prev / 2.0) if changed else min(max_hours, prev * 2.0) | |
| 60 | + return timedelta(hours=hours) | |
| 61 | + | |
| 62 | + | |
| 63 | +def retry_delay(retries: int) -> timedelta: | |
| 64 | + """Backoff exponentiel plafonné pour les erreurs transitoires.""" | |
| 65 | + return timedelta(minutes=min(15 * (2**retries), 60 * 24)) | |
added
M2M32crouve-ka/services/indexer/__init__.py
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +# Trouve-KA — indexeur | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Construction et indexation immédiate des documents (étape 1, CLAUDE.md §4). | |
| 6 | + | |
| 7 | +L'indexation ne dépend JAMAIS de l'enrichissement : les champs des étapes 2-3 | |
| 8 | +(embedding, entités, autorité…) sont remplis plus tard par mise à jour partielle. | |
| 9 | +""" | |
| 10 | + | |
| 11 | +from .build import build_search_document | |
| 12 | + | |
| 13 | +__all__ = ["build_search_document"] | |
added
M2M32crouve-ka/services/indexer/build.py
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +# Trouve-KA — construction du document d'index | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Transforme une page parsée + signaux Québec en document OpenSearch (étape 1).""" | |
| 6 | + | |
| 7 | +from datetime import UTC, datetime | |
| 8 | +from typing import Any | |
| 9 | + | |
| 10 | +from trouveka.types import ParsedPage, QuebecSignals | |
| 11 | + | |
| 12 | +# Catégorisation grossière par domaine — raffinée en asynchrone (étape 2) | |
| 13 | +_GOV_SUFFIXES = (".gouv.qc.ca", ".gc.ca", ".quebec.ca") | |
| 14 | +_GOV_DOMAINS = {"quebec.ca", "canada.ca", "montreal.ca", "laval.ca", "gatineau.ca", "sherbrooke.ca"} | |
| 15 | +_NEWS_DOMAINS = { | |
| 16 | + "lapresse.ca", "ledevoir.com", "journaldemontreal.com", "journaldequebec.com", | |
| 17 | + "radio-canada.ca", "tvanouvelles.ca", "lesoleil.com", "ledroit.com", | |
| 18 | + "latribune.ca", "lenouvelliste.ca", "lequotidien.com", "lavoixdelest.ca", | |
| 19 | + "noovo.info", "24heures.ca", "montrealgazette.com", | |
| 20 | +} | |
| 21 | +_EDU_SUFFIXES = (".ulaval.ca", ".umontreal.ca", ".mcgill.ca", ".uqam.ca", ".usherbrooke.ca", | |
| 22 | + ".concordia.ca", ".polymtl.ca", ".etsmtl.ca", ".hec.ca") | |
| 23 | + | |
| 24 | + | |
| 25 | +def categorize_domain(domain: str) -> list[str]: | |
| 26 | + d = domain.lower() | |
| 27 | + cats: list[str] = [] | |
| 28 | + if d in _GOV_DOMAINS or any(d.endswith(s) for s in _GOV_SUFFIXES) or ".gouv." in d: | |
| 29 | + cats.append("government") | |
| 30 | + if d in _NEWS_DOMAINS: | |
| 31 | + cats.append("news") | |
| 32 | + if any(d.endswith(s) or d == s.lstrip(".") for s in _EDU_SUFFIXES) or d.endswith(".edu"): | |
| 33 | + cats.append("education") | |
| 34 | + return cats | |
| 35 | + | |
| 36 | + | |
| 37 | +def build_search_document( | |
| 38 | + page: ParsedPage, | |
| 39 | + signals: QuebecSignals, | |
| 40 | + *, | |
| 41 | + domain: str, | |
| 42 | + domain_quebec_score: float, | |
| 43 | + authority_score: float = 0.0, | |
| 44 | +) -> dict[str, Any]: | |
| 45 | + return { | |
| 46 | + "url": page.url, | |
| 47 | + "canonical_url": page.canonical_url or page.url, | |
| 48 | + "domain": domain, | |
| 49 | + "title": page.title, | |
| 50 | + "description": page.description, | |
| 51 | + "body": page.body[:100_000], | |
| 52 | + "headings": page.headings, | |
| 53 | + "language": page.language, | |
| 54 | + "page_quebec_score": signals.score, | |
| 55 | + "domain_quebec_score": round(domain_quebec_score, 4), | |
| 56 | + "locations": signals.locations, | |
| 57 | + "organizations": [], # enrichissement étape 2 | |
| 58 | + "people": [], # enrichissement étape 2 | |
| 59 | + "categories": categorize_domain(domain), | |
| 60 | + "published_at": page.published_at.isoformat() if page.published_at else None, | |
| 61 | + "crawled_at": datetime.now(UTC).isoformat(), | |
| 62 | + "authority_score": round(authority_score, 4), | |
| 63 | + "freshness_score": 0.0, # enrichissement étape 3 | |
| 64 | + "quality_score": 0.0, # enrichissement étape 3 | |
| 65 | + "spam_score": 0.0, # enrichissement étape 3 | |
| 66 | + } | |
added
M2M32crouve-ka/services/parser/__init__.py
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +# Trouve-KA — extraction de contenu | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Extraction de contenu des pages crawlées (CLAUDE.md §6). | |
| 6 | + | |
| 7 | +Chaque page est traitée comme non fiable : texte extrait puis assaini, | |
| 8 | +URLs re-canonicalisées, MIME types jamais crus sur parole. | |
| 9 | +""" | |
| 10 | + | |
| 11 | +from .html_parser import parse_html, decode_html, looks_like_garbage | |
| 12 | + | |
| 13 | +__all__ = ["parse_html", "decode_html", "looks_like_garbage"] | |
added
M2M32crouve-ka/services/parser/html_parser.py
+246 −0
@@ -0,0 +1,246 @@ | ||
| 1 | +# Trouve-KA — parseur HTML | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Extraction HTML avec selectolax (rapide, tolérant). | |
| 6 | + | |
| 7 | +Extrait : titre, meta description, corps principal (boilerplate retiré), | |
| 8 | +headings, liens + anchors + nofollow, canonical, langue, dates de publication, | |
| 9 | +auteur, indices structurés (JSON-LD/OpenGraph) pour le scoring Québec. | |
| 10 | +""" | |
| 11 | + | |
| 12 | +import json | |
| 13 | +import re | |
| 14 | +from datetime import datetime | |
| 15 | + | |
| 16 | +from dateutil import parser as dateparser | |
| 17 | +from langdetect import DetectorFactory, LangDetectException, detect | |
| 18 | +from selectolax.parser import HTMLParser | |
| 19 | + | |
| 20 | +from trouveka.shared import canonicalize_url | |
| 21 | +from trouveka.types import ExtractedLink, ParsedPage | |
| 22 | + | |
| 23 | +DetectorFactory.seed = 42 # détection de langue déterministe | |
| 24 | + | |
| 25 | +# Éléments retirés avant extraction du corps (navigation, pub, scripts…) | |
| 26 | +_STRIP_SELECTORS = ( | |
| 27 | + "script", "style", "noscript", "template", "svg", "iframe", "form", | |
| 28 | + "nav", "header", "footer", "aside", | |
| 29 | + "[role=navigation]", "[role=banner]", "[role=contentinfo]", "[role=complementary]", | |
| 30 | + "[aria-hidden=true]", ".cookie-banner", "#cookie-banner", ".cookies", "[class*=cookie-consent]", | |
| 31 | +) | |
| 32 | + | |
| 33 | +_MAIN_SELECTORS = ("main", "article", "[role=main]", "#main", "#content", ".main-content", ".content") | |
| 34 | + | |
| 35 | +_WS = re.compile(r"\s+") | |
| 36 | +_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") | |
| 37 | + | |
| 38 | + | |
| 39 | +def _clean(text: str | None) -> str: | |
| 40 | + if not text: | |
| 41 | + return "" | |
| 42 | + return _WS.sub(" ", _CTRL.sub(" ", text)).strip() | |
| 43 | + | |
| 44 | + | |
| 45 | +def _parse_date(value: str | None) -> datetime | None: | |
| 46 | + if not value: | |
| 47 | + return None | |
| 48 | + try: | |
| 49 | + dt = dateparser.parse(value) | |
| 50 | + except (ValueError, OverflowError, TypeError): | |
| 51 | + return None | |
| 52 | + if dt and dt.year >= 1990: | |
| 53 | + return dt | |
| 54 | + return None | |
| 55 | + | |
| 56 | + | |
| 57 | +def _meta(tree: HTMLParser, *names: str) -> str | None: | |
| 58 | + for name in names: | |
| 59 | + for attr in ("name", "property", "itemprop"): | |
| 60 | + node = tree.css_first(f'meta[{attr}="{name}"]') | |
| 61 | + if node: | |
| 62 | + content = node.attributes.get("content") | |
| 63 | + if content and content.strip(): | |
| 64 | + return content.strip() | |
| 65 | + return None | |
| 66 | + | |
| 67 | + | |
| 68 | +def _extract_jsonld_hints(tree: HTMLParser, max_blocks: int = 10) -> tuple[list[str], datetime | None]: | |
| 69 | + """Extrait des textes utiles des blocs JSON-LD (adresses, noms d'org) + datePublished.""" | |
| 70 | + hints: list[str] = [] | |
| 71 | + published: datetime | None = None | |
| 72 | + for node in tree.css('script[type="application/ld+json"]')[:max_blocks]: | |
| 73 | + raw = node.text() | |
| 74 | + if not raw or len(raw) > 100_000: | |
| 75 | + continue | |
| 76 | + try: | |
| 77 | + data = json.loads(raw) | |
| 78 | + except (json.JSONDecodeError, ValueError): | |
| 79 | + continue | |
| 80 | + stack = [data] | |
| 81 | + while stack: | |
| 82 | + item = stack.pop() | |
| 83 | + if isinstance(item, list): | |
| 84 | + stack.extend(item[:20]) | |
| 85 | + elif isinstance(item, dict): | |
| 86 | + for key in ("name", "legalName", "addressLocality", "addressRegion", | |
| 87 | + "postalCode", "streetAddress", "telephone"): | |
| 88 | + val = item.get(key) | |
| 89 | + if isinstance(val, str) and len(val) < 200: | |
| 90 | + hints.append(val) | |
| 91 | + if published is None: | |
| 92 | + published = _parse_date(item.get("datePublished")) or _parse_date(item.get("dateCreated")) | |
| 93 | + stack.extend(v for v in item.values() if isinstance(v, (dict, list))) | |
| 94 | + if len(hints) > 60: | |
| 95 | + break | |
| 96 | + return hints[:60], published | |
| 97 | + | |
| 98 | + | |
| 99 | +_META_CHARSET_RE = re.compile( | |
| 100 | + rb'<meta[^>]+charset=["\']?\s*([a-zA-Z0-9_-]{2,20})', re.IGNORECASE | |
| 101 | +) | |
| 102 | +_CHARSET_ALIASES = {"iso-8859-1": "cp1252", "latin-1": "cp1252", "latin1": "cp1252", "ansi": "cp1252"} | |
| 103 | + | |
| 104 | + | |
| 105 | +def decode_html(body: bytes, declared_charset: str | None = None) -> str: | |
| 106 | + """Décode le HTML : charset HTTP déclaré → meta charset → UTF-8 strict → cp1252. | |
| 107 | + | |
| 108 | + Les vieux sites québécois sont souvent en ISO-8859-1/cp1252; décoder | |
| 109 | + aveuglément en UTF-8 produit du charabia qui finirait dans l'index. | |
| 110 | + """ | |
| 111 | + candidates: list[str] = [] | |
| 112 | + if declared_charset: | |
| 113 | + candidates.append(declared_charset.lower()) | |
| 114 | + meta = _META_CHARSET_RE.search(body[:4096]) | |
| 115 | + if meta: | |
| 116 | + candidates.append(meta.group(1).decode("ascii", errors="ignore").lower()) | |
| 117 | + candidates.extend(["utf-8", "cp1252"]) | |
| 118 | + for charset in candidates: | |
| 119 | + charset = _CHARSET_ALIASES.get(charset, charset) | |
| 120 | + try: | |
| 121 | + return body.decode(charset) | |
| 122 | + except (UnicodeDecodeError, LookupError): | |
| 123 | + continue | |
| 124 | + return body.decode("utf-8", errors="replace") | |
| 125 | + | |
| 126 | + | |
| 127 | +def looks_like_garbage(text: str, *, threshold: float = 0.03) -> bool: | |
| 128 | + """True si le texte contient trop de caractères de remplacement/contrôle | |
| 129 | + (contenu binaire ou mal décodé — ne doit jamais être indexé).""" | |
| 130 | + if not text: | |
| 131 | + return False | |
| 132 | + sample = text[:20_000] | |
| 133 | + bad = sum(1 for c in sample if c == "�" or (ord(c) < 32 and c not in "\t\n\r")) | |
| 134 | + return bad / len(sample) > threshold | |
| 135 | + | |
| 136 | + | |
| 137 | +def parse_html( | |
| 138 | + url: str, html: bytes | str, *, max_links: int = 300, charset: str | None = None | |
| 139 | +) -> ParsedPage: | |
| 140 | + """Parse une page HTML en ParsedPage. Ne lève pas sur du HTML dégueulasse.""" | |
| 141 | + if isinstance(html, bytes): | |
| 142 | + html = decode_html(html, charset) | |
| 143 | + tree = HTMLParser(html) | |
| 144 | + | |
| 145 | + # Directives robots de la page | |
| 146 | + robots_meta = (_meta(tree, "robots", "googlebot") or "").lower() | |
| 147 | + noindex = "noindex" in robots_meta | |
| 148 | + nofollow_page = "nofollow" in robots_meta | |
| 149 | + | |
| 150 | + # Canonical | |
| 151 | + canonical = None | |
| 152 | + link_canonical = tree.css_first('link[rel="canonical"]') | |
| 153 | + if link_canonical: | |
| 154 | + canonical = canonicalize_url(link_canonical.attributes.get("href") or "", base=url) | |
| 155 | + | |
| 156 | + # Titre / description | |
| 157 | + title = _clean(tree.css_first("title").text() if tree.css_first("title") else None) | |
| 158 | + og_title = _meta(tree, "og:title") | |
| 159 | + if not title and og_title: | |
| 160 | + title = _clean(og_title) | |
| 161 | + description = _clean(_meta(tree, "description", "og:description") or "") | |
| 162 | + | |
| 163 | + # Langue déclarée | |
| 164 | + declared_lang = None | |
| 165 | + html_node = tree.css_first("html") | |
| 166 | + if html_node: | |
| 167 | + lang_attr = (html_node.attributes.get("lang") or "").strip().lower() | |
| 168 | + if lang_attr: | |
| 169 | + declared_lang = lang_attr[:2] | |
| 170 | + | |
| 171 | + # Dates / auteur | |
| 172 | + published = _parse_date(_meta(tree, "article:published_time", "datePublished", "date", "dc.date")) | |
| 173 | + modified = _parse_date(_meta(tree, "article:modified_time", "dateModified")) | |
| 174 | + author = _meta(tree, "author", "article:author") | |
| 175 | + | |
| 176 | + # Indices structurés (avant strip : JSON-LD est dans <script>) | |
| 177 | + structured_hints, jsonld_published = _extract_jsonld_hints(tree) | |
| 178 | + published = published or jsonld_published | |
| 179 | + | |
| 180 | + # Liens (avant strip : les liens de nav comptent pour la découverte) | |
| 181 | + links: list[ExtractedLink] = [] | |
| 182 | + seen: set[str] = set() | |
| 183 | + for node in tree.css("a[href]"): | |
| 184 | + if len(links) >= max_links: | |
| 185 | + break | |
| 186 | + href = node.attributes.get("href") or "" | |
| 187 | + if href.startswith(("#", "javascript:", "mailto:", "tel:", "data:")): | |
| 188 | + continue | |
| 189 | + normalized = canonicalize_url(href, base=url) | |
| 190 | + if not normalized or normalized in seen: | |
| 191 | + continue | |
| 192 | + seen.add(normalized) | |
| 193 | + rel = (node.attributes.get("rel") or "").lower() | |
| 194 | + links.append( | |
| 195 | + ExtractedLink( | |
| 196 | + url=normalized, | |
| 197 | + anchor=_clean(node.text())[:200], | |
| 198 | + nofollow="nofollow" in rel or nofollow_page, | |
| 199 | + ) | |
| 200 | + ) | |
| 201 | + | |
| 202 | + # Headings | |
| 203 | + headings = [_clean(n.text()) for n in tree.css("h1, h2, h3")[:40]] | |
| 204 | + headings = [h for h in headings if h] | |
| 205 | + | |
| 206 | + # Corps principal : strip du boilerplate, puis zone principale si identifiable | |
| 207 | + for selector in _STRIP_SELECTORS: | |
| 208 | + for node in tree.css(selector): | |
| 209 | + node.decompose() | |
| 210 | + body_node = None | |
| 211 | + for selector in _MAIN_SELECTORS: | |
| 212 | + body_node = tree.css_first(selector) | |
| 213 | + if body_node: | |
| 214 | + break | |
| 215 | + if body_node is None: | |
| 216 | + body_node = tree.css_first("body") or tree.root | |
| 217 | + body = _clean(body_node.text(separator=" ", deep=True))[:200_000] | |
| 218 | + | |
| 219 | + # Langue : déclaration HTML validée/complétée par détection statistique | |
| 220 | + language = declared_lang if declared_lang in ("fr", "en") else None | |
| 221 | + sample = body[:4000] or title | |
| 222 | + if sample and len(sample) > 40: | |
| 223 | + try: | |
| 224 | + detected = detect(sample) | |
| 225 | + if detected in ("fr", "en"): | |
| 226 | + # La détection l'emporte si elle contredit une déclaration douteuse | |
| 227 | + language = detected if language is None or detected != language else language | |
| 228 | + except LangDetectException: | |
| 229 | + pass | |
| 230 | + | |
| 231 | + return ParsedPage( | |
| 232 | + url=url, | |
| 233 | + canonical_url=canonical, | |
| 234 | + title=title[:500], | |
| 235 | + description=description[:1000], | |
| 236 | + body=body, | |
| 237 | + headings=headings, | |
| 238 | + language=language, | |
| 239 | + links=links, | |
| 240 | + published_at=published, | |
| 241 | + modified_at=modified, | |
| 242 | + author=_clean(author)[:200] if author else None, | |
| 243 | + noindex=noindex, | |
| 244 | + nofollow_page=nofollow_page, | |
| 245 | + structured_hints=structured_hints, | |
| 246 | + ) | |
added
M2M32crouve-ka/services/ranking/__init__.py
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +# Trouve-KA — ranking | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Ranking Québec-first (CLAUDE.md §9). | |
| 6 | + | |
| 7 | +Score(d,q) = w_b·BM25 + w_q·Québec + w_a·Autorité + w_f·Fraîcheur + w_l·Localité | |
| 8 | +Chaque composant avancé est optionnel : BM25 fonctionne même si tout le reste tombe. | |
| 9 | +""" | |
| 10 | + | |
| 11 | +from .query import analyze_query, build_search_body | |
| 12 | + | |
| 13 | +__all__ = ["analyze_query", "build_search_body"] | |
added
M2M32crouve-ka/services/ranking/query.py
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +# Trouve-KA — pipeline de requête et ranking | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Pipeline de requête : normalisation → langue → lieux → requête OpenSearch. | |
| 6 | + | |
| 7 | +BM25 (multi_match bilingue) au cœur, function_score par-dessus : | |
| 8 | +Québec, autorité, fraîcheur, localité. Poids nommés, non figés (§9). | |
| 9 | +""" | |
| 10 | + | |
| 11 | +import re | |
| 12 | +import unicodedata | |
| 13 | +from typing import Any | |
| 14 | + | |
| 15 | +from trouveka.classifier import QUEBEC_TOPONYMS | |
| 16 | + | |
| 17 | +# Poids de ranking — à calibrer avec le dataset d'évaluation (§16) | |
| 18 | +W_PAGE_QUEBEC = 1.6 | |
| 19 | +W_DOMAIN_QUEBEC = 1.0 | |
| 20 | +W_AUTHORITY = 0.8 | |
| 21 | +W_FRESHNESS = 0.5 | |
| 22 | +W_LOCALITY = 2.0 | |
| 23 | + | |
| 24 | +_FR_HINTS = {"le", "la", "les", "des", "une", "un", "du", "de", "et", "ou", "pour", | |
| 25 | + "avec", "dans", "sur", "meilleur", "meilleure", "comment", "où", "quel", "quelle"} | |
| 26 | +_EN_HINTS = {"the", "a", "an", "of", "and", "or", "for", "with", "in", "on", "best", "how", "what", "where"} | |
| 27 | + | |
| 28 | + | |
| 29 | +def _fold(text: str) -> str: | |
| 30 | + return "".join(c for c in unicodedata.normalize("NFD", text) if unicodedata.category(c) != "Mn") | |
| 31 | + | |
| 32 | + | |
| 33 | +def analyze_query(q: str) -> dict[str, Any]: | |
| 34 | + """Normalisation, détection de langue heuristique, extraction de lieux québécois.""" | |
| 35 | + normalized = re.sub(r"\s+", " ", q).strip()[:200] | |
| 36 | + tokens = [t.lower() for t in re.findall(r"[\w'-]+", normalized, re.UNICODE)] | |
| 37 | + | |
| 38 | + fr_hits = sum(1 for t in tokens if t in _FR_HINTS) | |
| 39 | + en_hits = sum(1 for t in tokens if t in _EN_HINTS) | |
| 40 | + has_accents = normalized != _fold(normalized) | |
| 41 | + language = "fr" if (fr_hits > en_hits or has_accents) else ("en" if en_hits > fr_hits else None) | |
| 42 | + | |
| 43 | + # Lieux : tokens simples + bigrammes contre le gazetteer | |
| 44 | + folded_gazetteer = {_fold(t): t for t in QUEBEC_TOPONYMS} | |
| 45 | + locations: list[str] = [] | |
| 46 | + candidates = tokens + [f"{a}-{b}" for a, b in zip(tokens, tokens[1:])] + [ | |
| 47 | + f"{a} {b}" for a, b in zip(tokens, tokens[1:]) | |
| 48 | + ] | |
| 49 | + for cand in candidates: | |
| 50 | + folded = _fold(cand) | |
| 51 | + if folded in folded_gazetteer: | |
| 52 | + locations.append(folded_gazetteer[folded]) | |
| 53 | + return {"query": normalized, "language": language, "locations": sorted(set(locations))} | |
| 54 | + | |
| 55 | + | |
| 56 | +def build_search_body( | |
| 57 | + q: str, | |
| 58 | + *, | |
| 59 | + page: int = 1, | |
| 60 | + limit: int = 10, | |
| 61 | + language: str | None = None, | |
| 62 | + category: str | None = None, | |
| 63 | + quebec_only: bool = False, | |
| 64 | + freshness: str | None = None, | |
| 65 | +) -> tuple[dict[str, Any], dict[str, Any]]: | |
| 66 | + """Construit le corps de requête OpenSearch. Retourne (body, analyse).""" | |
| 67 | + analysis = analyze_query(q) | |
| 68 | + normalized = analysis["query"] | |
| 69 | + | |
| 70 | + # BM25 bilingue : les deux analyzers interrogés, le meilleur champ gagne | |
| 71 | + text_query: dict[str, Any] = { | |
| 72 | + "multi_match": { | |
| 73 | + "query": normalized, | |
| 74 | + "type": "most_fields", | |
| 75 | + "fields": [ | |
| 76 | + "title^4", "title.en^4", | |
| 77 | + "headings^2", "headings.en^2", | |
| 78 | + "description^2", "description.en^2", | |
| 79 | + "body", "body.en", | |
| 80 | + ], | |
| 81 | + "fuzziness": "AUTO", | |
| 82 | + "prefix_length": 2, | |
| 83 | + } | |
| 84 | + } | |
| 85 | + | |
| 86 | + filters: list[dict[str, Any]] = [] | |
| 87 | + if language in ("fr", "en"): | |
| 88 | + filters.append({"term": {"language": language}}) | |
| 89 | + if category: | |
| 90 | + filters.append({"term": {"categories": category}}) | |
| 91 | + if quebec_only: | |
| 92 | + filters.append({ | |
| 93 | + "bool": { | |
| 94 | + "should": [ | |
| 95 | + {"range": {"page_quebec_score": {"gte": 0.45}}}, | |
| 96 | + {"range": {"domain_quebec_score": {"gte": 0.6}}}, | |
| 97 | + ], | |
| 98 | + "minimum_should_match": 1, | |
| 99 | + } | |
| 100 | + }) | |
| 101 | + if freshness in ("day", "week", "month", "year"): | |
| 102 | + filters.append({"range": {"crawled_at": {"gte": f"now-1{freshness[0]}/d"}}}) | |
| 103 | + | |
| 104 | + functions: list[dict[str, Any]] = [ | |
| 105 | + {"field_value_factor": {"field": "page_quebec_score", "factor": W_PAGE_QUEBEC, "missing": 0}}, | |
| 106 | + {"field_value_factor": {"field": "domain_quebec_score", "factor": W_DOMAIN_QUEBEC, "missing": 0}}, | |
| 107 | + {"field_value_factor": {"field": "authority_score", "factor": W_AUTHORITY, "missing": 0}}, | |
| 108 | + { | |
| 109 | + "gauss": {"published_at": {"origin": "now", "scale": "180d", "decay": 0.6}}, | |
| 110 | + "weight": W_FRESHNESS, | |
| 111 | + }, | |
| 112 | + ] | |
| 113 | + # Localité : « plombier Gatineau » booste les documents avec preuve géographique explicite | |
| 114 | + if analysis["locations"]: | |
| 115 | + functions.append({ | |
| 116 | + "filter": {"terms": {"locations": analysis["locations"]}}, | |
| 117 | + "weight": W_LOCALITY, | |
| 118 | + }) | |
| 119 | + | |
| 120 | + body = { | |
| 121 | + "from": max(page - 1, 0) * limit, | |
| 122 | + "size": limit, | |
| 123 | + "query": { | |
| 124 | + "function_score": { | |
| 125 | + "query": {"bool": {"must": [text_query], "filter": filters}}, | |
| 126 | + "functions": functions, | |
| 127 | + "score_mode": "sum", | |
| 128 | + "boost_mode": "sum", | |
| 129 | + } | |
| 130 | + }, | |
| 131 | + "highlight": { | |
| 132 | + "pre_tags": ["<em>"], | |
| 133 | + "post_tags": ["</em>"], | |
| 134 | + "fields": { | |
| 135 | + "body": {"fragment_size": 180, "number_of_fragments": 2}, | |
| 136 | + "description": {"fragment_size": 180, "number_of_fragments": 1}, | |
| 137 | + }, | |
| 138 | + "encoder": "html", | |
| 139 | + }, | |
| 140 | + "_source": [ | |
| 141 | + "url", "canonical_url", "domain", "title", "description", "language", | |
| 142 | + "page_quebec_score", "domain_quebec_score", "categories", "published_at", | |
| 143 | + ], | |
| 144 | + "track_total_hits": True, | |
| 145 | + } | |
| 146 | + return body, analysis | |
added
M2M32crouve-ka/services/scheduler/__init__.py
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +# Trouve-KA — scheduler | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Boucle de maintenance : items abandonnés, autorité de domaine, hygiène du frontier.""" | |
| 6 | + | |
| 7 | +from .loop import SchedulerLoop | |
| 8 | + | |
| 9 | +__all__ = ["SchedulerLoop"] | |
added
M2M32crouve-ka/services/scheduler/loop.py
+81 −0
@@ -0,0 +1,81 @@ | ||
| 1 | +# Trouve-KA — boucle de maintenance | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Scheduler : tâches périodiques légères. | |
| 6 | + | |
| 7 | +- Relance les items in_progress abandonnés (worker mort) — dégradation gracieuse §13. | |
| 8 | +- Recalcule l'autorité de domaine à partir du graphe de liens (inlinks pondérés | |
| 9 | + par le score Québec des domaines source) — version simple, PageRank-like plus tard. | |
| 10 | +""" | |
| 11 | + | |
| 12 | +import asyncio | |
| 13 | +import signal | |
| 14 | + | |
| 15 | +from trouveka.config import get_settings | |
| 16 | +from trouveka.database import Database | |
| 17 | +from trouveka.logging import get_logger | |
| 18 | + | |
| 19 | +log = get_logger("scheduler") | |
| 20 | + | |
| 21 | +STALE_RESET_INTERVAL = 60 # secondes | |
| 22 | +AUTHORITY_INTERVAL = 15 * 60 # secondes | |
| 23 | + | |
| 24 | + | |
| 25 | +class SchedulerLoop: | |
| 26 | + def __init__(self) -> None: | |
| 27 | + self.s = get_settings() | |
| 28 | + self.db = Database(self.s.database_url, pool_min=1, pool_max=3) | |
| 29 | + self.stop_event = asyncio.Event() | |
| 30 | + | |
| 31 | + async def recompute_authority(self) -> None: | |
| 32 | + """Autorité ∈ [0,1] : log-saturation des inlinks pondérés par le Québec-score des sources.""" | |
| 33 | + await self.db.pool.execute( | |
| 34 | + """ | |
| 35 | + WITH weighted AS ( | |
| 36 | + SELECT dl.to_domain_id AS id, | |
| 37 | + sum(least(dl.link_count, 50) * greatest(d.quebec_score, 0.1)) AS w, | |
| 38 | + count(DISTINCT dl.from_domain_id) AS in_domains | |
| 39 | + FROM domain_links dl JOIN domains d ON d.id = dl.from_domain_id | |
| 40 | + GROUP BY dl.to_domain_id | |
| 41 | + ) | |
| 42 | + UPDATE domains SET | |
| 43 | + authority_score = least(1.0, ln(1 + w.w) / ln(1 + 5000)), | |
| 44 | + inlink_domains = w.in_domains | |
| 45 | + FROM weighted w WHERE domains.id = w.id | |
| 46 | + """ | |
| 47 | + ) | |
| 48 | + log.info("autorité de domaine recalculée") | |
| 49 | + | |
| 50 | + async def start(self) -> None: | |
| 51 | + await self.db.connect() | |
| 52 | + loop = asyncio.get_running_loop() | |
| 53 | + for sig in (signal.SIGINT, signal.SIGTERM): | |
| 54 | + loop.add_signal_handler(sig, self.stop_event.set) | |
| 55 | + log.info("scheduler démarré") | |
| 56 | + | |
| 57 | + elapsed_authority = AUTHORITY_INTERVAL # premier calcul immédiat | |
| 58 | + while not self.stop_event.is_set(): | |
| 59 | + try: | |
| 60 | + reset = await self.db.reset_stale_items(older_than_minutes=30) | |
| 61 | + if reset: | |
| 62 | + log.info("items abandonnés relancés", extra={"ctx": {"count": reset}}) | |
| 63 | + if elapsed_authority >= AUTHORITY_INTERVAL: | |
| 64 | + await self.recompute_authority() | |
| 65 | + elapsed_authority = 0 | |
| 66 | + except Exception: | |
| 67 | + log.exception("erreur scheduler (on continue)") | |
| 68 | + try: | |
| 69 | + await asyncio.wait_for(self.stop_event.wait(), timeout=STALE_RESET_INTERVAL) | |
| 70 | + except TimeoutError: | |
| 71 | + pass | |
| 72 | + elapsed_authority += STALE_RESET_INTERVAL | |
| 73 | + await self.db.close() | |
| 74 | + | |
| 75 | + | |
| 76 | +def main() -> None: | |
| 77 | + asyncio.run(SchedulerLoop().start()) | |
| 78 | + | |
| 79 | + | |
| 80 | +if __name__ == "__main__": | |
| 81 | + main() | |
added
M2M32crouve-ka/tests/test_fetcher.py
+110 −0
@@ -0,0 +1,110 @@ | ||
| 1 | +# Trouve-KA — tests du fetcher (web de fixtures via MockTransport) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +import functools | |
| 6 | + | |
| 7 | +import httpx | |
| 8 | +import pytest | |
| 9 | + | |
| 10 | +import trouveka.crawler.fetcher as fetcher_mod | |
| 11 | +from trouveka.config import Settings | |
| 12 | +from trouveka.crawler.fetcher import Fetcher | |
| 13 | +from trouveka.shared import is_safe_url | |
| 14 | +from trouveka.types import ErrorCode | |
| 15 | + | |
| 16 | + | |
| 17 | +@pytest.fixture(autouse=True) | |
| 18 | +def no_dns(monkeypatch): | |
| 19 | + # Les hôtes de fixtures ne résolvent pas en vrai DNS : on garde toute la | |
| 20 | + # logique SSRF (schémas, hôtes bloqués, IP littérales) sans résolution. | |
| 21 | + monkeypatch.setattr(fetcher_mod, "is_safe_url", functools.partial(is_safe_url, resolve=False)) | |
| 22 | + | |
| 23 | + | |
| 24 | +def make_fetcher(handler, **overrides) -> Fetcher: | |
| 25 | + settings = Settings(max_response_bytes=1000, max_redirects=3, fetch_timeout=5, **overrides) | |
| 26 | + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) | |
| 27 | + return Fetcher(client, settings) | |
| 28 | + | |
| 29 | + | |
| 30 | +async def test_simple_fetch(): | |
| 31 | + def handler(request): | |
| 32 | + return httpx.Response(200, html="<html><title>ok</title></html>", | |
| 33 | + headers={"content-type": "text/html; charset=utf-8"}) | |
| 34 | + | |
| 35 | + result = await make_fetcher(handler).fetch("https://site.qc.ca/page") | |
| 36 | + assert result.status_code == 200 | |
| 37 | + assert result.error_code == ErrorCode.OK | |
| 38 | + assert b"ok" in result.body | |
| 39 | + | |
| 40 | + | |
| 41 | +async def test_redirect_followed_with_ssrf_recheck(): | |
| 42 | + def handler(request): | |
| 43 | + if request.url.path == "/a": | |
| 44 | + return httpx.Response(301, headers={"location": "/b"}) | |
| 45 | + return httpx.Response(200, html="fini", headers={"content-type": "text/html"}) | |
| 46 | + | |
| 47 | + result = await make_fetcher(handler).fetch("https://site.qc.ca/a") | |
| 48 | + assert result.status_code == 200 | |
| 49 | + assert result.final_url == "https://site.qc.ca/b" | |
| 50 | + assert result.redirect_chain == ["https://site.qc.ca/b"] | |
| 51 | + | |
| 52 | + | |
| 53 | +async def test_redirect_to_private_ip_blocked(): | |
| 54 | + def handler(request): | |
| 55 | + return httpx.Response(302, headers={"location": "http://169.254.169.254/latest/meta-data/"}) | |
| 56 | + | |
| 57 | + result = await make_fetcher(handler).fetch("https://site.qc.ca/evil") | |
| 58 | + assert result.error_code == ErrorCode.SSRF_BLOCKED | |
| 59 | + | |
| 60 | + | |
| 61 | +async def test_too_many_redirects(): | |
| 62 | + def handler(request): | |
| 63 | + n = int(request.url.path.strip("/") or 0) | |
| 64 | + return httpx.Response(301, headers={"location": f"/{n + 1}"}) | |
| 65 | + | |
| 66 | + result = await make_fetcher(handler).fetch("https://site.qc.ca/0") | |
| 67 | + assert result.error_code == ErrorCode.TOO_MANY_REDIRECTS | |
| 68 | + | |
| 69 | + | |
| 70 | +async def test_body_size_limit(): | |
| 71 | + def handler(request): | |
| 72 | + return httpx.Response(200, content=b"x" * 5000, headers={"content-type": "text/html"}) | |
| 73 | + | |
| 74 | + result = await make_fetcher(handler).fetch("https://site.qc.ca/gros") | |
| 75 | + assert result.error_code == ErrorCode.TOO_LARGE | |
| 76 | + | |
| 77 | + | |
| 78 | +async def test_unsupported_content_type(): | |
| 79 | + def handler(request): | |
| 80 | + return httpx.Response(200, content=b"%PDF-1.4", headers={"content-type": "application/pdf"}) | |
| 81 | + | |
| 82 | + result = await make_fetcher(handler).fetch("https://site.qc.ca/doc.pdf") | |
| 83 | + assert result.error_code == ErrorCode.UNSUPPORTED_CONTENT | |
| 84 | + | |
| 85 | + | |
| 86 | +async def test_conditional_304(): | |
| 87 | + def handler(request): | |
| 88 | + assert request.headers.get("if-none-match") == 'W/"abc"' | |
| 89 | + return httpx.Response(304) | |
| 90 | + | |
| 91 | + result = await make_fetcher(handler).fetch("https://site.qc.ca/page", etag='W/"abc"') | |
| 92 | + assert result.status_code == 304 | |
| 93 | + | |
| 94 | + | |
| 95 | +async def test_4xx_and_5xx_classified(): | |
| 96 | + async def run(status): | |
| 97 | + def handler(request): | |
| 98 | + return httpx.Response(status) | |
| 99 | + return await make_fetcher(handler).fetch("https://site.qc.ca/x") | |
| 100 | + | |
| 101 | + assert (await run(404)).error_code == ErrorCode.HTTP_4XX | |
| 102 | + assert (await run(503)).error_code == ErrorCode.HTTP_5XX | |
| 103 | + | |
| 104 | + | |
| 105 | +async def test_ssrf_blocked_upfront(): | |
| 106 | + def handler(request): # pragma: no cover — ne doit jamais être appelé | |
| 107 | + raise AssertionError("fetch ne doit pas partir") | |
| 108 | + | |
| 109 | + result = await make_fetcher(handler).fetch("http://127.0.0.1:8080/admin") | |
| 110 | + assert result.error_code == ErrorCode.SSRF_BLOCKED | |
added
M2M32crouve-ka/tests/test_frontier_policy.py
+44 −0
@@ -0,0 +1,44 @@ | ||
| 1 | +# Trouve-KA — tests de la politique du frontier | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +from trouveka.frontier import compute_priority, next_recrawl_delay, retry_delay | |
| 6 | + | |
| 7 | + | |
| 8 | +def test_seed_priority_is_max(): | |
| 9 | + assert compute_priority(domain_quebec_score=0, is_seed=True) == 1.0 | |
| 10 | + | |
| 11 | + | |
| 12 | +def test_priority_bounded(): | |
| 13 | + assert 0.0 <= compute_priority(domain_quebec_score=1.0, authority_score=1.0, | |
| 14 | + link_signal=1.0, is_new_domain=True) <= 1.0 | |
| 15 | + assert compute_priority(domain_quebec_score=0.0, spam_signal=1.0, depth=10) == 0.0 | |
| 16 | + | |
| 17 | + | |
| 18 | +def test_quebec_dominates_priority(): | |
| 19 | + high_q = compute_priority(domain_quebec_score=0.9, depth=2) | |
| 20 | + low_q = compute_priority(domain_quebec_score=0.1, depth=2) | |
| 21 | + assert high_q > low_q + 0.2 | |
| 22 | + | |
| 23 | + | |
| 24 | +def test_recrawl_backoff_doubles_when_unchanged(): | |
| 25 | + d = next_recrawl_delay(changed=False, previous_delay_hours=24.0) | |
| 26 | + assert d.total_seconds() == 48 * 3600 | |
| 27 | + | |
| 28 | + | |
| 29 | +def test_recrawl_halves_when_changed(): | |
| 30 | + d = next_recrawl_delay(changed=True, previous_delay_hours=24.0) | |
| 31 | + assert d.total_seconds() == 12 * 3600 | |
| 32 | + | |
| 33 | + | |
| 34 | +def test_recrawl_bounded(): | |
| 35 | + fast = next_recrawl_delay(changed=True, previous_delay_hours=0.5, min_hours=1.0) | |
| 36 | + slow = next_recrawl_delay(changed=False, previous_delay_hours=10_000, max_hours=720) | |
| 37 | + assert fast.total_seconds() == 3600 | |
| 38 | + assert slow.total_seconds() == 720 * 3600 | |
| 39 | + | |
| 40 | + | |
| 41 | +def test_retry_delay_grows_and_caps(): | |
| 42 | + assert retry_delay(0).total_seconds() == 15 * 60 | |
| 43 | + assert retry_delay(1).total_seconds() == 30 * 60 | |
| 44 | + assert retry_delay(10).total_seconds() == 24 * 3600 | |
added
M2M32crouve-ka/tests/test_parser.py
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +# Trouve-KA — tests d'extraction HTML | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +from trouveka.parser import decode_html, looks_like_garbage, parse_html | |
| 6 | + | |
| 7 | +FIXTURE = """<!DOCTYPE html> | |
| 8 | +<html lang="fr"> | |
| 9 | +<head> | |
| 10 | + <title>Plomberie XYZ — Plombier à Gatineau</title> | |
| 11 | + <meta name="description" content="Service de plomberie en Outaouais depuis 1998."> | |
| 12 | + <link rel="canonical" href="https://plomberiexyz.qc.ca/services"> | |
| 13 | + <meta property="article:published_time" content="2024-06-12T10:00:00-04:00"> | |
| 14 | + <script type="application/ld+json"> | |
| 15 | + {"@type": "Plumber", "name": "Plomberie XYZ", | |
| 16 | + "address": {"addressLocality": "Gatineau", "addressRegion": "QC", "postalCode": "J8X 3X3"}} | |
| 17 | + </script> | |
| 18 | +</head> | |
| 19 | +<body> | |
| 20 | + <nav><a href="/menu1">Menu qui ne doit pas polluer le corps</a></nav> | |
| 21 | + <main> | |
| 22 | + <h1>Plombier Gatineau</h1> | |
| 23 | + <h2>Nos services</h2> | |
| 24 | + <p>Nous desservons Gatineau et tout l'Outaouais depuis 1998.</p> | |
| 25 | + <a href="/tarifs?utm_source=nav">Tarifs</a> | |
| 26 | + <a href="https://autresite.qc.ca/page" rel="nofollow">Partenaire</a> | |
| 27 | + <a href="mailto:info@x.ca">Courriel</a> | |
| 28 | + <a href="javascript:void(0)">JS</a> | |
| 29 | + </main> | |
| 30 | + <footer>123 rue Principale, Gatineau (Québec) J8X 3X3 — 819-555-1234</footer> | |
| 31 | + <script>trackingStuff();</script> | |
| 32 | +</body> | |
| 33 | +</html>""" | |
| 34 | + | |
| 35 | + | |
| 36 | +def test_full_extraction(): | |
| 37 | + page = parse_html("https://plomberiexyz.qc.ca/services/", FIXTURE) | |
| 38 | + assert page.title == "Plomberie XYZ — Plombier à Gatineau" | |
| 39 | + assert "Outaouais" in page.description | |
| 40 | + assert page.canonical_url == "https://plomberiexyz.qc.ca/services" | |
| 41 | + assert page.language == "fr" | |
| 42 | + assert "Plombier Gatineau" in page.headings | |
| 43 | + assert page.published_at and page.published_at.year == 2024 | |
| 44 | + # Corps : contenu principal présent, nav/script exclus | |
| 45 | + assert "Nous desservons Gatineau" in page.body | |
| 46 | + assert "Menu qui ne doit pas" not in page.body | |
| 47 | + assert "trackingStuff" not in page.body | |
| 48 | + # JSON-LD → indices structurés | |
| 49 | + assert "Gatineau" in page.structured_hints | |
| 50 | + | |
| 51 | + | |
| 52 | +def test_links_normalized_and_flagged(): | |
| 53 | + page = parse_html("https://plomberiexyz.qc.ca/services/", FIXTURE) | |
| 54 | + urls = {link.url for link in page.links} | |
| 55 | + assert "https://plomberiexyz.qc.ca/tarifs" in urls # utm retiré, relatif résolu | |
| 56 | + nofollow = {link.url: link.nofollow for link in page.links} | |
| 57 | + assert nofollow["https://autresite.qc.ca/page"] is True | |
| 58 | + assert not any(u.startswith(("mailto:", "javascript:")) for u in urls) | |
| 59 | + | |
| 60 | + | |
| 61 | +def test_noindex_detected(): | |
| 62 | + html = '<html><head><meta name="robots" content="noindex, nofollow"><title>x</title></head><body>corps</body></html>' | |
| 63 | + page = parse_html("https://x.qc.ca/", html) | |
| 64 | + assert page.noindex is True | |
| 65 | + assert page.nofollow_page is True | |
| 66 | + | |
| 67 | + | |
| 68 | +def test_garbage_html_does_not_crash(): | |
| 69 | + page = parse_html("https://x.qc.ca/", b"\x00\xffPas du HTML <div <<< &&& </span>") | |
| 70 | + assert page.url == "https://x.qc.ca/" | |
| 71 | + | |
| 72 | + | |
| 73 | +def test_decode_latin1_quebec_site(): | |
| 74 | + # Vieux site québécois en ISO-8859-1 : les accents doivent survivre | |
| 75 | + body = "<html><head><meta charset=\"iso-8859-1\"><title>Éducation à Québec</title></head><body>Ministère de l'Éducation</body></html>".encode("cp1252") | |
| 76 | + assert "Éducation à Québec" in decode_html(body) | |
| 77 | + page = parse_html("https://vieux-site.qc.ca/", body) | |
| 78 | + assert page.title == "Éducation à Québec" | |
| 79 | + | |
| 80 | + | |
| 81 | +def test_decode_http_charset_priority(): | |
| 82 | + body = "<html><title>Montréal</title></html>".encode("cp1252") | |
| 83 | + assert "Montréal" in decode_html(body, "iso-8859-1") | |
| 84 | + | |
| 85 | + | |
| 86 | +def test_binary_garbage_detected(): | |
| 87 | + # Contenu binaire (ex. brotli non décodé) décodé de force → détecté comme charabia | |
| 88 | + binary = bytes(range(256)) * 40 | |
| 89 | + text = binary.decode("utf-8", errors="replace") | |
| 90 | + assert looks_like_garbage(text) | |
| 91 | + assert not looks_like_garbage("Une page normale sur le Québec, avec des accents é à ç.") | |
| 92 | + assert not looks_like_garbage("") | |
added
M2M32crouve-ka/tests/test_quebec_scoring.py
+88 −0
@@ -0,0 +1,88 @@ | ||
| 1 | +# Trouve-KA — tests du scoring Québec | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +from trouveka.classifier import score_page | |
| 6 | +from trouveka.types import ParsedPage | |
| 7 | + | |
| 8 | + | |
| 9 | +def _page(**kwargs) -> ParsedPage: | |
| 10 | + defaults = {"url": "https://example.com/page", "title": "", "body": ""} | |
| 11 | + return ParsedPage(**(defaults | kwargs)) | |
| 12 | + | |
| 13 | + | |
| 14 | +def test_quebec_business_scores_high(): | |
| 15 | + page = _page( | |
| 16 | + url="https://plomberiexyz.qc.ca/services", | |
| 17 | + title="Plomberie XYZ — Plombier à Gatineau", | |
| 18 | + description="Service de plomberie résidentielle en Outaouais", | |
| 19 | + body="Plomberie XYZ dessert Gatineau et l'Outaouais. 123 rue Principale, Gatineau (Québec) J8X 3X3. " | |
| 20 | + "Appelez-nous au 819-555-1234. Urgences 24h partout au Québec.", | |
| 21 | + headings=["Plombier Gatineau", "Nos services"], | |
| 22 | + language="fr", | |
| 23 | + ) | |
| 24 | + signals = score_page(page, "plomberiexyz.qc.ca") | |
| 25 | + assert signals.score > 0.7 | |
| 26 | + assert "gatineau" in signals.locations | |
| 27 | + assert "tld_quebec" in signals.reasons | |
| 28 | + assert "code_postal_qc" in signals.reasons | |
| 29 | + | |
| 30 | + | |
| 31 | +def test_ontario_business_scores_low(): | |
| 32 | + page = _page( | |
| 33 | + url="https://ottawaplumbing.ca/", | |
| 34 | + title="Ottawa Plumbing Services", | |
| 35 | + description="Plumbing services in Ottawa, Ontario", | |
| 36 | + body="We serve Ottawa, Kanata and Nepean. 456 Bank Street, Ottawa, ON K1S 3T4. Call 613-555-9999.", | |
| 37 | + headings=["Plumbing Ottawa"], | |
| 38 | + language="en", | |
| 39 | + ) | |
| 40 | + signals = score_page(page, "ottawaplumbing.ca") | |
| 41 | + assert signals.score < 0.2 | |
| 42 | + | |
| 43 | + | |
| 44 | +def test_nyt_article_about_montreal_gets_page_signal(): | |
| 45 | + # Un article international SUR Montréal : le domaine n'est pas québécois, | |
| 46 | + # mais la page a un signal réel (page_quebec_score > 0, sans être maximal). | |
| 47 | + page = _page( | |
| 48 | + url="https://www.nytimes.com/2026/01/01/travel/montreal.html", | |
| 49 | + title="36 Hours in Montreal", | |
| 50 | + description="What to do in Montreal, Quebec's largest city", | |
| 51 | + body="Montreal is the largest city in Quebec. From the Plateau-Mont-Royal to Old Montreal, " | |
| 52 | + "the city offers poutine, festivals and more. Nearby Laval and Longueuil...", | |
| 53 | + headings=["36 Hours in Montreal"], | |
| 54 | + language="en", | |
| 55 | + ) | |
| 56 | + signals = score_page(page, "nytimes.com") | |
| 57 | + assert 0.2 < signals.score < 0.8 | |
| 58 | + assert "montreal" in signals.locations | |
| 59 | + | |
| 60 | + | |
| 61 | +def test_generic_english_page_scores_near_zero(): | |
| 62 | + page = _page( | |
| 63 | + url="https://techblog.com/post", | |
| 64 | + title="Understanding Rust lifetimes", | |
| 65 | + body="Rust lifetimes are a way to express the scope of references in your program. " * 20, | |
| 66 | + language="en", | |
| 67 | + ) | |
| 68 | + signals = score_page(page, "techblog.com") | |
| 69 | + assert signals.score < 0.1 | |
| 70 | + | |
| 71 | + | |
| 72 | +def test_french_alone_is_not_proof(): | |
| 73 | + # Une page française de France ne doit pas passer le seuil sur la langue seule | |
| 74 | + page = _page( | |
| 75 | + url="https://lemonde.fr/article", | |
| 76 | + title="Actualités françaises", | |
| 77 | + body="La France annonce de nouvelles mesures. Paris, Lyon et Marseille concernées. " * 10, | |
| 78 | + language="fr", | |
| 79 | + ) | |
| 80 | + signals = score_page(page, "lemonde.fr") | |
| 81 | + assert signals.score < 0.3 | |
| 82 | + | |
| 83 | + | |
| 84 | +def test_score_saturates_below_one(): | |
| 85 | + body = "Montréal Québec Gatineau Sherbrooke Laval " * 100 + " H2X 1Y6 G1R 4S9 514-555-0000 Hydro-Québec" | |
| 86 | + page = _page(url="https://x.qc.ca/", title="Montréal Québec", body=body, language="fr") | |
| 87 | + signals = score_page(page, "x.qc.ca") | |
| 88 | + assert 0.8 < signals.score <= 1.0 | |
added
M2M32crouve-ka/tests/test_ranking.py
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +# Trouve-KA — tests du pipeline de requête et du ranking | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +from trouveka.ranking import analyze_query, build_search_body | |
| 6 | + | |
| 7 | + | |
| 8 | +def test_language_detection_heuristics(): | |
| 9 | + assert analyze_query("meilleur programme de subvention pour thermopompe")["language"] == "fr" | |
| 10 | + assert analyze_query("best heat pump rebate program")["language"] == "en" | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_location_extraction_with_accents(): | |
| 14 | + a = analyze_query("plombier gatineau") | |
| 15 | + assert "gatineau" in a["locations"] | |
| 16 | + b = analyze_query("subvention Trois-Rivières") | |
| 17 | + assert any("trois-rivieres" in loc or "trois-rivières" in loc for loc in b["locations"]) | |
| 18 | + | |
| 19 | + | |
| 20 | +def test_body_structure_bm25_core(): | |
| 21 | + body, analysis = build_search_body("plombier Gatineau", page=2, limit=10) | |
| 22 | + assert body["from"] == 10 and body["size"] == 10 | |
| 23 | + fs = body["query"]["function_score"] | |
| 24 | + fields = fs["query"]["bool"]["must"][0]["multi_match"]["fields"] | |
| 25 | + assert "title^4" in fields and "body" in fields and "body.en" in fields | |
| 26 | + # Localité : fonction de boost présente quand un lieu est détecté | |
| 27 | + locality = [f for f in fs["functions"] if "filter" in f] | |
| 28 | + assert locality and locality[0]["filter"]["terms"]["locations"] == ["gatineau"] | |
| 29 | + | |
| 30 | + | |
| 31 | +def test_filters(): | |
| 32 | + body, _ = build_search_body("test", language="fr", category="government", quebec_only=True) | |
| 33 | + filters = body["query"]["function_score"]["query"]["bool"]["filter"] | |
| 34 | + assert {"term": {"language": "fr"}} in filters | |
| 35 | + assert {"term": {"categories": "government"}} in filters | |
| 36 | + assert any("bool" in f for f in filters) # quebec_only | |
| 37 | + | |
| 38 | + | |
| 39 | +def test_no_locality_function_without_location(): | |
| 40 | + body, _ = build_search_body("recette de tourtière") | |
| 41 | + assert not [f for f in body["query"]["function_score"]["functions"] if "filter" in f] | |
added
M2M32crouve-ka/tests/test_ssrf.py
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +# Trouve-KA — tests de prévention SSRF | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +from trouveka.shared import is_safe_ip, is_safe_url | |
| 6 | + | |
| 7 | + | |
| 8 | +def test_blocks_loopback_and_private(): | |
| 9 | + for ip in ("127.0.0.1", "10.0.0.5", "192.168.2.10", "172.16.0.1", "0.0.0.0", "::1", "fc00::1"): | |
| 10 | + assert not is_safe_ip(ip), ip | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_blocks_link_local_and_metadata(): | |
| 14 | + assert not is_safe_ip("169.254.169.254") | |
| 15 | + assert not is_safe_ip("169.254.1.1") | |
| 16 | + assert not is_safe_ip("fe80::1") | |
| 17 | + | |
| 18 | + | |
| 19 | +def test_allows_public_ips(): | |
| 20 | + assert is_safe_ip("142.226.10.10") | |
| 21 | + assert is_safe_ip("2607:f8b0::1") | |
| 22 | + | |
| 23 | + | |
| 24 | +def test_blocks_localhost_and_schemes(): | |
| 25 | + assert not is_safe_url("http://localhost/admin", resolve=False) | |
| 26 | + assert not is_safe_url("http://foo.localhost/x", resolve=False) | |
| 27 | + assert not is_safe_url("http://metadata.google.internal/", resolve=False) | |
| 28 | + assert not is_safe_url("file:///etc/passwd", resolve=False) | |
| 29 | + assert not is_safe_url("gopher://x.ca", resolve=False) | |
| 30 | + | |
| 31 | + | |
| 32 | +def test_blocks_ip_literal_urls(): | |
| 33 | + assert not is_safe_url("http://127.0.0.1:8080/", resolve=False) | |
| 34 | + assert not is_safe_url("http://169.254.169.254/latest/meta-data/", resolve=False) | |
| 35 | + assert not is_safe_url("http://[::1]/", resolve=False) | |
| 36 | + | |
| 37 | + | |
| 38 | +def test_allows_normal_url_without_resolution(): | |
| 39 | + assert is_safe_url("https://www.quebec.ca/", resolve=False) | |
added
M2M32crouve-ka/tests/test_traps.py
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +# Trouve-KA — tests de détection de pièges | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +from trouveka.crawler.traps import looks_like_trap | |
| 6 | + | |
| 7 | + | |
| 8 | +def test_session_ids(): | |
| 9 | + assert looks_like_trap("https://x.ca/p?PHPSESSID=abc123") | |
| 10 | + assert looks_like_trap("https://x.ca/p?sid=9f8e7d") | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_too_many_params(): | |
| 14 | + url = "https://x.ca/p?" + "&".join(f"f{i}={i}" for i in range(12)) | |
| 15 | + assert looks_like_trap(url) | |
| 16 | + | |
| 17 | + | |
| 18 | +def test_repeated_facet_param(): | |
| 19 | + assert looks_like_trap("https://x.ca/s?f=a&f=b&f=c&f=d&f=e") | |
| 20 | + | |
| 21 | + | |
| 22 | +def test_deep_paths_and_loops(): | |
| 23 | + assert looks_like_trap("https://x.ca/" + "/".join(["seg"] * 15)) | |
| 24 | + assert looks_like_trap("https://x.ca/a/b/a/b/a/b/a/b") | |
| 25 | + | |
| 26 | + | |
| 27 | +def test_far_future_calendar(): | |
| 28 | + assert looks_like_trap("https://x.ca/events/2085/05/17") | |
| 29 | + assert not looks_like_trap("https://x.ca/nouvelles/2024/06/12") | |
| 30 | + | |
| 31 | + | |
| 32 | +def test_excessive_pagination(): | |
| 33 | + assert looks_like_trap("https://x.ca/liste?page=9999") | |
| 34 | + assert not looks_like_trap("https://x.ca/liste?page=3") | |
| 35 | + | |
| 36 | + | |
| 37 | +def test_normal_urls_pass(): | |
| 38 | + assert not looks_like_trap("https://www.quebec.ca/services/permis?type=conduire") | |
| 39 | + assert not looks_like_trap("https://ici.radio-canada.ca/nouvelle/2024/ceci-est-un-titre") | |
added
M2M32crouve-ka/tests/test_urls.py
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +# Trouve-KA — tests de canonicalisation d'URL | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +from trouveka.shared import canonicalize_url, display_url, extract_domain | |
| 6 | + | |
| 7 | + | |
| 8 | +def test_removes_fragment_and_default_port(): | |
| 9 | + assert canonicalize_url("https://quebec.ca:443/services#section") == "https://quebec.ca/services" | |
| 10 | + assert canonicalize_url("http://quebec.ca:80/") == "http://quebec.ca/" | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_preserves_non_default_port(): | |
| 14 | + assert canonicalize_url("http://example.qc.ca:8080/x") == "http://example.qc.ca:8080/x" | |
| 15 | + | |
| 16 | + | |
| 17 | +def test_lowercases_host_but_preserves_path_case(): | |
| 18 | + assert canonicalize_url("HTTPS://WWW.Quebec.CA/Services/PDF") == "https://www.quebec.ca/Services/PDF" | |
| 19 | + | |
| 20 | + | |
| 21 | +def test_strips_tracking_params_only(): | |
| 22 | + url = "https://ledevoir.com/a?utm_source=fb&fbclid=xyz&id=42&gclid=1" | |
| 23 | + assert canonicalize_url(url) == "https://ledevoir.com/a?id=42" | |
| 24 | + | |
| 25 | + | |
| 26 | +def test_preserves_unknown_params_and_order(): | |
| 27 | + # Deux ressources distinctes ne doivent jamais fusionner : ordre préservé | |
| 28 | + assert canonicalize_url("https://x.ca/p?b=2&a=1") == "https://x.ca/p?b=2&a=1" | |
| 29 | + assert canonicalize_url("https://x.ca/p?a=1&b=2") == "https://x.ca/p?a=1&b=2" | |
| 30 | + | |
| 31 | + | |
| 32 | +def test_rejects_non_http_schemes(): | |
| 33 | + assert canonicalize_url("file:///etc/passwd") is None | |
| 34 | + assert canonicalize_url("javascript:alert(1)") is None | |
| 35 | + assert canonicalize_url("ftp://x.ca/f") is None | |
| 36 | + | |
| 37 | + | |
| 38 | +def test_relative_resolution(): | |
| 39 | + assert canonicalize_url("../b", base="https://x.ca/a/c/") == "https://x.ca/a/b" | |
| 40 | + | |
| 41 | + | |
| 42 | +def test_collapses_duplicate_slashes(): | |
| 43 | + assert canonicalize_url("https://x.ca//a///b") == "https://x.ca/a/b" | |
| 44 | + | |
| 45 | + | |
| 46 | +def test_extract_domain_strips_www(): | |
| 47 | + assert extract_domain("https://www.quebec.ca/services") == "quebec.ca" | |
| 48 | + assert extract_domain("https://ici.radio-canada.ca/n") == "ici.radio-canada.ca" | |
| 49 | + | |
| 50 | + | |
| 51 | +def test_display_url_breadcrumb(): | |
| 52 | + crumb = display_url("https://www.quebec.ca/services/permis/conduire") | |
| 53 | + assert crumb.startswith("quebec.ca › services › permis") | |
added
README.md
+224 −0
@@ -0,0 +1,224 @@ | ||
| 1 | +# Trouve-KA — Cherche le Québec. | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | +**Moteur de recherche web indépendant, Québec-first.** Son propre crawler, son propre | |
| 17 | +index, son propre ranking, son API et son application web publique. Pas un métamoteur : | |
| 18 | +aucune dépendance à Google, Bing ou Brave pour les résultats. | |
| 19 | + | |
| 20 | +> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.** | |
| 21 | + | |
| 22 | +En production : **https://www.trouve-ka.com** — un moteur de recherche par | |
| 23 | +[Groupe KA](https://www.groupe-ka.com). | |
| 24 | + | |
| 25 | +Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 26 | + | |
| 27 | +--- | |
| 28 | + | |
| 29 | +## Aperçu | |
| 30 | + | |
| 31 | +| | | | |
| 32 | +|---|---| | |
| 33 | +|  |  | | |
| 34 | +| **Accueil** — la boîte de recherche, le compteur vivant, le flux « KA bot scrappe en ce moment » au footer | **Résultats** — pastilles éditoriales (◆ gradué selon le score Québec), vignettes og:image, snippets surlignés, 21 ms | | |
| 35 | +|  |  | | |
| 36 | +| **Onglet Images** — galerie des pages avec image représentative (hotlink + attribution, jamais de crawl d'images) | **/status** — métriques publiques réelles, auto-refresh 10 s | | |
| 37 | + | |
| 38 | +## Métriques réelles (2026-08-13, jour 1 — ~6 h après le premier crawl) | |
| 39 | + | |
| 40 | +| Métrique | Valeur | | |
| 41 | +|---|---| | |
| 42 | +| Pages indexées et cherchables | **59 073** (+~35 000/heure) | | |
| 43 | +| Domaines québécois découverts | **7 898** (à partir de 64 seeds) | | |
| 44 | +| URLs découvertes en attente (frontier) | **433 999** | | |
| 45 | +| Pages fetchées / heure | **76 198** | | |
| 46 | +| Latence de recherche (mesurée en prod) | **20–95 ms** (cible p50 < 100 ms ✓) | | |
| 47 | +| Délai fetch → cherchable | **~2–4 secondes** | | |
| 48 | +| Nodes de crawl | **3** (M2M32 ×5 workers Docker, M2M32b, M2M32c) | | |
| 49 | +| Taille de l'index | ~14 Ko/document (791 Mo à 55 k docs) | | |
| 50 | +| Tests unitaires | 57 ✓ (canonicalisation, SSRF, robots, scoring Québec, pièges, fetcher, ranking) | | |
| 51 | + | |
| 52 | +*Chaque chiffre ci-dessus vient des vraies tables (`crawl_attempts`, `frontier_items`) et de | |
| 53 | +l'index réel — aucun compteur simulé (règle § fake du projet).* | |
| 54 | + | |
| 55 | +--- | |
| 56 | + | |
| 57 | +## Ce que c'est | |
| 58 | + | |
| 59 | +``` | |
| 60 | +Crawler → Frontier → Fetcher → Parser → Classification Québec | |
| 61 | +→ Déduplication → Indexer → Index → Ranking → API → Web App | |
| 62 | +``` | |
| 63 | + | |
| 64 | +Le crawler découvre le web québécois à partir de seeds à forte autorité (gouvernement, | |
| 65 | +municipalités, universités, médias), suit les liens sortants, juge la pertinence | |
| 66 | +québécoise de chaque page (`page_quebec_score` **et** `domain_quebec_score`), | |
| 67 | +et indexe **immédiatement** : une page fetchée est cherchable en ~2-4 secondes, | |
| 68 | +pendant que le frontier continue de grandir. L'enrichissement (autorité, entités, | |
| 69 | +embeddings) arrive après, en asynchrone, sans jamais bloquer. | |
| 70 | + | |
| 71 | +Détails : [docs/architecture.md](docs/architecture.md) (diagrammes Mermaid) et | |
| 72 | +[docs/decisions.md](docs/decisions.md) (pourquoi OpenSearch, pourquoi Postgres | |
| 73 | +comme frontier, etc.). | |
| 74 | + | |
| 75 | +## Stack | |
| 76 | + | |
| 77 | +| Couche | Choix | | |
| 78 | +|---|---| | |
| 79 | +| Web | Next.js 15, TypeScript, React 19, Tailwind (composants style shadcn/ui) | | |
| 80 | +| API | FastAPI (Python 3.12) | | |
| 81 | +| Pipeline | Python 3.12 async — httpx, selectolax, Protego | | |
| 82 | +| BD relationnelle | PostgreSQL 16 (frontier, domaines, documents, graphe de liens, analytics) | | |
| 83 | +| Coordination | Redis 7 (politesse par hôte, pause, Redis Streams pour l'enrichissement) | | |
| 84 | +| Recherche | OpenSearch 2.17 (BM25 FR/EN, synonymes bilingues, function_score Québec-first) | | |
| 85 | +| Déploiement | Docker Compose sur m2m32 + ngrok (www.trouve-ka.com) | | |
| 86 | + | |
| 87 | +## Layout du monorepo | |
| 88 | + | |
| 89 | +``` | |
| 90 | +apps/web # moteur public + dashboard /admin (Next.js) | |
| 91 | +apps/api # FastAPI (trouveka.api) | |
| 92 | +services/ # crawler, frontier, parser, classifier, indexer, ranking, scheduler, enrichment | |
| 93 | +packages/ # config, database, logging, queue, search-core, shared, types | |
| 94 | +infrastructure/ # docker/, migrations/, monitoring/, deployment/ (m2m32 + ngrok) | |
| 95 | +scripts/ # bootstrap-seeds/, start-crawler/, health-check/, eval/, check-headers.py | |
| 96 | +tests/ # unitaires : canonicalisation, SSRF, robots, scoring Québec, fetcher, ranking | |
| 97 | +``` | |
| 98 | + | |
| 99 | +Le backend Python est un seul package namespace `trouveka.*` mappé sur ce layout | |
| 100 | +(voir `pyproject.toml`). | |
| 101 | + | |
| 102 | +## Démarrage rapide (dev) | |
| 103 | + | |
| 104 | +```bash | |
| 105 | +git clone … && cd trouve-ka | |
| 106 | +cp .env.example .env | |
| 107 | +docker compose up -d postgres redis opensearch # infra | |
| 108 | +uv venv --python 3.12 .venv && uv pip install -e ".[dev]" --python .venv/bin/python | |
| 109 | +pnpm install | |
| 110 | + | |
| 111 | +pnpm crawl:seed # migrations + 60+ seeds québécoises | |
| 112 | +bash scripts/start-crawler/start.sh & # le crawl démarre | |
| 113 | +.venv/bin/uvicorn trouveka.api.main:app --port 8080 & | |
| 114 | +pnpm dev # → http://localhost:3000 | |
| 115 | +# → des résultats apparaissent en quelques secondes | |
| 116 | +``` | |
| 117 | + | |
| 118 | +Ports occupés sur la machine? Surcharger dans `.env` : `PG_PORT`, `REDIS_PORT`, | |
| 119 | +`SEARCH_PORT`, `API_PORT` (et les URLs correspondantes). | |
| 120 | + | |
| 121 | +Stack complet en containers : `docker compose up -d --build` (le service `migrate` | |
| 122 | +applique les migrations, `crawler-worker` se scale avec | |
| 123 | +`docker compose up -d --scale crawler-worker=3`). | |
| 124 | + | |
| 125 | +## Comment ça marche | |
| 126 | + | |
| 127 | +### Crawl et politesse | |
| 128 | +- **Identité assumée** : UA `Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)`, | |
| 129 | + page publique [/trouveka-bot](https://www.trouve-ka.com/trouveka-bot), pas de stealth. | |
| 130 | +- **robots.txt** parsé avec Protego, cache 24 h en base; `noindex`/`nofollow`/`X-Robots-Tag` respectés. | |
| 131 | +- **Politesse par origine** : verrou Redis par hôte (défaut 2 s entre requêtes, `Crawl-delay` respecté), | |
| 132 | + quel que soit le nombre de workers. | |
| 133 | +- **Sécurité** : garde SSRF (IP privées/loopback/métadonnées cloud bloquées, revalidée à chaque | |
| 134 | + redirection), limites par réponse (3 Mo, 5 redirections, timeout 20 s), détection de pièges | |
| 135 | + (session IDs, calendriers infinis, facettes explosives, pagination sans fin). | |
| 136 | + | |
| 137 | +### Indexation incrémentale | |
| 138 | +Le worker exécute fetch→parse→score→index **inline** : `refresh_interval: 1s` côté | |
| 139 | +OpenSearch → cherchable en secondes. Détection de changement par hash de contenu + | |
| 140 | +ETag/If-Modified-Since; recrawl adaptatif (inchangé → intervalle ×2, volatil → ÷2). | |
| 141 | + | |
| 142 | +### Détection Québec | |
| 143 | +Deux scores distincts (`page` et `domaine`) calculés à partir de : TLD (.qc.ca, .quebec), | |
| 144 | +gazetteer de toponymes (pondération réduite pour les ambigus type Laval/Hull), codes | |
| 145 | +postaux G/H/J, indicatifs (418/514/438/…), organisations connues (Hydro-Québec, RAMQ, | |
| 146 | +UQAM…), mentions structurées de la province (JSON-LD), langue française (indice, pas preuve). | |
| 147 | +Déterministe et gratuit — aucun LLM dans le chemin chaud (§12). | |
| 148 | + | |
| 149 | +### Ranking | |
| 150 | +`function_score` OpenSearch : BM25 bilingue (analyzers FR + EN, synonymes | |
| 151 | +thermopompe↔heat pump à la recherche) + scores Québec + autorité de domaine | |
| 152 | +(inlinks pondérés) + fraîcheur + boost de localité (« plombier Gatineau » → documents | |
| 153 | +avec preuve géographique `locations`). Chaque composant est optionnel; BM25 tient seul. | |
| 154 | + | |
| 155 | +Évaluation mesurable : `python3 scripts/eval/run-eval.py --api http://localhost:8080` | |
| 156 | +(dataset dans `scripts/eval/ranking-eval.yaml`). | |
| 157 | + | |
| 158 | +## API | |
| 159 | + | |
| 160 | +- `GET /api/search?q=&page=&limit=&language=&category=&quebec_only=&freshness=` | |
| 161 | +- `GET /api/status` — compteurs publics (pages, domaines, débit, état du crawler) | |
| 162 | +- `POST /api/submit {"url": …}` — soumettre un site québécois (soumission ≠ inclusion) | |
| 163 | +- `GET|POST /api/admin/*` — protégé par header `X-Admin-Token` : overview, flux live, | |
| 164 | + pause/reprise, seeds, recrawl, blocage de domaine, inspection du frontier | |
| 165 | + | |
| 166 | +## Tests | |
| 167 | + | |
| 168 | +```bash | |
| 169 | +.venv/bin/python -m pytest tests/ # 54 tests : URLs, SSRF, pièges, Québec, frontier, parser, fetcher, ranking | |
| 170 | +python3 scripts/check-headers.py # header auteur obligatoire dans chaque fichier source (CI) | |
| 171 | +bash scripts/health-check/check.sh # santé du stack | |
| 172 | +``` | |
| 173 | + | |
| 174 | +## Déploiement m2m32 + ngrok (www.trouve-ka.com) | |
| 175 | + | |
| 176 | +Prérequis sur m2m32 : colima + docker + docker-compose (brew), ngrok authentifié, | |
| 177 | +domaine `www.trouve-ka.com` réservé dans le compte ngrok. | |
| 178 | + | |
| 179 | +```bash | |
| 180 | +pnpm deploy:m2m32 # = bash infrastructure/deployment/deploy-m2m32.sh | |
| 181 | +``` | |
| 182 | + | |
| 183 | +Le script : rsync du monorepo → `docker compose up -d --build` → migrations + seeds | |
| 184 | +(idempotent) → tunnel `ngrok http --url=www.trouve-ka.com 3000` → health-check. | |
| 185 | +Le web proxifie `/api/*` vers l'API interne : un seul port exposé, pas d'URL absolues | |
| 186 | +côté client, cookies/CORS sans surprise derrière le tunnel. | |
| 187 | + | |
| 188 | +Opérations courantes sur le node : | |
| 189 | + | |
| 190 | +```bash | |
| 191 | +ssh M2M32 'cd trouve-ka && docker compose logs -f crawler-worker' # crawl en direct | |
| 192 | +ssh M2M32 'cd trouve-ka && docker compose up -d --scale crawler-worker=5' | |
| 193 | +``` | |
| 194 | + | |
| 195 | +### Crawl distribué (M2M32b, M2M32c) | |
| 196 | + | |
| 197 | +Des workers satellites tournent en natif sur d'autres nodes du cluster et se | |
| 198 | +coordonnent **sans orchestrateur** via le frontier Postgres (`FOR UPDATE SKIP LOCKED`) | |
| 199 | +et les verrous de politesse Redis (tous nodes confondus : jamais plus d'une requête | |
| 200 | +par site par seconde). Communication par l'Ethernet LAN interne (`192.168.2.x`). | |
| 201 | + | |
| 202 | +```bash | |
| 203 | +# Sur un node satellite (uv + python 3.12, .env pointé sur le node central) : | |
| 204 | +cd ~/trouve-ka && pm2 start .venv/bin/python --name trouveka-crawler \ | |
| 205 | + --interpreter none -- -m trouveka.crawler.worker | |
| 206 | +``` | |
| 207 | + | |
| 208 | +⚠️ macOS Sequoia bloque l'accès « réseau local » des processus launchd : lancer les | |
| 209 | +workers satellites via pm2/nohup depuis une session SSH, pas via LaunchAgent/Daemon. | |
| 210 | + | |
| 211 | +## Observabilité | |
| 212 | + | |
| 213 | +Logs JSON structurés par service, métriques réelles via `/api/status` et | |
| 214 | +`/api/admin/overview`, dashboard `/admin` (files, débits, distribution HTTP, latences | |
| 215 | +p50/p95, flux live). Voir [infrastructure/monitoring/README.md](infrastructure/monitoring/README.md). | |
| 216 | + | |
| 217 | +## Règles du dépôt | |
| 218 | + | |
| 219 | +- **Header auteur obligatoire** dans chaque fichier source (§0.1) — vérifié par | |
| 220 | + `scripts/check-headers.py`. | |
| 221 | +- **Aucune donnée factice** : pas de compteurs simulés, pas de résultats hard-codés; | |
| 222 | + les fixtures vivent dans `tests/` uniquement. | |
| 223 | +- Provenance préservée : URL originale, canonique, timestamp de crawl, domaine source. | |
| 224 | + Trouve-KA renvoie vers les éditeurs originaux. | |
added
apps/api/__init__.py
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +# Trouve-KA — application API | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""API publique (recherche, statut, soumission) et API de contrôle du crawler (protégée).""" | |
added
apps/api/main.py
+305 −0
@@ -0,0 +1,305 @@ | ||
| 1 | +# Trouve-KA — API FastAPI | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""API de Trouve-KA. | |
| 6 | + | |
| 7 | +Publique : /api/search, /api/status, /api/submit, /api/health | |
| 8 | +Protégée (X-Admin-Token) : /api/admin/* — contrôle du crawler, jamais public sans auth. | |
| 9 | + | |
| 10 | +Dégradation gracieuse : si OpenSearch tombe, /api/status répond quand même; | |
| 11 | +si Postgres tombe, la recherche répond quand même (analytics sautées). | |
| 12 | +""" | |
| 13 | + | |
| 14 | +import html | |
| 15 | +import re | |
| 16 | +import time | |
| 17 | +from contextlib import asynccontextmanager | |
| 18 | +from typing import Annotated | |
| 19 | + | |
| 20 | +from fastapi import Depends, FastAPI, Header, HTTPException, Query | |
| 21 | +from fastapi.middleware.cors import CORSMiddleware | |
| 22 | +from pydantic import BaseModel | |
| 23 | + | |
| 24 | +from trouveka.config import get_settings | |
| 25 | +from trouveka.database import Database | |
| 26 | +from trouveka.logging import get_logger | |
| 27 | +from trouveka.queue import Coordination | |
| 28 | +from trouveka.ranking import build_search_body | |
| 29 | +from trouveka.search_core import SearchCore | |
| 30 | +from trouveka.shared import canonicalize_url, display_url, extract_domain, is_http_url | |
| 31 | + | |
| 32 | +log = get_logger("api") | |
| 33 | +settings = get_settings() | |
| 34 | + | |
| 35 | +db = Database(settings.database_url, pool_min=settings.pg_pool_min, pool_max=settings.pg_pool_max) | |
| 36 | +coord = Coordination(settings.redis_url) | |
| 37 | +search = SearchCore(settings.search_url, settings.search_index) | |
| 38 | + | |
| 39 | + | |
| 40 | +@asynccontextmanager | |
| 41 | +async def lifespan(_app: FastAPI): | |
| 42 | + await db.connect() | |
| 43 | + await search.ensure_index() | |
| 44 | + yield | |
| 45 | + await search.close() | |
| 46 | + await coord.close() | |
| 47 | + await db.close() | |
| 48 | + | |
| 49 | + | |
| 50 | +app = FastAPI(title="Trouve-KA API", version="0.1.0", lifespan=lifespan) | |
| 51 | + | |
| 52 | +# Derrière ngrok (www.trouve-ka.com), le web app proxifie /api : CORS permissif inutile | |
| 53 | +# en prod, mais pratique en dev local (web sur :3000, API sur :8080). | |
| 54 | +app.add_middleware( | |
| 55 | + CORSMiddleware, | |
| 56 | + allow_origins=["http://localhost:3000", settings.public_url], | |
| 57 | + allow_methods=["GET", "POST"], | |
| 58 | + allow_headers=["*", "X-Admin-Token"], | |
| 59 | +) | |
| 60 | + | |
| 61 | + | |
| 62 | +def require_admin(x_admin_token: Annotated[str | None, Header()] = None) -> None: | |
| 63 | + if not x_admin_token or x_admin_token != settings.admin_token: | |
| 64 | + raise HTTPException(status_code=401, detail="Jeton admin invalide") | |
| 65 | + | |
| 66 | + | |
| 67 | +_TAG_RE = re.compile(r"<(?!/?em>)[^>]*>") | |
| 68 | + | |
| 69 | + | |
| 70 | +def _safe_snippet(fragments: list[str]) -> str: | |
| 71 | + """Ne laisse passer que <em>/</em> (highlight); tout le reste est échappé par OpenSearch.""" | |
| 72 | + snippet = _TAG_RE.sub("", " … ".join(fragments))[:400] | |
| 73 | + # La troncature peut couper une balise en deux (« …Plombier</em ») : on nettoie | |
| 74 | + snippet = re.sub(r"<[^>]*$", "", snippet) | |
| 75 | + if snippet.count("<em>") > snippet.count("</em>"): | |
| 76 | + snippet += "</em>" | |
| 77 | + return snippet | |
| 78 | + | |
| 79 | + | |
| 80 | +BADGE_LABELS = {"government": "Gouvernement", "news": "Actualités", "education": "Éducation"} | |
| 81 | + | |
| 82 | + | |
| 83 | +# ---------------------------------------------------------------------- publique | |
| 84 | + | |
| 85 | +@app.get("/api/health") | |
| 86 | +async def health(): | |
| 87 | + return {"ok": True, "search_ok": await search.ping()} | |
| 88 | + | |
| 89 | + | |
| 90 | +@app.get("/api/search") | |
| 91 | +async def api_search( | |
| 92 | + q: str = Query(..., min_length=1, max_length=200), | |
| 93 | + page: int = Query(1, ge=1, le=100), | |
| 94 | + limit: int = Query(10, ge=1, le=50), | |
| 95 | + language: str | None = Query(None, pattern="^(fr|en)$"), | |
| 96 | + location: str | None = None, | |
| 97 | + category: str | None = Query(None, max_length=40), | |
| 98 | + quebec_only: bool = False, | |
| 99 | + freshness: str | None = Query(None, pattern="^(day|week|month|year)$"), | |
| 100 | + images: bool = False, | |
| 101 | +): | |
| 102 | + started = time.monotonic() | |
| 103 | + query_text = f"{q} {location}" if location else q | |
| 104 | + body, analysis = build_search_body( | |
| 105 | + query_text, page=page, limit=limit, language=language, | |
| 106 | + category=category, quebec_only=quebec_only, freshness=freshness, | |
| 107 | + images_only=images, | |
| 108 | + ) | |
| 109 | + try: | |
| 110 | + res = await search.search(body) | |
| 111 | + except Exception: | |
| 112 | + log.exception("recherche échouée", extra={"ctx": {"q": q}}) | |
| 113 | + raise HTTPException(status_code=503, detail="Le moteur de recherche est temporairement indisponible") | |
| 114 | + | |
| 115 | + took_ms = int((time.monotonic() - started) * 1000) | |
| 116 | + results = [] | |
| 117 | + for hit in res["hits"]["hits"]: | |
| 118 | + src = hit["_source"] | |
| 119 | + highlight = hit.get("highlight", {}) | |
| 120 | + fragments = highlight.get("body") or highlight.get("description") or [] | |
| 121 | + snippet = _safe_snippet(fragments) if fragments else html.escape(src.get("description") or "")[:400] | |
| 122 | + badges = [BADGE_LABELS[c] for c in src.get("categories", []) if c in BADGE_LABELS] | |
| 123 | + if src.get("page_quebec_score", 0) >= 0.45 or src.get("domain_quebec_score", 0) >= 0.6: | |
| 124 | + badges.insert(0, "Québec") | |
| 125 | + results.append({ | |
| 126 | + "title": src.get("title") or src["url"], | |
| 127 | + "url": src["url"], | |
| 128 | + "display_url": display_url(src["url"]), | |
| 129 | + "snippet": snippet, | |
| 130 | + "domain": src["domain"], | |
| 131 | + "language": src.get("language"), | |
| 132 | + "quebec_score": src.get("page_quebec_score", 0), | |
| 133 | + "badges": badges, | |
| 134 | + "published_at": src.get("published_at"), | |
| 135 | + "image": src.get("image_url"), | |
| 136 | + }) | |
| 137 | + | |
| 138 | + total = res["hits"]["total"]["value"] | |
| 139 | + # Analytics agrégées, respectueuses de la vie privée — jamais bloquantes | |
| 140 | + try: | |
| 141 | + await db.record_search_query(q, analysis["language"], total, took_ms) | |
| 142 | + except Exception: | |
| 143 | + log.exception("analytics de recherche sautées") | |
| 144 | + | |
| 145 | + return {"query": q, "total": total, "took_ms": took_ms, "page": page, "limit": limit, "results": results} | |
| 146 | + | |
| 147 | + | |
| 148 | +@app.get("/api/status") | |
| 149 | +async def api_status(): | |
| 150 | + snapshot: dict = {} | |
| 151 | + try: | |
| 152 | + snapshot = await db.status_snapshot() | |
| 153 | + except Exception: | |
| 154 | + log.exception("statut PG indisponible") | |
| 155 | + search_ok = await search.ping() | |
| 156 | + try: | |
| 157 | + paused = await coord.is_paused() | |
| 158 | + except Exception: | |
| 159 | + paused = False | |
| 160 | + return { | |
| 161 | + "pages_indexed": snapshot.get("pages_indexed", 0), | |
| 162 | + "domains_count": snapshot.get("domains_count", 0), | |
| 163 | + "indexed_last_hour": snapshot.get("indexed_last_hour", 0), | |
| 164 | + "fetched_last_hour": snapshot.get("fetched_last_hour", 0), | |
| 165 | + "errors_last_hour": snapshot.get("errors_last_hour", 0), | |
| 166 | + "frontier_pending": snapshot.get("frontier_pending", 0), | |
| 167 | + "frontier_in_progress": snapshot.get("frontier_in_progress", 0), | |
| 168 | + "crawler_state": "paused" if paused else "running", | |
| 169 | + "search_ok": search_ok, | |
| 170 | + } | |
| 171 | + | |
| 172 | + | |
| 173 | +@app.get("/api/live") | |
| 174 | +async def api_live(): | |
| 175 | + """Dernière page visitée par TrouveKABot — alimente le flux temps réel du footer. | |
| 176 | + | |
| 177 | + Public mais volontairement minimal : domaine + URL + horodatage, rien d'interne. | |
| 178 | + """ | |
| 179 | + try: | |
| 180 | + events = await db.recent_events(1) | |
| 181 | + except Exception: | |
| 182 | + return {"event": None} | |
| 183 | + if not events: | |
| 184 | + return {"event": None} | |
| 185 | + e = events[0] | |
| 186 | + return { | |
| 187 | + "event": { | |
| 188 | + "at": e["at"], | |
| 189 | + "url": e["url"], | |
| 190 | + "domain": extract_domain(e["url"]), | |
| 191 | + "outcome": e["outcome"], | |
| 192 | + } | |
| 193 | + } | |
| 194 | + | |
| 195 | + | |
| 196 | +class SubmitBody(BaseModel): | |
| 197 | + url: str | |
| 198 | + | |
| 199 | + | |
| 200 | +@app.post("/api/submit") | |
| 201 | +async def api_submit(payload: SubmitBody): | |
| 202 | + """Soumettre un site québécois. Soumission ≠ inclusion : le crawler valide.""" | |
| 203 | + if not is_http_url(payload.url): | |
| 204 | + raise HTTPException(status_code=422, detail="URL invalide (http/https seulement)") | |
| 205 | + url = canonicalize_url(payload.url) | |
| 206 | + domain = extract_domain(url) if url else None | |
| 207 | + if not url or not domain: | |
| 208 | + raise HTTPException(status_code=422, detail="URL invalide") | |
| 209 | + await db.add_submission(url) | |
| 210 | + await db.enqueue_url(url, domain, priority=0.7, depth=0) | |
| 211 | + return { | |
| 212 | + "accepted": True, | |
| 213 | + "message": "Merci! Le site sera visité par TrouveKABot. La soumission ne garantit pas l'inclusion.", | |
| 214 | + } | |
| 215 | + | |
| 216 | + | |
| 217 | +# ---------------------------------------------------------------------- admin | |
| 218 | + | |
| 219 | +class SeedsBody(BaseModel): | |
| 220 | + urls: list[str] | |
| 221 | + | |
| 222 | + | |
| 223 | +class RecrawlBody(BaseModel): | |
| 224 | + url: str | None = None | |
| 225 | + domain: str | None = None | |
| 226 | + | |
| 227 | + | |
| 228 | +class DomainBody(BaseModel): | |
| 229 | + domain: str | |
| 230 | + | |
| 231 | + | |
| 232 | +@app.get("/api/admin/overview", dependencies=[Depends(require_admin)]) | |
| 233 | +async def admin_overview(): | |
| 234 | + overview = await db.admin_overview() | |
| 235 | + overview["paused"] = await coord.is_paused() | |
| 236 | + try: | |
| 237 | + overview["enrich_backlog"] = await coord.enrich_backlog() | |
| 238 | + except Exception: | |
| 239 | + overview["enrich_backlog"] = None | |
| 240 | + return overview | |
| 241 | + | |
| 242 | + | |
| 243 | +@app.get("/api/admin/recent", dependencies=[Depends(require_admin)]) | |
| 244 | +async def admin_recent(limit: int = Query(50, ge=1, le=200)): | |
| 245 | + return {"events": await db.recent_events(limit)} | |
| 246 | + | |
| 247 | + | |
| 248 | +@app.post("/api/admin/pause", dependencies=[Depends(require_admin)]) | |
| 249 | +async def admin_pause(): | |
| 250 | + await coord.pause_crawler() | |
| 251 | + return {"paused": True} | |
| 252 | + | |
| 253 | + | |
| 254 | +@app.post("/api/admin/resume", dependencies=[Depends(require_admin)]) | |
| 255 | +async def admin_resume(): | |
| 256 | + await coord.resume_crawler() | |
| 257 | + return {"paused": False} | |
| 258 | + | |
| 259 | + | |
| 260 | +@app.post("/api/admin/seeds", dependencies=[Depends(require_admin)]) | |
| 261 | +async def admin_seeds(payload: SeedsBody): | |
| 262 | + added = 0 | |
| 263 | + for raw in payload.urls[:500]: | |
| 264 | + url = canonicalize_url(raw.strip()) | |
| 265 | + domain = extract_domain(url) if url else None | |
| 266 | + if url and domain: | |
| 267 | + if await db.enqueue_url(url, domain, priority=1.0, depth=0, is_seed=True): | |
| 268 | + added += 1 | |
| 269 | + return {"added": added} | |
| 270 | + | |
| 271 | + | |
| 272 | +@app.post("/api/admin/recrawl", dependencies=[Depends(require_admin)]) | |
| 273 | +async def admin_recrawl(payload: RecrawlBody): | |
| 274 | + if payload.url: | |
| 275 | + url = canonicalize_url(payload.url) | |
| 276 | + ok = await db.requeue_url(url) if url else False | |
| 277 | + return {"requeued": 1 if ok else 0} | |
| 278 | + if payload.domain: | |
| 279 | + return {"requeued": await db.requeue_domain(payload.domain.lower())} | |
| 280 | + raise HTTPException(status_code=422, detail="url ou domain requis") | |
| 281 | + | |
| 282 | + | |
| 283 | +@app.post("/api/admin/domains/block", dependencies=[Depends(require_admin)]) | |
| 284 | +async def admin_block_domain(payload: DomainBody): | |
| 285 | + await db.block_domain(payload.domain.lower()) | |
| 286 | + return {"blocked": payload.domain.lower()} | |
| 287 | + | |
| 288 | + | |
| 289 | +@app.get("/api/admin/frontier", dependencies=[Depends(require_admin)]) | |
| 290 | +async def admin_frontier( | |
| 291 | + domain: str | None = None, | |
| 292 | + status: str | None = Query(None, pattern="^(pending|in_progress|done|failed|blocked)$"), | |
| 293 | + limit: int = Query(100, ge=1, le=500), | |
| 294 | +): | |
| 295 | + return {"items": await db.frontier_inspect(domain, status, limit)} | |
| 296 | + | |
| 297 | + | |
| 298 | +def main() -> None: | |
| 299 | + import uvicorn | |
| 300 | + | |
| 301 | + uvicorn.run("trouveka.api.main:app", host=settings.api_host, port=settings.api_port, workers=1) | |
| 302 | + | |
| 303 | + | |
| 304 | +if __name__ == "__main__": | |
| 305 | + main() | |
added
apps/web/.eslintrc.json
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +{ | |
| 2 | + "extends": "next/core-web-vitals", | |
| 3 | + "rules": { | |
| 4 | + "react/no-unescaped-entities": "off" | |
| 5 | + } | |
| 6 | +} | |
added
apps/web/.gitignore
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +# Trouve-KA — artefacts générés de l'app web (non versionnés) | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | +.next/ | |
| 5 | +node_modules/ | |
| 6 | +next-env.d.ts | |
| 7 | +*.tsbuildinfo | |
added
apps/web/app/admin/layout.tsx
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +/** | |
| 2 | + * Trouve-KA — layout de la section admin (métadonnées, non indexée) | |
| 3 | + * Author: Simon-Pierre Boucher | |
| 4 | + * Contact: contact@spboucher.ai | |
| 5 | + */ | |
| 6 | + | |
| 7 | +import type { Metadata } from "next"; | |
| 8 | + | |
| 9 | +export const metadata: Metadata = { | |
| 10 | + title: "Administration", | |
| 11 | + robots: { index: false, follow: false }, | |
| 12 | +}; | |
| 13 | + | |
| 14 | +export default function AdminLayout({ | |
| 15 | + children, | |
| 16 | +}: Readonly<{ children: React.ReactNode }>) { | |
| 17 | + return children; | |
| 18 | +} | |
added
apps/web/app/admin/page.tsx
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +/** | |
| 2 | + * Trouve-KA — page admin (porte à jeton + dashboard) | |
| 3 | + * Author: Simon-Pierre Boucher | |
| 4 | + * Contact: contact@spboucher.ai | |
| 5 | + */ | |
| 6 | + | |
| 7 | +"use client"; | |
| 8 | + | |
| 9 | +import { useCallback, useEffect, useState } from "react"; | |
| 10 | +import { AdminDashboard } from "@/components/admin/dashboard"; | |
| 11 | +import { TokenForm } from "@/components/admin/token-form"; | |
| 12 | +import { ADMIN_TOKEN_KEY } from "@/lib/admin"; | |
| 13 | + | |
| 14 | +export default function AdminPage() { | |
| 15 | + // undefined = lecture de sessionStorage en cours; null = pas de jeton. | |
| 16 | + const [token, setToken] = useState<string | null | undefined>(undefined); | |
| 17 | + const [expired, setExpired] = useState(false); | |
| 18 | + | |
| 19 | + useEffect(() => { | |
| 20 | + setToken(sessionStorage.getItem(ADMIN_TOKEN_KEY)); | |
| 21 | + }, []); | |
| 22 | + | |
| 23 | + const handleUnauthorized = useCallback(() => { | |
| 24 | + sessionStorage.removeItem(ADMIN_TOKEN_KEY); | |
| 25 | + setExpired(true); | |
| 26 | + setToken(null); | |
| 27 | + }, []); | |
| 28 | + | |
| 29 | + const handleToken = useCallback((value: string) => { | |
| 30 | + sessionStorage.setItem(ADMIN_TOKEN_KEY, value); | |
| 31 | + setExpired(false); | |
| 32 | + setToken(value); | |
| 33 | + }, []); | |
| 34 | + | |
| 35 | + const handleLogout = useCallback(() => { | |
| 36 | + sessionStorage.removeItem(ADMIN_TOKEN_KEY); | |
| 37 | + setExpired(false); | |
| 38 | + setToken(null); | |
| 39 | + }, []); | |
| 40 | + | |
| 41 | + if (token === undefined) { | |
| 42 | + return null; | |
| 43 | + } | |
| 44 | + | |
| 45 | + if (!token) { | |
| 46 | + return <TokenForm onSubmit={handleToken} expired={expired} />; | |
| 47 | + } | |
| 48 | + | |
| 49 | + return ( | |
| 50 | + <AdminDashboard | |
| 51 | + token={token} | |
| 52 | + onUnauthorized={handleUnauthorized} | |
| 53 | + onLogout={handleLogout} | |
| 54 | + /> | |
| 55 | + ); | |
| 56 | +} | |
added
apps/web/app/globals.css
+138 −0
@@ -0,0 +1,356 @@ | ||
| 1 | +/** | |
| 2 | + * Trouve-KA — styles globaux (système « éditorial sharp » de Groupe KA) | |
| 3 | + * Author: Simon-Pierre Boucher | |
| 4 | + * Contact: contact@spboucher.ai | |
| 5 | + * | |
| 6 | + * Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono | |
| 7 | + * Signature : bordures encre + ombres décalées (néo-brutalisme raffiné) | |
| 8 | + * Accent bleu pâle marine #b9cfee sur encre | |
| 9 | + */ | |
| 10 | + | |
| 11 | +@tailwind base; | |
| 12 | +@tailwind components; | |
| 13 | +@tailwind utilities; | |
| 14 | + | |
| 15 | +:root { | |
| 16 | + --paper: #f5f3ee; | |
| 17 | + --surface: #ffffff; | |
| 18 | + --surface-2: #faf9f5; | |
| 19 | + --ink: #141814; | |
| 20 | + --ink-2: #4d5551; | |
| 21 | + --ink-3: #8b928c; | |
| 22 | + --line: rgba(20, 24, 20, 0.14); | |
| 23 | + --line-strong: rgba(20, 24, 20, 0.85); | |
| 24 | + --green: #1c5c41; | |
| 25 | + --green-deep: #123f2e; | |
| 26 | + --lime: #b9cfee; | |
| 27 | + --lime-soft: #e6eef9; | |
| 28 | + --amber: #e8a33d; | |
| 29 | + --amber-soft: #fdf3e2; | |
| 30 | + --danger: #b3423a; | |
| 31 | + --danger-soft: #fbe9e7; | |
| 32 | + | |
| 33 | + --r-card: 10px; | |
| 34 | + --r-ctl: 6px; | |
| 35 | + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05); | |
| 36 | + --shadow-off: 6px 6px 0 var(--ink); | |
| 37 | + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08); | |
| 38 | + | |
| 39 | + --font-display: var(--font-space-grotesk), system-ui, sans-serif; | |
| 40 | + --font-body: var(--font-inter), system-ui, sans-serif; | |
| 41 | + --font-mono: var(--font-jetbrains), ui-monospace, monospace; | |
| 42 | +} | |
| 43 | + | |
| 44 | +@layer base { | |
| 45 | + html { | |
| 46 | + -webkit-text-size-adjust: 100%; | |
| 47 | + scroll-behavior: smooth; | |
| 48 | + } | |
| 49 | + | |
| 50 | + body { | |
| 51 | + background: var(--paper); | |
| 52 | + color: var(--ink); | |
| 53 | + font-family: var(--font-body); | |
| 54 | + font-size: 15px; | |
| 55 | + line-height: 1.55; | |
| 56 | + -webkit-font-smoothing: antialiased; | |
| 57 | + } | |
| 58 | + | |
| 59 | + /* Grain de film pleine page — jamais de z-index sur body > * (position: | |
| 60 | + relative seulement) pour ne pas casser les utilitaires z-* de Tailwind. */ | |
| 61 | + body::before { | |
| 62 | + content: ""; | |
| 63 | + position: fixed; | |
| 64 | + inset: 0; | |
| 65 | + pointer-events: none; | |
| 66 | + opacity: 0.35; | |
| 67 | + z-index: 9999; | |
| 68 | + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E"); | |
| 69 | + } | |
| 70 | + body > * { | |
| 71 | + position: relative; | |
| 72 | + } | |
| 73 | + | |
| 74 | + h1, | |
| 75 | + h2, | |
| 76 | + h3, | |
| 77 | + h4 { | |
| 78 | + font-family: var(--font-display); | |
| 79 | + letter-spacing: -0.03em; | |
| 80 | + } | |
| 81 | + | |
| 82 | + ::selection { | |
| 83 | + background: var(--lime); | |
| 84 | + color: var(--ink); | |
| 85 | + } | |
| 86 | + | |
| 87 | + :focus-visible { | |
| 88 | + outline: 2px solid var(--green); | |
| 89 | + outline-offset: 2px; | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +@layer components { | |
| 94 | + /* ---------- Micro-typographie ---------- */ | |
| 95 | + | |
| 96 | + .kicker { | |
| 97 | + display: inline-flex; | |
| 98 | + align-items: center; | |
| 99 | + gap: 10px; | |
| 100 | + font-family: var(--font-mono); | |
| 101 | + font-size: 11.5px; | |
| 102 | + font-weight: 500; | |
| 103 | + text-transform: uppercase; | |
| 104 | + letter-spacing: 0.12em; | |
| 105 | + color: var(--green); | |
| 106 | + } | |
| 107 | + .kicker::before { | |
| 108 | + content: ""; | |
| 109 | + width: 22px; | |
| 110 | + height: 2px; | |
| 111 | + background: var(--green); | |
| 112 | + } | |
| 113 | + | |
| 114 | + .klabel { | |
| 115 | + font-family: var(--font-mono); | |
| 116 | + font-size: 10px; | |
| 117 | + font-weight: 700; | |
| 118 | + text-transform: uppercase; | |
| 119 | + letter-spacing: 0.1em; | |
| 120 | + color: var(--ink-3); | |
| 121 | + } | |
| 122 | + | |
| 123 | + .gk-display { | |
| 124 | + font-family: var(--font-display); | |
| 125 | + } | |
| 126 | + .gk-mono { | |
| 127 | + font-family: var(--font-mono); | |
| 128 | + } | |
| 129 | + | |
| 130 | + /* Traitements de titre signature */ | |
| 131 | + .outline-txt { | |
| 132 | + color: transparent; | |
| 133 | + -webkit-text-stroke: 2px var(--ink); | |
| 134 | + } | |
| 135 | + .hl { | |
| 136 | + display: inline-block; | |
| 137 | + background: var(--lime); | |
| 138 | + border-radius: 8px; | |
Diff truncated — file too large.