Crawler équitable, recherche hybride sémantique, architecture native multi-nodes
Crawl : claim 1 URL/domaine (DISTINCT ON), backoff adaptatif réparé (defer ne touche plus last_crawled_at), décroissance de priorité sur pages inchangées, enqueue_urls_bulk (1 transaction/page vs 1/lien), cap réel urls/domaine (migration 003), blocklist plateformes+CDN, timeout fetch 10s, boucle de claim résiliente aux pannes DB. Recherche : minimum_should_match 2<70%, index kNN (e5-small 384d, migration alias v2), service embeddings+reranker cross-encoder (services/embedding), fusion RRF avec gate de couverture, reranking du top-20 aussi en lexical, 55 groupes de synonymes QC appliqués à chaud, endpoint admin zero-results. Infra : OpenSearch sur M2M32b (heap 8G, 2 shards), Postgres max_connections 200 + timeouts anti-zombies + shared_buffers 2G, backfill embeddings anti-blocage avec superviseur, migration 004 autovacuum domains. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
161 changed files +880 −18,822
modified
.env.example
+4 −0
@@ -32,3 +32,7 @@ API_URL=http://localhost:8080 | ||
| 32 | 32 | |
| 33 | 33 | # --- Index --- |
| 34 | 34 | SEARCH_INDEX=trouveka-docs |
| 35 | + | |
| 36 | +# Recherche sémantique (optionnel) : URL du service d'embeddings | |
| 37 | +# (services/embedding — venv dédié sur un node satellite). Vide = lexical seul. | |
| 38 | +EMBEDDING_URL= | |
deleted
M2M32brouve-ka/.env.example
+0 −34
@@ -1,34 +0,0 @@ | ||
| 1 | −# Trouve-KA — variables d'environnement (exemple, sans secrets) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −# --- Bases de données --- | |
| 6 | −DATABASE_URL=postgresql://trouveka:trouveka@localhost:5432/trouveka | |
| 7 | −REDIS_URL=redis://localhost:6379/0 | |
| 8 | −SEARCH_URL=http://localhost:9200 | |
| 9 | − | |
| 10 | −# --- Crawler --- | |
| 11 | −CRAWLER_USER_AGENT="Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)" | |
| 12 | −CRAWLER_CONTACT_URL=https://www.trouve-ka.com/trouveka-bot | |
| 13 | −MAX_GLOBAL_CONCURRENCY=24 | |
| 14 | −MAX_PER_HOST_CONCURRENCY=2 | |
| 15 | −DEFAULT_HOST_DELAY=2.0 | |
| 16 | −MAX_RESPONSE_BYTES=3000000 | |
| 17 | −MAX_REDIRECTS=5 | |
| 18 | −FETCH_TIMEOUT=20 | |
| 19 | −MAX_CRAWL_DEPTH=8 | |
| 20 | −MAX_LINKS_PER_PAGE=300 | |
| 21 | −MAX_URLS_PER_DOMAIN=5000 | |
| 22 | − | |
| 23 | −# --- API --- | |
| 24 | −API_HOST=0.0.0.0 | |
| 25 | −API_PORT=8080 | |
| 26 | −ADMIN_TOKEN=change-me-admin-token | |
| 27 | − | |
| 28 | −# --- Web --- | |
| 29 | −PUBLIC_URL=https://www.trouve-ka.com | |
| 30 | −NGROK_DOMAIN=www.trouve-ka.com | |
| 31 | −API_URL=http://localhost:8080 | |
| 32 | − | |
| 33 | −# --- Index --- | |
| 34 | −SEARCH_INDEX=trouveka-docs | |
deleted
M2M32brouve-ka/.gitignore
+0 −13
@@ -1,13 +0,0 @@ | ||
| 1 | −node_modules/ | |
| 2 | −.next/ | |
| 3 | −dist/ | |
| 4 | −__pycache__/ | |
| 5 | −*.pyc | |
| 6 | −.venv/ | |
| 7 | −.env | |
| 8 | −.env.local | |
| 9 | −*.egg-info/ | |
| 10 | −.pytest_cache/ | |
| 11 | −.ruff_cache/ | |
| 12 | −data/ | |
| 13 | −.DS_Store | |
deleted
M2M32brouve-ka/CLAUDE.md
+0 −363
@@ -1,363 +0,0 @@ | ||
| 1 | −# CLAUDE.md — Trouve-KA | |
| 2 | − | |
| 3 | −**Projet :** Trouve-KA — *Cherche le Québec.* | |
| 4 | −**Mission :** Moteur de recherche web indépendant, Québec-first, avec son propre crawler, index, ranking, API et application web publique. | |
| 5 | − | |
| 6 | −Trouve-KA n'est **pas** un métamoteur. Aucune dépendance à Google, Bing ou Brave pour les résultats primaires. L'objectif : un index structuré et continuellement mis à jour du web québécois. | |
| 7 | − | |
| 8 | −``` | |
| 9 | −Crawler → Frontier → Fetcher → Parser → Classification Québec | |
| 10 | −→ Déduplication → Indexer → Index → Ranking → API → Web App | |
| 11 | −``` | |
| 12 | − | |
| 13 | −Le crawler fait partie du produit. Tout vit dans un seul monorepo. | |
| 14 | − | |
| 15 | −--- | |
| 16 | − | |
| 17 | −## 0. Règles non négociables | |
| 18 | − | |
| 19 | −### 0.1 Header obligatoire dans chaque fichier de code | |
| 20 | − | |
| 21 | −**Chaque fichier source** (TS, JS, Python, SQL, config exécutable, scripts) doit commencer par un header d'auteur : | |
| 22 | − | |
| 23 | −```ts | |
| 24 | −/** | |
| 25 | − * Trouve-KA — <nom du module> | |
| 26 | − * Author: Simon-Pierre Boucher | |
| 27 | − * Contact: contact@spboucher.ai | |
| 28 | − */ | |
| 29 | −``` | |
| 30 | − | |
| 31 | −```python | |
| 32 | −# Trouve-KA — <nom du module> | |
| 33 | −# Author: Simon-Pierre Boucher | |
| 34 | −# Contact: contact@spboucher.ai | |
| 35 | −``` | |
| 36 | − | |
| 37 | −Adapter la syntaxe de commentaire au langage. Aucune exception. Un lint/check CI doit vérifier la présence du header. | |
| 38 | − | |
| 39 | −### 0.2 Cible de déploiement | |
| 40 | − | |
| 41 | −- **Node de déploiement : `m2m32`** (32 Go RAM). Tout le stack (Postgres, Redis, backend de recherche, workers, API, web) doit tourner confortablement sur cette machine. | |
| 42 | −- **Exposition publique via ngrok : `www.trouve-ka.com`.** Configurer ngrok avec le domaine réservé; l'app web et l'API doivent fonctionner correctement derrière le tunnel (URLs absolues, cookies, CORS, headers `X-Forwarded-*`). | |
| 43 | −- Docker Compose est l'outil de déploiement. Pas de Kubernetes. | |
| 44 | −- Fournir un script/target `deploy:m2m32` et documenter la procédure complète (compose up + tunnel ngrok) dans le README. | |
| 45 | −- Dimensionner les défauts (concurrence, tailles de heap du moteur de recherche, connexions Postgres) pour 32 Go, avec configuration par variables d'environnement. | |
| 46 | − | |
| 47 | −### 0.3 Principe cardinal | |
| 48 | − | |
| 49 | −> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Enrichis en asynchrone.** | |
| 50 | − | |
| 51 | −Le moteur doit être utilisable **dès que le crawl démarre**. Jamais de cycle « crawler tout → construire l'index → lancer la recherche ». Chaque page traitée avec succès devient cherchable en secondes. | |
| 52 | − | |
| 53 | −``` | |
| 54 | −12:00:00 démarrage crawler | |
| 55 | −12:00:04 première page fetchée | |
| 56 | −12:00:06 score Québec calculé | |
| 57 | −12:00:07 document indexé | |
| 58 | −12:00:08 cherchable par l'utilisateur | |
| 59 | −``` | |
| 60 | − | |
| 61 | −Aucune interdiction plus importante que celle-ci : **ne jamais bloquer l'indexation sur l'enrichissement** (embeddings, entités, scoring avancé = asynchrone, mise à jour du document après coup). | |
| 62 | − | |
| 63 | −--- | |
| 64 | − | |
| 65 | −## 1. Philosophie produit | |
| 66 | − | |
| 67 | −Trouve-KA est un vrai moteur, pas : un annuaire, une liste curée, un wrapper ChatGPT, un frontend Google CSE, un dataset statique. | |
| 68 | − | |
| 69 | −Il doit de façon autonome : découvrir des sites → les crawler → comprendre le contenu → juger la pertinence québécoise → indexer → ranker → exposer via recherche → découvrir davantage → rafraîchir en continu. | |
| 70 | − | |
| 71 | −**Interdictions absolues (§ fake) :** pas de données de crawl factices, pas de compteurs simulés présentés comme réels, pas de résultats hard-codés, pas d'API de recherche placeholder. Les fixtures sont réservées aux tests. Le dev tourne sur le vrai crawler et le vrai index local. | |
| 72 | − | |
| 73 | −--- | |
| 74 | − | |
| 75 | −## 2. Architecture du dépôt | |
| 76 | − | |
| 77 | −Monorepo : | |
| 78 | − | |
| 79 | −``` | |
| 80 | −trouve-ka/ | |
| 81 | −├── apps/ | |
| 82 | −│ ├── web/ # moteur public | |
| 83 | −│ ├── api/ # search + APIs internes | |
| 84 | −│ └── admin/ # dashboard crawl/index | |
| 85 | −├── services/ | |
| 86 | −│ ├── crawler/ frontier/ parser/ classifier/ | |
| 87 | −│ ├── indexer/ ranking/ scheduler/ enrichment/ | |
| 88 | −├── packages/ | |
| 89 | −│ ├── database/ shared/ config/ logging/ | |
| 90 | −│ ├── queue/ types/ search-core/ | |
| 91 | −├── infrastructure/ | |
| 92 | −│ ├── docker/ migrations/ monitoring/ deployment/ # inclut config ngrok + m2m32 | |
| 93 | −├── scripts/ | |
| 94 | −│ ├── bootstrap-seeds/ start-crawler/ rebuild-index/ health-check/ | |
| 95 | −├── docs/ | |
| 96 | −├── CLAUDE.md README.md docker-compose.yml | |
| 97 | −``` | |
| 98 | − | |
| 99 | −Claude peut améliorer cette structure sur justification technique solide. | |
| 100 | − | |
| 101 | −--- | |
| 102 | − | |
| 103 | −## 3. Stack technologique | |
| 104 | − | |
| 105 | −| Couche | Choix | | |
| 106 | −|---|---| | |
| 107 | −| Frontend | Next.js, TypeScript, React, Tailwind, shadcn/ui | | |
| 108 | −| API | FastAPI (Python) ou backend TypeScript si ça réduit la complexité; le crawler peut rester Python même si l'API est TS | | |
| 109 | −| BD relationnelle | PostgreSQL (domaines, URLs, état de crawl, métadonnées, entités, scheduler, bookkeeping) | | |
| 110 | −| Queue | Redis + vraie file de tâches/streams (évaluer avant de choisir) | | |
| 111 | −| Recherche | À sélectionner parmi : OpenSearch, Elasticsearch, Typesense, Meilisearch, Vespa, Tantivy, Quickwit | | |
| 112 | − | |
| 113 | −Critères de sélection du backend de recherche : qualité BM25, indexation incrémentale, facettes, tolérance aux typos, ranking custom, recherche hybride, performance, scaling horizontal, **complexité opérationnelle sur un seul node m2m32**. Pour le MVP : qualité de recherche + indexation incrémentale + opérations simples. Ne pas choisir le plus facile par défaut. | |
| 114 | − | |
| 115 | −--- | |
| 116 | − | |
| 117 | −## 4. Qualité de recherche progressive | |
| 118 | − | |
| 119 | −Chaque document s'enrichit par étapes; **la disponibilité en recherche ne dépend jamais des étapes suivantes.** | |
| 120 | − | |
| 121 | −- **Étape 1 (immédiat) :** URL, titre, description, corps, headings, domaine, langue, timestamp, score Québec → index BM25. | |
| 122 | −- **Étape 2 (async) :** embedding, classification thématique, organisations, signaux de localisation, entité canonique. | |
| 123 | −- **Étape 3 (async) :** autorité de domaine, score de graphe de liens / PageRank-like, fraîcheur, qualité, spam, intention commerciale, pertinence locale. | |
| 124 | − | |
| 125 | −--- | |
| 126 | − | |
| 127 | −## 5. Crawler | |
| 128 | − | |
| 129 | −Un vrai crawler : frontier d'URLs, scheduling par domaine, robots.txt, canonicalisation, redirections, retries, cache HTTP, compression, gestion des content-types, budgets de crawl, concurrence par hôte, rate limiting, files de priorité, prévention des doublons, historique, hash de contenu, détection de changements. | |
| 130 | − | |
| 131 | −### 5.1 Identité | |
| 132 | −User-agent identifiable : `Mozilla-compatible / TrouveKABot`. Page publique `/trouveka-bot` : quoi, pourquoi, UA, contact (**contact@spboucher.ai**), comment bloquer, respect de robots.txt. **Pas de stealth, pas de rotation de proxys par défaut.** IPs stables et comportement poli. | |
| 133 | − | |
| 134 | −### 5.2 robots.txt et politesse | |
| 135 | −Parsing conforme aux standards, cache des règles, respect de `noindex` / `nofollow` / `canonical` / `X-Robots-Tag`. Politesse par origine : 1–2 requêtes concurrentes max par hôte, budgets indépendants, politiques configurables. Un gros débit global ne doit jamais agresser un site individuel. | |
| 136 | − | |
| 137 | −### 5.3 Frontier | |
| 138 | −Chaque URL : `url, domain, priority, depth, source_url, discovered_at, last_crawled_at, next_crawl_at, status`. Priorités évolutives selon : pertinence Québec, autorité, source de découverte, profondeur, importance du domaine, fraîcheur, historique de changement, succès, duplication. | |
| 139 | − | |
| 140 | −Fonction de priorité conceptuelle : | |
| 141 | −`P = w_q·Q + w_a·A + w_f·F + w_l·L + w_n·N − w_d·D − w_s·S` | |
| 142 | −(Québec, Autorité, Fraîcheur, Liens, Nouveauté, Doublon, Spam). Poids calibrés par mesures, jamais figés. | |
| 143 | − | |
| 144 | −### 5.4 Canonicalisation d'URL | |
| 145 | −Extrême prudence. Normaliser : fragments, ports par défaut, slashs, paramètres de tracking/UTM, ordre des paramètres quand approprié, http/https, www, trailing slash, tags canonical. **Ne jamais fusionner deux ressources distinctes par accident.** | |
| 146 | − | |
| 147 | −### 5.5 Recrawl adaptatif | |
| 148 | −Homepage de nouvelles → minutes; article → heures puis décroissant; page gouvernementale → quotidien; site statique → mensuel; archive inchangée → rarement. Fréquence de changement mesurée empiriquement : page inchangée → intervalle ↑; page volatile → intervalle ↓. | |
| 149 | − | |
| 150 | −### 5.6 Détection de changement et doublons | |
| 151 | −Stocker `content_hash`, etag, last-modified, `last_changed_at`. Réindexer seulement si changement. Doublons : exacts (hash) d'abord, puis SimHash/MinHash/shingling si justifié. Gérer miroirs, versions imprimables, doublons de paramètres, syndication. Conserver la provenance. | |
| 152 | − | |
| 153 | −### 5.7 Sécurité du crawl (SSRF) | |
| 154 | −Bloquer : localhost, 127.0.0.0/8, plages privées IPv4/IPv6, endpoints de métadonnées cloud, `file://`, schémas dangereux. Revalider DNS/IP. Chaque réponse a des limites : taille max, redirections max, timeout, temps de parse max, liens extraits max, profondeur max. | |
| 155 | − | |
| 156 | −### 5.8 Pièges de crawl | |
| 157 | −Détecter : calendriers infinis, session IDs, explosions de navigation à facettes, pagination infinie, paramètres aléatoires, boucles. Limites par pattern/domaine. | |
| 158 | − | |
| 159 | −### 5.9 Erreurs | |
| 160 | −Codes structurés : DNS, timeout, TLS, 4xx, 5xx, robots refusé, parse échoué, contenu non supporté, trop gros, doublon, spam, non pertinent Québec. L'échec est normal; il est traqué. | |
| 161 | − | |
| 162 | −### 5.10 Rendu navigateur | |
| 163 | −**Jamais** de Chromium par page. Défaut : fetch HTTP. Le navigateur est un fallback spécialisé par domaine — essentiel pour l'échelle et pour tenir sur m2m32. | |
| 164 | − | |
| 165 | −--- | |
| 166 | − | |
| 167 | −## 6. Extraction de contenu | |
| 168 | − | |
| 169 | −Extraire : titre, meta description, corps principal, headings, données structurées (JSON-LD, schema.org, microdata, OpenGraph), URL canonique, langue, liens + anchors, dates de publication/modification, auteur, indices d'organisation et d'adresse. | |
| 170 | − | |
| 171 | −Retirer : navigation, menus, bannières cookies, footers répétitifs, scripts, styles, pub. | |
| 172 | − | |
| 173 | −Types au départ : HTML, texte, PDF. Architecture extensible (DOCX/XLSX/PPTX, RSS/Atom) sans que l'extraction coûteuse bloque le crawl HTML. | |
| 174 | − | |
| 175 | −Traiter chaque page crawlée comme **non fiable** : sanitizer HTML, URLs, métadonnées; ne jamais faire confiance aux MIME types distants. | |
| 176 | − | |
| 177 | −--- | |
| 178 | − | |
| 179 | −## 7. Détection Québec | |
| 180 | − | |
| 181 | −L'innovation clé. Un `.ca` seul ne suffit pas. Calculer `quebec_score ∈ [0,1]` avec **deux scores distincts** : | |
| 182 | − | |
| 183 | −- `domain_quebec_score` | |
| 184 | −- `page_quebec_score` (un article du NYT sur Montréal peut être pertinent sans que le domaine le soit) | |
| 185 | − | |
| 186 | −Signaux : toponymes (Québec, Montréal, Gatineau, Sherbrooke, Trois-Rivières, Saguenay, Laval, Longueuil…), adresses postales QC, province dans les adresses structurées, indicatifs téléphoniques (signal faible), organisations québécoises connues (entreprises, municipalités, universités, médias, gouvernement), langue (le français augmente la probabilité sans la prouver), graphe de domaines (un domaine massivement lié par des domaines québécois gagne du signal), métadonnées structurées, pages contact/footer. | |
| 187 | − | |
| 188 | −--- | |
| 189 | − | |
| 190 | −## 8. Découverte | |
| 191 | − | |
| 192 | −**Qualité des seeds > quantité.** Démarrer avec des nœuds fortement connectés : gouvernement du Québec, municipalités, universités, cégeps, grands médias, annuaires d'affaires, associations professionnelles, chambres de commerce, tourisme régional, grandes entreprises. | |
| 193 | − | |
| 194 | −Boucle : crawl → extraction des liens sortants → scoring Québec des domaines cibles → ajout au frontier → répéter. Détecter automatiquement sitemaps (`/sitemap.xml`, entrées robots.txt, index de sitemaps) et flux RSS/Atom, sans confiance aveugle. | |
| 195 | − | |
| 196 | −Maintenir une base de domaines : `domain, first_seen, last_crawled, page_count, quebec_score, language_distribution, robots_status, authority_score, inlinks, outlinks, content_change_rate…` | |
| 197 | − | |
| 198 | −Construire le graphe de liens dès le crawl (Page→Page, Domain→Domain, Org→Domain…) — d'abord pour le ranking et la découverte, plus tard pour l'écosystème KA. | |
| 199 | − | |
| 200 | −--- | |
| 201 | − | |
| 202 | −## 9. Index et ranking | |
| 203 | − | |
| 204 | −Document indexé (cible) : `id, url, canonical_url, domain, title, description, body, headings, language, page_quebec_score, domain_quebec_score, locations, organizations, people, categories, published_at, crawled_at, authority_score, freshness_score, quality_score, spam_score, embedding`. | |
| 205 | − | |
| 206 | −**BM25 d'abord.** Puis fonction de ranking dédiée : | |
| 207 | − | |
| 208 | −`Score(d,q) = w_b·BM25 + w_s·Sémantique + w_q·Québec + w_a·Autorité + w_f·Fraîcheur + w_l·Localité + w_u·Qualité − w_p·Spam` | |
| 209 | − | |
| 210 | −Le ranking est un package/service indépendant, évolutif, aux poids non figés. | |
| 211 | − | |
| 212 | −**Québec-first :** pour `meilleur programme thermopompe`, Hydro-Québec, un programme gouvernemental ou une entreprise CVC québécoise doivent battre un article international générique, à pertinence égale. | |
| 213 | − | |
| 214 | −**Bilingue dès le départ :** comprendre `thermopompe` ↔ `heat pump`. Pas de traduction à l'ingestion; plus tard : embeddings multilingues, expansion de requête, dictionnaires de synonymes. | |
| 215 | − | |
| 216 | −**Pipeline de requête :** normalisation → détection de langue → correction → intention → extraction d'entités/lieux → retrieval lexical → retrieval sémantique → fusion → reranking. **Chaque composant avancé est optionnel; BM25 fonctionne même si tout le reste tombe.** | |
| 217 | − | |
| 218 | −**Localisation :** `plombier Gatineau`, `subvention Sherbrooke` boostent les documents géographiquement pertinents sur preuve géographique explicite, pas juste du keyword matching. | |
| 219 | − | |
| 220 | −--- | |
| 221 | − | |
| 222 | −## 10. Interface | |
| 223 | − | |
| 224 | −**Homepage = la boîte de recherche.** Rien d'autre d'important. | |
| 225 | − | |
| 226 | −``` | |
| 227 | − Trouve-KA | |
| 228 | − [ Rechercher... ] | |
| 229 | − Chercher | |
| 230 | − Cherche le Québec. | |
| 231 | −``` | |
| 232 | − | |
| 233 | −Résultats : favicon, titre, URL/breadcrumb, snippet, badges optionnels (Québec, Gouvernement, Entreprise…). Sensation : rapide, propre, premium, minimal, fiable. Filtres MVP sobres : Tout / Actualités / Gouvernement / Français / English / Québec seulement. | |
| 234 | − | |
| 235 | −**Compteur d'index visible** (« 18 432 pages indexées » le jour 1, c'est parfait — la croissance fait partie du produit) + page `/status` : pages/domaines indexés, débit horaire, état du crawler et de l'index. | |
| 236 | − | |
| 237 | −**Dashboard admin** : longueur des files, débits fetch/parse/index, distribution HTTP, domaines actifs, blocages robots, retries, latences, stockage, taux de doublons, taux d'acceptation Québec, flux live du crawl. | |
| 238 | − | |
| 239 | −Mobile impeccable, accessibilité complète (navigation clavier, HTML sémantique, contrastes, focus states). Design : minimal, moderne, québécois sans clichés — **pas** de fleurs de lys partout, pas de hero marketing, pas de gradients IA. | |
| 240 | − | |
| 241 | −--- | |
| 242 | − | |
| 243 | −## 11. APIs | |
| 244 | − | |
| 245 | −**Recherche :** `GET /api/search?q=&page=&limit=&language=&location=&category=&freshness=` → résultats structurés (`title, url, display_url, snippet, domain, score`, `took_ms`, `total`). Ne pas exposer le scoring interne brut en prod. | |
| 246 | − | |
| 247 | −**Contrôle du crawler (protégé, jamais public sans auth) :** pause/reprise, ajout de seeds, recrawl URL/domaine, inspection du frontier, priorités, blocage de domaine. | |
| 248 | − | |
| 249 | −**Soumission d'URL** (feature simple) : « Soumettre un site québécois » → frontier. Soumission ≠ inclusion; le crawler valide. | |
| 250 | − | |
| 251 | −--- | |
| 252 | − | |
| 253 | −## 12. Coûts, LLM et embeddings | |
| 254 | − | |
| 255 | −Le crawl doit être **économique** : bande passante, compute, stockage — pas d'API LLM ni d'API de scraping commerciale par page. Le cœur (fetch, parse, canonicalisation, langue, hash, doublons, BM25) est déterministe. Les LLM enrichissent des pages **sélectionnées** en asynchrone (catégorisation, entités complexes, classification géographique ambiguë). Embeddings : toujours en arrière-plan, jamais bloquants. | |
| 256 | − | |
| 257 | −**Stockage brut :** métadonnées → Postgres; contenu cherchable → index; HTML brut compressé optionnel → object storage. Pas de blobs HTML géants dans Postgres. | |
| 258 | − | |
| 259 | −Analytics de recherche : métriques agrégées et respectueuses de la vie privée (requête, latence, position cliquée, zéro-résultat, langue). **Les requêtes zéro-résultat sont de l'or** : les stocker pour piloter le crawl par la demande. Pas de profils utilisateurs invasifs. | |
| 260 | − | |
| 261 | −--- | |
| 262 | − | |
| 263 | −## 13. Observabilité et dégradation | |
| 264 | − | |
| 265 | −Logs structurés avec `crawl_id, url_id, domain_id, worker_id, job_id`. Métriques compatibles Prometheus/Grafana/OpenTelemetry (choix pragmatique). | |
| 266 | − | |
| 267 | −**Dégradation gracieuse obligatoire :** embeddings en panne → lexical continue; enrichissement en retard → nouvelles pages cherchables quand même; un worker crash → frontier continue; frontend redémarre → crawler continue. | |
| 268 | − | |
| 269 | −Workers scalables indépendamment (`crawler-worker × N`, `parser-worker × N`, `indexer-worker × N`) — mais **pas de complexité distribuée prématurée** : Postgres + Redis + backend de recherche + quelques workers suffisent au départ. | |
| 270 | − | |
| 271 | −--- | |
| 272 | − | |
| 273 | −## 14. Provenance, droit d'auteur, avenir | |
| 274 | − | |
| 275 | −- Toujours préserver : URL originale, canonique, timestamp de crawl, domaine source. Trouve-KA renvoie vers les éditeurs originaux — c'est un index, pas un remplacement de contenu. | |
| 276 | −- Snippets raisonnables, attribution, liens sortants. Stockage brut, indexation, cache et affichage conçus séparément. | |
| 277 | −- Futur (non-MVP mais à ne pas bloquer architecturalement) : webmaster tools, verticales (news, entreprises, gouvernement, immobilier…), historique de versions des pages, réponses IA basées **uniquement** sur l'index avec citations, écosystème KA (Person-KA, Service-KA, Entreprise-KA…) alimenté par l'extraction d'entités asynchrone. Pas de collecte de données personnelles invasives. | |
| 278 | − | |
| 279 | −--- | |
| 280 | − | |
| 281 | −## 15. Configuration et DX | |
| 282 | − | |
| 283 | −Variables d'env validées : `DATABASE_URL, REDIS_URL, SEARCH_URL, CRAWLER_USER_AGENT, CRAWLER_CONTACT_URL, MAX_GLOBAL_CONCURRENCY, DEFAULT_HOST_DELAY, MAX_RESPONSE_BYTES, NGROK_DOMAIN=www.trouve-ka.com, PUBLIC_URL=https://www.trouve-ka.com`. Fournir `.env.example` sans secrets. | |
| 284 | − | |
| 285 | −Expérience développeur cible : | |
| 286 | − | |
| 287 | −``` | |
| 288 | −git clone … && cd trouve-ka | |
| 289 | −cp .env.example .env | |
| 290 | −docker compose up -d | |
| 291 | −pnpm install && pnpm dev | |
| 292 | −pnpm crawl:seed | |
| 293 | −# → des résultats apparaissent en quelques instants sur localhost:3000 | |
| 294 | −``` | |
| 295 | − | |
| 296 | −Déploiement m2m32 : même stack via compose + tunnel ngrok vers www.trouve-ka.com, scripté et documenté. | |
| 297 | − | |
| 298 | −--- | |
| 299 | − | |
| 300 | −## 16. Tests et évaluation | |
| 301 | − | |
| 302 | −Tests unitaires sur la logique critique : normalisation d'URL, robots, scoring Québec, canonical, doublons, scheduling, prévention SSRF, extraction HTML, API de recherche. | |
| 303 | − | |
| 304 | −Tests d'intégration sur un **web de fixtures local** (plombier québécois, université montréalaise, entreprise ontarienne, doublon, redirection, chemin bloqué par robots, article FR, article EN québécois) : fixture → crawler → parser → index → résultat de recherche. | |
| 305 | − | |
| 306 | −Dataset d'évaluation du ranking (« université québec », « plombier gatineau », « subvention thermopompe »…) avec domaines attendus. Les expériences de ranking doivent être mesurables. Scripts de benchmark : fetches/s, parses/s, docs indexés/s, latence de recherche, lag de queue, RAM/CPU/disque — l'optimisation est fondée sur des preuves, pas des intuitions. | |
| 307 | − | |
| 308 | −**Cibles :** recherche p50 < 100 ms, p95 < 300 ms à l'échelle MVP; page HTML normale cherchable en secondes; UI quasi instantanée. | |
| 309 | − | |
| 310 | −--- | |
| 311 | − | |
| 312 | −## 17. Ordre d'implémentation | |
| 313 | − | |
| 314 | −1. **Fondation** — monorepo, BD, queue, backend de recherche, web, API, crawler basique. | |
| 315 | −2. **Boucle complète** — seed → fetch → parse → index → search. **Ne pas continuer tant que ça ne marche pas de bout en bout.** | |
| 316 | −3. **Découverte continue** — liens sortants, frontier, domaines, scheduler, robots. | |
| 317 | −4. **Intelligence Québec** — scores, langue, localisation, scoring de domaines. | |
| 318 | −5. **Qualité de recherche** — BM25 custom, snippets, autorité, fraîcheur, doublons, compréhension de requête. | |
| 319 | −6. **Échelle** — plus de workers, meilleures queues, recrawl, monitoring. | |
| 320 | −7. **Couche sémantique** — embeddings, hybride, reranking, entités. | |
| 321 | −8. **Écosystème KA.** | |
| 322 | − | |
| 323 | −--- | |
| 324 | − | |
| 325 | −## 18. Milestone critique (non négociable) | |
| 326 | − | |
| 327 | −La première implémentation n'est réussie que si Claude démontre : | |
| 328 | − | |
| 329 | −1. démarrage du stack; | |
| 330 | −2. seed de sites québécois; | |
| 331 | −3. le crawler découvre des URLs; | |
| 332 | −4. télécharge des pages; | |
| 333 | −5. le parser extrait du texte utile; | |
| 334 | −6. les pages reçoivent un score Québec; | |
| 335 | −7. insertion **immédiate** dans l'index; | |
| 336 | −8. l'app web publique cherche ces pages; | |
| 337 | −9. les nouvelles pages deviennent cherchables **sans rien redémarrer**; | |
| 338 | −10. le frontier continue de découvrir des sites. | |
| 339 | − | |
| 340 | −**Succès Jour 1 :** même avec 10 000 pages, l'architecture se comporte exactement comme elle le fera avec 10 000 000+. Le crawler étend sa connaissance du web québécois pendant que le moteur est déjà opérationnel sur tout ce qui a été découvert. | |
| 341 | − | |
| 342 | −--- | |
| 343 | − | |
| 344 | −## 19. Méthode de travail de Claude | |
| 345 | − | |
| 346 | −Avant tout code substantiel : analyser cette spec → inspecter le dépôt existant → identifier le réutilisable → rechercher les décisions techniques incertaines → planifier → choisir sur preuves → documenter les décisions d'architecture → **implémenter verticalement** (du logiciel qui marche, pas de l'architecture spéculative). | |
| 347 | − | |
| 348 | −**Autonomie :** décisions raisonnables sans demander en permanence. Plusieurs options valables → analyser, choisir la meilleure, documenter, avancer. Ne bloquer que si c'est réellement bloquant. | |
| 349 | − | |
| 350 | −**Standard de qualité :** code typé, documenté, modulaire, observable, testable, sécurisé, efficace, orienté production. Pas de fichiers géants ni de couplage profond. Schémas migrables (concepts propres : `domains, urls, crawl_attempts, documents, document_versions, links, frontier_items, robots_rules, entities, index_jobs`). Et bien sûr : **header auteur dans chaque fichier** (§0.1). | |
| 351 | − | |
| 352 | −**Documentation :** README excellent (quoi, architecture, quick start, crawl, indexation incrémentale, recherche, layout, env, dev, tests, déploiement m2m32 + ngrok) et docs d'architecture avec diagrammes Mermaid. | |
| 353 | − | |
| 354 | −--- | |
| 355 | − | |
| 356 | −## 20. Vision | |
| 357 | − | |
| 358 | −L'objectif n'est pas de battre Google sur « Taylor Swift » ou « iPhone ». C'est de devenir extraordinairement bon sur : entreprises, institutions, gouvernement, municipalités, services, personnes, événements, produits, immobilier, documents, nouvelles et savoir local **du Québec**. | |
| 359 | − | |
| 360 | −Chaque page crawlée doit accroître la connaissance propre de Trouve-KA — jamais générer une requête vers l'API de quelqu'un d'autre. Chaque crawl améliore la couverture, le graphe de liens, la classification Québec, l'autorité, la fraîcheur, les priorités futures. **La valeur du dataset se compose dans le temps.** | |
| 361 | − | |
| 362 | −> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.** | |
| 363 | −> Le web ne « finit » jamais. Trouve-KA non plus. | |
deleted
M2M32brouve-ka/README.md
+0 −166
@@ -1,166 +0,0 @@ | ||
| 1 | −# Trouve-KA — Cherche le Québec. | |
| 2 | − | |
| 3 | −**Moteur de recherche web indépendant, Québec-first.** Son propre crawler, son propre | |
| 4 | −index, son propre ranking, son API et son application web publique. Pas un métamoteur : | |
| 5 | −aucune dépendance à Google, Bing ou Brave pour les résultats. | |
| 6 | − | |
| 7 | −> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.** | |
| 8 | − | |
| 9 | −En production : **https://www.trouve-ka.com** (node m2m32 du cluster MacLustr, tunnel ngrok). | |
| 10 | − | |
| 11 | −Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 12 | − | |
| 13 | −--- | |
| 14 | − | |
| 15 | −## Ce que c'est | |
| 16 | − | |
| 17 | −``` | |
| 18 | −Crawler → Frontier → Fetcher → Parser → Classification Québec | |
| 19 | −→ Déduplication → Indexer → Index → Ranking → API → Web App | |
| 20 | −``` | |
| 21 | − | |
| 22 | −Le crawler découvre le web québécois à partir de seeds à forte autorité (gouvernement, | |
| 23 | −municipalités, universités, médias), suit les liens sortants, juge la pertinence | |
| 24 | −québécoise de chaque page (`page_quebec_score` **et** `domain_quebec_score`), | |
| 25 | −et indexe **immédiatement** : une page fetchée est cherchable en ~2-4 secondes, | |
| 26 | −pendant que le frontier continue de grandir. L'enrichissement (autorité, entités, | |
| 27 | −embeddings) arrive après, en asynchrone, sans jamais bloquer. | |
| 28 | − | |
| 29 | −Détails : [docs/architecture.md](docs/architecture.md) (diagrammes Mermaid) et | |
| 30 | −[docs/decisions.md](docs/decisions.md) (pourquoi OpenSearch, pourquoi Postgres | |
| 31 | −comme frontier, etc.). | |
| 32 | − | |
| 33 | −## Stack | |
| 34 | − | |
| 35 | −| Couche | Choix | | |
| 36 | −|---|---| | |
| 37 | −| Web | Next.js 15, TypeScript, React 19, Tailwind (composants style shadcn/ui) | | |
| 38 | −| API | FastAPI (Python 3.12) | | |
| 39 | −| Pipeline | Python 3.12 async — httpx, selectolax, Protego | | |
| 40 | −| BD relationnelle | PostgreSQL 16 (frontier, domaines, documents, graphe de liens, analytics) | | |
| 41 | −| Coordination | Redis 7 (politesse par hôte, pause, Redis Streams pour l'enrichissement) | | |
| 42 | −| Recherche | OpenSearch 2.17 (BM25 FR/EN, synonymes bilingues, function_score Québec-first) | | |
| 43 | −| Déploiement | Docker Compose sur m2m32 + ngrok (www.trouve-ka.com) | | |
| 44 | − | |
| 45 | −## Layout du monorepo | |
| 46 | − | |
| 47 | −``` | |
| 48 | −apps/web # moteur public + dashboard /admin (Next.js) | |
| 49 | −apps/api # FastAPI (trouveka.api) | |
| 50 | −services/ # crawler, frontier, parser, classifier, indexer, ranking, scheduler, enrichment | |
| 51 | −packages/ # config, database, logging, queue, search-core, shared, types | |
| 52 | −infrastructure/ # docker/, migrations/, monitoring/, deployment/ (m2m32 + ngrok) | |
| 53 | −scripts/ # bootstrap-seeds/, start-crawler/, health-check/, eval/, check-headers.py | |
| 54 | −tests/ # unitaires : canonicalisation, SSRF, robots, scoring Québec, fetcher, ranking | |
| 55 | −``` | |
| 56 | − | |
| 57 | −Le backend Python est un seul package namespace `trouveka.*` mappé sur ce layout | |
| 58 | −(voir `pyproject.toml`). | |
| 59 | − | |
| 60 | −## Démarrage rapide (dev) | |
| 61 | − | |
| 62 | −```bash | |
| 63 | −git clone … && cd trouve-ka | |
| 64 | −cp .env.example .env | |
| 65 | −docker compose up -d postgres redis opensearch # infra | |
| 66 | −uv venv --python 3.12 .venv && uv pip install -e ".[dev]" --python .venv/bin/python | |
| 67 | −pnpm install | |
| 68 | − | |
| 69 | −pnpm crawl:seed # migrations + 60+ seeds québécoises | |
| 70 | −bash scripts/start-crawler/start.sh & # le crawl démarre | |
| 71 | −.venv/bin/uvicorn trouveka.api.main:app --port 8080 & | |
| 72 | −pnpm dev # → http://localhost:3000 | |
| 73 | −# → des résultats apparaissent en quelques secondes | |
| 74 | −``` | |
| 75 | − | |
| 76 | −Ports occupés sur la machine? Surcharger dans `.env` : `PG_PORT`, `REDIS_PORT`, | |
| 77 | −`SEARCH_PORT`, `API_PORT` (et les URLs correspondantes). | |
| 78 | − | |
| 79 | −Stack complet en containers : `docker compose up -d --build` (le service `migrate` | |
| 80 | −applique les migrations, `crawler-worker` se scale avec | |
| 81 | −`docker compose up -d --scale crawler-worker=3`). | |
| 82 | − | |
| 83 | −## Comment ça marche | |
| 84 | − | |
| 85 | −### Crawl et politesse | |
| 86 | −- **Identité assumée** : UA `Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)`, | |
| 87 | − page publique [/trouveka-bot](https://www.trouve-ka.com/trouveka-bot), pas de stealth. | |
| 88 | −- **robots.txt** parsé avec Protego, cache 24 h en base; `noindex`/`nofollow`/`X-Robots-Tag` respectés. | |
| 89 | −- **Politesse par origine** : verrou Redis par hôte (défaut 2 s entre requêtes, `Crawl-delay` respecté), | |
| 90 | − quel que soit le nombre de workers. | |
| 91 | −- **Sécurité** : garde SSRF (IP privées/loopback/métadonnées cloud bloquées, revalidée à chaque | |
| 92 | − redirection), limites par réponse (3 Mo, 5 redirections, timeout 20 s), détection de pièges | |
| 93 | − (session IDs, calendriers infinis, facettes explosives, pagination sans fin). | |
| 94 | − | |
| 95 | −### Indexation incrémentale | |
| 96 | −Le worker exécute fetch→parse→score→index **inline** : `refresh_interval: 1s` côté | |
| 97 | −OpenSearch → cherchable en secondes. Détection de changement par hash de contenu + | |
| 98 | −ETag/If-Modified-Since; recrawl adaptatif (inchangé → intervalle ×2, volatil → ÷2). | |
| 99 | − | |
| 100 | −### Détection Québec | |
| 101 | −Deux scores distincts (`page` et `domaine`) calculés à partir de : TLD (.qc.ca, .quebec), | |
| 102 | −gazetteer de toponymes (pondération réduite pour les ambigus type Laval/Hull), codes | |
| 103 | −postaux G/H/J, indicatifs (418/514/438/…), organisations connues (Hydro-Québec, RAMQ, | |
| 104 | −UQAM…), mentions structurées de la province (JSON-LD), langue française (indice, pas preuve). | |
| 105 | −Déterministe et gratuit — aucun LLM dans le chemin chaud (§12). | |
| 106 | − | |
| 107 | −### Ranking | |
| 108 | −`function_score` OpenSearch : BM25 bilingue (analyzers FR + EN, synonymes | |
| 109 | −thermopompe↔heat pump à la recherche) + scores Québec + autorité de domaine | |
| 110 | −(inlinks pondérés) + fraîcheur + boost de localité (« plombier Gatineau » → documents | |
| 111 | −avec preuve géographique `locations`). Chaque composant est optionnel; BM25 tient seul. | |
| 112 | − | |
| 113 | −Évaluation mesurable : `python3 scripts/eval/run-eval.py --api http://localhost:8080` | |
| 114 | −(dataset dans `scripts/eval/ranking-eval.yaml`). | |
| 115 | − | |
| 116 | −## API | |
| 117 | − | |
| 118 | −- `GET /api/search?q=&page=&limit=&language=&category=&quebec_only=&freshness=` | |
| 119 | −- `GET /api/status` — compteurs publics (pages, domaines, débit, état du crawler) | |
| 120 | −- `POST /api/submit {"url": …}` — soumettre un site québécois (soumission ≠ inclusion) | |
| 121 | −- `GET|POST /api/admin/*` — protégé par header `X-Admin-Token` : overview, flux live, | |
| 122 | − pause/reprise, seeds, recrawl, blocage de domaine, inspection du frontier | |
| 123 | − | |
| 124 | −## Tests | |
| 125 | − | |
| 126 | −```bash | |
| 127 | −.venv/bin/python -m pytest tests/ # 54 tests : URLs, SSRF, pièges, Québec, frontier, parser, fetcher, ranking | |
| 128 | −python3 scripts/check-headers.py # header auteur obligatoire dans chaque fichier source (CI) | |
| 129 | −bash scripts/health-check/check.sh # santé du stack | |
| 130 | −``` | |
| 131 | − | |
| 132 | −## Déploiement m2m32 + ngrok (www.trouve-ka.com) | |
| 133 | − | |
| 134 | −Prérequis sur m2m32 : colima + docker + docker-compose (brew), ngrok authentifié, | |
| 135 | −domaine `www.trouve-ka.com` réservé dans le compte ngrok. | |
| 136 | − | |
| 137 | −```bash | |
| 138 | −pnpm deploy:m2m32 # = bash infrastructure/deployment/deploy-m2m32.sh | |
| 139 | −``` | |
| 140 | − | |
| 141 | −Le script : rsync du monorepo → `docker compose up -d --build` → migrations + seeds | |
| 142 | −(idempotent) → tunnel `ngrok http --url=www.trouve-ka.com 3000` → health-check. | |
| 143 | −Le web proxifie `/api/*` vers l'API interne : un seul port exposé, pas d'URL absolues | |
| 144 | −côté client, cookies/CORS sans surprise derrière le tunnel. | |
| 145 | − | |
| 146 | −Opérations courantes sur le node : | |
| 147 | − | |
| 148 | −```bash | |
| 149 | −ssh M2M32 'cd trouve-ka && docker compose logs -f crawler-worker' # crawl en direct | |
| 150 | −ssh M2M32 'cd trouve-ka && docker compose up -d --scale crawler-worker=3' | |
| 151 | −``` | |
| 152 | − | |
| 153 | −## Observabilité | |
| 154 | − | |
| 155 | −Logs JSON structurés par service, métriques réelles via `/api/status` et | |
| 156 | −`/api/admin/overview`, dashboard `/admin` (files, débits, distribution HTTP, latences | |
| 157 | −p50/p95, flux live). Voir [infrastructure/monitoring/README.md](infrastructure/monitoring/README.md). | |
| 158 | − | |
| 159 | −## Règles du dépôt | |
| 160 | − | |
| 161 | −- **Header auteur obligatoire** dans chaque fichier source (§0.1) — vérifié par | |
| 162 | − `scripts/check-headers.py`. | |
| 163 | −- **Aucune donnée factice** : pas de compteurs simulés, pas de résultats hard-codés; | |
| 164 | − les fixtures vivent dans `tests/` uniquement. | |
| 165 | −- Provenance préservée : URL originale, canonique, timestamp de crawl, domaine source. | |
| 166 | − Trouve-KA renvoie vers les éditeurs originaux. | |
deleted
M2M32brouve-ka/apps/api/__init__.py
+0 −5
@@ -1,5 +0,0 @@ | ||
| 1 | −# Trouve-KA — application API | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""API publique (recherche, statut, soumission) et API de contrôle du crawler (protégée).""" | |
deleted
M2M32brouve-ka/apps/api/main.py
+0 −297
@@ -1,297 +0,0 @@ | ||
| 1 | −# Trouve-KA — API FastAPI | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""API de Trouve-KA. | |
| 6 | − | |
| 7 | −Publique : /api/search, /api/status, /api/submit, /api/health | |
| 8 | −Protégée (X-Admin-Token) : /api/admin/* — contrôle du crawler, jamais public sans auth. | |
| 9 | − | |
| 10 | −Dégradation gracieuse : si OpenSearch tombe, /api/status répond quand même; | |
| 11 | −si Postgres tombe, la recherche répond quand même (analytics sautées). | |
| 12 | −""" | |
| 13 | − | |
| 14 | −import html | |
| 15 | −import re | |
| 16 | −import time | |
| 17 | −from contextlib import asynccontextmanager | |
| 18 | −from typing import Annotated | |
| 19 | − | |
| 20 | −from fastapi import Depends, FastAPI, Header, HTTPException, Query | |
| 21 | −from fastapi.middleware.cors import CORSMiddleware | |
| 22 | −from pydantic import BaseModel | |
| 23 | − | |
| 24 | −from trouveka.config import get_settings | |
| 25 | −from trouveka.database import Database | |
| 26 | −from trouveka.logging import get_logger | |
| 27 | −from trouveka.queue import Coordination | |
| 28 | −from trouveka.ranking import build_search_body | |
| 29 | −from trouveka.search_core import SearchCore | |
| 30 | −from trouveka.shared import canonicalize_url, display_url, extract_domain, is_http_url | |
| 31 | − | |
| 32 | −log = get_logger("api") | |
| 33 | −settings = get_settings() | |
| 34 | − | |
| 35 | −db = Database(settings.database_url, pool_min=settings.pg_pool_min, pool_max=settings.pg_pool_max) | |
| 36 | −coord = Coordination(settings.redis_url) | |
| 37 | −search = SearchCore(settings.search_url, settings.search_index) | |
| 38 | − | |
| 39 | − | |
| 40 | −@asynccontextmanager | |
| 41 | −async def lifespan(_app: FastAPI): | |
| 42 | − await db.connect() | |
| 43 | − await search.ensure_index() | |
| 44 | − yield | |
| 45 | − await search.close() | |
| 46 | − await coord.close() | |
| 47 | − await db.close() | |
| 48 | − | |
| 49 | − | |
| 50 | −app = FastAPI(title="Trouve-KA API", version="0.1.0", lifespan=lifespan) | |
| 51 | − | |
| 52 | −# Derrière ngrok (www.trouve-ka.com), le web app proxifie /api : CORS permissif inutile | |
| 53 | −# en prod, mais pratique en dev local (web sur :3000, API sur :8080). | |
| 54 | −app.add_middleware( | |
| 55 | − CORSMiddleware, | |
| 56 | − allow_origins=["http://localhost:3000", settings.public_url], | |
| 57 | − allow_methods=["GET", "POST"], | |
| 58 | − allow_headers=["*", "X-Admin-Token"], | |
| 59 | −) | |
| 60 | − | |
| 61 | − | |
| 62 | −def require_admin(x_admin_token: Annotated[str | None, Header()] = None) -> None: | |
| 63 | − if not x_admin_token or x_admin_token != settings.admin_token: | |
| 64 | − raise HTTPException(status_code=401, detail="Jeton admin invalide") | |
| 65 | − | |
| 66 | − | |
| 67 | −_TAG_RE = re.compile(r"<(?!/?em>)[^>]*>") | |
| 68 | − | |
| 69 | − | |
| 70 | −def _safe_snippet(fragments: list[str]) -> str: | |
| 71 | − """Ne laisse passer que <em>/</em> (highlight); tout le reste est échappé par OpenSearch.""" | |
| 72 | − return _TAG_RE.sub("", " … ".join(fragments))[:400] | |
| 73 | − | |
| 74 | − | |
| 75 | −BADGE_LABELS = {"government": "Gouvernement", "news": "Actualités", "education": "Éducation"} | |
| 76 | − | |
| 77 | − | |
| 78 | −# ---------------------------------------------------------------------- publique | |
| 79 | − | |
| 80 | −@app.get("/api/health") | |
| 81 | −async def health(): | |
| 82 | − return {"ok": True, "search_ok": await search.ping()} | |
| 83 | − | |
| 84 | − | |
| 85 | −@app.get("/api/search") | |
| 86 | −async def api_search( | |
| 87 | − q: str = Query(..., min_length=1, max_length=200), | |
| 88 | − page: int = Query(1, ge=1, le=100), | |
| 89 | − limit: int = Query(10, ge=1, le=50), | |
| 90 | − language: str | None = Query(None, pattern="^(fr|en)$"), | |
| 91 | − location: str | None = None, | |
| 92 | − category: str | None = Query(None, max_length=40), | |
| 93 | − quebec_only: bool = False, | |
| 94 | − freshness: str | None = Query(None, pattern="^(day|week|month|year)$"), | |
| 95 | −): | |
| 96 | − started = time.monotonic() | |
| 97 | − query_text = f"{q} {location}" if location else q | |
| 98 | − body, analysis = build_search_body( | |
| 99 | − query_text, page=page, limit=limit, language=language, | |
| 100 | − category=category, quebec_only=quebec_only, freshness=freshness, | |
| 101 | − ) | |
| 102 | − try: | |
| 103 | − res = await search.search(body) | |
| 104 | − except Exception: | |
| 105 | − log.exception("recherche échouée", extra={"ctx": {"q": q}}) | |
| 106 | − raise HTTPException(status_code=503, detail="Le moteur de recherche est temporairement indisponible") | |
| 107 | − | |
| 108 | − took_ms = int((time.monotonic() - started) * 1000) | |
| 109 | − results = [] | |
| 110 | − for hit in res["hits"]["hits"]: | |
| 111 | − src = hit["_source"] | |
| 112 | − highlight = hit.get("highlight", {}) | |
| 113 | − fragments = highlight.get("body") or highlight.get("description") or [] | |
| 114 | − snippet = _safe_snippet(fragments) if fragments else html.escape(src.get("description") or "")[:400] | |
| 115 | − badges = [BADGE_LABELS[c] for c in src.get("categories", []) if c in BADGE_LABELS] | |
| 116 | − if src.get("page_quebec_score", 0) >= 0.45 or src.get("domain_quebec_score", 0) >= 0.6: | |
| 117 | − badges.insert(0, "Québec") | |
| 118 | − results.append({ | |
| 119 | − "title": src.get("title") or src["url"], | |
| 120 | − "url": src["url"], | |
| 121 | − "display_url": display_url(src["url"]), | |
| 122 | − "snippet": snippet, | |
| 123 | − "domain": src["domain"], | |
| 124 | − "language": src.get("language"), | |
| 125 | − "quebec_score": src.get("page_quebec_score", 0), | |
| 126 | − "badges": badges, | |
| 127 | − "published_at": src.get("published_at"), | |
| 128 | − }) | |
| 129 | − | |
| 130 | − total = res["hits"]["total"]["value"] | |
| 131 | − # Analytics agrégées, respectueuses de la vie privée — jamais bloquantes | |
| 132 | − try: | |
| 133 | − await db.record_search_query(q, analysis["language"], total, took_ms) | |
| 134 | − except Exception: | |
| 135 | − log.exception("analytics de recherche sautées") | |
| 136 | − | |
| 137 | − return {"query": q, "total": total, "took_ms": took_ms, "page": page, "limit": limit, "results": results} | |
| 138 | − | |
| 139 | − | |
| 140 | −@app.get("/api/status") | |
| 141 | −async def api_status(): | |
| 142 | − snapshot: dict = {} | |
| 143 | − try: | |
| 144 | − snapshot = await db.status_snapshot() | |
| 145 | − except Exception: | |
| 146 | − log.exception("statut PG indisponible") | |
| 147 | − search_ok = await search.ping() | |
| 148 | − try: | |
| 149 | − paused = await coord.is_paused() | |
| 150 | − except Exception: | |
| 151 | − paused = False | |
| 152 | − return { | |
| 153 | − "pages_indexed": snapshot.get("pages_indexed", 0), | |
| 154 | − "domains_count": snapshot.get("domains_count", 0), | |
| 155 | − "indexed_last_hour": snapshot.get("indexed_last_hour", 0), | |
| 156 | − "fetched_last_hour": snapshot.get("fetched_last_hour", 0), | |
| 157 | − "errors_last_hour": snapshot.get("errors_last_hour", 0), | |
| 158 | − "frontier_pending": snapshot.get("frontier_pending", 0), | |
| 159 | − "frontier_in_progress": snapshot.get("frontier_in_progress", 0), | |
| 160 | − "crawler_state": "paused" if paused else "running", | |
| 161 | − "search_ok": search_ok, | |
| 162 | − } | |
| 163 | − | |
| 164 | − | |
| 165 | −@app.get("/api/live") | |
| 166 | −async def api_live(): | |
| 167 | − """Dernière page visitée par TrouveKABot — alimente le flux temps réel du footer. | |
| 168 | − | |
| 169 | − Public mais volontairement minimal : domaine + URL + horodatage, rien d'interne. | |
| 170 | − """ | |
| 171 | − try: | |
| 172 | − events = await db.recent_events(1) | |
| 173 | − except Exception: | |
| 174 | − return {"event": None} | |
| 175 | − if not events: | |
| 176 | − return {"event": None} | |
| 177 | − e = events[0] | |
| 178 | − return { | |
| 179 | − "event": { | |
| 180 | − "at": e["at"], | |
| 181 | − "url": e["url"], | |
| 182 | − "domain": extract_domain(e["url"]), | |
| 183 | − "outcome": e["outcome"], | |
| 184 | − } | |
| 185 | − } | |
| 186 | − | |
| 187 | − | |
| 188 | −class SubmitBody(BaseModel): | |
| 189 | − url: str | |
| 190 | − | |
| 191 | − | |
| 192 | −@app.post("/api/submit") | |
| 193 | −async def api_submit(payload: SubmitBody): | |
| 194 | − """Soumettre un site québécois. Soumission ≠ inclusion : le crawler valide.""" | |
| 195 | − if not is_http_url(payload.url): | |
| 196 | − raise HTTPException(status_code=422, detail="URL invalide (http/https seulement)") | |
| 197 | − url = canonicalize_url(payload.url) | |
| 198 | − domain = extract_domain(url) if url else None | |
| 199 | − if not url or not domain: | |
| 200 | − raise HTTPException(status_code=422, detail="URL invalide") | |
| 201 | − await db.add_submission(url) | |
| 202 | − await db.enqueue_url(url, domain, priority=0.7, depth=0) | |
| 203 | − return { | |
| 204 | − "accepted": True, | |
| 205 | − "message": "Merci! Le site sera visité par TrouveKABot. La soumission ne garantit pas l'inclusion.", | |
| 206 | − } | |
| 207 | − | |
| 208 | − | |
| 209 | −# ---------------------------------------------------------------------- admin | |
| 210 | − | |
| 211 | −class SeedsBody(BaseModel): | |
| 212 | − urls: list[str] | |
| 213 | − | |
| 214 | − | |
| 215 | −class RecrawlBody(BaseModel): | |
| 216 | − url: str | None = None | |
| 217 | − domain: str | None = None | |
| 218 | − | |
| 219 | − | |
| 220 | −class DomainBody(BaseModel): | |
| 221 | − domain: str | |
| 222 | − | |
| 223 | − | |
| 224 | −@app.get("/api/admin/overview", dependencies=[Depends(require_admin)]) | |
| 225 | −async def admin_overview(): | |
| 226 | − overview = await db.admin_overview() | |
| 227 | − overview["paused"] = await coord.is_paused() | |
| 228 | − try: | |
| 229 | − overview["enrich_backlog"] = await coord.enrich_backlog() | |
| 230 | − except Exception: | |
| 231 | − overview["enrich_backlog"] = None | |
| 232 | − return overview | |
| 233 | − | |
| 234 | − | |
| 235 | −@app.get("/api/admin/recent", dependencies=[Depends(require_admin)]) | |
| 236 | −async def admin_recent(limit: int = Query(50, ge=1, le=200)): | |
| 237 | − return {"events": await db.recent_events(limit)} | |
| 238 | − | |
| 239 | − | |
| 240 | −@app.post("/api/admin/pause", dependencies=[Depends(require_admin)]) | |
| 241 | −async def admin_pause(): | |
| 242 | − await coord.pause_crawler() | |
| 243 | − return {"paused": True} | |
| 244 | − | |
| 245 | − | |
| 246 | −@app.post("/api/admin/resume", dependencies=[Depends(require_admin)]) | |
| 247 | −async def admin_resume(): | |
| 248 | − await coord.resume_crawler() | |
| 249 | − return {"paused": False} | |
| 250 | − | |
| 251 | − | |
| 252 | −@app.post("/api/admin/seeds", dependencies=[Depends(require_admin)]) | |
| 253 | −async def admin_seeds(payload: SeedsBody): | |
| 254 | − added = 0 | |
| 255 | − for raw in payload.urls[:500]: | |
| 256 | − url = canonicalize_url(raw.strip()) | |
| 257 | − domain = extract_domain(url) if url else None | |
| 258 | − if url and domain: | |
| 259 | − if await db.enqueue_url(url, domain, priority=1.0, depth=0, is_seed=True): | |
| 260 | − added += 1 | |
| 261 | − return {"added": added} | |
| 262 | − | |
| 263 | − | |
| 264 | −@app.post("/api/admin/recrawl", dependencies=[Depends(require_admin)]) | |
| 265 | −async def admin_recrawl(payload: RecrawlBody): | |
| 266 | − if payload.url: | |
| 267 | − url = canonicalize_url(payload.url) | |
| 268 | − ok = await db.requeue_url(url) if url else False | |
| 269 | − return {"requeued": 1 if ok else 0} | |
| 270 | − if payload.domain: | |
| 271 | − return {"requeued": await db.requeue_domain(payload.domain.lower())} | |
| 272 | − raise HTTPException(status_code=422, detail="url ou domain requis") | |
| 273 | − | |
| 274 | − | |
| 275 | −@app.post("/api/admin/domains/block", dependencies=[Depends(require_admin)]) | |
| 276 | −async def admin_block_domain(payload: DomainBody): | |
| 277 | − await db.block_domain(payload.domain.lower()) | |
| 278 | − return {"blocked": payload.domain.lower()} | |
| 279 | − | |
| 280 | − | |
| 281 | −@app.get("/api/admin/frontier", dependencies=[Depends(require_admin)]) | |
| 282 | −async def admin_frontier( | |
| 283 | − domain: str | None = None, | |
| 284 | − status: str | None = Query(None, pattern="^(pending|in_progress|done|failed|blocked)$"), | |
| 285 | − limit: int = Query(100, ge=1, le=500), | |
| 286 | −): | |
| 287 | − return {"items": await db.frontier_inspect(domain, status, limit)} | |
| 288 | − | |
| 289 | − | |
| 290 | −def main() -> None: | |
| 291 | − import uvicorn | |
| 292 | − | |
| 293 | − uvicorn.run("trouveka.api.main:app", host=settings.api_host, port=settings.api_port, workers=1) | |
| 294 | − | |
| 295 | − | |
| 296 | −if __name__ == "__main__": | |
| 297 | − main() | |
deleted
M2M32brouve-ka/docker-compose.yml
+0 −149
@@ -1,149 +0,0 @@ | ||
| 1 | −# Trouve-KA — stack Docker Compose (dev local et déploiement m2m32) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | −# | |
| 5 | −# Dimensionné pour m2m32 (32 Go RAM) : OpenSearch 2 Go de heap, Postgres léger, | |
| 6 | −# workers scalables via `docker compose up -d --scale crawler-worker=3`. | |
| 7 | − | |
| 8 | −services: | |
| 9 | − postgres: | |
| 10 | − image: postgres:16-alpine | |
| 11 | − environment: | |
| 12 | − POSTGRES_USER: trouveka | |
| 13 | − POSTGRES_PASSWORD: trouveka | |
| 14 | − POSTGRES_DB: trouveka | |
| 15 | − ports: | |
| 16 | − - "${PG_PORT:-5432}:5432" | |
| 17 | − volumes: | |
| 18 | − - pgdata:/var/lib/postgresql/data | |
| 19 | − healthcheck: | |
| 20 | − test: ["CMD-SHELL", "pg_isready -U trouveka"] | |
| 21 | − interval: 5s | |
| 22 | − timeout: 3s | |
| 23 | − retries: 20 | |
| 24 | − | |
| 25 | − redis: | |
| 26 | − image: redis:7-alpine | |
| 27 | − ports: | |
| 28 | − - "${REDIS_PORT:-6379}:6379" | |
| 29 | − volumes: | |
| 30 | − - redisdata:/data | |
| 31 | − command: ["redis-server", "--appendonly", "yes"] | |
| 32 | − healthcheck: | |
| 33 | − test: ["CMD", "redis-cli", "ping"] | |
| 34 | − interval: 5s | |
| 35 | − timeout: 3s | |
| 36 | − retries: 20 | |
| 37 | − | |
| 38 | − opensearch: | |
| 39 | − image: opensearchproject/opensearch:2.17.1 | |
| 40 | − environment: | |
| 41 | − discovery.type: single-node | |
| 42 | − plugins.security.disabled: "true" | |
| 43 | − OPENSEARCH_JAVA_OPTS: "-Xms2g -Xmx2g" | |
| 44 | − OPENSEARCH_INITIAL_ADMIN_PASSWORD: "TrouveKA!Local1" | |
| 45 | − bootstrap.memory_lock: "true" | |
| 46 | − ulimits: | |
| 47 | − memlock: | |
| 48 | − soft: -1 | |
| 49 | − hard: -1 | |
| 50 | − ports: | |
| 51 | − - "${SEARCH_PORT:-9200}:9200" | |
| 52 | − volumes: | |
| 53 | − - osdata:/usr/share/opensearch/data | |
| 54 | − healthcheck: | |
| 55 | − test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"] | |
| 56 | − interval: 10s | |
| 57 | − timeout: 5s | |
| 58 | − retries: 30 | |
| 59 | − | |
| 60 | − api: | |
| 61 | − build: | |
| 62 | − context: . | |
| 63 | − dockerfile: infrastructure/docker/backend.Dockerfile | |
| 64 | − command: ["uvicorn", "trouveka.api.main:app", "--host", "0.0.0.0", "--port", "8080"] | |
| 65 | − environment: &backend_env | |
| 66 | − DATABASE_URL: postgresql://trouveka:trouveka@postgres:5432/trouveka | |
| 67 | − REDIS_URL: redis://redis:6379/0 | |
| 68 | − SEARCH_URL: http://opensearch:9200 | |
| 69 | − ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me-admin-token} | |
| 70 | − PUBLIC_URL: ${PUBLIC_URL:-https://www.trouve-ka.com} | |
| 71 | − MAX_GLOBAL_CONCURRENCY: ${MAX_GLOBAL_CONCURRENCY:-24} | |
| 72 | − DEFAULT_HOST_DELAY: ${DEFAULT_HOST_DELAY:-2.0} | |
| 73 | − MAX_RESPONSE_BYTES: ${MAX_RESPONSE_BYTES:-3000000} | |
| 74 | − ports: | |
| 75 | − - "8080:8080" | |
| 76 | − depends_on: | |
| 77 | − postgres: | |
| 78 | − condition: service_healthy | |
| 79 | − redis: | |
| 80 | − condition: service_healthy | |
| 81 | − opensearch: | |
| 82 | − condition: service_healthy | |
| 83 | − restart: unless-stopped | |
| 84 | − | |
| 85 | − migrate: | |
| 86 | − build: | |
| 87 | − context: . | |
| 88 | − dockerfile: infrastructure/docker/backend.Dockerfile | |
| 89 | − command: ["python", "-c", "import asyncio; from trouveka.database import run_migrations; from trouveka.config import get_settings; print(asyncio.run(run_migrations(get_settings().database_url)))"] | |
| 90 | − environment: *backend_env | |
| 91 | − depends_on: | |
| 92 | − postgres: | |
| 93 | − condition: service_healthy | |
| 94 | − restart: "no" | |
| 95 | − | |
| 96 | − crawler-worker: | |
| 97 | − build: | |
| 98 | − context: . | |
| 99 | − dockerfile: infrastructure/docker/backend.Dockerfile | |
| 100 | − command: ["python", "-m", "trouveka.crawler.worker"] | |
| 101 | − environment: *backend_env | |
| 102 | − depends_on: | |
| 103 | − migrate: | |
| 104 | − condition: service_completed_successfully | |
| 105 | − opensearch: | |
| 106 | − condition: service_healthy | |
| 107 | − redis: | |
| 108 | − condition: service_healthy | |
| 109 | − restart: unless-stopped | |
| 110 | − | |
| 111 | − enrichment-worker: | |
| 112 | − build: | |
| 113 | − context: . | |
| 114 | − dockerfile: infrastructure/docker/backend.Dockerfile | |
| 115 | − command: ["python", "-m", "trouveka.enrichment.worker"] | |
| 116 | − environment: *backend_env | |
| 117 | − depends_on: | |
| 118 | − migrate: | |
| 119 | − condition: service_completed_successfully | |
| 120 | − restart: unless-stopped | |
| 121 | − | |
| 122 | − scheduler: | |
| 123 | − build: | |
| 124 | − context: . | |
| 125 | − dockerfile: infrastructure/docker/backend.Dockerfile | |
| 126 | − command: ["python", "-m", "trouveka.scheduler.loop"] | |
| 127 | − environment: *backend_env | |
| 128 | − depends_on: | |
| 129 | − migrate: | |
| 130 | − condition: service_completed_successfully | |
| 131 | − restart: unless-stopped | |
| 132 | − | |
| 133 | − web: | |
| 134 | − build: | |
| 135 | − context: . | |
| 136 | − dockerfile: infrastructure/docker/web.Dockerfile | |
| 137 | − environment: | |
| 138 | − API_URL: http://api:8080 | |
| 139 | − PORT: "3000" | |
| 140 | − ports: | |
| 141 | − - "3000:3000" | |
| 142 | − depends_on: | |
| 143 | − - api | |
| 144 | − restart: unless-stopped | |
| 145 | − | |
| 146 | −volumes: | |
| 147 | − pgdata: | |
| 148 | − redisdata: | |
| 149 | − osdata: | |
deleted
M2M32brouve-ka/docs/architecture.md
+0 −106
@@ -1,106 +0,0 @@ | ||
| 1 | −# Trouve-KA — Architecture | |
| 2 | − | |
| 3 | −Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −## Vue d'ensemble | |
| 6 | − | |
| 7 | −```mermaid | |
| 8 | −flowchart LR | |
| 9 | − subgraph Découverte | |
| 10 | − SEEDS[Seeds §8] --> FRONTIER | |
| 11 | − SUBMIT[Soumissions /soumettre] --> FRONTIER | |
| 12 | − LINKS[Liens sortants] --> FRONTIER | |
| 13 | − end | |
| 14 | − FRONTIER[(Frontier<br/>Postgres)] -->|claim SKIP LOCKED| CW[crawler-worker × N] | |
| 15 | − CW -->|politesse SET NX PX| REDIS[(Redis)] | |
| 16 | − CW --> ROBOTS[robots.txt cache] | |
| 17 | − CW --> FETCH[Fetcher HTTP<br/>garde SSRF] | |
| 18 | − FETCH --> PARSE[Parser selectolax] | |
| 19 | − PARSE --> QC[Classification Québec<br/>page_quebec_score] | |
| 20 | − QC --> DEDUP[Hash contenu / doublons] | |
| 21 | − DEDUP -->|indexation IMMÉDIATE| OS[(OpenSearch<br/>trouveka-docs)] | |
| 22 | − DEDUP --> PG[(Postgres<br/>documents, domaines, liens)] | |
| 23 | − CW -->|jamais bloquant| STREAM[Redis Stream<br/>trouveka:enrich] | |
| 24 | − STREAM --> EW[enrichment-worker] | |
| 25 | − EW -->|update partiel| OS | |
| 26 | − SCHED[scheduler] -->|items abandonnés,<br/>autorité de domaine| PG | |
| 27 | − OS --> API[API FastAPI] | |
| 28 | − PG --> API | |
| 29 | − API --> WEB[Next.js web<br/>+ /admin] | |
| 30 | − WEB --> NGROK[ngrok<br/>www.trouve-ka.com] | |
| 31 | −``` | |
| 32 | − | |
| 33 | −## Principe cardinal (§0.3) | |
| 34 | − | |
| 35 | −```mermaid | |
| 36 | −sequenceDiagram | |
| 37 | − participant F as Frontier | |
| 38 | − participant W as crawler-worker | |
| 39 | − participant O as OpenSearch | |
| 40 | − participant U as Utilisateur | |
| 41 | − F->>W: claim URL (t+0s) | |
| 42 | − W->>W: fetch + parse + score Québec (t+2s) | |
| 43 | − W->>O: index_document (t+3s) | |
| 44 | − Note over O: refresh_interval 1s | |
| 45 | − U->>O: recherche (t+4s) — la page est déjà cherchable | |
| 46 | − W--)W: enrichissement async (étapes 2-3, plus tard) | |
| 47 | −``` | |
| 48 | − | |
| 49 | −Jamais de cycle « crawler tout → indexer → chercher ». L'enrichissement met à jour | |
| 50 | −des documents **déjà cherchables** (update partiel), il ne conditionne rien. | |
| 51 | − | |
| 52 | −## Cycle de vie d'une URL | |
| 53 | − | |
| 54 | −```mermaid | |
| 55 | −stateDiagram-v2 | |
| 56 | − [*] --> pending: découverte (seed, lien, soumission) | |
| 57 | − pending --> in_progress: claim (priorité DESC, SKIP LOCKED) | |
| 58 | − in_progress --> pending: politesse (trop tôt pour cet hôte) | |
| 59 | − in_progress --> pending: succès → next_crawl_at adaptatif | |
| 60 | − in_progress --> pending: erreur transitoire (retry backoff ≤3) | |
| 61 | − in_progress --> done: robots refusé / redirection / doublon | |
| 62 | − in_progress --> failed: erreur permanente ou retries épuisés | |
| 63 | − in_progress --> blocked: domaine bloqué | |
| 64 | − pending --> in_progress: recrawl (fréquence mesurée §5.5) | |
| 65 | −``` | |
| 66 | − | |
| 67 | −## Scores (§4, §7, §9) | |
| 68 | − | |
| 69 | −| Étape | Quand | Champs | | |
| 70 | −|---|---|---| | |
| 71 | −| 1 — immédiat | pipeline inline | titre, corps, headings, langue, `page_quebec_score`, `locations`, catégories grossières | | |
| 72 | −| 2 — async | enrichment-worker | `domain_quebec_score` à jour, `authority_score` propagé (plus tard : embeddings, entités) | | |
| 73 | −| 3 — async | scheduler | autorité de domaine depuis `domain_links` (inlinks pondérés Québec) | | |
| 74 | − | |
| 75 | −Ranking à la requête : `function_score = BM25 (multi_match FR/EN + synonymes) + | |
| 76 | −w·page_quebec + w·domain_quebec + w·autorité + gauss(published_at) + boost localité` | |
| 77 | −(désactivable composant par composant — BM25 reste seul debout si tout tombe, §9). | |
| 78 | − | |
| 79 | −## Dégradation gracieuse (§13) | |
| 80 | − | |
| 81 | −- OpenSearch en panne → l'API répond 503 sur /search, /status reste up; le crawler continue d'alimenter Postgres? Non : l'indexation échoue → l'item est relâché en retry; le frontier survit. | |
| 82 | −- Redis en panne → politesse locale impossible : le worker s'arrête proprement; Postgres intact. | |
| 83 | −- Un worker crash → `scheduler.reset_stale_items` relance ses URLs après 30 min. | |
| 84 | −- Enrichissement en retard → aucune conséquence sur la recherche (backlog visible dans /admin). | |
| 85 | − | |
| 86 | −## Déploiement m2m32 (§0.2) | |
| 87 | − | |
| 88 | −```mermaid | |
| 89 | −flowchart LR | |
| 90 | − NG[ngrok www.trouve-ka.com] --> WEB3000[web :3000] | |
| 91 | − WEB3000 -->|rewrite /api/*| API8080[api :8080] | |
| 92 | − subgraph m2m32[Docker Compose sur m2m32 — 32 Go] | |
| 93 | − WEB3000 | |
| 94 | − API8080 | |
| 95 | − PG5432[postgres] | |
| 96 | − RD[redis] | |
| 97 | − OS9200[opensearch 2 Go heap] | |
| 98 | − CWX[crawler-worker × N] | |
| 99 | − EN[enrichment-worker] | |
| 100 | − SC[scheduler] | |
| 101 | − end | |
| 102 | −``` | |
| 103 | − | |
| 104 | −Un seul port exposé publiquement (3000 via ngrok). Budgets mémoire : OpenSearch 2 Go | |
| 105 | −de heap (~3 Go RSS), Postgres < 1 Go, workers Python ~100-200 Mo chacun, web ~150 Mo — | |
| 106 | −large marge sur 32 Go, scalable par `docker compose up -d --scale crawler-worker=3`. | |
deleted
M2M32brouve-ka/docs/decisions.md
+0 −43
@@ -1,43 +0,0 @@ | ||
| 1 | −# Trouve-KA — Décisions d'architecture | |
| 2 | − | |
| 3 | −Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −## D1 — Backend de recherche : OpenSearch 2.x | |
| 6 | − | |
| 7 | −**Candidats évalués :** OpenSearch, Elasticsearch, Typesense, Meilisearch, Vespa, Tantivy, Quickwit (CLAUDE.md §3). | |
| 8 | − | |
| 9 | −**Choix : OpenSearch 2.17**, parce que : | |
| 10 | −- **BM25 réel et paramétrable** par champ (multi_match, boosts), critère n°1 du MVP. | |
| 11 | −- **Indexation incrémentale** near-real-time : `refresh_interval: 1s` → une page indexée est cherchable en ~1 s (principe cardinal §0.3). | |
| 12 | −- **`function_score`** : la fonction de ranking Québec-first custom (§9) s'exprime nativement, poids ajustables sans réindexation. | |
| 13 | −- **Analyzers FR/EN** + `synonym_graph` search-time (bilinguisme sans traduction à l'ingestion). | |
| 14 | −- **Highlighting** natif pour les snippets, **facettes** pour les filtres, **k-NN natif** pour l'hybride sémantique futur (§17.7) sans changer de moteur. | |
| 15 | −- Licence Apache 2.0, image ARM64, tourne en 2 Go de heap sur un seul node m2m32. | |
| 16 | − | |
| 17 | −**Rejetés :** Meilisearch/Typesense (ranking custom trop rigide pour `Score(d,q)` pondéré; c'était le choix « facile » interdit par la spec), Tantivy (librairie Rust, il faudrait construire le serveur), Quickwit (orienté logs append-only, pas de mise à jour de documents), Vespa (excellent mais complexité opérationnelle démesurée pour un node unique), Elasticsearch (équivalent fonctionnel d'OpenSearch, licence moins permissive). | |
| 18 | − | |
| 19 | −## D2 — Backend Python unique (FastAPI + pipeline) | |
| 20 | − | |
| 21 | −Crawler, parser, classifier, indexer, ranking, scheduler, enrichment et API partagent un seul langage (Python 3.12) et un seul packaging (`pyproject.toml` racine, namespace `trouveka.*` mappé sur `packages/` et `services/`). Réduit la complexité (§3 : « le crawler peut rester Python même si l'API est TS » — ici tout le backend est Python, seul le web est TS). | |
| 22 | − | |
| 23 | −## D3 — Frontier dans Postgres, coordination dans Redis | |
| 24 | − | |
| 25 | −- **Frontier = Postgres** : état relationnel (priorités, retries, scheduling), claims multi-workers via `FOR UPDATE SKIP LOCKED` — N workers sans coordinateur. | |
| 26 | −- **Redis** : politesse par hôte (`SET NX PX`, un fetch par hôte par fenêtre, tous workers confondus), drapeau pause, **Redis Streams** pour l'enrichissement asynchrone (consumer groups, ack explicite). | |
| 27 | −- Pas de Celery/RQ : la file d'enrichissement est un stream nu, observable (`XLEN`), sans dépendance lourde. | |
| 28 | − | |
| 29 | −## D4 — Pipeline inline (fetch→parse→score→index) dans le crawler-worker | |
| 30 | − | |
| 31 | −Le chemin rapide est exécuté inline par le worker : c'est la garantie la plus simple du « cherchable en secondes ». La scalabilité passe par le nombre de workers (`--scale crawler-worker=N`), pas par une séparation prématurée fetch/parse/index (§13 : pas de complexité distribuée prématurée). Les étapes 2-3 (§4) passent par le stream d'enrichissement et ne bloquent jamais. | |
| 32 | − | |
| 33 | −## D5 — apps/admin fusionné dans apps/web (route /admin) | |
| 34 | − | |
| 35 | −Un seul runtime Next.js sur m2m32 au lieu de deux (~150 Mo RSS économisés), même design system, même proxy API. La séparation reste possible plus tard (le dashboard est un groupe de composants isolés sous `components/admin/`). Justification prévue par §2 (« Claude peut améliorer cette structure sur justification technique solide »). | |
| 36 | − | |
| 37 | −## D6 — Graphe de liens au niveau domaine d'abord | |
| 38 | − | |
| 39 | −`domain_links` agrégé (from, to, count) plutôt que page→page : suffisant pour l'autorité de domaine et la découverte au MVP, volume contrôlé. Le graphe page→page viendra quand le ranking l'exigera (§8). | |
| 40 | − | |
| 41 | −## D7 — Réseau : le web proxifie /api | |
| 42 | − | |
| 43 | −Next.js rewrite `/api/*` → API interne. Derrière ngrok (www.trouve-ka.com), un seul port exposé (3000), pas d'URL absolues côté client, pas de CORS en prod. | |
deleted
M2M32brouve-ka/infrastructure/deployment/deploy-m2m32.sh
+0 −38
@@ -1,38 +0,0 @@ | ||
| 1 | −#!/usr/bin/env bash | |
| 2 | −# Trouve-KA — déploiement sur le node m2m32 (cluster MacLustr) + tunnel ngrok | |
| 3 | −# Author: Simon-Pierre Boucher | |
| 4 | −# Contact: contact@spboucher.ai | |
| 5 | −# | |
| 6 | −# Prérequis sur m2m32 : Docker (ou colima) + docker compose, ngrok authentifié. | |
| 7 | −# Le domaine www.trouve-ka.com doit être réservé dans le compte ngrok. | |
| 8 | −set -euo pipefail | |
| 9 | − | |
| 10 | −NODE="${NODE:-M2M32}" | |
| 11 | −REMOTE_DIR="~/trouve-ka" | |
| 12 | −NGROK_DOMAIN="${NGROK_DOMAIN:-www.trouve-ka.com}" | |
| 13 | − | |
| 14 | −cd "$(dirname "$0")/../.." | |
| 15 | − | |
| 16 | −echo "→ Synchronisation du code vers $NODE:$REMOTE_DIR" | |
| 17 | −rsync -az --delete \ | |
| 18 | − --exclude node_modules --exclude .next --exclude .venv --exclude __pycache__ \ | |
| 19 | − --exclude .git --exclude data --exclude '*.egg-info' \ | |
| 20 | − ./ "$NODE:$REMOTE_DIR/" | |
| 21 | − | |
| 22 | −echo "→ Build et démarrage du stack sur $NODE" | |
| 23 | −ssh "$NODE" "cd $REMOTE_DIR && docker compose up -d --build" | |
| 24 | − | |
| 25 | −echo "→ Chargement des seeds (idempotent)" | |
| 26 | −ssh "$NODE" "cd $REMOTE_DIR && docker compose run --rm migrate && \ | |
| 27 | − docker compose run --rm --entrypoint python crawler-worker -m trouveka.crawler.seed scripts/bootstrap-seeds/seeds.txt" | |
| 28 | − | |
| 29 | −echo "→ Tunnel ngrok ($NGROK_DOMAIN → :3000)" | |
| 30 | −ssh "$NODE" "pgrep -f 'ngrok http.*$NGROK_DOMAIN' > /dev/null || \ | |
| 31 | − nohup ngrok http --url=$NGROK_DOMAIN 3000 > ~/trouve-ka-ngrok.log 2>&1 &" | |
| 32 | − | |
| 33 | −echo "→ Vérification de santé" | |
| 34 | −sleep 5 | |
| 35 | −ssh "$NODE" "cd $REMOTE_DIR && bash scripts/health-check/check.sh" || true | |
| 36 | − | |
| 37 | −echo "✓ Déploiement terminé : https://$NGROK_DOMAIN" | |
| 38 | −echo " N'oublie pas de mettre à jour ~/Desktop/cluster-skill/cluster-deployments.json" | |
deleted
M2M32brouve-ka/infrastructure/docker/backend.Dockerfile
+0 −20
@@ -1,20 +0,0 @@ | ||
| 1 | −# Trouve-KA — image backend Python (API, crawler, enrichissement, scheduler) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −FROM python:3.12-slim | |
| 6 | − | |
| 7 | −WORKDIR /app | |
| 8 | − | |
| 9 | −COPY pyproject.toml ./ | |
| 10 | −COPY packages ./packages | |
| 11 | −COPY services ./services | |
| 12 | −COPY apps/api ./apps/api | |
| 13 | −COPY infrastructure/migrations ./infrastructure/migrations | |
| 14 | −COPY scripts ./scripts | |
| 15 | − | |
| 16 | −RUN pip install --no-cache-dir . | |
| 17 | − | |
| 18 | −ENV PYTHONUNBUFFERED=1 | |
| 19 | − | |
| 20 | −CMD ["uvicorn", "trouveka.api.main:app", "--host", "0.0.0.0", "--port", "8080"] | |
deleted
M2M32brouve-ka/infrastructure/docker/web.Dockerfile
+0 −26
@@ -1,26 +0,0 @@ | ||
| 1 | −# Trouve-KA — image web Next.js | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −FROM node:22-alpine AS builder | |
| 6 | −WORKDIR /repo | |
| 7 | −RUN corepack enable | |
| 8 | −COPY package.json pnpm-workspace.yaml ./ | |
| 9 | −COPY apps/web/package.json apps/web/ | |
| 10 | −# sharp et unrs-resolver ont des scripts de build napi légitimes requis par Next | |
| 11 | −RUN pnpm install --frozen-lockfile=false --dangerously-allow-all-builds | |
| 12 | −COPY apps/web apps/web | |
| 13 | −# Les rewrites Next.js sont compilés au build : l'URL interne de l'API doit être fixée ici | |
| 14 | −ARG API_URL=http://api:8080 | |
| 15 | −ENV API_URL=${API_URL} | |
| 16 | −RUN pnpm --filter @trouveka/web build | |
| 17 | − | |
| 18 | −FROM node:22-alpine | |
| 19 | −WORKDIR /repo | |
| 20 | −RUN corepack enable | |
| 21 | −ENV NODE_ENV=production | |
| 22 | −COPY --from=builder /repo/package.json /repo/pnpm-workspace.yaml ./ | |
| 23 | −COPY --from=builder /repo/node_modules ./node_modules | |
| 24 | −COPY --from=builder /repo/apps/web ./apps/web | |
| 25 | −EXPOSE 3000 | |
| 26 | −CMD ["pnpm", "--filter", "@trouveka/web", "start"] | |
deleted
M2M32brouve-ka/infrastructure/migrations/001_init.sql
+0 −124
@@ -1,152 +0,0 @@ | ||
| 1 | − | |
| 2 | −BEGIN; | |
| 3 | − | |
| 4 | −CREATE TABLE IF NOT EXISTS schema_migrations ( | |
| 5 | − version INTEGER PRIMARY KEY, | |
| 6 | − applied_at TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 7 | −); | |
| 8 | − | |
| 9 | −CREATE TABLE IF NOT EXISTS domains ( | |
| 10 | − id BIGSERIAL PRIMARY KEY, | |
| 11 | − domain TEXT NOT NULL UNIQUE, | |
| 12 | − first_seen TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 13 | − last_crawled_at TIMESTAMPTZ, | |
| 14 | − robots_fetched_at TIMESTAMPTZ, | |
| 15 | − robots_body TEXT, | |
| 16 | − robots_status TEXT, -- ok | not_found | error | forbidden | |
| 17 | − crawl_delay_ms INTEGER, -- délai imposé par robots.txt (Crawl-delay) | |
| 18 | − quebec_score REAL NOT NULL DEFAULT 0, | |
| 19 | − authority_score REAL NOT NULL DEFAULT 0, | |
| 20 | − page_count INTEGER NOT NULL DEFAULT 0, | |
| 21 | − inlink_domains INTEGER NOT NULL DEFAULT 0, | |
| 22 | − outlink_domains INTEGER NOT NULL DEFAULT 0, | |
| 23 | − language_stats JSONB NOT NULL DEFAULT '{}'::jsonb, | |
| 24 | − content_change_rate REAL, | |
| 25 | − blocked BOOLEAN NOT NULL DEFAULT FALSE, | |
| 26 | − is_seed BOOLEAN NOT NULL DEFAULT FALSE | |
| 27 | −); | |
| 28 | −CREATE INDEX IF NOT EXISTS idx_domains_quebec ON domains (quebec_score DESC); | |
| 29 | − | |
| 30 | −CREATE TABLE IF NOT EXISTS urls ( | |
| 31 | − id BIGSERIAL PRIMARY KEY, | |
| 32 | − url TEXT NOT NULL UNIQUE, | |
| 33 | − canonical_url TEXT, | |
| 34 | − domain_id BIGINT NOT NULL REFERENCES domains(id), | |
| 35 | − first_seen TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 36 | −); | |
| 37 | −CREATE INDEX IF NOT EXISTS idx_urls_domain ON urls (domain_id); | |
| 38 | − | |
| 39 | −CREATE TABLE IF NOT EXISTS frontier_items ( | |
| 40 | − id BIGSERIAL PRIMARY KEY, | |
| 41 | − url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id), | |
| 42 | − priority REAL NOT NULL DEFAULT 0.5, | |
| 43 | − depth INTEGER NOT NULL DEFAULT 0, | |
| 44 | − source_url_id BIGINT REFERENCES urls(id), | |
| 45 | − discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 46 | − last_crawled_at TIMESTAMPTZ, | |
| 47 | − next_crawl_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 48 | − status TEXT NOT NULL DEFAULT 'pending', -- pending|in_progress|done|failed|blocked | |
| 49 | − retries INTEGER NOT NULL DEFAULT 0, | |
| 50 | − error_code TEXT, | |
| 51 | − locked_by TEXT, | |
| 52 | − locked_at TIMESTAMPTZ | |
| 53 | −); | |
| 54 | −CREATE INDEX IF NOT EXISTS idx_frontier_ready | |
| 55 | − ON frontier_items (next_crawl_at, priority DESC) | |
| 56 | − WHERE status = 'pending'; | |
| 57 | −CREATE INDEX IF NOT EXISTS idx_frontier_status ON frontier_items (status); | |
| 58 | − | |
| 59 | −CREATE TABLE IF NOT EXISTS crawl_attempts ( | |
| 60 | − id BIGSERIAL PRIMARY KEY, | |
| 61 | − url_id BIGINT NOT NULL REFERENCES urls(id), | |
| 62 | − fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 63 | − status_code INTEGER, | |
| 64 | − error_code TEXT, -- dns|timeout|tls|http_4xx|http_5xx|robots_denied|parse_failed| | |
| 65 | − -- unsupported_content|too_large|duplicate|spam|not_quebec|ssrf_blocked|ok | |
| 66 | − outcome TEXT NOT NULL, -- indexed|duplicate|error|not_quebec|robots_blocked|redirect|unchanged | |
| 67 | − content_hash TEXT, | |
| 68 | − bytes INTEGER, | |
| 69 | − duration_ms INTEGER, | |
| 70 | − redirect_url TEXT, | |
| 71 | − title TEXT, | |
| 72 | − quebec_score REAL | |
| 73 | −); | |
| 74 | −CREATE INDEX IF NOT EXISTS idx_attempts_time ON crawl_attempts (fetched_at DESC); | |
| 75 | −CREATE INDEX IF NOT EXISTS idx_attempts_url ON crawl_attempts (url_id); | |
| 76 | − | |
| 77 | −CREATE TABLE IF NOT EXISTS documents ( | |
| 78 | − id BIGSERIAL PRIMARY KEY, | |
| 79 | − url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id), | |
| 80 | − content_hash TEXT NOT NULL, | |
| 81 | − etag TEXT, | |
| 82 | − last_modified TEXT, | |
| 83 | − title TEXT, | |
| 84 | − description TEXT, | |
| 85 | − language TEXT, | |
| 86 | − page_quebec_score REAL NOT NULL DEFAULT 0, | |
| 87 | − published_at TIMESTAMPTZ, | |
| 88 | − last_changed_at TIMESTAMPTZ, | |
| 89 | − first_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 90 | − last_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 91 | − enrichment_stage INTEGER NOT NULL DEFAULT 1 | |
| 92 | −); | |
| 93 | −CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents (content_hash); | |
| 94 | − | |
| 95 | −CREATE TABLE IF NOT EXISTS domain_links ( | |
| 96 | − from_domain_id BIGINT NOT NULL REFERENCES domains(id), | |
| 97 | − to_domain_id BIGINT NOT NULL REFERENCES domains(id), | |
| 98 | − link_count INTEGER NOT NULL DEFAULT 1, | |
| 99 | − updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 100 | − PRIMARY KEY (from_domain_id, to_domain_id) | |
| 101 | −); | |
| 102 | −CREATE INDEX IF NOT EXISTS idx_domain_links_to ON domain_links (to_domain_id); | |
| 103 | − | |
| 104 | −CREATE TABLE IF NOT EXISTS search_queries ( | |
| 105 | − id BIGSERIAL PRIMARY KEY, | |
| 106 | − query TEXT NOT NULL, | |
| 107 | − language TEXT, | |
| 108 | − results_total INTEGER NOT NULL, | |
| 109 | − took_ms INTEGER NOT NULL, | |
| 110 | − zero_result BOOLEAN NOT NULL DEFAULT FALSE, | |
| 111 | − created_at TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 112 | −); | |
| 113 | −CREATE INDEX IF NOT EXISTS idx_queries_zero ON search_queries (created_at) WHERE zero_result; | |
| 114 | − | |
| 115 | −CREATE TABLE IF NOT EXISTS submissions ( | |
| 116 | − id BIGSERIAL PRIMARY KEY, | |
| 117 | − url TEXT NOT NULL, | |
| 118 | − submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 119 | − status TEXT NOT NULL DEFAULT 'queued' -- queued|accepted|rejected | |
| 120 | −); | |
| 121 | − | |
| 122 | −INSERT INTO schema_migrations (version) VALUES (1) ON CONFLICT DO NOTHING; | |
| 123 | − | |
| 124 | −COMMIT; | |
deleted
M2M32brouve-ka/infrastructure/migrations/002_frontier_priority_index.sql
+0 −11
@@ -1,17 +0,0 @@ | ||
| 1 | −-- | |
| 2 | − | |
| 3 | −BEGIN; | |
| 4 | − | |
| 5 | −CREATE INDEX IF NOT EXISTS idx_frontier_priority | |
| 6 | − ON frontier_items (priority DESC, next_crawl_at) | |
| 7 | − WHERE status = 'pending'; | |
| 8 | − | |
| 9 | −INSERT INTO schema_migrations (version) VALUES (2) ON CONFLICT DO NOTHING; | |
| 10 | − | |
| 11 | −COMMIT; | |
deleted
M2M32brouve-ka/infrastructure/monitoring/README.md
+0 −22
@@ -1,22 +0,0 @@ | ||
| 1 | −# Trouve-KA — Observabilité | |
| 2 | − | |
| 3 | −Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −## En place (MVP) | |
| 6 | − | |
| 7 | −- **Logs structurés JSON** (une ligne par événement) sur stdout de chaque service, | |
| 8 | − avec contexte `worker_id, url_id, domain_id, url, outcome, quebec_score, ms` | |
| 9 | − (`packages/logging`). Consultables via `docker compose logs -f crawler-worker`. | |
| 10 | −- **Métriques opérationnelles** exposées par l'API : | |
| 11 | − - `GET /api/status` (public) : pages indexées, domaines, débits horaires, état du crawler. | |
| 12 | − - `GET /api/admin/overview` (protégé) : files par statut, débits, distribution HTTP, | |
| 13 | − latences fetch p50/p95, top domaines, erreurs récentes, backlog d'enrichissement. | |
| 14 | − - Toutes calculées sur les vraies tables (`crawl_attempts`, `frontier_items`) — | |
| 15 | − aucun compteur simulé (§ fake). | |
| 16 | −- **Dashboard admin** : `/admin` sur le web (flux live du crawl, contrôles pause/seeds/blocage). | |
| 17 | − | |
| 18 | −## Prochaine étape (post-MVP) | |
| 19 | − | |
| 20 | −Exporter ces mêmes agrégats au format Prometheus (`/metrics`) et brancher | |
| 21 | −Grafana — les requêtes SQL de `Database.admin_overview()` sont la source de vérité, | |
| 22 | −l'export est un simple reformatage. Choix pragmatique conforme au CLAUDE.md §13. | |
deleted
M2M32brouve-ka/package.json
+0 −19
@@ -1,19 +0,0 @@ | ||
| 1 | −{ | |
| 2 | − "name": "trouve-ka", | |
| 3 | − "private": true, | |
| 4 | − "version": "0.1.0", | |
| 5 | − "packageManager": "pnpm@11.1.2", | |
| 6 | − "author": "Simon-Pierre Boucher <contact@spboucher.ai>", | |
| 7 | − "description": "Trouve-KA — Cherche le Québec. Moteur de recherche web indépendant, Québec-first.", | |
| 8 | − "pnpm": { | |
| 9 | − "onlyBuiltDependencies": ["sharp", "unrs-resolver"] | |
| 10 | − }, | |
| 11 | − "scripts": { | |
| 12 | − "dev": "pnpm --filter @trouveka/web dev", | |
| 13 | − "build": "pnpm --filter @trouveka/web build", | |
| 14 | − "start": "pnpm --filter @trouveka/web start", | |
| 15 | − "crawl:seed": "bash scripts/bootstrap-seeds/seed.sh", | |
| 16 | − "check:headers": "python3 scripts/check-headers.py", | |
| 17 | − "deploy:m2m32": "bash infrastructure/deployment/deploy-m2m32.sh" | |
| 18 | − } | |
| 19 | −} | |
deleted
M2M32brouve-ka/packages/config/__init__.py
+0 −67
@@ -1,67 +0,0 @@ | ||
| 1 | −# Trouve-KA — configuration validée par variables d'environnement | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Configuration centrale de Trouve-KA. | |
| 6 | − | |
| 7 | −Toutes les valeurs sont surchargeables par variable d'environnement et | |
| 8 | −dimensionnées par défaut pour le node de déploiement m2m32 (32 Go RAM). | |
| 9 | −""" | |
| 10 | − | |
| 11 | −from functools import lru_cache | |
| 12 | − | |
| 13 | −from pydantic_settings import BaseSettings, SettingsConfigDict | |
| 14 | − | |
| 15 | − | |
| 16 | −class Settings(BaseSettings): | |
| 17 | − model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") | |
| 18 | − | |
| 19 | − # Bases de données | |
| 20 | − database_url: str = "postgresql://trouveka:trouveka@localhost:5432/trouveka" | |
| 21 | − redis_url: str = "redis://localhost:6379/0" | |
| 22 | − search_url: str = "http://localhost:9200" | |
| 23 | − search_index: str = "trouveka-docs" | |
| 24 | − | |
| 25 | − # Crawler — identité et politesse | |
| 26 | − crawler_user_agent: str = ( | |
| 27 | − "Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)" | |
| 28 | − ) | |
| 29 | − crawler_contact_url: str = "https://www.trouve-ka.com/trouveka-bot" | |
| 30 | − max_global_concurrency: int = 24 | |
| 31 | − max_per_host_concurrency: int = 2 | |
| 32 | − default_host_delay: float = 2.0 # secondes entre deux requêtes vers un même hôte | |
| 33 | − | |
| 34 | − # Crawler — limites de sécurité (chaque réponse a des limites) | |
| 35 | − max_response_bytes: int = 3_000_000 | |
| 36 | − max_redirects: int = 5 | |
| 37 | − fetch_timeout: float = 20.0 | |
| 38 | − max_crawl_depth: int = 8 | |
| 39 | − max_links_per_page: int = 300 | |
| 40 | − max_urls_per_domain: int = 5_000 | |
| 41 | − max_query_params: int = 8 | |
| 42 | − max_path_segments: int = 12 | |
| 43 | − | |
| 44 | − # Recrawl adaptatif (bornes, en heures) | |
| 45 | − min_recrawl_hours: float = 1.0 | |
| 46 | − max_recrawl_hours: float = 24 * 30.0 | |
| 47 | − default_recrawl_hours: float = 24.0 | |
| 48 | − | |
| 49 | − # Indexation | |
| 50 | − min_quebec_score_to_index: float = 0.15 | |
| 51 | − min_body_length: int = 80 | |
| 52 | − | |
| 53 | − # API | |
| 54 | − api_host: str = "0.0.0.0" | |
| 55 | − api_port: int = 8080 | |
| 56 | − admin_token: str = "change-me-admin-token" | |
| 57 | − public_url: str = "https://www.trouve-ka.com" | |
| 58 | − ngrok_domain: str = "www.trouve-ka.com" | |
| 59 | − | |
| 60 | − # Postgres pool (dimensionné pour 32 Go / plusieurs workers) | |
| 61 | − pg_pool_min: int = 2 | |
| 62 | − pg_pool_max: int = 10 | |
| 63 | − | |
| 64 | − | |
| 65 | −@lru_cache | |
| 66 | −def get_settings() -> Settings: | |
| 67 | − return Settings() | |
deleted
M2M32brouve-ka/packages/database/__init__.py
+0 −10
@@ -1,10 +0,0 @@ | ||
| 1 | −# Trouve-KA — accès PostgreSQL (pool asyncpg + dépôts) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Couche d'accès PostgreSQL : pool asyncpg, migrations, dépôts frontier/domaines/documents.""" | |
| 6 | − | |
| 7 | −from .db import Database | |
| 8 | −from .migrate import run_migrations | |
| 9 | − | |
| 10 | −__all__ = ["Database", "run_migrations"] | |
deleted
M2M32brouve-ka/packages/database/db.py
+0 −522
@@ -1,522 +0,0 @@ | ||
| 1 | −# Trouve-KA — dépôts PostgreSQL | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Dépôts PostgreSQL de Trouve-KA. | |
| 6 | − | |
| 7 | −Le frontier vit dans Postgres : les workers réclament des lots d'URLs via | |
| 8 | −FOR UPDATE SKIP LOCKED, ce qui permet N workers concurrents sans coordination | |
| 9 | −externe (CLAUDE.md §13 : pas de complexité distribuée prématurée). | |
| 10 | −""" | |
| 11 | − | |
| 12 | −from datetime import UTC, datetime, timedelta | |
| 13 | −from typing import Any | |
| 14 | − | |
| 15 | −import asyncpg | |
| 16 | − | |
| 17 | − | |
| 18 | −def _dsn(url: str) -> str: | |
| 19 | − # asyncpg accepte postgresql:// mais pas postgresql+asyncpg:// | |
| 20 | − return url.replace("postgresql+asyncpg://", "postgresql://") | |
| 21 | − | |
| 22 | − | |
| 23 | −class Database: | |
| 24 | − def __init__(self, database_url: str, *, pool_min: int = 2, pool_max: int = 10): | |
| 25 | − self._url = _dsn(database_url) | |
| 26 | − self._pool_min = pool_min | |
| 27 | − self._pool_max = pool_max | |
| 28 | − self.pool: asyncpg.Pool | None = None | |
| 29 | − | |
| 30 | − async def connect(self) -> None: | |
| 31 | − self.pool = await asyncpg.create_pool( | |
| 32 | − self._url, min_size=self._pool_min, max_size=self._pool_max, command_timeout=30 | |
| 33 | − ) | |
| 34 | − | |
| 35 | − async def close(self) -> None: | |
| 36 | − if self.pool: | |
| 37 | − await self.pool.close() | |
| 38 | − self.pool = None | |
| 39 | − | |
| 40 | − # ------------------------------------------------------------------ domaines | |
| 41 | − | |
| 42 | − async def upsert_domain(self, domain: str, *, is_seed: bool = False) -> int: | |
| 43 | − row = await self.pool.fetchrow( | |
| 44 | − """ | |
| 45 | − INSERT INTO domains (domain, is_seed) VALUES ($1, $2) | |
| 46 | − ON CONFLICT (domain) DO UPDATE SET is_seed = domains.is_seed OR EXCLUDED.is_seed | |
| 47 | − RETURNING id | |
| 48 | − """, | |
| 49 | − domain, | |
| 50 | − is_seed, | |
| 51 | − ) | |
| 52 | − return row["id"] | |
| 53 | − | |
| 54 | − async def get_domain(self, domain: str) -> asyncpg.Record | None: | |
| 55 | − return await self.pool.fetchrow("SELECT * FROM domains WHERE domain = $1", domain) | |
| 56 | − | |
| 57 | − async def get_domain_by_id(self, domain_id: int) -> asyncpg.Record | None: | |
| 58 | − return await self.pool.fetchrow("SELECT * FROM domains WHERE id = $1", domain_id) | |
| 59 | − | |
| 60 | − async def save_robots( | |
| 61 | − self, domain_id: int, body: str | None, status: str, crawl_delay_ms: int | None | |
| 62 | − ) -> None: | |
| 63 | − await self.pool.execute( | |
| 64 | − """ | |
| 65 | − UPDATE domains SET robots_body = $2, robots_status = $3, | |
| 66 | − robots_fetched_at = now(), crawl_delay_ms = $4 | |
| 67 | − WHERE id = $1 | |
| 68 | − """, | |
| 69 | − domain_id, | |
| 70 | − body, | |
| 71 | − status, | |
| 72 | − crawl_delay_ms, | |
| 73 | − ) | |
| 74 | − | |
| 75 | − async def block_domain(self, domain: str) -> None: | |
| 76 | − await self.pool.execute( | |
| 77 | − "UPDATE domains SET blocked = TRUE WHERE domain = $1", domain | |
| 78 | − ) | |
| 79 | − await self.pool.execute( | |
| 80 | − """ | |
| 81 | − UPDATE frontier_items SET status = 'blocked' | |
| 82 | − WHERE status IN ('pending', 'in_progress') | |
| 83 | − AND url_id IN (SELECT u.id FROM urls u JOIN domains d ON d.id = u.domain_id | |
| 84 | − WHERE d.domain = $1) | |
| 85 | − """, | |
| 86 | − domain, | |
| 87 | − ) | |
| 88 | − | |
| 89 | − async def update_domain_after_page( | |
| 90 | − self, domain_id: int, page_quebec_score: float, language: str | None | |
| 91 | − ) -> None: | |
| 92 | − """Met à jour le score Québec du domaine (moyenne mobile) et ses stats de langue.""" | |
| 93 | − lang = language or "unknown" | |
| 94 | − await self.pool.execute( | |
| 95 | − """ | |
| 96 | − UPDATE domains SET | |
| 97 | − page_count = page_count + 1, | |
| 98 | − last_crawled_at = now(), | |
| 99 | − quebec_score = (quebec_score * page_count + $2) / (page_count + 1), | |
| 100 | − language_stats = jsonb_set( | |
| 101 | − language_stats, ARRAY[$3], | |
| 102 | − (COALESCE(language_stats->>$3, '0')::int + 1)::text::jsonb) | |
| 103 | − WHERE id = $1 | |
| 104 | − """, | |
| 105 | − domain_id, | |
| 106 | − page_quebec_score, | |
| 107 | − lang, | |
| 108 | − ) | |
| 109 | − | |
| 110 | − async def record_domain_links(self, from_domain_id: int, to_domain_counts: dict[int, int]) -> None: | |
| 111 | − if not to_domain_counts: | |
| 112 | − return | |
| 113 | − await self.pool.executemany( | |
| 114 | − """ | |
| 115 | − INSERT INTO domain_links (from_domain_id, to_domain_id, link_count) | |
| 116 | − VALUES ($1, $2, $3) | |
| 117 | − ON CONFLICT (from_domain_id, to_domain_id) | |
| 118 | − DO UPDATE SET link_count = domain_links.link_count + EXCLUDED.link_count, | |
| 119 | − updated_at = now() | |
| 120 | − """, | |
| 121 | − [(from_domain_id, to_id, n) for to_id, n in to_domain_counts.items() if to_id != from_domain_id], | |
| 122 | − ) | |
| 123 | − | |
| 124 | − # ------------------------------------------------------------------ frontier | |
| 125 | − | |
| 126 | − async def enqueue_url( | |
| 127 | − self, | |
| 128 | − url: str, | |
| 129 | − domain: str, | |
| 130 | − *, | |
| 131 | − priority: float = 0.5, | |
| 132 | − depth: int = 0, | |
| 133 | − source_url_id: int | None = None, | |
| 134 | − is_seed: bool = False, | |
| 135 | − max_urls_per_domain: int = 5000, | |
| 136 | − ) -> int | None: | |
| 137 | − """Ajoute une URL au frontier si inconnue. Retourne url_id si ajoutée, None sinon.""" | |
| 138 | − async with self.pool.acquire() as conn: | |
| 139 | − async with conn.transaction(): | |
| 140 | − domain_id = await conn.fetchval( | |
| 141 | − """ | |
| 142 | − INSERT INTO domains (domain, is_seed) VALUES ($1, $2) | |
| 143 | − ON CONFLICT (domain) DO UPDATE SET is_seed = domains.is_seed OR EXCLUDED.is_seed | |
| 144 | − RETURNING id | |
| 145 | − """, | |
| 146 | − domain, | |
| 147 | − is_seed, | |
| 148 | − ) | |
| 149 | − dom = await conn.fetchrow( | |
| 150 | − "SELECT blocked, page_count FROM domains WHERE id = $1", domain_id | |
| 151 | − ) | |
| 152 | − if dom["blocked"]: | |
| 153 | − return None | |
| 154 | − if not is_seed and dom["page_count"] >= max_urls_per_domain: | |
| 155 | − return None | |
| 156 | − url_id = await conn.fetchval( | |
| 157 | − """ | |
| 158 | − INSERT INTO urls (url, domain_id) VALUES ($1, $2) | |
| 159 | − ON CONFLICT (url) DO NOTHING RETURNING id | |
| 160 | − """, | |
| 161 | − url, | |
| 162 | − domain_id, | |
| 163 | − ) | |
| 164 | − if url_id is None: | |
| 165 | − return None # URL déjà connue : pas de doublon dans le frontier | |
| 166 | − await conn.execute( | |
| 167 | − """ | |
| 168 | − INSERT INTO frontier_items (url_id, priority, depth, source_url_id) | |
| 169 | − VALUES ($1, $2, $3, $4) ON CONFLICT (url_id) DO NOTHING | |
| 170 | − """, | |
| 171 | − url_id, | |
| 172 | − priority, | |
| 173 | − depth, | |
| 174 | − source_url_id, | |
| 175 | − ) | |
| 176 | − return url_id | |
| 177 | − | |
| 178 | − async def claim_batch(self, worker_id: str, limit: int = 20) -> list[asyncpg.Record]: | |
| 179 | − """Réclame un lot d'URLs prêtes à crawler (SKIP LOCKED, multi-workers sûr).""" | |
| 180 | − return await self.pool.fetch( | |
| 181 | − """ | |
| 182 | − WITH claimed AS ( | |
| 183 | − SELECT f.id FROM frontier_items f | |
| 184 | − WHERE f.status = 'pending' AND f.next_crawl_at <= now() | |
| 185 | − ORDER BY f.priority DESC, f.next_crawl_at | |
| 186 | − LIMIT $2 | |
| 187 | − FOR UPDATE SKIP LOCKED | |
| 188 | − ) | |
| 189 | − UPDATE frontier_items f SET status = 'in_progress', locked_by = $1, locked_at = now() | |
| 190 | − FROM claimed | |
| 191 | − WHERE f.id = claimed.id | |
| 192 | − RETURNING f.id, f.url_id, f.priority, f.depth, f.retries, | |
| 193 | − f.last_crawled_at, f.next_crawl_at, | |
| 194 | − (SELECT url FROM urls WHERE id = f.url_id) AS url, | |
| 195 | − (SELECT domain_id FROM urls WHERE id = f.url_id) AS domain_id | |
| 196 | − """, | |
| 197 | − worker_id, | |
| 198 | − limit, | |
| 199 | − ) | |
| 200 | − | |
| 201 | − async def release_item( | |
| 202 | − self, | |
| 203 | − url_id: int, | |
| 204 | − *, | |
| 205 | − status: str, | |
| 206 | − error_code: str | None = None, | |
| 207 | − next_crawl_at: datetime | None = None, | |
| 208 | − priority: float | None = None, | |
| 209 | − increment_retries: bool = False, | |
| 210 | − ) -> None: | |
| 211 | − await self.pool.execute( | |
| 212 | − """ | |
| 213 | − UPDATE frontier_items SET | |
| 214 | − status = $2, | |
| 215 | − error_code = $3, | |
| 216 | − last_crawled_at = now(), | |
| 217 | − next_crawl_at = COALESCE($4, next_crawl_at), | |
| 218 | − priority = COALESCE($5, priority), | |
| 219 | − retries = retries + CASE WHEN $6 THEN 1 ELSE 0 END, | |
| 220 | − locked_by = NULL, locked_at = NULL | |
| 221 | − WHERE url_id = $1 | |
| 222 | − """, | |
| 223 | − url_id, | |
| 224 | − status, | |
| 225 | − error_code, | |
| 226 | − next_crawl_at, | |
| 227 | − priority, | |
| 228 | − increment_retries, | |
| 229 | − ) | |
| 230 | − | |
| 231 | − async def reset_stale_items(self, older_than_minutes: int = 30) -> int: | |
| 232 | − """Relance les items in_progress abandonnés (worker mort). Retourne le nombre.""" | |
| 233 | − result = await self.pool.execute( | |
| 234 | − """ | |
| 235 | − UPDATE frontier_items SET status = 'pending', locked_by = NULL, locked_at = NULL | |
| 236 | − WHERE status = 'in_progress' AND locked_at < now() - ($1 || ' minutes')::interval | |
| 237 | − """, | |
| 238 | − str(older_than_minutes), | |
| 239 | − ) | |
| 240 | − return int(result.split()[-1]) | |
| 241 | − | |
| 242 | − async def requeue_url(self, url: str) -> bool: | |
| 243 | − result = await self.pool.execute( | |
| 244 | − """ | |
| 245 | − UPDATE frontier_items SET status = 'pending', next_crawl_at = now(), retries = 0 | |
| 246 | − WHERE url_id = (SELECT id FROM urls WHERE url = $1) | |
| 247 | − """, | |
| 248 | − url, | |
| 249 | − ) | |
| 250 | − return result.endswith("1") | |
| 251 | − | |
| 252 | − async def requeue_domain(self, domain: str) -> int: | |
| 253 | − result = await self.pool.execute( | |
| 254 | − """ | |
| 255 | − UPDATE frontier_items SET status = 'pending', next_crawl_at = now() | |
| 256 | − WHERE status IN ('done', 'failed') | |
| 257 | − AND url_id IN (SELECT u.id FROM urls u JOIN domains d ON d.id = u.domain_id | |
| 258 | − WHERE d.domain = $1) | |
| 259 | − """, | |
| 260 | − domain, | |
| 261 | − ) | |
| 262 | − return int(result.split()[-1]) | |
| 263 | − | |
| 264 | − # ------------------------------------------------------------------ crawl / documents | |
| 265 | − | |
| 266 | − async def record_attempt( | |
| 267 | − self, | |
| 268 | − url_id: int, | |
| 269 | − *, | |
| 270 | − status_code: int | None, | |
| 271 | − error_code: str, | |
| 272 | − outcome: str, | |
| 273 | − content_hash: str | None = None, | |
| 274 | − num_bytes: int | None = None, | |
| 275 | − duration_ms: int | None = None, | |
| 276 | − redirect_url: str | None = None, | |
| 277 | − title: str | None = None, | |
| 278 | − quebec_score: float | None = None, | |
| 279 | − ) -> None: | |
| 280 | − await self.pool.execute( | |
| 281 | − """ | |
| 282 | − INSERT INTO crawl_attempts | |
| 283 | − (url_id, status_code, error_code, outcome, content_hash, bytes, | |
| 284 | − duration_ms, redirect_url, title, quebec_score) | |
| 285 | − VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) | |
| 286 | − """, | |
| 287 | − url_id, | |
| 288 | − status_code, | |
| 289 | − error_code, | |
| 290 | − outcome, | |
| 291 | − content_hash, | |
| 292 | − num_bytes, | |
| 293 | − duration_ms, | |
| 294 | − redirect_url, | |
| 295 | − title, | |
| 296 | − quebec_score, | |
| 297 | − ) | |
| 298 | − | |
| 299 | − async def get_document(self, url_id: int) -> asyncpg.Record | None: | |
| 300 | − return await self.pool.fetchrow("SELECT * FROM documents WHERE url_id = $1", url_id) | |
| 301 | − | |
| 302 | − async def find_duplicate(self, chash: str, url_id: int) -> asyncpg.Record | None: | |
| 303 | − """Doublon exact : même hash de contenu sur une autre URL.""" | |
| 304 | − return await self.pool.fetchrow( | |
| 305 | − "SELECT * FROM documents WHERE content_hash = $1 AND url_id != $2 LIMIT 1", | |
| 306 | − chash, | |
| 307 | − url_id, | |
| 308 | − ) | |
| 309 | − | |
| 310 | − async def upsert_document( | |
| 311 | − self, | |
| 312 | − url_id: int, | |
| 313 | − *, | |
| 314 | − content_hash: str, | |
| 315 | − etag: str | None, | |
| 316 | − last_modified: str | None, | |
| 317 | − title: str, | |
| 318 | − description: str, | |
| 319 | − language: str | None, | |
| 320 | − page_quebec_score: float, | |
| 321 | − published_at: datetime | None, | |
| 322 | − changed: bool, | |
| 323 | − ) -> int: | |
| 324 | − row = await self.pool.fetchrow( | |
| 325 | − """ | |
| 326 | − INSERT INTO documents | |
| 327 | − (url_id, content_hash, etag, last_modified, title, description, language, | |
| 328 | − page_quebec_score, published_at, last_changed_at) | |
| 329 | − VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) | |
| 330 | − ON CONFLICT (url_id) DO UPDATE SET | |
| 331 | − content_hash = EXCLUDED.content_hash, | |
| 332 | − etag = EXCLUDED.etag, | |
| 333 | − last_modified = EXCLUDED.last_modified, | |
| 334 | − title = EXCLUDED.title, | |
| 335 | − description = EXCLUDED.description, | |
| 336 | − language = EXCLUDED.language, | |
| 337 | − page_quebec_score = EXCLUDED.page_quebec_score, | |
| 338 | − published_at = EXCLUDED.published_at, | |
| 339 | − last_indexed_at = now(), | |
| 340 | − last_changed_at = CASE WHEN $10 THEN now() ELSE documents.last_changed_at END | |
| 341 | − RETURNING id | |
| 342 | − """, | |
| 343 | − url_id, | |
| 344 | − content_hash, | |
| 345 | − etag, | |
| 346 | − last_modified, | |
| 347 | − title, | |
| 348 | − description, | |
| 349 | − language, | |
| 350 | − page_quebec_score, | |
| 351 | − published_at, | |
| 352 | − changed, | |
| 353 | − ) | |
| 354 | − return row["id"] | |
| 355 | − | |
| 356 | − async def set_canonical(self, url_id: int, canonical_url: str) -> None: | |
| 357 | − await self.pool.execute( | |
| 358 | − "UPDATE urls SET canonical_url = $2 WHERE id = $1", url_id, canonical_url | |
| 359 | − ) | |
| 360 | − | |
| 361 | − # ------------------------------------------------------------------ analytics / statut | |
| 362 | − | |
| 363 | − async def record_search_query( | |
| 364 | − self, query: str, language: str | None, total: int, took_ms: int | |
| 365 | − ) -> None: | |
| 366 | − await self.pool.execute( | |
| 367 | − """ | |
| 368 | − INSERT INTO search_queries (query, language, results_total, took_ms, zero_result) | |
| 369 | − VALUES ($1, $2, $3, $4, $3 = 0) | |
| 370 | − """, | |
| 371 | − query[:500], | |
| 372 | − language, | |
| 373 | − total, | |
| 374 | − took_ms, | |
| 375 | − ) | |
| 376 | − | |
| 377 | − async def add_submission(self, url: str) -> None: | |
| 378 | − await self.pool.execute("INSERT INTO submissions (url) VALUES ($1)", url) | |
| 379 | − | |
| 380 | − async def status_snapshot(self) -> dict[str, Any]: | |
| 381 | − since = datetime.now(UTC) - timedelta(hours=1) | |
| 382 | − frontier = { | |
| 383 | − r["status"]: r["n"] | |
| 384 | − for r in await self.pool.fetch( | |
| 385 | − "SELECT status, count(*)::int AS n FROM frontier_items GROUP BY status" | |
| 386 | − ) | |
| 387 | − } | |
| 388 | − row = await self.pool.fetchrow( | |
| 389 | − """ | |
| 390 | − SELECT | |
| 391 | − (SELECT count(*)::int FROM documents) AS docs, | |
| 392 | − (SELECT count(*)::int FROM domains WHERE page_count > 0) AS domains, | |
| 393 | − (SELECT count(*)::int FROM documents WHERE last_indexed_at >= $1) AS indexed_1h, | |
| 394 | − (SELECT count(*)::int FROM crawl_attempts WHERE fetched_at >= $1) AS fetched_1h, | |
| 395 | − (SELECT count(*)::int FROM crawl_attempts | |
| 396 | − WHERE fetched_at >= $1 AND outcome = 'error') AS errors_1h | |
| 397 | − """, | |
| 398 | − since, | |
| 399 | − ) | |
| 400 | − return { | |
| 401 | − "pages_indexed": row["docs"], | |
| 402 | − "domains_count": row["domains"], | |
| 403 | − "indexed_last_hour": row["indexed_1h"], | |
| 404 | − "fetched_last_hour": row["fetched_1h"], | |
| 405 | − "errors_last_hour": row["errors_1h"], | |
| 406 | − "frontier_pending": frontier.get("pending", 0), | |
| 407 | − "frontier_in_progress": frontier.get("in_progress", 0), | |
| 408 | − "frontier": frontier, | |
| 409 | − } | |
| 410 | − | |
| 411 | − async def admin_overview(self) -> dict[str, Any]: | |
| 412 | − since = datetime.now(UTC) - timedelta(hours=1) | |
| 413 | − base = await self.status_snapshot() | |
| 414 | − http_status = { | |
| 415 | − str(r["status_code"]): r["n"] | |
| 416 | − for r in await self.pool.fetch( | |
| 417 | − """ | |
| 418 | − SELECT status_code, count(*)::int AS n FROM crawl_attempts | |
| 419 | − WHERE fetched_at >= $1 AND status_code IS NOT NULL | |
| 420 | − GROUP BY status_code ORDER BY n DESC | |
| 421 | − """, | |
| 422 | − since, | |
| 423 | − ) | |
| 424 | − } | |
| 425 | − outcomes = { | |
| 426 | − r["outcome"]: r["n"] | |
| 427 | − for r in await self.pool.fetch( | |
| 428 | − "SELECT outcome, count(*)::int AS n FROM crawl_attempts WHERE fetched_at >= $1 GROUP BY outcome", | |
| 429 | − since, | |
| 430 | − ) | |
| 431 | − } | |
| 432 | − top_domains = [ | |
| 433 | − dict(r) | |
| 434 | − for r in await self.pool.fetch( | |
| 435 | − """ | |
| 436 | − SELECT domain, page_count AS pages, round(quebec_score::numeric, 3)::float AS quebec_score | |
| 437 | − FROM domains WHERE page_count > 0 | |
| 438 | − ORDER BY page_count DESC LIMIT 20 | |
| 439 | − """ | |
| 440 | − ) | |
| 441 | − ] | |
| 442 | − recent_errors = [ | |
| 443 | − {"url": r["url"], "error_code": r["error_code"], "at": r["fetched_at"].isoformat()} | |
| 444 | − for r in await self.pool.fetch( | |
| 445 | − """ | |
| 446 | − SELECT u.url, a.error_code, a.fetched_at | |
| 447 | − FROM crawl_attempts a JOIN urls u ON u.id = a.url_id | |
| 448 | − WHERE a.outcome = 'error' ORDER BY a.fetched_at DESC LIMIT 20 | |
| 449 | − """ | |
| 450 | − ) | |
| 451 | − ] | |
| 452 | − latency = await self.pool.fetchrow( | |
| 453 | − """ | |
| 454 | − SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms) AS p50, | |
| 455 | − percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95 | |
| 456 | − FROM crawl_attempts WHERE fetched_at >= $1 AND duration_ms IS NOT NULL | |
| 457 | − """, | |
| 458 | − since, | |
| 459 | − ) | |
| 460 | − return { | |
| 461 | − "frontier": { | |
| 462 | − "pending": base["frontier"].get("pending", 0), | |
| 463 | − "in_progress": base["frontier"].get("in_progress", 0), | |
| 464 | − "done": base["frontier"].get("done", 0), | |
| 465 | − "failed": base["frontier"].get("failed", 0), | |
| 466 | − "blocked": base["frontier"].get("blocked", 0), | |
| 467 | − }, | |
| 468 | − "rates": { | |
| 469 | − "fetched_1h": base["fetched_last_hour"], | |
| 470 | − "indexed_1h": outcomes.get("indexed", 0), | |
| 471 | − "errors_1h": outcomes.get("error", 0), | |
| 472 | − "robots_blocked_1h": outcomes.get("robots_blocked", 0), | |
| 473 | − "duplicates_1h": outcomes.get("duplicate", 0), | |
| 474 | − "parsed_1h": outcomes.get("indexed", 0) + outcomes.get("not_quebec", 0), | |
| 475 | − }, | |
| 476 | − "http_status": http_status, | |
| 477 | − "top_domains": top_domains, | |
| 478 | − "recent_errors": recent_errors, | |
| 479 | − "latency": { | |
| 480 | − "fetch_p50_ms": round(latency["p50"]) if latency and latency["p50"] else None, | |
| 481 | − "fetch_p95_ms": round(latency["p95"]) if latency and latency["p95"] else None, | |
| 482 | − }, | |
| 483 | − } | |
| 484 | − | |
| 485 | − async def recent_events(self, limit: int = 50) -> list[dict[str, Any]]: | |
| 486 | − rows = await self.pool.fetch( | |
| 487 | − """ | |
| 488 | − SELECT a.fetched_at, u.url, a.status_code, a.outcome, a.quebec_score, a.title | |
| 489 | − FROM crawl_attempts a JOIN urls u ON u.id = a.url_id | |
| 490 | − ORDER BY a.fetched_at DESC LIMIT $1 | |
| 491 | − """, | |
| 492 | − min(limit, 200), | |
| 493 | − ) | |
| 494 | − return [ | |
| 495 | − { | |
| 496 | − "at": r["fetched_at"].isoformat(), | |
| 497 | − "url": r["url"], | |
| 498 | − "status": r["status_code"], | |
| 499 | − "outcome": r["outcome"], | |
| 500 | − "quebec_score": r["quebec_score"], | |
| 501 | − "title": r["title"], | |
| 502 | − } | |
| 503 | − for r in rows | |
| 504 | − ] | |
| 505 | − | |
| 506 | − async def frontier_inspect( | |
| 507 | − self, domain: str | None = None, status: str | None = None, limit: int = 100 | |
| 508 | − ) -> list[dict[str, Any]]: | |
| 509 | − rows = await self.pool.fetch( | |
| 510 | − """ | |
| 511 | − SELECT u.url, f.priority, f.depth, f.status, f.retries, f.next_crawl_at, f.error_code | |
| 512 | − FROM frontier_items f JOIN urls u ON u.id = f.url_id | |
| 513 | − JOIN domains d ON d.id = u.domain_id | |
| 514 | − WHERE ($1::text IS NULL OR d.domain = $1) | |
| 515 | − AND ($2::text IS NULL OR f.status = $2) | |
| 516 | − ORDER BY f.priority DESC LIMIT $3 | |
| 517 | − """, | |
| 518 | − domain, | |
| 519 | − status, | |
| 520 | − min(limit, 500), | |
| 521 | − ) | |
| 522 | − return [dict(r) | {"next_crawl_at": r["next_crawl_at"].isoformat()} for r in rows] | |
deleted
M2M32brouve-ka/packages/database/migrate.py
+0 −46
@@ -1,46 +0,0 @@ | ||
| 1 | −# Trouve-KA — exécution des migrations SQL | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Applique les migrations SQL de infrastructure/migrations dans l'ordre.""" | |
| 6 | − | |
| 7 | −import os | |
| 8 | −import pathlib | |
| 9 | −import re | |
| 10 | − | |
| 11 | −import asyncpg | |
| 12 | − | |
| 13 | − | |
| 14 | −def _find_migrations_dir() -> pathlib.Path: | |
| 15 | − """Résout le dossier de migrations : env, cwd (container /app), ou racine du dépôt (editable).""" | |
| 16 | − candidates = [ | |
| 17 | − os.environ.get("TROUVEKA_MIGRATIONS_DIR"), | |
| 18 | − pathlib.Path.cwd() / "infrastructure" / "migrations", | |
| 19 | − pathlib.Path(__file__).resolve().parents[2] / "infrastructure" / "migrations", | |
| 20 | − ] | |
| 21 | − for cand in candidates: | |
| 22 | − if cand and pathlib.Path(cand).is_dir(): | |
| 23 | − return pathlib.Path(cand) | |
| 24 | − raise FileNotFoundError("dossier infrastructure/migrations introuvable") | |
| 25 | − | |
| 26 | − | |
| 27 | −async def run_migrations(database_url: str) -> list[str]: | |
| 28 | − """Applique les migrations manquantes. Retourne la liste des fichiers appliqués.""" | |
| 29 | − migrations_dir = _find_migrations_dir() | |
| 30 | − conn = await asyncpg.connect(database_url) | |
| 31 | − applied: list[str] = [] | |
| 32 | − try: | |
| 33 | − await conn.execute( | |
| 34 | − "CREATE TABLE IF NOT EXISTS schema_migrations (" | |
| 35 | − " version INTEGER PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())" | |
| 36 | − ) | |
| 37 | − done = {r["version"] for r in await conn.fetch("SELECT version FROM schema_migrations")} | |
| 38 | − for path in sorted(migrations_dir.glob("*.sql")): | |
| 39 | − match = re.match(r"^(\d+)_", path.name) | |
| 40 | − if not match or int(match.group(1)) in done: | |
| 41 | − continue | |
| 42 | − await conn.execute(path.read_text(encoding="utf-8")) | |
| 43 | − applied.append(path.name) | |
| 44 | − finally: | |
| 45 | − await conn.close() | |
| 46 | − return applied | |
deleted
M2M32brouve-ka/packages/logging/__init__.py
+0 −39
@@ -1,39 +0,0 @@ | ||
| 1 | −# Trouve-KA — logging structuré JSON | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Logs structurés (JSON une ligne) avec contexte crawl_id/url_id/domain_id/worker_id.""" | |
| 6 | − | |
| 7 | −import json | |
| 8 | −import logging | |
| 9 | −import sys | |
| 10 | −import time | |
| 11 | −from typing import Any | |
| 12 | − | |
| 13 | − | |
| 14 | −class JsonFormatter(logging.Formatter): | |
| 15 | − def format(self, record: logging.LogRecord) -> str: | |
| 16 | − payload: dict[str, Any] = { | |
| 17 | − "ts": round(time.time(), 3), | |
| 18 | − "level": record.levelname.lower(), | |
| 19 | − "logger": record.name, | |
| 20 | − "msg": record.getMessage(), | |
| 21 | − } | |
| 22 | − extra = getattr(record, "ctx", None) | |
| 23 | − if isinstance(extra, dict): | |
| 24 | − payload.update(extra) | |
| 25 | − if record.exc_info and record.exc_info[0] is not None: | |
| 26 | − payload["exc"] = self.formatException(record.exc_info) | |
| 27 | − return json.dumps(payload, ensure_ascii=False, default=str) | |
| 28 | − | |
| 29 | − | |
| 30 | −def get_logger(name: str) -> logging.Logger: | |
| 31 | − """Retourne un logger JSON prêt à l'emploi. Utiliser `log.info("msg", extra={"ctx": {...}})`.""" | |
| 32 | − logger = logging.getLogger(name) | |
| 33 | − if not logger.handlers: | |
| 34 | − handler = logging.StreamHandler(sys.stdout) | |
| 35 | − handler.setFormatter(JsonFormatter()) | |
| 36 | − logger.addHandler(handler) | |
| 37 | − logger.setLevel(logging.INFO) | |
| 38 | − logger.propagate = False | |
| 39 | − return logger | |
deleted
M2M32brouve-ka/packages/queue/__init__.py
+0 −71
@@ -1,71 +0,0 @@ | ||
| 1 | −# Trouve-KA — coordination Redis (politesse, pause, enrichissement) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Coordination inter-workers via Redis. | |
| 6 | − | |
| 7 | −- Politesse par hôte : lock SET NX PX — un seul fetch par hôte par fenêtre, | |
| 8 | − quel que soit le nombre de workers. | |
| 9 | −- Pause globale du crawler : simple clé drapeau. | |
| 10 | −- Enrichissement asynchrone : Redis Stream (jamais bloquant pour l'indexation). | |
| 11 | −""" | |
| 12 | − | |
| 13 | −import json | |
| 14 | −from typing import Any | |
| 15 | − | |
| 16 | −import redis.asyncio as aioredis | |
| 17 | − | |
| 18 | −PAUSE_KEY = "trouveka:crawler:paused" | |
| 19 | −HOST_LOCK_PREFIX = "trouveka:host-lock:" | |
| 20 | −ENRICH_STREAM = "trouveka:enrich" | |
| 21 | − | |
| 22 | − | |
| 23 | −class Coordination: | |
| 24 | − def __init__(self, redis_url: str): | |
| 25 | − self.redis: aioredis.Redis = aioredis.from_url(redis_url, decode_responses=True) | |
| 26 | − | |
| 27 | − async def close(self) -> None: | |
| 28 | − await self.redis.aclose() | |
| 29 | − | |
| 30 | − # ------------------------------------------------------------- politesse | |
| 31 | − async def acquire_host_slot(self, host: str, delay_seconds: float) -> bool: | |
| 32 | − """Réserve le droit de fetcher cet hôte. False = trop tôt, repasser plus tard.""" | |
| 33 | − px = max(int(delay_seconds * 1000), 100) | |
| 34 | − return bool(await self.redis.set(HOST_LOCK_PREFIX + host, "1", nx=True, px=px)) | |
| 35 | − | |
| 36 | − # ------------------------------------------------------------- pause | |
| 37 | − async def pause_crawler(self) -> None: | |
| 38 | − await self.redis.set(PAUSE_KEY, "1") | |
| 39 | − | |
| 40 | − async def resume_crawler(self) -> None: | |
| 41 | − await self.redis.delete(PAUSE_KEY) | |
| 42 | − | |
| 43 | − async def is_paused(self) -> bool: | |
| 44 | − return await self.redis.exists(PAUSE_KEY) == 1 | |
| 45 | − | |
| 46 | − # ------------------------------------------------------------- enrichissement | |
| 47 | − async def enqueue_enrichment(self, payload: dict[str, Any]) -> None: | |
| 48 | − await self.redis.xadd(ENRICH_STREAM, {"data": json.dumps(payload, default=str)}, maxlen=100_000) | |
| 49 | − | |
| 50 | − async def read_enrichment( | |
| 51 | − self, group: str, consumer: str, count: int = 10, block_ms: int = 5000 | |
| 52 | − ) -> list[tuple[str, dict[str, Any]]]: | |
| 53 | − try: | |
| 54 | − await self.redis.xgroup_create(ENRICH_STREAM, group, id="0", mkstream=True) | |
| 55 | − except aioredis.ResponseError as exc: | |
| 56 | − if "BUSYGROUP" not in str(exc): | |
| 57 | − raise | |
| 58 | − entries = await self.redis.xreadgroup( | |
| 59 | − group, consumer, {ENRICH_STREAM: ">"}, count=count, block=block_ms | |
| 60 | − ) | |
| 61 | − out: list[tuple[str, dict[str, Any]]] = [] | |
| 62 | − for _stream, items in entries or []: | |
| 63 | − for msg_id, fields in items: | |
| 64 | − out.append((msg_id, json.loads(fields["data"]))) | |
| 65 | − return out | |
| 66 | − | |
| 67 | − async def ack_enrichment(self, group: str, msg_id: str) -> None: | |
| 68 | − await self.redis.xack(ENRICH_STREAM, group, msg_id) | |
| 69 | − | |
| 70 | − async def enrich_backlog(self) -> int: | |
| 71 | − return await self.redis.xlen(ENRICH_STREAM) | |
deleted
M2M32brouve-ka/packages/search-core/__init__.py
+0 −10
@@ -1,10 +0,0 @@ | ||
| 1 | −# Trouve-KA — couche OpenSearch (client, mapping, indexation) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Couche OpenSearch : client async, mapping bilingue FR/EN, indexation immédiate.""" | |
| 6 | − | |
| 7 | −from .client import SearchCore | |
| 8 | −from .mapping import INDEX_SETTINGS | |
| 9 | − | |
| 10 | −__all__ = ["SearchCore", "INDEX_SETTINGS"] | |
deleted
M2M32brouve-ka/packages/search-core/client.py
+0 −66
@@ -1,66 +0,0 @@ | ||
| 1 | −# Trouve-KA — client OpenSearch | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Client OpenSearch async : création d'index, indexation immédiate, recherche, statut.""" | |
| 6 | − | |
| 7 | −import hashlib | |
| 8 | −from typing import Any | |
| 9 | − | |
| 10 | −from opensearchpy import AsyncOpenSearch | |
| 11 | −from opensearchpy.exceptions import NotFoundError | |
| 12 | − | |
| 13 | −from .mapping import INDEX_SETTINGS | |
| 14 | − | |
| 15 | − | |
| 16 | −def doc_id_for_url(url: str) -> str: | |
| 17 | − """ID de document stable dérivé de l'URL canonique.""" | |
| 18 | − return hashlib.sha256(url.encode("utf-8")).hexdigest()[:32] | |
| 19 | − | |
| 20 | − | |
| 21 | −class SearchCore: | |
| 22 | − def __init__(self, search_url: str, index: str): | |
| 23 | − self.index = index | |
| 24 | − self.client = AsyncOpenSearch(hosts=[search_url], timeout=15, max_retries=2, retry_on_timeout=True) | |
| 25 | − | |
| 26 | − async def close(self) -> None: | |
| 27 | − await self.client.close() | |
| 28 | − | |
| 29 | − async def ensure_index(self) -> None: | |
| 30 | − if not await self.client.indices.exists(index=self.index): | |
| 31 | − await self.client.indices.create(index=self.index, body=INDEX_SETTINGS) | |
| 32 | − | |
| 33 | − async def index_document(self, doc: dict[str, Any]) -> str: | |
| 34 | − """Indexation immédiate d'un document (cherchable au prochain refresh, ~1 s).""" | |
| 35 | − _id = doc_id_for_url(doc["canonical_url"] or doc["url"]) | |
| 36 | − await self.client.index(index=self.index, id=_id, body=doc) | |
| 37 | − return _id | |
| 38 | − | |
| 39 | − async def update_document(self, url: str, partial: dict[str, Any]) -> None: | |
| 40 | − """Mise à jour partielle (enrichissement asynchrone, étapes 2-3).""" | |
| 41 | − try: | |
| 42 | − await self.client.update(index=self.index, id=doc_id_for_url(url), body={"doc": partial}) | |
| 43 | − except NotFoundError: | |
| 44 | − pass | |
| 45 | − | |
| 46 | − async def delete_document(self, url: str) -> None: | |
| 47 | − try: | |
| 48 | − await self.client.delete(index=self.index, id=doc_id_for_url(url)) | |
| 49 | − except NotFoundError: | |
| 50 | − pass | |
| 51 | − | |
| 52 | − async def search(self, body: dict[str, Any]) -> dict[str, Any]: | |
| 53 | − return await self.client.search(index=self.index, body=body) | |
| 54 | − | |
| 55 | − async def count(self) -> int: | |
| 56 | − try: | |
| 57 | − res = await self.client.count(index=self.index) | |
| 58 | − return res["count"] | |
| 59 | − except NotFoundError: | |
| 60 | − return 0 | |
| 61 | − | |
| 62 | − async def ping(self) -> bool: | |
| 63 | − try: | |
| 64 | − return await self.client.ping() | |
| 65 | − except Exception: | |
| 66 | − return False | |
deleted
M2M32brouve-ka/packages/search-core/mapping.py
+0 −124
@@ -1,124 +0,0 @@ | ||
| 1 | −# Trouve-KA — mapping OpenSearch bilingue | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Mapping et settings de l'index documents. | |
| 6 | − | |
| 7 | −- Analyzers français ET anglais sur les champs texte (bilingue dès le départ). | |
| 8 | −- Filtre de synonymes bilingues (thermopompe ↔ heat pump) appliqué à la recherche | |
| 9 | − seulement : pas de traduction à l'ingestion. | |
| 10 | −- Refresh 1 s : une page indexée est cherchable en ~1 seconde (principe cardinal). | |
| 11 | −""" | |
| 12 | − | |
| 13 | −# Synonymes bilingues de départ — extensibles sans réindexation (search-time) | |
| 14 | −BILINGUAL_SYNONYMS = [ | |
| 15 | − "thermopompe, heat pump", | |
| 16 | − "subvention, grant, subsidy", | |
| 17 | − "emploi, job, employment", | |
| 18 | − "entreprise, company, business", | |
| 19 | − "université, university", | |
| 20 | − "cégep, cegep, college", | |
| 21 | − "garderie, daycare, cpe", | |
| 22 | − "impôt, tax, taxes", | |
| 23 | − "santé, health", | |
| 24 | − "plombier, plumber", | |
| 25 | − "électricien, electrician", | |
| 26 | − "déneigement, snow removal", | |
| 27 | − "assurance, insurance", | |
| 28 | − "logement, housing, apartment", | |
| 29 | − "ville, city, municipalité, municipality", | |
| 30 | − "gouvernement, government", | |
| 31 | − "permis, permit, license, licence", | |
| 32 | −] | |
| 33 | − | |
| 34 | −INDEX_SETTINGS = { | |
| 35 | − "settings": { | |
| 36 | − "number_of_shards": 1, | |
| 37 | − "number_of_replicas": 0, | |
| 38 | − "refresh_interval": "1s", | |
| 39 | − "analysis": { | |
| 40 | − "filter": { | |
| 41 | − "french_elision": { | |
| 42 | − "type": "elision", | |
| 43 | − "articles_case": True, | |
| 44 | − "articles": ["l", "m", "t", "qu", "n", "s", "j", "d", "c", "jusqu", "quoiqu", "lorsqu", "puisqu"], | |
| 45 | − }, | |
| 46 | − "french_stop": {"type": "stop", "stopwords": "_french_"}, | |
| 47 | − "french_stemmer": {"type": "stemmer", "language": "light_french"}, | |
| 48 | − "english_stop": {"type": "stop", "stopwords": "_english_"}, | |
| 49 | − "english_stemmer": {"type": "stemmer", "language": "light_english"}, | |
| 50 | − "bilingual_synonyms": { | |
| 51 | − "type": "synonym_graph", | |
| 52 | − "lenient": True, | |
| 53 | − "synonyms": BILINGUAL_SYNONYMS, | |
| 54 | − }, | |
| 55 | − }, | |
| 56 | − "analyzer": { | |
| 57 | − "fr_text": { | |
| 58 | − "tokenizer": "standard", | |
| 59 | − "filter": ["french_elision", "lowercase", "asciifolding", "french_stop", "french_stemmer"], | |
| 60 | − }, | |
| 61 | − "fr_search": { | |
| 62 | − "tokenizer": "standard", | |
| 63 | − "filter": ["french_elision", "lowercase", "asciifolding", "bilingual_synonyms", | |
| 64 | − "french_stop", "french_stemmer"], | |
| 65 | − }, | |
| 66 | − "en_text": { | |
| 67 | − "tokenizer": "standard", | |
| 68 | − "filter": ["lowercase", "asciifolding", "english_stop", "english_stemmer"], | |
| 69 | − }, | |
| 70 | − "en_search": { | |
| 71 | − "tokenizer": "standard", | |
| 72 | − "filter": ["lowercase", "asciifolding", "bilingual_synonyms", | |
| 73 | − "english_stop", "english_stemmer"], | |
| 74 | − }, | |
| 75 | − }, | |
| 76 | − }, | |
| 77 | − }, | |
| 78 | − "mappings": { | |
| 79 | − "properties": { | |
| 80 | − "url": {"type": "keyword"}, | |
| 81 | − "canonical_url": {"type": "keyword"}, | |
| 82 | − "domain": {"type": "keyword"}, | |
| 83 | − "title": { | |
| 84 | − "type": "text", | |
| 85 | − "analyzer": "fr_text", | |
| 86 | − "search_analyzer": "fr_search", | |
| 87 | − "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 88 | − }, | |
| 89 | − "description": { | |
| 90 | − "type": "text", | |
| 91 | − "analyzer": "fr_text", | |
| 92 | − "search_analyzer": "fr_search", | |
| 93 | − "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 94 | − }, | |
| 95 | − "body": { | |
| 96 | − "type": "text", | |
| 97 | − "analyzer": "fr_text", | |
| 98 | − "search_analyzer": "fr_search", | |
| 99 | − "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 100 | − }, | |
| 101 | − "headings": { | |
| 102 | − "type": "text", | |
| 103 | − "analyzer": "fr_text", | |
| 104 | − "search_analyzer": "fr_search", | |
| 105 | − "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 106 | − }, | |
| 107 | − "language": {"type": "keyword"}, | |
| 108 | − "page_quebec_score": {"type": "float"}, | |
| 109 | − "domain_quebec_score": {"type": "float"}, | |
| 110 | − "locations": {"type": "keyword"}, | |
| 111 | − "organizations": {"type": "keyword"}, | |
| 112 | − "people": {"type": "keyword"}, | |
| 113 | − "categories": {"type": "keyword"}, | |
| 114 | − "published_at": {"type": "date"}, | |
| 115 | − "crawled_at": {"type": "date"}, | |
| 116 | − "authority_score": {"type": "float"}, | |
| 117 | − "freshness_score": {"type": "float"}, | |
| 118 | − "quality_score": {"type": "float"}, | |
| 119 | − "spam_score": {"type": "float"}, | |
| 120 | − # embedding (dense_vector/knn) ajouté à l'étape sémantique (§17.7), | |
| 121 | − # le mapping est extensible sans réindexation pour un nouveau champ. | |
| 122 | − } | |
| 123 | − }, | |
| 124 | −} | |
deleted
M2M32brouve-ka/packages/shared/__init__.py
+0 −20
@@ -1,20 +0,0 @@ | ||
| 1 | −# Trouve-KA — utilitaires partagés | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Utilitaires partagés : canonicalisation d'URL, garde SSRF, hachage de contenu.""" | |
| 6 | − | |
| 7 | −from .urls import canonicalize_url, display_url, extract_domain, is_http_url | |
| 8 | −from .ssrf import is_safe_url, is_safe_ip | |
| 9 | −from .hashing import content_hash, text_fingerprint | |
| 10 | − | |
| 11 | −__all__ = [ | |
| 12 | − "canonicalize_url", | |
| 13 | − "display_url", | |
| 14 | − "extract_domain", | |
| 15 | − "is_http_url", | |
| 16 | − "is_safe_url", | |
| 17 | − "is_safe_ip", | |
| 18 | − "content_hash", | |
| 19 | − "text_fingerprint", | |
| 20 | −] | |
deleted
M2M32brouve-ka/packages/shared/hashing.py
+0 −25
@@ -1,25 +0,0 @@ | ||
| 1 | −# Trouve-KA — hachage de contenu et détection de changement | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Hachage de contenu pour la déduplication exacte et la détection de changement. | |
| 6 | − | |
| 7 | −Le hash est calculé sur le texte extrait normalisé (pas le HTML brut) pour | |
| 8 | −ignorer le bruit de balisage (nonces, timestamps de rendu, etc.). | |
| 9 | −""" | |
| 10 | − | |
| 11 | −import hashlib | |
| 12 | −import re | |
| 13 | − | |
| 14 | −_WHITESPACE = re.compile(r"\s+") | |
| 15 | − | |
| 16 | − | |
| 17 | −def text_fingerprint(text: str) -> str: | |
| 18 | − """Texte normalisé pour hachage : espaces réduits, minuscules.""" | |
| 19 | − return _WHITESPACE.sub(" ", text).strip().lower() | |
| 20 | − | |
| 21 | − | |
| 22 | −def content_hash(title: str, body: str) -> str: | |
| 23 | − """SHA-256 du contenu textuel normalisé (titre + corps).""" | |
| 24 | − payload = text_fingerprint(title) + "\n" + text_fingerprint(body) | |
| 25 | − return hashlib.sha256(payload.encode("utf-8")).hexdigest() | |
deleted
M2M32brouve-ka/packages/shared/ssrf.py
+0 −75
@@ -1,75 +0,0 @@ | ||
| 1 | −# Trouve-KA — prévention SSRF | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Garde SSRF du crawler (CLAUDE.md §5.7). | |
| 6 | − | |
| 7 | −Bloque localhost, plages privées IPv4/IPv6, link-local, métadonnées cloud, | |
| 8 | −schémas non-HTTP. La résolution DNS est revalidée à chaque saut de redirection | |
| 9 | −par l'appelant (fetcher). | |
| 10 | −""" | |
| 11 | − | |
| 12 | −import ipaddress | |
| 13 | −import socket | |
| 14 | −from urllib.parse import urlsplit | |
| 15 | − | |
| 16 | −BLOCKED_HOSTS = { | |
| 17 | − "localhost", | |
| 18 | − "metadata.google.internal", | |
| 19 | − "metadata.gke.internal", | |
| 20 | −} | |
| 21 | − | |
| 22 | −# Endpoint de métadonnées AWS/GCP/Azure/OpenStack | |
| 23 | −METADATA_IPS = {"169.254.169.254", "fd00:ec2::254"} | |
| 24 | − | |
| 25 | − | |
| 26 | −def is_safe_ip(ip_str: str) -> bool: | |
| 27 | − try: | |
| 28 | − ip = ipaddress.ip_address(ip_str) | |
| 29 | − except ValueError: | |
| 30 | − return False | |
| 31 | − if ip_str in METADATA_IPS: | |
| 32 | − return False | |
| 33 | − return not ( | |
| 34 | − ip.is_private | |
| 35 | − or ip.is_loopback | |
| 36 | − or ip.is_link_local | |
| 37 | − or ip.is_multicast | |
| 38 | − or ip.is_reserved | |
| 39 | − or ip.is_unspecified | |
| 40 | − ) | |
| 41 | − | |
| 42 | − | |
| 43 | −def resolve_host(host: str) -> list[str]: | |
| 44 | − """Résout un hôte en IPs (IPv4+IPv6). Lève socket.gaierror si introuvable.""" | |
| 45 | − infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP) | |
| 46 | − return list({info[4][0] for info in infos}) | |
| 47 | − | |
| 48 | − | |
| 49 | −def is_safe_url(url: str, *, resolve: bool = True) -> bool: | |
| 50 | − """Vérifie qu'une URL est sûre à fetcher (schéma, hôte, IPs résolues).""" | |
| 51 | − try: | |
| 52 | − parts = urlsplit(url) | |
| 53 | − except ValueError: | |
| 54 | − return False | |
| 55 | − if parts.scheme.lower() not in ("http", "https"): | |
| 56 | − return False | |
| 57 | − host = parts.hostname | |
| 58 | − if not host: | |
| 59 | − return False | |
| 60 | − host = host.lower().strip(".") | |
| 61 | − if host in BLOCKED_HOSTS or host.endswith(".localhost") or host.endswith(".internal"): | |
| 62 | − return False | |
| 63 | − # Hôte littéral IP | |
| 64 | − try: | |
| 65 | − ipaddress.ip_address(host) | |
| 66 | − return is_safe_ip(host) | |
| 67 | − except ValueError: | |
| 68 | − pass | |
| 69 | − if not resolve: | |
| 70 | − return True | |
| 71 | − try: | |
| 72 | − ips = resolve_host(host) | |
| 73 | − except (socket.gaierror, OSError): | |
| 74 | − return False | |
| 75 | − return bool(ips) and all(is_safe_ip(ip) for ip in ips) | |
deleted
M2M32brouve-ka/packages/shared/urls.py
+0 −103
@@ -1,103 +0,0 @@ | ||
| 1 | −# Trouve-KA — canonicalisation d'URL | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Canonicalisation d'URL prudente. | |
| 6 | − | |
| 7 | −Règle cardinale (CLAUDE.md §5.4) : ne JAMAIS fusionner deux ressources | |
| 8 | −distinctes par accident. On normalise seulement ce qui est sûr : | |
| 9 | −fragments, ports par défaut, casse de l'hôte, paramètres de tracking connus, | |
| 10 | −encodage. On ne touche ni à la casse du chemin ni aux paramètres inconnus. | |
| 11 | −""" | |
| 12 | − | |
| 13 | −from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit, quote, unquote | |
| 14 | − | |
| 15 | −# Paramètres de tracking sûrs à retirer (jamais porteurs de contenu) | |
| 16 | −TRACKING_PARAMS = { | |
| 17 | − "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id", | |
| 18 | − "fbclid", "gclid", "gclsrc", "dclid", "msclkid", "twclid", "igshid", | |
| 19 | − "mc_cid", "mc_eid", "_ga", "_gl", "yclid", "wbraid", "gbraid", | |
| 20 | − "ref_src", "cmpid", "s_kwcid", "spm", | |
| 21 | −} | |
| 22 | − | |
| 23 | −DEFAULT_PORTS = {"http": 80, "https": 443} | |
| 24 | − | |
| 25 | − | |
| 26 | −def is_http_url(url: str) -> bool: | |
| 27 | − try: | |
| 28 | − scheme = urlsplit(url).scheme.lower() | |
| 29 | − except ValueError: | |
| 30 | − return False | |
| 31 | − return scheme in ("http", "https") | |
| 32 | − | |
| 33 | − | |
| 34 | −def canonicalize_url(url: str, base: str | None = None) -> str | None: | |
| 35 | − """Normalise une URL. Retourne None si l'URL n'est pas crawlable (schéma non http).""" | |
| 36 | − url = url.strip() | |
| 37 | − if not url: | |
| 38 | − return None | |
| 39 | − if base: | |
| 40 | − from urllib.parse import urljoin | |
| 41 | − | |
| 42 | − url = urljoin(base, url) | |
| 43 | − try: | |
| 44 | − parts = urlsplit(url) | |
| 45 | − except ValueError: | |
| 46 | − return None | |
| 47 | − scheme = parts.scheme.lower() | |
| 48 | − if scheme not in ("http", "https"): | |
| 49 | − return None | |
| 50 | − host = parts.hostname | |
| 51 | − if not host: | |
| 52 | − return None | |
| 53 | − host = host.strip(".").lower() | |
| 54 | − try: | |
| 55 | − host = host.encode("idna").decode("ascii") if any(ord(c) > 127 for c in host) else host | |
| 56 | − except UnicodeError: | |
| 57 | − return None | |
| 58 | − | |
| 59 | − port = parts.port | |
| 60 | − netloc = host | |
| 61 | − if port and port != DEFAULT_PORTS.get(scheme): | |
| 62 | − netloc = f"{host}:{port}" | |
| 63 | − | |
| 64 | − # Chemin : ré-encoder proprement sans changer la sémantique (casse préservée) | |
| 65 | − path = quote(unquote(parts.path or "/"), safe="/%:@!$&'()*+,;=~-._") | |
| 66 | − # Slashs multiples consécutifs → un seul (sûr pour HTTP) | |
| 67 | − while "//" in path: | |
| 68 | − path = path.replace("//", "/") | |
| 69 | − if not path: | |
| 70 | − path = "/" | |
| 71 | − | |
| 72 | − # Query : retirer uniquement les paramètres de tracking connus; préserver l'ordre | |
| 73 | − query = "" | |
| 74 | − if parts.query: | |
| 75 | − kept = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) | |
| 76 | − if k.lower() not in TRACKING_PARAMS] | |
| 77 | − query = urlencode(kept) | |
| 78 | − | |
| 79 | − # Fragment : toujours retiré (jamais envoyé au serveur) | |
| 80 | − return urlunsplit((scheme, netloc, path, query, "")) | |
| 81 | − | |
| 82 | − | |
| 83 | −def extract_domain(url: str) -> str | None: | |
| 84 | − """Domaine enregistrable approximatif : hôte sans le préfixe www.""" | |
| 85 | − try: | |
| 86 | − host = urlsplit(url).hostname | |
| 87 | − except ValueError: | |
| 88 | − return None | |
| 89 | − if not host: | |
| 90 | − return None | |
| 91 | − host = host.lower().strip(".") | |
| 92 | − return host[4:] if host.startswith("www.") else host | |
| 93 | − | |
| 94 | − | |
| 95 | −def display_url(url: str, max_len: int = 80) -> str: | |
| 96 | − """URL d'affichage façon breadcrumb : quebec.ca › services › permis.""" | |
| 97 | − parts = urlsplit(url) | |
| 98 | − host = (parts.hostname or "").removeprefix("www.") | |
| 99 | − segments = [unquote(s) for s in parts.path.split("/") if s] | |
| 100 | − crumb = " › ".join([host, *segments[:3]]) | |
| 101 | − if len(crumb) > max_len: | |
| 102 | − crumb = crumb[: max_len - 1] + "…" | |
| 103 | − return crumb | |
deleted
M2M32brouve-ka/packages/types/__init__.py
+0 −103
@@ -1,103 +0,0 @@ | ||
| 1 | −# Trouve-KA — types partagés du pipeline | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Modèles Pydantic partagés entre crawler, parser, classifier, indexer et API.""" | |
| 6 | − | |
| 7 | −from datetime import datetime | |
| 8 | −from enum import StrEnum | |
| 9 | − | |
| 10 | −from pydantic import BaseModel, Field | |
| 11 | − | |
| 12 | − | |
| 13 | −class ErrorCode(StrEnum): | |
| 14 | − DNS = "dns" | |
| 15 | − TIMEOUT = "timeout" | |
| 16 | − TLS = "tls" | |
| 17 | − HTTP_4XX = "http_4xx" | |
| 18 | − HTTP_5XX = "http_5xx" | |
| 19 | − ROBOTS_DENIED = "robots_denied" | |
| 20 | − PARSE_FAILED = "parse_failed" | |
| 21 | − UNSUPPORTED_CONTENT = "unsupported_content" | |
| 22 | − TOO_LARGE = "too_large" | |
| 23 | − DUPLICATE = "duplicate" | |
| 24 | − SPAM = "spam" | |
| 25 | − NOT_QUEBEC = "not_quebec" | |
| 26 | − SSRF_BLOCKED = "ssrf_blocked" | |
| 27 | − TOO_MANY_REDIRECTS = "too_many_redirects" | |
| 28 | − CONNECTION = "connection" | |
| 29 | − OK = "ok" | |
| 30 | − | |
| 31 | − | |
| 32 | −class Outcome(StrEnum): | |
| 33 | − INDEXED = "indexed" | |
| 34 | − DUPLICATE = "duplicate" | |
| 35 | − UNCHANGED = "unchanged" | |
| 36 | − ERROR = "error" | |
| 37 | − NOT_QUEBEC = "not_quebec" | |
| 38 | − ROBOTS_BLOCKED = "robots_blocked" | |
| 39 | − REDIRECT = "redirect" | |
| 40 | − | |
| 41 | − | |
| 42 | −class FetchResult(BaseModel): | |
| 43 | − url: str | |
| 44 | − final_url: str | |
| 45 | − status_code: int | None = None | |
| 46 | − error_code: ErrorCode = ErrorCode.OK | |
| 47 | − content_type: str | None = None | |
| 48 | − charset: str | None = None | |
| 49 | − body: bytes | None = None | |
| 50 | − etag: str | None = None | |
| 51 | − last_modified: str | None = None | |
| 52 | − duration_ms: int = 0 | |
| 53 | − redirect_chain: list[str] = Field(default_factory=list) | |
| 54 | − | |
| 55 | − | |
| 56 | −class ExtractedLink(BaseModel): | |
| 57 | − url: str | |
| 58 | − anchor: str = "" | |
| 59 | − nofollow: bool = False | |
| 60 | − | |
| 61 | − | |
| 62 | −class ParsedPage(BaseModel): | |
| 63 | − url: str | |
| 64 | − canonical_url: str | None = None | |
| 65 | − title: str = "" | |
| 66 | − description: str = "" | |
| 67 | − body: str = "" | |
| 68 | − headings: list[str] = Field(default_factory=list) | |
| 69 | − language: str | None = None | |
| 70 | − links: list[ExtractedLink] = Field(default_factory=list) | |
| 71 | − published_at: datetime | None = None | |
| 72 | − modified_at: datetime | None = None | |
| 73 | − author: str | None = None | |
| 74 | − noindex: bool = False | |
| 75 | − nofollow_page: bool = False | |
| 76 | − structured_hints: list[str] = Field(default_factory=list) # textes JSON-LD/OG utiles au scoring | |
| 77 | − | |
| 78 | − | |
| 79 | −class QuebecSignals(BaseModel): | |
| 80 | − score: float = 0.0 | |
| 81 | − locations: list[str] = Field(default_factory=list) | |
| 82 | − reasons: list[str] = Field(default_factory=list) | |
| 83 | − | |
| 84 | − | |
| 85 | −class SearchResult(BaseModel): | |
| 86 | − title: str | |
| 87 | − url: str | |
| 88 | − display_url: str | |
| 89 | − snippet: str | |
| 90 | − domain: str | |
| 91 | − language: str | None = None | |
| 92 | − quebec_score: float = 0.0 | |
| 93 | − badges: list[str] = Field(default_factory=list) | |
| 94 | − published_at: datetime | None = None | |
| 95 | − | |
| 96 | − | |
| 97 | −class SearchResponse(BaseModel): | |
| 98 | − query: str | |
| 99 | − total: int | |
| 100 | − took_ms: int | |
| 101 | − page: int | |
| 102 | − limit: int | |
| 103 | − results: list[SearchResult] | |
deleted
M2M32brouve-ka/pnpm-lock.yaml
+0 −3967
@@ -1,3967 +0,0 @@ | ||
| 1 | −lockfileVersion: '9.0' | |
| 2 | − | |
| 3 | −settings: | |
| 4 | − autoInstallPeers: true | |
| 5 | − excludeLinksFromLockfile: false | |
| 6 | − | |
| 7 | −importers: | |
| 8 | − | |
| 9 | − .: {} | |
| 10 | − | |
| 11 | − apps/web: | |
| 12 | − dependencies: | |
| 13 | − class-variance-authority: | |
| 14 | − specifier: ^0.7.1 | |
| 15 | − version: 0.7.1 | |
| 16 | − clsx: | |
| 17 | − specifier: ^2.1.1 | |
| 18 | − version: 2.1.1 | |
| 19 | − next: | |
| 20 | − specifier: ^15.3.5 | |
| 21 | − version: 15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8) | |
| 22 | − react: | |
| 23 | − specifier: ^19.1.0 | |
| 24 | − version: 19.2.8 | |
| 25 | − react-dom: | |
| 26 | − specifier: ^19.1.0 | |
| 27 | − version: 19.2.8(react@19.2.8) | |
| 28 | − tailwind-merge: | |
| 29 | − specifier: ^2.6.0 | |
| 30 | − version: 2.6.1 | |
| 31 | − devDependencies: | |
| 32 | − '@types/node': | |
| 33 | − specifier: ^22.15.0 | |
| 34 | − version: 22.20.1 | |
| 35 | − '@types/react': | |
| 36 | − specifier: ^19.1.0 | |
| 37 | − version: 19.2.18 | |
| 38 | − '@types/react-dom': | |
| 39 | − specifier: ^19.1.0 | |
| 40 | − version: 19.2.4(@types/react@19.2.18) | |
| 41 | − autoprefixer: | |
| 42 | − specifier: ^10.4.21 | |
| 43 | − version: 10.5.4(postcss@8.5.26) | |
| 44 | − eslint: | |
| 45 | − specifier: ^8.57.1 | |
| 46 | − version: 8.57.1 | |
| 47 | − eslint-config-next: | |
| 48 | − specifier: ^15.3.5 | |
| 49 | − version: 15.5.23(eslint@8.57.1)(typescript@5.9.3) | |
| 50 | − postcss: | |
| 51 | − specifier: ^8.5.3 | |
| 52 | − version: 8.5.26 | |
| 53 | − tailwindcss: | |
| 54 | − specifier: ^3.4.17 | |
| 55 | − version: 3.4.19 | |
| 56 | − typescript: | |
| 57 | − specifier: ^5.8.3 | |
| 58 | − version: 5.9.3 | |
| 59 | − | |
| 60 | −packages: | |
| 61 | − | |
| 62 | − '@alloc/quick-lru@5.2.0': | |
| 63 | − resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} | |
| 64 | − engines: {node: '>=10'} | |
| 65 | − | |
| 66 | − '@emnapi/core@1.10.0': | |
| 67 | − resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} | |
| 68 | − | |
| 69 | − '@emnapi/runtime@1.10.0': | |
| 70 | − resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} | |
| 71 | − | |
| 72 | − '@emnapi/runtime@1.11.3': | |
| 73 | − resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} | |
| 74 | − | |
| 75 | − '@emnapi/wasi-threads@1.2.1': | |
| 76 | − resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} | |
| 77 | − | |
| 78 | − '@eslint-community/eslint-utils@4.10.1': | |
| 79 | − resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} | |
| 80 | − engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 81 | − peerDependencies: | |
| 82 | − eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 | |
| 83 | − | |
| 84 | − '@eslint-community/regexpp@4.12.2': | |
| 85 | − resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} | |
| 86 | − engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} | |
| 87 | − | |
| 88 | − '@eslint/eslintrc@2.1.4': | |
| 89 | − resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} | |
| 90 | − engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 91 | − | |
| 92 | − '@eslint/js@8.57.1': | |
| 93 | − resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} | |
| 94 | − engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 95 | − | |
| 96 | − '@humanwhocodes/config-array@0.13.0': | |
| 97 | − resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} | |
| 98 | − engines: {node: '>=10.10.0'} | |
| 99 | − deprecated: Use @eslint/config-array instead | |
| 100 | − | |
| 101 | − '@humanwhocodes/module-importer@1.0.1': | |
| 102 | − resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} | |
| 103 | − engines: {node: '>=12.22'} | |
| 104 | − | |
| 105 | − '@humanwhocodes/object-schema@2.0.3': | |
| 106 | − resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} | |
| 107 | − deprecated: Use @eslint/object-schema instead | |
| 108 | − | |
| 109 | − '@img/colour@1.1.0': | |
| 110 | − resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} | |
| 111 | − engines: {node: '>=18'} | |
| 112 | − | |
| 113 | − '@img/sharp-darwin-arm64@0.34.5': | |
| 114 | − resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} | |
| 115 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 116 | − cpu: [arm64] | |
| 117 | − os: [darwin] | |
| 118 | − | |
| 119 | − '@img/sharp-darwin-x64@0.34.5': | |
| 120 | − resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} | |
| 121 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 122 | − cpu: [x64] | |
| 123 | − os: [darwin] | |
| 124 | − | |
| 125 | − '@img/sharp-libvips-darwin-arm64@1.2.4': | |
| 126 | − resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} | |
| 127 | − cpu: [arm64] | |
| 128 | − os: [darwin] | |
| 129 | − | |
| 130 | − '@img/sharp-libvips-darwin-x64@1.2.4': | |
| 131 | − resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} | |
| 132 | − cpu: [x64] | |
| 133 | − os: [darwin] | |
| 134 | − | |
| 135 | − '@img/sharp-libvips-linux-arm64@1.2.4': | |
| 136 | − resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} | |
| 137 | − cpu: [arm64] | |
| 138 | − os: [linux] | |
| 139 | − libc: [glibc] | |
| 140 | − | |
| 141 | − '@img/sharp-libvips-linux-arm@1.2.4': | |
| 142 | − resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} | |
| 143 | − cpu: [arm] | |
| 144 | − os: [linux] | |
| 145 | − libc: [glibc] | |
| 146 | − | |
| 147 | − '@img/sharp-libvips-linux-ppc64@1.2.4': | |
| 148 | − resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} | |
| 149 | − cpu: [ppc64] | |
| 150 | − os: [linux] | |
| 151 | − libc: [glibc] | |
| 152 | − | |
| 153 | − '@img/sharp-libvips-linux-riscv64@1.2.4': | |
| 154 | − resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} | |
| 155 | − cpu: [riscv64] | |
| 156 | − os: [linux] | |
| 157 | − libc: [glibc] | |
| 158 | − | |
| 159 | − '@img/sharp-libvips-linux-s390x@1.2.4': | |
| 160 | − resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} | |
| 161 | − cpu: [s390x] | |
| 162 | − os: [linux] | |
| 163 | − libc: [glibc] | |
| 164 | − | |
| 165 | − '@img/sharp-libvips-linux-x64@1.2.4': | |
| 166 | − resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} | |
| 167 | − cpu: [x64] | |
| 168 | − os: [linux] | |
| 169 | − libc: [glibc] | |
| 170 | − | |
| 171 | − '@img/sharp-libvips-linuxmusl-arm64@1.2.4': | |
| 172 | − resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} | |
| 173 | − cpu: [arm64] | |
| 174 | − os: [linux] | |
| 175 | − libc: [musl] | |
| 176 | − | |
| 177 | − '@img/sharp-libvips-linuxmusl-x64@1.2.4': | |
| 178 | − resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} | |
| 179 | − cpu: [x64] | |
| 180 | − os: [linux] | |
| 181 | − libc: [musl] | |
| 182 | − | |
| 183 | − '@img/sharp-linux-arm64@0.34.5': | |
| 184 | − resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} | |
| 185 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 186 | − cpu: [arm64] | |
| 187 | − os: [linux] | |
| 188 | − libc: [glibc] | |
| 189 | − | |
| 190 | − '@img/sharp-linux-arm@0.34.5': | |
| 191 | − resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} | |
| 192 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 193 | − cpu: [arm] | |
| 194 | − os: [linux] | |
| 195 | − libc: [glibc] | |
| 196 | − | |
| 197 | − '@img/sharp-linux-ppc64@0.34.5': | |
| 198 | − resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} | |
| 199 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 200 | − cpu: [ppc64] | |
| 201 | − os: [linux] | |
| 202 | − libc: [glibc] | |
| 203 | − | |
| 204 | − '@img/sharp-linux-riscv64@0.34.5': | |
| 205 | − resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} | |
| 206 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 207 | − cpu: [riscv64] | |
| 208 | − os: [linux] | |
| 209 | − libc: [glibc] | |
| 210 | − | |
| 211 | − '@img/sharp-linux-s390x@0.34.5': | |
| 212 | − resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} | |
| 213 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 214 | − cpu: [s390x] | |
| 215 | − os: [linux] | |
| 216 | − libc: [glibc] | |
| 217 | − | |
| 218 | − '@img/sharp-linux-x64@0.34.5': | |
| 219 | − resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} | |
| 220 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 221 | − cpu: [x64] | |
| 222 | − os: [linux] | |
| 223 | − libc: [glibc] | |
| 224 | − | |
| 225 | − '@img/sharp-linuxmusl-arm64@0.34.5': | |
| 226 | − resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} | |
| 227 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 228 | − cpu: [arm64] | |
| 229 | − os: [linux] | |
| 230 | − libc: [musl] | |
| 231 | − | |
| 232 | − '@img/sharp-linuxmusl-x64@0.34.5': | |
| 233 | − resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} | |
| 234 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 235 | − cpu: [x64] | |
| 236 | − os: [linux] | |
| 237 | − libc: [musl] | |
| 238 | − | |
| 239 | − '@img/sharp-wasm32@0.34.5': | |
| 240 | − resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} | |
| 241 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 242 | − cpu: [wasm32] | |
| 243 | − | |
| 244 | − '@img/sharp-win32-arm64@0.34.5': | |
| 245 | − resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} | |
| 246 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 247 | − cpu: [arm64] | |
| 248 | − os: [win32] | |
| 249 | − | |
| 250 | − '@img/sharp-win32-ia32@0.34.5': | |
| 251 | − resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} | |
| 252 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 253 | − cpu: [ia32] | |
| 254 | − os: [win32] | |
| 255 | − | |
| 256 | − '@img/sharp-win32-x64@0.34.5': | |
| 257 | − resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} | |
| 258 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 259 | − cpu: [x64] | |
| 260 | − os: [win32] | |
| 261 | − | |
| 262 | − '@jridgewell/gen-mapping@0.3.13': | |
| 263 | − resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} | |
| 264 | − | |
| 265 | − '@jridgewell/resolve-uri@3.1.2': | |
| 266 | − resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} | |
| 267 | − engines: {node: '>=6.0.0'} | |
| 268 | − | |
| 269 | − '@jridgewell/sourcemap-codec@1.5.5': | |
| 270 | − resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} | |
| 271 | − | |
| 272 | − '@jridgewell/trace-mapping@0.3.31': | |
| 273 | − resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} | |
| 274 | − | |
| 275 | − '@napi-rs/wasm-runtime@1.2.3': | |
| 276 | − resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} | |
| 277 | − engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} | |
| 278 | − peerDependencies: | |
| 279 | − '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 | |
| 280 | − '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 | |
| 281 | − | |
| 282 | − '@next/env@15.5.23': | |
| 283 | − resolution: {integrity: sha512-Mv3Z9hVbFcPnoLevsZ6rnX1TBtyHb5E17yN7HTPDXSXxeNsGBjUFrdbjRXKKXIOhfth7/cg6Ay7PZ2UFawaWsQ==} | |
| 284 | − | |
| 285 | − '@next/eslint-plugin-next@15.5.23': | |
| 286 | − resolution: {integrity: sha512-0KnCFpiWVIsbwBhByZ0uIcjYM5xqGrFzN2eOPbwru/wuy5Z1dmA+3gP+PRbi4gl1Ny7an66BqAM/NHkX/50rbw==} | |
| 287 | − | |
| 288 | − '@next/swc-darwin-arm64@15.5.23': | |
| 289 | − resolution: {integrity: sha512-SrEwOROH/rhA03F59hHtdhgtfZMWGzr5duDBWgRQt2rS3mJhqMKOcnNx6txOd0/i3E3D3uFKYFvyHsEiwQxzag==} | |
| 290 | − engines: {node: '>= 10'} | |
| 291 | − cpu: [arm64] | |
| 292 | − os: [darwin] | |
| 293 | − | |
| 294 | − '@next/swc-darwin-x64@15.5.23': | |
| 295 | − resolution: {integrity: sha512-f0FpFbG2EhDCuptBGcfrLcYMDuQAhe6m1QA4VVfXFrIBoFXvXt/olGbBkYkloKlXQtmhuzvtdYyuu/6zf07GIg==} | |
| 296 | − engines: {node: '>= 10'} | |
| 297 | − cpu: [x64] | |
| 298 | − os: [darwin] | |
| 299 | − | |
| 300 | − '@next/swc-linux-arm64-gnu@15.5.23': | |
| 301 | − resolution: {integrity: sha512-WlNtfepUXKX2u2ZsJZ8c3c8+tJSRZqsYzoMwLOY72A8ucKCCgxgNhiePA3qzFYahVWrwcQd8jOeJmBinc+VFVQ==} | |
| 302 | − engines: {node: '>= 10'} | |
| 303 | − cpu: [arm64] | |
| 304 | − os: [linux] | |
| 305 | − libc: [glibc] | |
| 306 | − | |
| 307 | − '@next/swc-linux-arm64-musl@15.5.23': | |
| 308 | − resolution: {integrity: sha512-W/6qKk7UG93mg14PmQC+2urt69MIdwTBLNQ6MJyeC4wOCIHCjz+VfgssvS1pK7mgYBtLC1g6VKNoHD9xB0WWGg==} | |
| 309 | − engines: {node: '>= 10'} | |
| 310 | − cpu: [arm64] | |
| 311 | − os: [linux] | |
| 312 | − libc: [musl] | |
| 313 | − | |
| 314 | − '@next/swc-linux-x64-gnu@15.5.23': | |
| 315 | − resolution: {integrity: sha512-vzefI32mi6VMk96RaTAyxApgfGbiFzQBXVsekEjsDv1fr48mlABTWx0sUYhaYCBHWqCalxmz3DxbxFcbFvzNtw==} | |
| 316 | − engines: {node: '>= 10'} | |
| 317 | − cpu: [x64] | |
| 318 | − os: [linux] | |
| 319 | − libc: [glibc] | |
| 320 | − | |
| 321 | − '@next/swc-linux-x64-musl@15.5.23': | |
| 322 | − resolution: {integrity: sha512-qppK/3dTGOTI+aoWWBZc3DshFIhrzgL8guATlaN9V6M1QJxbkP/rhEZ22tdICsQ/2WWXopMZ2Jokzj2u3uKY3Q==} | |
| 323 | − engines: {node: '>= 10'} | |
| 324 | − cpu: [x64] | |
| 325 | − os: [linux] | |
| 326 | − libc: [musl] | |
| 327 | − | |
| 328 | − '@next/swc-win32-arm64-msvc@15.5.23': | |
| 329 | − resolution: {integrity: sha512-Wc29KFOdT7XBcII3Vtmw7aoU8Uk3Mes/FNJfhFeSHdYBFJWMcR/DsI8U9BCPUhq/uycsUVuqSKGthW15tLsigA==} | |
| 330 | − engines: {node: '>= 10'} | |
| 331 | − cpu: [arm64] | |
| 332 | − os: [win32] | |
| 333 | − | |
| 334 | − '@next/swc-win32-x64-msvc@15.5.23': | |
| 335 | − resolution: {integrity: sha512-/C7wRW4fa9s/PKA18zGPPpVmx8ycgVpP8yOxro4gzGTzjPJdscbAP3ODeFvgiIovxD176Z2J/SXO9t8PJKHLeQ==} | |
| 336 | − engines: {node: '>= 10'} | |
| 337 | − cpu: [x64] | |
| 338 | − os: [win32] | |
| 339 | − | |
| 340 | − '@nodelib/fs.scandir@2.1.5': | |
| 341 | − resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} | |
| 342 | − engines: {node: '>= 8'} | |
| 343 | − | |
| 344 | − '@nodelib/fs.stat@2.0.5': | |
| 345 | − resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} | |
| 346 | − engines: {node: '>= 8'} | |
| 347 | − | |
| 348 | − '@nodelib/fs.walk@1.2.8': | |
| 349 | − resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} | |
| 350 | − engines: {node: '>= 8'} | |
| 351 | − | |
| 352 | − '@nolyfill/is-core-module@1.0.39': | |
| 353 | − resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} | |
| 354 | − engines: {node: '>=12.4.0'} | |
| 355 | − | |
| 356 | − '@rtsao/scc@1.1.0': | |
| 357 | − resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} | |
| 358 | − | |
| 359 | − '@rushstack/eslint-patch@1.16.1': | |
| 360 | − resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} | |
| 361 | − | |
| 362 | − '@swc/helpers@0.5.15': | |
| 363 | − resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} | |
| 364 | − | |
| 365 | − '@tybys/wasm-util@0.10.3': | |
| 366 | − resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} | |
| 367 | − | |
| 368 | − '@types/json5@0.0.29': | |
| 369 | − resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} | |
| 370 | − | |
| 371 | − '@types/node@22.20.1': | |
| 372 | − resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} | |
| 373 | − | |
| 374 | − '@types/react-dom@19.2.4': | |
| 375 | − resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} | |
| 376 | − peerDependencies: | |
| 377 | − '@types/react': ^19.2.0 | |
| 378 | − | |
| 379 | − '@types/react@19.2.18': | |
| 380 | − resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} | |
| 381 | − | |
| 382 | − '@typescript-eslint/eslint-plugin@8.67.0': | |
| 383 | − resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} | |
| 384 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 385 | − peerDependencies: | |
| 386 | − '@typescript-eslint/parser': ^8.67.0 | |
| 387 | − eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 388 | − typescript: '>=4.8.4 <6.1.0' | |
| 389 | − | |
| 390 | − '@typescript-eslint/parser@8.67.0': | |
| 391 | − resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} | |
| 392 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 393 | − peerDependencies: | |
| 394 | − eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 395 | − typescript: '>=4.8.4 <6.1.0' | |
| 396 | − | |
| 397 | − '@typescript-eslint/project-service@8.67.0': | |
| 398 | − resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} | |
| 399 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 400 | − peerDependencies: | |
| 401 | − typescript: '>=4.8.4 <6.1.0' | |
| 402 | − | |
| 403 | − '@typescript-eslint/scope-manager@8.67.0': | |
| 404 | − resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} | |
| 405 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 406 | − | |
| 407 | − '@typescript-eslint/tsconfig-utils@8.67.0': | |
| 408 | − resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} | |
| 409 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 410 | − peerDependencies: | |
| 411 | − typescript: '>=4.8.4 <6.1.0' | |
| 412 | − | |
| 413 | − '@typescript-eslint/type-utils@8.67.0': | |
| 414 | − resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} | |
| 415 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 416 | − peerDependencies: | |
| 417 | − eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 418 | − typescript: '>=4.8.4 <6.1.0' | |
| 419 | − | |
| 420 | − '@typescript-eslint/types@8.67.0': | |
| 421 | − resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} | |
| 422 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 423 | − | |
| 424 | − '@typescript-eslint/typescript-estree@8.67.0': | |
| 425 | − resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} | |
| 426 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 427 | − peerDependencies: | |
| 428 | − typescript: '>=4.8.4 <6.1.0' | |
| 429 | − | |
| 430 | − '@typescript-eslint/utils@8.67.0': | |
| 431 | − resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} | |
| 432 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 433 | − peerDependencies: | |
| 434 | − eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 435 | − typescript: '>=4.8.4 <6.1.0' | |
| 436 | − | |
| 437 | − '@typescript-eslint/visitor-keys@8.67.0': | |
| 438 | − resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} | |
| 439 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 440 | − | |
| 441 | − '@ungap/structured-clone@1.3.3': | |
| 442 | − resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} | |
| 443 | − | |
| 444 | − '@unrs/resolver-binding-android-arm-eabi@1.12.2': | |
| 445 | − resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} | |
| 446 | − cpu: [arm] | |
| 447 | − os: [android] | |
| 448 | − | |
| 449 | − '@unrs/resolver-binding-android-arm64@1.12.2': | |
| 450 | − resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} | |
| 451 | − cpu: [arm64] | |
| 452 | − os: [android] | |
| 453 | − | |
| 454 | − '@unrs/resolver-binding-darwin-arm64@1.12.2': | |
| 455 | − resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} | |
| 456 | − cpu: [arm64] | |
| 457 | − os: [darwin] | |
| 458 | − | |
| 459 | − '@unrs/resolver-binding-darwin-x64@1.12.2': | |
| 460 | − resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} | |
| 461 | − cpu: [x64] | |
| 462 | − os: [darwin] | |
| 463 | − | |
| 464 | − '@unrs/resolver-binding-freebsd-x64@1.12.2': | |
| 465 | − resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} | |
| 466 | − cpu: [x64] | |
| 467 | − os: [freebsd] | |
| 468 | − | |
| 469 | − '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': | |
| 470 | − resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} | |
| 471 | − cpu: [arm] | |
| 472 | − os: [linux] | |
| 473 | − | |
| 474 | − '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': | |
| 475 | − resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} | |
| 476 | − cpu: [arm] | |
| 477 | − os: [linux] | |
| 478 | − | |
| 479 | − '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': | |
| 480 | − resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} | |
| 481 | − cpu: [arm64] | |
| 482 | − os: [linux] | |
| 483 | − libc: [glibc] | |
| 484 | − | |
| 485 | − '@unrs/resolver-binding-linux-arm64-musl@1.12.2': | |
| 486 | − resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} | |
| 487 | − cpu: [arm64] | |
| 488 | − os: [linux] | |
| 489 | − libc: [musl] | |
| 490 | − | |
| 491 | − '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': | |
| 492 | − resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} | |
| 493 | − cpu: [loong64] | |
| 494 | − os: [linux] | |
| 495 | − libc: [glibc] | |
| 496 | − | |
| 497 | − '@unrs/resolver-binding-linux-loong64-musl@1.12.2': | |
| 498 | − resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} | |
| 499 | − cpu: [loong64] | |
| 500 | − os: [linux] | |
| 501 | − libc: [musl] | |
| 502 | − | |
| 503 | − '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': | |
| 504 | − resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} | |
| 505 | − cpu: [ppc64] | |
| 506 | − os: [linux] | |
| 507 | − libc: [glibc] | |
| 508 | − | |
| 509 | − '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': | |
| 510 | − resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} | |
| 511 | − cpu: [riscv64] | |
| 512 | − os: [linux] | |
| 513 | − libc: [glibc] | |
| 514 | − | |
| 515 | − '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': | |
| 516 | − resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} | |
| 517 | − cpu: [riscv64] | |
| 518 | − os: [linux] | |
| 519 | − libc: [musl] | |
| 520 | − | |
| 521 | − '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': | |
| 522 | − resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} | |
| 523 | − cpu: [s390x] | |
| 524 | − os: [linux] | |
| 525 | − libc: [glibc] | |
| 526 | − | |
| 527 | − '@unrs/resolver-binding-linux-x64-gnu@1.12.2': | |
| 528 | − resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} | |
| 529 | − cpu: [x64] | |
| 530 | − os: [linux] | |
| 531 | − libc: [glibc] | |
| 532 | − | |
| 533 | − '@unrs/resolver-binding-linux-x64-musl@1.12.2': | |
| 534 | − resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} | |
| 535 | − cpu: [x64] | |
| 536 | − os: [linux] | |
| 537 | − libc: [musl] | |
| 538 | − | |
| 539 | − '@unrs/resolver-binding-openharmony-arm64@1.12.2': | |
| 540 | − resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} | |
| 541 | − cpu: [arm64] | |
| 542 | − os: [openharmony] | |
| 543 | − | |
| 544 | − '@unrs/resolver-binding-wasm32-wasi@1.12.2': | |
| 545 | − resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} | |
| 546 | − engines: {node: '>=14.0.0'} | |
| 547 | − cpu: [wasm32] | |
| 548 | − | |
| 549 | − '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': | |
| 550 | − resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} | |
| 551 | − cpu: [arm64] | |
| 552 | − os: [win32] | |
| 553 | − | |
| 554 | − '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': | |
| 555 | − resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} | |
| 556 | − cpu: [ia32] | |
| 557 | − os: [win32] | |
| 558 | − | |
| 559 | − '@unrs/resolver-binding-win32-x64-msvc@1.12.2': | |
| 560 | − resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} | |
| 561 | − cpu: [x64] | |
| 562 | − os: [win32] | |
| 563 | − | |
| 564 | − acorn-jsx@5.3.2: | |
| 565 | − resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} | |
| 566 | − peerDependencies: | |
| 567 | − acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 | |
| 568 | − | |
| 569 | − acorn@8.18.0: | |
| 570 | − resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} | |
| 571 | − engines: {node: '>=0.4.0'} | |
| 572 | − hasBin: true | |
| 573 | − | |
| 574 | − ajv@6.15.0: | |
| 575 | − resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} | |
| 576 | − | |
| 577 | − ansi-regex@5.0.1: | |
| 578 | − resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} | |
| 579 | − engines: {node: '>=8'} | |
| 580 | − | |
| 581 | − ansi-styles@4.3.0: | |
| 582 | − resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} | |
| 583 | − engines: {node: '>=8'} | |
| 584 | − | |
| 585 | − any-promise@1.3.0: | |
| 586 | − resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} | |
| 587 | − | |
| 588 | − anymatch@3.1.3: | |
| 589 | − resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} | |
| 590 | − engines: {node: '>= 8'} | |
| 591 | − | |
| 592 | − arg@5.0.2: | |
| 593 | − resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} | |
| 594 | − | |
| 595 | − argparse@2.0.1: | |
| 596 | − resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} | |
| 597 | − | |
| 598 | − aria-query@5.3.2: | |
| 599 | − resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} | |
| 600 | − engines: {node: '>= 0.4'} | |
| 601 | − | |
| 602 | − array-buffer-byte-length@1.0.2: | |
| 603 | − resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} | |
| 604 | − engines: {node: '>= 0.4'} | |
| 605 | − | |
| 606 | − array-includes@3.1.9: | |
| 607 | − resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} | |
| 608 | − engines: {node: '>= 0.4'} | |
| 609 | − | |
| 610 | − array.prototype.findlast@1.2.5: | |
| 611 | − resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} | |
| 612 | − engines: {node: '>= 0.4'} | |
| 613 | − | |
| 614 | − array.prototype.findlastindex@1.2.6: | |
| 615 | − resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} | |
| 616 | − engines: {node: '>= 0.4'} | |
| 617 | − | |
| 618 | − array.prototype.flat@1.3.3: | |
| 619 | − resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} | |
| 620 | − engines: {node: '>= 0.4'} | |
| 621 | − | |
| 622 | − array.prototype.flatmap@1.3.3: | |
| 623 | − resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} | |
| 624 | − engines: {node: '>= 0.4'} | |
| 625 | − | |
| 626 | − array.prototype.tosorted@1.1.4: | |
| 627 | − resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} | |
| 628 | − engines: {node: '>= 0.4'} | |
| 629 | − | |
| 630 | − arraybuffer.prototype.slice@1.0.4: | |
| 631 | − resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} | |
| 632 | − engines: {node: '>= 0.4'} | |
| 633 | − | |
| 634 | − ast-types-flow@0.0.8: | |
| 635 | − resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} | |
| 636 | − | |
| 637 | − async-function@1.0.0: | |
| 638 | − resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} | |
| 639 | − engines: {node: '>= 0.4'} | |
| 640 | − | |
| 641 | − autoprefixer@10.5.4: | |
| 642 | − resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} | |
| 643 | − engines: {node: ^10 || ^12 || >=14} | |
| 644 | − hasBin: true | |
| 645 | − peerDependencies: | |
| 646 | − postcss: ^8.1.0 | |
| 647 | − | |
| 648 | − available-typed-arrays@1.0.7: | |
| 649 | − resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} | |
| 650 | − engines: {node: '>= 0.4'} | |
| 651 | − | |
| 652 | − axe-core@4.13.0: | |
| 653 | − resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} | |
| 654 | − engines: {node: '>=4'} | |
| 655 | − | |
| 656 | − axobject-query@4.1.0: | |
| 657 | − resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} | |
| 658 | − engines: {node: '>= 0.4'} | |
| 659 | − | |
| 660 | − balanced-match@1.0.2: | |
| 661 | − resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} | |
| 662 | − | |
| 663 | − balanced-match@4.0.4: | |
| 664 | − resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} | |
| 665 | − engines: {node: 18 || 20 || >=22} | |
| 666 | − | |
| 667 | − baseline-browser-mapping@2.11.13: | |
| 668 | − resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} | |
| 669 | − engines: {node: '>=6.0.0'} | |
| 670 | − hasBin: true | |
| 671 | − | |
| 672 | − binary-extensions@2.3.0: | |
| 673 | − resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} | |
| 674 | − engines: {node: '>=8'} | |
| 675 | − | |
| 676 | − brace-expansion@1.1.18: | |
| 677 | − resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} | |
| 678 | − | |
| 679 | − brace-expansion@5.0.9: | |
| 680 | − resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} | |
| 681 | − engines: {node: 20 || >=22} | |
| 682 | − | |
| 683 | − braces@3.0.3: | |
| 684 | − resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} | |
| 685 | − engines: {node: '>=8'} | |
| 686 | − | |
| 687 | − browserslist@4.28.8: | |
| 688 | − resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} | |
| 689 | − engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} | |
| 690 | − hasBin: true | |
| 691 | − | |
| 692 | − call-bind-apply-helpers@1.0.2: | |
| 693 | − resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} | |
| 694 | − engines: {node: '>= 0.4'} | |
| 695 | − | |
| 696 | − call-bind@1.0.9: | |
| 697 | − resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} | |
| 698 | − engines: {node: '>= 0.4'} | |
| 699 | − | |
| 700 | − call-bound@1.0.4: | |
| 701 | − resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} | |
| 702 | − engines: {node: '>= 0.4'} | |
| 703 | − | |
| 704 | − callsites@3.1.0: | |
| 705 | − resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} | |
| 706 | − engines: {node: '>=6'} | |
| 707 | − | |
| 708 | − camelcase-css@2.0.1: | |
| 709 | − resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} | |
| 710 | − engines: {node: '>= 6'} | |
| 711 | − | |
| 712 | − caniuse-lite@1.0.30001809: | |
| 713 | − resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} | |
| 714 | − | |
| 715 | − chalk@4.1.2: | |
| 716 | − resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} | |
| 717 | − engines: {node: '>=10'} | |
| 718 | − | |
| 719 | − chokidar@3.6.0: | |
| 720 | − resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} | |
| 721 | − engines: {node: '>= 8.10.0'} | |
| 722 | − | |
| 723 | − class-variance-authority@0.7.1: | |
| 724 | − resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} | |
| 725 | − | |
| 726 | − client-only@0.0.1: | |
| 727 | − resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} | |
| 728 | − | |
| 729 | − clsx@2.1.1: | |
| 730 | − resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} | |
| 731 | − engines: {node: '>=6'} | |
| 732 | − | |
| 733 | − color-convert@2.0.1: | |
| 734 | − resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} | |
| 735 | − engines: {node: '>=7.0.0'} | |
| 736 | − | |
| 737 | − color-name@1.1.4: | |
| 738 | − resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} | |
| 739 | − | |
| 740 | − commander@4.1.1: | |
| 741 | − resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} | |
| 742 | − engines: {node: '>= 6'} | |
| 743 | − | |
| 744 | − concat-map@0.0.1: | |
| 745 | − resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} | |
| 746 | − | |
| 747 | − cross-spawn@7.0.6: | |
| 748 | − resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} | |
| 749 | − engines: {node: '>= 8'} | |
| 750 | − | |
| 751 | − cssesc@3.0.0: | |
| 752 | − resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} | |
| 753 | − engines: {node: '>=4'} | |
| 754 | − hasBin: true | |
| 755 | − | |
| 756 | − csstype@3.2.3: | |
| 757 | − resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} | |
| 758 | − | |
| 759 | − damerau-levenshtein@1.0.8: | |
| 760 | − resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} | |
| 761 | − | |
| 762 | − data-view-buffer@1.0.2: | |
| 763 | − resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} | |
| 764 | − engines: {node: '>= 0.4'} | |
| 765 | − | |
| 766 | − data-view-byte-length@1.0.2: | |
| 767 | − resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} | |
| 768 | − engines: {node: '>= 0.4'} | |
| 769 | − | |
| 770 | − data-view-byte-offset@1.0.1: | |
| 771 | − resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} | |
| 772 | − engines: {node: '>= 0.4'} | |
| 773 | − | |
| 774 | − debug@3.2.7: | |
| 775 | − resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} | |
| 776 | − peerDependencies: | |
| 777 | − supports-color: '*' | |
| 778 | − peerDependenciesMeta: | |
| 779 | − supports-color: | |
| 780 | − optional: true | |
| 781 | − | |
| 782 | − debug@4.4.3: | |
| 783 | − resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} | |
| 784 | − engines: {node: '>=6.0'} | |
| 785 | − peerDependencies: | |
| 786 | − supports-color: '*' | |
| 787 | − peerDependenciesMeta: | |
| 788 | − supports-color: | |
| 789 | − optional: true | |
| 790 | − | |
| 791 | − deep-is@0.1.4: | |
| 792 | − resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} | |
| 793 | − | |
| 794 | − define-data-property@1.1.4: | |
| 795 | − resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} | |
| 796 | − engines: {node: '>= 0.4'} | |
| 797 | − | |
| 798 | − define-properties@1.2.1: | |
| 799 | − resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} | |
| 800 | − engines: {node: '>= 0.4'} | |
| 801 | − | |
| 802 | − detect-libc@2.1.2: | |
| 803 | − resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} | |
| 804 | − engines: {node: '>=8'} | |
| 805 | − | |
| 806 | − didyoumean@1.2.2: | |
| 807 | − resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} | |
| 808 | − | |
| 809 | − dlv@1.1.3: | |
| 810 | − resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} | |
| 811 | − | |
| 812 | − doctrine@2.1.0: | |
| 813 | − resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} | |
| 814 | − engines: {node: '>=0.10.0'} | |
| 815 | − | |
| 816 | − doctrine@3.0.0: | |
| 817 | − resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} | |
| 818 | − engines: {node: '>=6.0.0'} | |
| 819 | − | |
| 820 | − dunder-proto@1.0.1: | |
| 821 | − resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} | |
| 822 | − engines: {node: '>= 0.4'} | |
| 823 | − | |
| 824 | − electron-to-chromium@1.5.405: | |
| 825 | − resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==} | |
| 826 | − | |
| 827 | − emoji-regex@9.2.2: | |
| 828 | − resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} | |
| 829 | − | |
| 830 | − es-abstract-get@1.0.0: | |
| 831 | − resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} | |
| 832 | − engines: {node: '>= 0.4'} | |
| 833 | − | |
| 834 | − es-abstract@1.24.2: | |
| 835 | − resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} | |
| 836 | − engines: {node: '>= 0.4'} | |
| 837 | − | |
| 838 | − es-define-property@1.0.1: | |
| 839 | − resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} | |
| 840 | − engines: {node: '>= 0.4'} | |
| 841 | − | |
| 842 | − es-errors@1.3.0: | |
| 843 | − resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} | |
| 844 | − engines: {node: '>= 0.4'} | |
| 845 | − | |
| 846 | − es-iterator-helpers@1.4.0: | |
| 847 | − resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} | |
| 848 | − engines: {node: '>= 0.4'} | |
| 849 | − | |
| 850 | − es-object-atoms@1.1.2: | |
| 851 | − resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} | |
| 852 | − engines: {node: '>= 0.4'} | |
| 853 | − | |
| 854 | − es-set-tostringtag@2.1.0: | |
| 855 | − resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} | |
| 856 | − engines: {node: '>= 0.4'} | |
| 857 | − | |
| 858 | − es-shim-unscopables@1.1.0: | |
| 859 | − resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} | |
| 860 | − engines: {node: '>= 0.4'} | |
| 861 | − | |
| 862 | − es-to-primitive@1.3.4: | |
| 863 | − resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} | |
| 864 | − engines: {node: '>= 0.4'} | |
| 865 | − | |
| 866 | − escalade@3.2.0: | |
| 867 | − resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} | |
| 868 | − engines: {node: '>=6'} | |
| 869 | − | |
| 870 | − escape-string-regexp@4.0.0: | |
| 871 | − resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} | |
| 872 | − engines: {node: '>=10'} | |
| 873 | − | |
| 874 | − eslint-config-next@15.5.23: | |
| 875 | − resolution: {integrity: sha512-z4WcTXNqFHwMG4V8WHb2xrlEPJvwarZa+H/6CR28vxr53icRnQzGXviO11p748BwrMZGl52itdPRzZfzYo0SKw==} | |
| 876 | − peerDependencies: | |
| 877 | − eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 | |
| 878 | − typescript: '>=3.3.1' | |
| 879 | − peerDependenciesMeta: | |
| 880 | − typescript: | |
| 881 | − optional: true | |
| 882 | − | |
| 883 | − eslint-import-resolver-node@0.3.10: | |
| 884 | − resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} | |
| 885 | − | |
| 886 | − eslint-import-resolver-typescript@3.10.1: | |
| 887 | − resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} | |
| 888 | − engines: {node: ^14.18.0 || >=16.0.0} | |
| 889 | − peerDependencies: | |
| 890 | − eslint: '*' | |
| 891 | − eslint-plugin-import: '*' | |
| 892 | − eslint-plugin-import-x: '*' | |
| 893 | − peerDependenciesMeta: | |
| 894 | − eslint-plugin-import: | |
| 895 | − optional: true | |
| 896 | − eslint-plugin-import-x: | |
| 897 | − optional: true | |
| 898 | − | |
| 899 | − eslint-module-utils@2.14.0: | |
| 900 | − resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} | |
| 901 | − engines: {node: '>=4'} | |
| 902 | − peerDependencies: | |
| 903 | − '@typescript-eslint/parser': '*' | |
| 904 | − eslint: '*' | |
| 905 | − eslint-import-resolver-node: '*' | |
| 906 | − eslint-import-resolver-typescript: '*' | |
| 907 | − eslint-import-resolver-webpack: '*' | |
| 908 | − peerDependenciesMeta: | |
| 909 | − '@typescript-eslint/parser': | |
| 910 | − optional: true | |
| 911 | − eslint: | |
| 912 | − optional: true | |
| 913 | − eslint-import-resolver-node: | |
| 914 | − optional: true | |
| 915 | − eslint-import-resolver-typescript: | |
| 916 | − optional: true | |
| 917 | − eslint-import-resolver-webpack: | |
| 918 | − optional: true | |
| 919 | − | |
| 920 | − eslint-plugin-import@2.32.0: | |
| 921 | − resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} | |
| 922 | − engines: {node: '>=4'} | |
| 923 | − peerDependencies: | |
| 924 | − '@typescript-eslint/parser': '*' | |
| 925 | − eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 | |
| 926 | − peerDependenciesMeta: | |
| 927 | − '@typescript-eslint/parser': | |
| 928 | − optional: true | |
| 929 | − | |
| 930 | − eslint-plugin-jsx-a11y@6.10.2: | |
| 931 | − resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} | |
| 932 | − engines: {node: '>=4.0'} | |
| 933 | − peerDependencies: | |
| 934 | − eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 | |
| 935 | − | |
| 936 | − eslint-plugin-react-hooks@5.2.0: | |
| 937 | − resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} | |
| 938 | − engines: {node: '>=10'} | |
| 939 | − peerDependencies: | |
| 940 | − eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 | |
| 941 | − | |
| 942 | − eslint-plugin-react@7.37.5: | |
| 943 | − resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} | |
| 944 | − engines: {node: '>=4'} | |
| 945 | − peerDependencies: | |
| 946 | − eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 | |
| 947 | − | |
| 948 | − eslint-scope@7.2.2: | |
| 949 | − resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} | |
| 950 | − engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 951 | − | |
| 952 | − eslint-visitor-keys@3.4.3: | |
| 953 | − resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} | |
| 954 | − engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 955 | − | |
| 956 | − eslint-visitor-keys@5.0.1: | |
| 957 | − resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} | |
| 958 | − engines: {node: ^20.19.0 || ^22.13.0 || >=24} | |
| 959 | − | |
| 960 | − eslint@8.57.1: | |
| 961 | − resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} | |
| 962 | − engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 963 | − deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. | |
| 964 | − hasBin: true | |
| 965 | − | |
| 966 | − espree@9.6.1: | |
| 967 | − resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} | |
| 968 | − engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 969 | − | |
| 970 | − esquery@1.7.0: | |
| 971 | − resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} | |
| 972 | − engines: {node: '>=0.10'} | |
| 973 | − | |
| 974 | − esrecurse@4.3.0: | |
| 975 | − resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} | |
| 976 | − engines: {node: '>=4.0'} | |
| 977 | − | |
| 978 | − estraverse@5.3.0: | |
| 979 | − resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} | |
| 980 | − engines: {node: '>=4.0'} | |
| 981 | − | |
| 982 | − esutils@2.0.3: | |
| 983 | − resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} | |
| 984 | − engines: {node: '>=0.10.0'} | |
| 985 | − | |
| 986 | − fast-deep-equal@3.1.3: | |
| 987 | − resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} | |
| 988 | − | |
| 989 | − fast-glob@3.3.1: | |
| 990 | − resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} | |
| 991 | − engines: {node: '>=8.6.0'} | |
| 992 | − | |
| 993 | − fast-glob@3.3.3: | |
| 994 | − resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} | |
| 995 | − engines: {node: '>=8.6.0'} | |
| 996 | − | |
| 997 | − fast-json-stable-stringify@2.1.0: | |
| 998 | − resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} | |
| 999 | − | |
| 1000 | − fast-levenshtein@2.0.6: | |
| 1001 | − resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} | |
| 1002 | − | |
| 1003 | − fastq@1.20.1: | |
| 1004 | − resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} | |
| 1005 | − | |
| 1006 | − fdir@6.5.0: | |
| 1007 | − resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} | |
| 1008 | − engines: {node: '>=12.0.0'} | |
| 1009 | − peerDependencies: | |
| 1010 | − picomatch: ^3 || ^4 | |
| 1011 | − peerDependenciesMeta: | |
| 1012 | − picomatch: | |
| 1013 | − optional: true | |
| 1014 | − | |
| 1015 | − file-entry-cache@6.0.1: | |
| 1016 | − resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} | |
| 1017 | − engines: {node: ^10.12.0 || >=12.0.0} | |
| 1018 | − | |
| 1019 | − fill-range@7.1.1: | |
| 1020 | − resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} | |
| 1021 | − engines: {node: '>=8'} | |
| 1022 | − | |
| 1023 | − find-up@5.0.0: | |
| 1024 | − resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} | |
| 1025 | − engines: {node: '>=10'} | |
| 1026 | − | |
| 1027 | − flat-cache@3.2.0: | |
| 1028 | − resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} | |
| 1029 | − engines: {node: ^10.12.0 || >=12.0.0} | |
| 1030 | − | |
| 1031 | − flatted@3.4.4: | |
| 1032 | − resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} | |
| 1033 | − | |
| 1034 | − for-each@0.3.5: | |
| 1035 | − resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} | |
| 1036 | − engines: {node: '>= 0.4'} | |
| 1037 | − | |
| 1038 | − fraction.js@5.3.4: | |
| 1039 | − resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} | |
| 1040 | − | |
| 1041 | − fs.realpath@1.0.0: | |
| 1042 | − resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} | |
| 1043 | − | |
| 1044 | − fsevents@2.3.3: | |
| 1045 | − resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} | |
| 1046 | − engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} | |
| 1047 | − os: [darwin] | |
| 1048 | − | |
| 1049 | − function-bind@1.1.2: | |
| 1050 | − resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} | |
| 1051 | − | |
| 1052 | − function.prototype.name@1.2.0: | |
| 1053 | − resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} | |
| 1054 | − engines: {node: '>= 0.4'} | |
| 1055 | − | |
| 1056 | − functions-have-names@1.2.3: | |
| 1057 | − resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} | |
| 1058 | − | |
| 1059 | − generator-function@2.0.1: | |
| 1060 | − resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} | |
| 1061 | − engines: {node: '>= 0.4'} | |
| 1062 | − | |
| 1063 | − get-intrinsic@1.3.0: | |
| 1064 | − resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} | |
| 1065 | − engines: {node: '>= 0.4'} | |
| 1066 | − | |
| 1067 | − get-proto@1.0.1: | |
| 1068 | − resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} | |
| 1069 | − engines: {node: '>= 0.4'} | |
| 1070 | − | |
| 1071 | − get-symbol-description@1.1.0: | |
| 1072 | − resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} | |
| 1073 | − engines: {node: '>= 0.4'} | |
| 1074 | − | |
| 1075 | − get-tsconfig@4.14.2: | |
| 1076 | − resolution: {integrity: sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==} | |
| 1077 | − | |
| 1078 | − glob-parent@5.1.2: | |
| 1079 | − resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} | |
| 1080 | − engines: {node: '>= 6'} | |
| 1081 | − | |
| 1082 | − glob-parent@6.0.2: | |
| 1083 | − resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} | |
| 1084 | − engines: {node: '>=10.13.0'} | |
| 1085 | − | |
| 1086 | − glob@7.2.3: | |
| 1087 | − resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} | |
| 1088 | − deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me | |
| 1089 | − | |
| 1090 | − globals@13.24.0: | |
| 1091 | − resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} | |
| 1092 | − engines: {node: '>=8'} | |
| 1093 | − | |
| 1094 | − globalthis@1.0.4: | |
| 1095 | − resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} | |
| 1096 | − engines: {node: '>= 0.4'} | |
| 1097 | − | |
| 1098 | − gopd@1.2.0: | |
| 1099 | − resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} | |
| 1100 | − engines: {node: '>= 0.4'} | |
| 1101 | − | |
| 1102 | − graphemer@1.4.0: | |
| 1103 | − resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} | |
| 1104 | − | |
| 1105 | − has-bigints@1.1.0: | |
| 1106 | − resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} | |
| 1107 | − engines: {node: '>= 0.4'} | |
| 1108 | − | |
| 1109 | − has-flag@4.0.0: | |
| 1110 | − resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} | |
| 1111 | − engines: {node: '>=8'} | |
| 1112 | − | |
| 1113 | − has-property-descriptors@1.0.2: | |
| 1114 | − resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} | |
| 1115 | − | |
| 1116 | − has-proto@1.2.0: | |
| 1117 | − resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} | |
| 1118 | − engines: {node: '>= 0.4'} | |
| 1119 | − | |
| 1120 | − has-symbols@1.1.0: | |
| 1121 | − resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} | |
| 1122 | − engines: {node: '>= 0.4'} | |
| 1123 | − | |
| 1124 | − has-tostringtag@1.0.2: | |
| 1125 | − resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} | |
| 1126 | − engines: {node: '>= 0.4'} | |
| 1127 | − | |
| 1128 | − hasown@2.0.4: | |
| 1129 | − resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} | |
| 1130 | − engines: {node: '>= 0.4'} | |
| 1131 | − | |
| 1132 | − ignore@5.3.2: | |
| 1133 | − resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} | |
| 1134 | − engines: {node: '>= 4'} | |
| 1135 | − | |
| 1136 | − ignore@7.0.6: | |
| 1137 | − resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} | |
| 1138 | − engines: {node: '>= 4'} | |
| 1139 | − | |
| 1140 | − import-fresh@3.3.1: | |
| 1141 | − resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} | |
| 1142 | − engines: {node: '>=6'} | |
| 1143 | − | |
| 1144 | − imurmurhash@0.1.4: | |
| 1145 | − resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} | |
| 1146 | − engines: {node: '>=0.8.19'} | |
| 1147 | − | |
| 1148 | − inflight@1.0.6: | |
| 1149 | − resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} | |
| 1150 | − deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. | |
| 1151 | − | |
| 1152 | − inherits@2.0.4: | |
| 1153 | − resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} | |
| 1154 | − | |
| 1155 | − internal-slot@1.1.0: | |
| 1156 | − resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} | |
| 1157 | − engines: {node: '>= 0.4'} | |
| 1158 | − | |
| 1159 | − is-array-buffer@3.0.5: | |
| 1160 | − resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} | |
| 1161 | − engines: {node: '>= 0.4'} | |
| 1162 | − | |
| 1163 | − is-async-function@2.1.1: | |
| 1164 | − resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} | |
| 1165 | − engines: {node: '>= 0.4'} | |
| 1166 | − | |
| 1167 | − is-bigint@1.1.0: | |
| 1168 | − resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} | |
| 1169 | − engines: {node: '>= 0.4'} | |
| 1170 | − | |
| 1171 | − is-binary-path@2.1.0: | |
| 1172 | − resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} | |
| 1173 | − engines: {node: '>=8'} | |
| 1174 | − | |
| 1175 | − is-boolean-object@1.2.2: | |
| 1176 | − resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} | |
| 1177 | − engines: {node: '>= 0.4'} | |
| 1178 | − | |
| 1179 | − is-bun-module@2.0.0: | |
| 1180 | − resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} | |
| 1181 | − | |
| 1182 | − is-callable@1.2.7: | |
| 1183 | − resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} | |
| 1184 | − engines: {node: '>= 0.4'} | |
| 1185 | − | |
| 1186 | − is-core-module@2.16.2: | |
| 1187 | − resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} | |
| 1188 | − engines: {node: '>= 0.4'} | |
| 1189 | − | |
| 1190 | − is-data-view@1.0.2: | |
| 1191 | − resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} | |
| 1192 | − engines: {node: '>= 0.4'} | |
| 1193 | − | |
| 1194 | − is-date-object@1.1.0: | |
| 1195 | − resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} | |
| 1196 | − engines: {node: '>= 0.4'} | |
| 1197 | − | |
| 1198 | − is-document.all@1.0.0: | |
| 1199 | − resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} | |
| 1200 | − engines: {node: '>= 0.4'} | |
| 1201 | − | |
| 1202 | − is-extglob@2.1.1: | |
| 1203 | − resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} | |
| 1204 | − engines: {node: '>=0.10.0'} | |
| 1205 | − | |
| 1206 | − is-finalizationregistry@1.1.1: | |
| 1207 | − resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} | |
| 1208 | − engines: {node: '>= 0.4'} | |
| 1209 | − | |
| 1210 | − is-generator-function@1.1.2: | |
| 1211 | − resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} | |
| 1212 | − engines: {node: '>= 0.4'} | |
| 1213 | − | |
| 1214 | − is-glob@4.0.3: | |
| 1215 | − resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} | |
| 1216 | − engines: {node: '>=0.10.0'} | |
| 1217 | − | |
| 1218 | − is-map@2.0.3: | |
| 1219 | − resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} | |
| 1220 | − engines: {node: '>= 0.4'} | |
| 1221 | − | |
| 1222 | − is-negative-zero@2.0.3: | |
| 1223 | − resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} | |
| 1224 | − engines: {node: '>= 0.4'} | |
| 1225 | − | |
| 1226 | − is-number-object@1.1.1: | |
| 1227 | − resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} | |
| 1228 | − engines: {node: '>= 0.4'} | |
| 1229 | − | |
| 1230 | − is-number@7.0.0: | |
| 1231 | − resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} | |
| 1232 | − engines: {node: '>=0.12.0'} | |
| 1233 | − | |
| 1234 | − is-path-inside@3.0.3: | |
| 1235 | − resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} | |
| 1236 | − engines: {node: '>=8'} | |
| 1237 | − | |
| 1238 | − is-regex@1.2.1: | |
| 1239 | − resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} | |
| 1240 | − engines: {node: '>= 0.4'} | |
| 1241 | − | |
| 1242 | − is-set@2.0.3: | |
| 1243 | − resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} | |
| 1244 | − engines: {node: '>= 0.4'} | |
| 1245 | − | |
| 1246 | − is-shared-array-buffer@1.0.4: | |
| 1247 | − resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} | |
| 1248 | − engines: {node: '>= 0.4'} | |
| 1249 | − | |
| 1250 | − is-string@1.1.1: | |
| 1251 | − resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} | |
| 1252 | − engines: {node: '>= 0.4'} | |
| 1253 | − | |
| 1254 | − is-symbol@1.1.1: | |
| 1255 | − resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} | |
| 1256 | − engines: {node: '>= 0.4'} | |
| 1257 | − | |
| 1258 | − is-typed-array@1.1.15: | |
| 1259 | − resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} | |
| 1260 | − engines: {node: '>= 0.4'} | |
| 1261 | − | |
| 1262 | − is-weakmap@2.0.2: | |
| 1263 | − resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} | |
| 1264 | − engines: {node: '>= 0.4'} | |
| 1265 | − | |
| 1266 | − is-weakref@1.1.1: | |
| 1267 | − resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} | |
| 1268 | − engines: {node: '>= 0.4'} | |
| 1269 | − | |
| 1270 | − is-weakset@2.0.4: | |
| 1271 | − resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} | |
| 1272 | − engines: {node: '>= 0.4'} | |
| 1273 | − | |
| 1274 | − isarray@2.0.5: | |
| 1275 | − resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} | |
| 1276 | − | |
| 1277 | − isexe@2.0.0: | |
| 1278 | − resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} | |
| 1279 | − | |
| 1280 | − iterator.prototype@1.1.5: | |
| 1281 | − resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} | |
| 1282 | − engines: {node: '>= 0.4'} | |
| 1283 | − | |
| 1284 | − jiti@1.21.7: | |
| 1285 | − resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} | |
| 1286 | − hasBin: true | |
| 1287 | − | |
| 1288 | − js-tokens@4.0.0: | |
| 1289 | − resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} | |
| 1290 | − | |
| 1291 | − js-yaml@4.3.1: | |
| 1292 | − resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} | |
| 1293 | − hasBin: true | |
| 1294 | − | |
| 1295 | − json-buffer@3.0.1: | |
| 1296 | − resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} | |
| 1297 | − | |
| 1298 | − json-schema-traverse@0.4.1: | |
| 1299 | − resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} | |
| 1300 | − | |
| 1301 | − json-stable-stringify-without-jsonify@1.0.1: | |
| 1302 | − resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} | |
| 1303 | − | |
| 1304 | − json5@1.0.2: | |
| 1305 | − resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} | |
| 1306 | − hasBin: true | |
| 1307 | − | |
| 1308 | − jsx-ast-utils@3.3.5: | |
| 1309 | − resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} | |
| 1310 | − engines: {node: '>=4.0'} | |
| 1311 | − | |
| 1312 | − keyv@4.5.4: | |
| 1313 | − resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} | |
| 1314 | − | |
| 1315 | − language-subtag-registry@0.3.23: | |
| 1316 | − resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} | |
| 1317 | − | |
| 1318 | − language-tags@1.0.9: | |
| 1319 | − resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} | |
| 1320 | − engines: {node: '>=0.10'} | |
| 1321 | − | |
| 1322 | − levn@0.4.1: | |
| 1323 | − resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} | |
| 1324 | − engines: {node: '>= 0.8.0'} | |
| 1325 | − | |
| 1326 | − lilconfig@3.1.3: | |
| 1327 | − resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} | |
| 1328 | − engines: {node: '>=14'} | |
| 1329 | − | |
| 1330 | − lines-and-columns@1.2.4: | |
| 1331 | − resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} | |
| 1332 | − | |
| 1333 | − locate-path@6.0.0: | |
| 1334 | − resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} | |
| 1335 | − engines: {node: '>=10'} | |
| 1336 | − | |
| 1337 | − lodash.merge@4.6.2: | |
| 1338 | − resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} | |
| 1339 | − | |
| 1340 | − loose-envify@1.4.0: | |
| 1341 | − resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} | |
| 1342 | − hasBin: true | |
| 1343 | − | |
| 1344 | − math-intrinsics@1.1.0: | |
| 1345 | − resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} | |
| 1346 | − engines: {node: '>= 0.4'} | |
| 1347 | − | |
| 1348 | − merge2@1.4.1: | |
| 1349 | − resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} | |
| 1350 | − engines: {node: '>= 8'} | |
| 1351 | − | |
| 1352 | − micromatch@4.0.8: | |
| 1353 | − resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} | |
| 1354 | − engines: {node: '>=8.6'} | |
| 1355 | − | |
| 1356 | − minimatch@10.2.6: | |
| 1357 | − resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} | |
| 1358 | − engines: {node: 18 || 20 || >=22} | |
| 1359 | − | |
| 1360 | − minimatch@3.1.5: | |
| 1361 | − resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} | |
| 1362 | − | |
| 1363 | − minimist@1.2.8: | |
| 1364 | − resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} | |
| 1365 | − | |
| 1366 | − ms@2.1.3: | |
| 1367 | − resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} | |
| 1368 | − | |
| 1369 | − mz@2.7.0: | |
| 1370 | − resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} | |
| 1371 | − | |
| 1372 | − nanoid@3.3.18: | |
| 1373 | − resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} | |
| 1374 | − engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} | |
| 1375 | − hasBin: true | |
| 1376 | − | |
| 1377 | − napi-postinstall@0.3.4: | |
| 1378 | − resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} | |
| 1379 | − engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} | |
| 1380 | − hasBin: true | |
| 1381 | − | |
| 1382 | − natural-compare@1.4.0: | |
| 1383 | − resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} | |
| 1384 | − | |
| 1385 | − next@15.5.23: | |
| 1386 | − resolution: {integrity: sha512-Gvd2WKgvxIXCGotxcI1im/Uf3rS3J3oZGw0g/uskg6AVBZhyE3aAbujkYWzS3xLmEPEtTLfkaVQUKK0KMTSIkA==} | |
| 1387 | − engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} | |
| 1388 | − hasBin: true | |
| 1389 | − peerDependencies: | |
| 1390 | − '@opentelemetry/api': ^1.1.0 | |
| 1391 | − '@playwright/test': ^1.51.1 | |
| 1392 | − babel-plugin-react-compiler: '*' | |
| 1393 | − react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 | |
| 1394 | − react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 | |
| 1395 | − sass: ^1.3.0 | |
| 1396 | − peerDependenciesMeta: | |
| 1397 | − '@opentelemetry/api': | |
| 1398 | − optional: true | |
| 1399 | − '@playwright/test': | |
| 1400 | − optional: true | |
| 1401 | − babel-plugin-react-compiler: | |
| 1402 | − optional: true | |
| 1403 | − sass: | |
| 1404 | − optional: true | |
| 1405 | − | |
| 1406 | − node-exports-info@1.6.2: | |
| 1407 | − resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} | |
| 1408 | − engines: {node: '>= 0.4'} | |
| 1409 | − | |
| 1410 | − node-releases@2.0.53: | |
| 1411 | − resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} | |
| 1412 | − engines: {node: '>=18'} | |
| 1413 | − | |
| 1414 | − normalize-path@3.0.0: | |
| 1415 | − resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} | |
| 1416 | − engines: {node: '>=0.10.0'} | |
| 1417 | − | |
| 1418 | − object-assign@4.1.1: | |
| 1419 | − resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} | |
| 1420 | − engines: {node: '>=0.10.0'} | |
| 1421 | − | |
| 1422 | − object-hash@3.0.0: | |
| 1423 | − resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} | |
| 1424 | − engines: {node: '>= 6'} | |
| 1425 | − | |
| 1426 | − object-inspect@1.13.4: | |
| 1427 | − resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} | |
| 1428 | − engines: {node: '>= 0.4'} | |
| 1429 | − | |
| 1430 | − object-keys@1.1.1: | |
| 1431 | − resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} | |
| 1432 | − engines: {node: '>= 0.4'} | |
| 1433 | − | |
| 1434 | − object.assign@4.1.7: | |
| 1435 | − resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} | |
| 1436 | − engines: {node: '>= 0.4'} | |
| 1437 | − | |
| 1438 | − object.entries@1.1.9: | |
| 1439 | − resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} | |
| 1440 | − engines: {node: '>= 0.4'} | |
| 1441 | − | |
| 1442 | − object.fromentries@2.0.8: | |
| 1443 | − resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} | |
| 1444 | − engines: {node: '>= 0.4'} | |
| 1445 | − | |
| 1446 | − object.groupby@1.0.3: | |
| 1447 | − resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} | |
| 1448 | − engines: {node: '>= 0.4'} | |
| 1449 | − | |
| 1450 | − object.values@1.2.1: | |
| 1451 | − resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} | |
| 1452 | − engines: {node: '>= 0.4'} | |
| 1453 | − | |
| 1454 | − once@1.4.0: | |
| 1455 | − resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} | |
| 1456 | − | |
| 1457 | − optionator@0.9.4: | |
| 1458 | − resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} | |
| 1459 | − engines: {node: '>= 0.8.0'} | |
| 1460 | − | |
| 1461 | − own-keys@1.0.2: | |
| 1462 | − resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} | |
| 1463 | − engines: {node: '>= 0.4'} | |
| 1464 | − | |
| 1465 | − p-limit@3.1.0: | |
| 1466 | − resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} | |
| 1467 | − engines: {node: '>=10'} | |
| 1468 | − | |
| 1469 | − p-locate@5.0.0: | |
| 1470 | − resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} | |
| 1471 | − engines: {node: '>=10'} | |
| 1472 | − | |
| 1473 | − parent-module@1.0.1: | |
| 1474 | − resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} | |
| 1475 | − engines: {node: '>=6'} | |
| 1476 | − | |
| 1477 | − path-exists@4.0.0: | |
| 1478 | − resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} | |
| 1479 | − engines: {node: '>=8'} | |
| 1480 | − | |
| 1481 | − path-is-absolute@1.0.1: | |
| 1482 | − resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} | |
| 1483 | − engines: {node: '>=0.10.0'} | |
| 1484 | − | |
| 1485 | − path-key@3.1.1: | |
| 1486 | − resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} | |
| 1487 | − engines: {node: '>=8'} | |
| 1488 | − | |
| 1489 | − path-parse@1.0.7: | |
| 1490 | − resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} | |
| 1491 | − | |
| 1492 | − picocolors@1.1.1: | |
| 1493 | − resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} | |
| 1494 | − | |
| 1495 | − picomatch@2.3.2: | |
| 1496 | − resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} | |
| 1497 | − engines: {node: '>=8.6'} | |
| 1498 | − | |
| 1499 | − picomatch@4.0.5: | |
| 1500 | − resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} | |
| 1501 | − engines: {node: '>=12'} | |
| 1502 | − | |
| 1503 | − pify@2.3.0: | |
| 1504 | − resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} | |
| 1505 | − engines: {node: '>=0.10.0'} | |
| 1506 | − | |
| 1507 | − pirates@4.0.7: | |
| 1508 | − resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} | |
| 1509 | − engines: {node: '>= 6'} | |
| 1510 | − | |
| 1511 | − possible-typed-array-names@1.1.0: | |
| 1512 | − resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} | |
| 1513 | − engines: {node: '>= 0.4'} | |
| 1514 | − | |
| 1515 | − postcss-import@15.1.0: | |
| 1516 | − resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} | |
| 1517 | − engines: {node: '>=14.0.0'} | |
| 1518 | − peerDependencies: | |
| 1519 | − postcss: ^8.0.0 | |
| 1520 | − | |
| 1521 | − postcss-js@4.1.0: | |
| 1522 | − resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} | |
| 1523 | − engines: {node: ^12 || ^14 || >= 16} | |
| 1524 | − peerDependencies: | |
| 1525 | − postcss: ^8.4.21 | |
| 1526 | − | |
| 1527 | − postcss-load-config@6.0.1: | |
| 1528 | − resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} | |
| 1529 | − engines: {node: '>= 18'} | |
| 1530 | − peerDependencies: | |
| 1531 | − jiti: '>=1.21.0' | |
| 1532 | − postcss: '>=8.0.9' | |
| 1533 | − tsx: ^4.8.1 | |
| 1534 | − yaml: ^2.4.2 | |
| 1535 | − peerDependenciesMeta: | |
| 1536 | − jiti: | |
| 1537 | − optional: true | |
| 1538 | − postcss: | |
| 1539 | − optional: true | |
| 1540 | − tsx: | |
| 1541 | − optional: true | |
| 1542 | − yaml: | |
| 1543 | − optional: true | |
| 1544 | − | |
| 1545 | − postcss-nested@6.2.0: | |
| 1546 | − resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} | |
| 1547 | − engines: {node: '>=12.0'} | |
| 1548 | − peerDependencies: | |
| 1549 | − postcss: ^8.2.14 | |
| 1550 | − | |
| 1551 | − postcss-selector-parser@6.1.4: | |
| 1552 | − resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} | |
| 1553 | − engines: {node: '>=4'} | |
| 1554 | − | |
| 1555 | − postcss-value-parser@4.2.0: | |
| 1556 | − resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} | |
| 1557 | − | |
| 1558 | − postcss@8.4.31: | |
| 1559 | − resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} | |
| 1560 | − engines: {node: ^10 || ^12 || >=14} | |
| 1561 | − | |
| 1562 | − postcss@8.5.26: | |
| 1563 | − resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} | |
| 1564 | − engines: {node: ^10 || ^12 || >=14} | |
| 1565 | − | |
| 1566 | − prelude-ls@1.2.1: | |
| 1567 | − resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} | |
| 1568 | − engines: {node: '>= 0.8.0'} | |
| 1569 | − | |
| 1570 | − prop-types@15.8.1: | |
| 1571 | − resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} | |
| 1572 | − | |
| 1573 | − punycode@2.3.1: | |
| 1574 | − resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} | |
| 1575 | − engines: {node: '>=6'} | |
| 1576 | − | |
| 1577 | − queue-microtask@1.2.3: | |
| 1578 | − resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} | |
| 1579 | − | |
| 1580 | − react-dom@19.2.8: | |
| 1581 | − resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} | |
| 1582 | − peerDependencies: | |
| 1583 | − react: ^19.2.8 | |
| 1584 | − | |
| 1585 | − react-is@16.13.1: | |
| 1586 | − resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} | |
| 1587 | − | |
| 1588 | − react@19.2.8: | |
| 1589 | − resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} | |
| 1590 | − engines: {node: '>=0.10.0'} | |
| 1591 | − | |
| 1592 | − read-cache@1.0.0: | |
| 1593 | − resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} | |
| 1594 | − | |
| 1595 | − readdirp@3.6.0: | |
| 1596 | − resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} | |
| 1597 | − engines: {node: '>=8.10.0'} | |
| 1598 | − | |
| 1599 | − reflect.getprototypeof@1.0.10: | |
| 1600 | − resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} | |
| 1601 | − engines: {node: '>= 0.4'} | |
| 1602 | − | |
| 1603 | − regexp.prototype.flags@1.5.4: | |
| 1604 | − resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} | |
| 1605 | − engines: {node: '>= 0.4'} | |
| 1606 | − | |
| 1607 | − resolve-from@4.0.0: | |
| 1608 | − resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} | |
| 1609 | − engines: {node: '>=4'} | |
| 1610 | − | |
| 1611 | − resolve-pkg-maps@1.0.0: | |
| 1612 | − resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} | |
| 1613 | − | |
| 1614 | − resolve@1.22.12: | |
| 1615 | − resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} | |
| 1616 | − engines: {node: '>= 0.4'} | |
| 1617 | − hasBin: true | |
| 1618 | − | |
| 1619 | − resolve@2.0.0-next.7: | |
| 1620 | − resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} | |
| 1621 | − engines: {node: '>= 0.4'} | |
| 1622 | − hasBin: true | |
| 1623 | − | |
| 1624 | − reusify@1.1.0: | |
| 1625 | − resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} | |
| 1626 | − engines: {iojs: '>=1.0.0', node: '>=0.10.0'} | |
| 1627 | − | |
| 1628 | − rimraf@3.0.2: | |
| 1629 | − resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} | |
| 1630 | − deprecated: Rimraf versions prior to v4 are no longer supported | |
| 1631 | − hasBin: true | |
| 1632 | − | |
| 1633 | − run-parallel@1.2.0: | |
| 1634 | − resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} | |
| 1635 | − | |
| 1636 | − safe-array-concat@1.1.4: | |
| 1637 | − resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} | |
| 1638 | − engines: {node: '>=0.4'} | |
| 1639 | − | |
| 1640 | − safe-push-apply@1.0.0: | |
| 1641 | − resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} | |
| 1642 | − engines: {node: '>= 0.4'} | |
| 1643 | − | |
| 1644 | − safe-regex-test@1.1.0: | |
| 1645 | − resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} | |
| 1646 | − engines: {node: '>= 0.4'} | |
| 1647 | − | |
| 1648 | − scheduler@0.27.0: | |
| 1649 | − resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} | |
| 1650 | − | |
| 1651 | − semver@6.3.1: | |
| 1652 | − resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} | |
| 1653 | − hasBin: true | |
| 1654 | − | |
| 1655 | − semver@7.8.5: | |
| 1656 | − resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} | |
| 1657 | − engines: {node: '>=10'} | |
| 1658 | − hasBin: true | |
| 1659 | − | |
| 1660 | − set-function-length@1.2.2: | |
| 1661 | − resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} | |
| 1662 | − engines: {node: '>= 0.4'} | |
| 1663 | − | |
| 1664 | − set-function-name@2.0.2: | |
| 1665 | − resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} | |
| 1666 | − engines: {node: '>= 0.4'} | |
| 1667 | − | |
| 1668 | − set-proto@1.0.0: | |
| 1669 | − resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} | |
| 1670 | − engines: {node: '>= 0.4'} | |
| 1671 | − | |
| 1672 | − sharp@0.34.5: | |
| 1673 | − resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} | |
| 1674 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 1675 | − | |
| 1676 | − shebang-command@2.0.0: | |
| 1677 | − resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} | |
| 1678 | − engines: {node: '>=8'} | |
| 1679 | − | |
| 1680 | − shebang-regex@3.0.0: | |
| 1681 | − resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} | |
| 1682 | − engines: {node: '>=8'} | |
| 1683 | − | |
| 1684 | − side-channel-list@1.0.1: | |
| 1685 | − resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} | |
| 1686 | − engines: {node: '>= 0.4'} | |
| 1687 | − | |
| 1688 | − side-channel-map@1.0.1: | |
| 1689 | − resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} | |
| 1690 | − engines: {node: '>= 0.4'} | |
| 1691 | − | |
| 1692 | − side-channel-weakmap@1.0.2: | |
| 1693 | − resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} | |
| 1694 | − engines: {node: '>= 0.4'} | |
| 1695 | − | |
| 1696 | − side-channel@1.1.1: | |
| 1697 | − resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} | |
| 1698 | − engines: {node: '>= 0.4'} | |
| 1699 | − | |
| 1700 | − source-map-js@1.2.1: | |
| 1701 | − resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} | |
| 1702 | − engines: {node: '>=0.10.0'} | |
| 1703 | − | |
| 1704 | − stable-hash@0.0.5: | |
| 1705 | − resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} | |
| 1706 | − | |
| 1707 | − stop-iteration-iterator@1.1.0: | |
| 1708 | − resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} | |
| 1709 | − engines: {node: '>= 0.4'} | |
| 1710 | − | |
| 1711 | − string.prototype.includes@2.0.1: | |
| 1712 | − resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} | |
| 1713 | − engines: {node: '>= 0.4'} | |
| 1714 | − | |
| 1715 | − string.prototype.matchall@4.0.12: | |
| 1716 | − resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} | |
| 1717 | − engines: {node: '>= 0.4'} | |
| 1718 | − | |
| 1719 | − string.prototype.repeat@1.0.0: | |
| 1720 | − resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} | |
| 1721 | − | |
| 1722 | − string.prototype.trim@1.2.11: | |
| 1723 | − resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} | |
| 1724 | − engines: {node: '>= 0.4'} | |
| 1725 | − | |
| 1726 | − string.prototype.trimend@1.0.10: | |
| 1727 | − resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} | |
| 1728 | − engines: {node: '>= 0.4'} | |
| 1729 | − | |
| 1730 | − string.prototype.trimstart@1.0.8: | |
| 1731 | − resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} | |
| 1732 | − engines: {node: '>= 0.4'} | |
| 1733 | − | |
| 1734 | − strip-ansi@6.0.1: | |
| 1735 | − resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} | |
| 1736 | − engines: {node: '>=8'} | |
| 1737 | − | |
| 1738 | − strip-bom@3.0.0: | |
| 1739 | − resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} | |
| 1740 | − engines: {node: '>=4'} | |
| 1741 | − | |
| 1742 | − strip-json-comments@3.1.1: | |
| 1743 | − resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} | |
| 1744 | − engines: {node: '>=8'} | |
| 1745 | − | |
| 1746 | − styled-jsx@5.1.6: | |
| 1747 | − resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} | |
| 1748 | − engines: {node: '>= 12.0.0'} | |
| 1749 | − peerDependencies: | |
| 1750 | − '@babel/core': '*' | |
| 1751 | − babel-plugin-macros: '*' | |
| 1752 | − react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' | |
| 1753 | − peerDependenciesMeta: | |
| 1754 | − '@babel/core': | |
| 1755 | − optional: true | |
| 1756 | − babel-plugin-macros: | |
| 1757 | − optional: true | |
| 1758 | − | |
| 1759 | − sucrase@3.35.1: | |
| 1760 | − resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} | |
| 1761 | − engines: {node: '>=16 || 14 >=14.17'} | |
| 1762 | − hasBin: true | |
| 1763 | − | |
| 1764 | − supports-color@7.2.0: | |
| 1765 | − resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} | |
| 1766 | − engines: {node: '>=8'} | |
| 1767 | − | |
| 1768 | − supports-preserve-symlinks-flag@1.0.0: | |
| 1769 | − resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} | |
| 1770 | − engines: {node: '>= 0.4'} | |
| 1771 | − | |
| 1772 | − tailwind-merge@2.6.1: | |
| 1773 | − resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} | |
| 1774 | − | |
| 1775 | − tailwindcss@3.4.19: | |
| 1776 | − resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} | |
| 1777 | − engines: {node: '>=14.0.0'} | |
| 1778 | − hasBin: true | |
| 1779 | − | |
| 1780 | − text-table@0.2.0: | |
| 1781 | − resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} | |
| 1782 | − | |
| 1783 | − thenify-all@1.6.0: | |
| 1784 | − resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} | |
| 1785 | − engines: {node: '>=0.8'} | |
| 1786 | − | |
| 1787 | − thenify@3.3.1: | |
| 1788 | − resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} | |
| 1789 | − | |
| 1790 | − tinyglobby@0.2.17: | |
| 1791 | − resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} | |
| 1792 | − engines: {node: '>=12.0.0'} | |
| 1793 | − | |
| 1794 | − to-regex-range@5.0.1: | |
| 1795 | − resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} | |
| 1796 | − engines: {node: '>=8.0'} | |
| 1797 | − | |
| 1798 | − ts-api-utils@2.5.0: | |
| 1799 | − resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} | |
| 1800 | − engines: {node: '>=18.12'} | |
| 1801 | − peerDependencies: | |
| 1802 | − typescript: '>=4.8.4' | |
| 1803 | − | |
| 1804 | − ts-interface-checker@0.1.13: | |
| 1805 | − resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} | |
| 1806 | − | |
| 1807 | − tsconfig-paths@3.15.0: | |
| 1808 | − resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} | |
| 1809 | − | |
| 1810 | − tslib@2.8.1: | |
| 1811 | − resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} | |
| 1812 | − | |
| 1813 | − type-check@0.4.0: | |
| 1814 | − resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} | |
| 1815 | − engines: {node: '>= 0.8.0'} | |
| 1816 | − | |
| 1817 | − type-fest@0.20.2: | |
| 1818 | − resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} | |
| 1819 | − engines: {node: '>=10'} | |
| 1820 | − | |
| 1821 | − typed-array-buffer@1.0.3: | |
| 1822 | − resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} | |
| 1823 | − engines: {node: '>= 0.4'} | |
| 1824 | − | |
| 1825 | − typed-array-byte-length@1.0.3: | |
| 1826 | − resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} | |
| 1827 | − engines: {node: '>= 0.4'} | |
| 1828 | − | |
| 1829 | − typed-array-byte-offset@1.0.4: | |
| 1830 | − resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} | |
| 1831 | − engines: {node: '>= 0.4'} | |
| 1832 | − | |
| 1833 | − typed-array-length@1.0.8: | |
| 1834 | − resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} | |
| 1835 | − engines: {node: '>= 0.4'} | |
| 1836 | − | |
| 1837 | − typescript@5.9.3: | |
| 1838 | − resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} | |
| 1839 | − engines: {node: '>=14.17'} | |
| 1840 | − hasBin: true | |
| 1841 | − | |
| 1842 | − unbox-primitive@1.1.0: | |
| 1843 | − resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} | |
| 1844 | − engines: {node: '>= 0.4'} | |
| 1845 | − | |
| 1846 | − undici-types@6.21.0: | |
| 1847 | − resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} | |
| 1848 | − | |
| 1849 | − unrs-resolver@1.12.2: | |
| 1850 | − resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} | |
| 1851 | − | |
| 1852 | − update-browserslist-db@1.3.1: | |
| 1853 | − resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} | |
| 1854 | − hasBin: true | |
| 1855 | − peerDependencies: | |
| 1856 | − browserslist: '>= 4.21.0' | |
| 1857 | − | |
| 1858 | − uri-js@4.4.1: | |
| 1859 | − resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} | |
| 1860 | − | |
| 1861 | − util-deprecate@1.0.2: | |
| 1862 | − resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} | |
| 1863 | − | |
| 1864 | − which-boxed-primitive@1.1.1: | |
| 1865 | − resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} | |
| 1866 | − engines: {node: '>= 0.4'} | |
| 1867 | − | |
| 1868 | − which-builtin-type@1.2.1: | |
| 1869 | − resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} | |
| 1870 | − engines: {node: '>= 0.4'} | |
| 1871 | − | |
| 1872 | − which-collection@1.0.2: | |
| 1873 | − resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} | |
| 1874 | − engines: {node: '>= 0.4'} | |
| 1875 | − | |
| 1876 | − which-typed-array@1.1.22: | |
| 1877 | − resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} | |
| 1878 | − engines: {node: '>= 0.4'} | |
| 1879 | − | |
| 1880 | − which@2.0.2: | |
| 1881 | − resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} | |
| 1882 | − engines: {node: '>= 8'} | |
| 1883 | − hasBin: true | |
| 1884 | − | |
| 1885 | − word-wrap@1.2.5: | |
| 1886 | − resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} | |
| 1887 | − engines: {node: '>=0.10.0'} | |
| 1888 | − | |
| 1889 | − wrappy@1.0.2: | |
| 1890 | − resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} | |
| 1891 | − | |
| 1892 | − yocto-queue@0.1.0: | |
| 1893 | − resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} | |
| 1894 | − engines: {node: '>=10'} | |
| 1895 | − | |
| 1896 | −snapshots: | |
| 1897 | − | |
| 1898 | − '@alloc/quick-lru@5.2.0': {} | |
| 1899 | − | |
| 1900 | − '@emnapi/core@1.10.0': | |
| 1901 | − dependencies: | |
| 1902 | − '@emnapi/wasi-threads': 1.2.1 | |
| 1903 | − tslib: 2.8.1 | |
| 1904 | − optional: true | |
| 1905 | − | |
| 1906 | − '@emnapi/runtime@1.10.0': | |
| 1907 | − dependencies: | |
| 1908 | − tslib: 2.8.1 | |
| 1909 | − optional: true | |
| 1910 | − | |
| 1911 | − '@emnapi/runtime@1.11.3': | |
| 1912 | − dependencies: | |
| 1913 | − tslib: 2.8.1 | |
| 1914 | − optional: true | |
| 1915 | − | |
| 1916 | − '@emnapi/wasi-threads@1.2.1': | |
| 1917 | − dependencies: | |
| 1918 | − tslib: 2.8.1 | |
| 1919 | − optional: true | |
| 1920 | − | |
| 1921 | − '@eslint-community/eslint-utils@4.10.1(eslint@8.57.1)': | |
| 1922 | − dependencies: | |
| 1923 | − eslint: 8.57.1 | |
| 1924 | − eslint-visitor-keys: 3.4.3 | |
| 1925 | − | |
| 1926 | − '@eslint-community/regexpp@4.12.2': {} | |
| 1927 | − | |
| 1928 | − '@eslint/eslintrc@2.1.4': | |
| 1929 | − dependencies: | |
| 1930 | − ajv: 6.15.0 | |
| 1931 | − debug: 4.4.3 | |
| 1932 | − espree: 9.6.1 | |
| 1933 | − globals: 13.24.0 | |
| 1934 | − ignore: 5.3.2 | |
| 1935 | − import-fresh: 3.3.1 | |
| 1936 | − js-yaml: 4.3.1 | |
| 1937 | − minimatch: 3.1.5 | |
| 1938 | − strip-json-comments: 3.1.1 | |
| 1939 | − transitivePeerDependencies: | |
| 1940 | − - supports-color | |
| 1941 | − | |
| 1942 | − '@eslint/js@8.57.1': {} | |
| 1943 | − | |
| 1944 | − '@humanwhocodes/config-array@0.13.0': | |
| 1945 | − dependencies: | |
| 1946 | − '@humanwhocodes/object-schema': 2.0.3 | |
| 1947 | − debug: 4.4.3 | |
| 1948 | − minimatch: 3.1.5 | |
| 1949 | − transitivePeerDependencies: | |
| 1950 | − - supports-color | |
| 1951 | − | |
| 1952 | − '@humanwhocodes/module-importer@1.0.1': {} | |
| 1953 | − | |
| 1954 | − '@humanwhocodes/object-schema@2.0.3': {} | |
| 1955 | − | |
| 1956 | − '@img/colour@1.1.0': | |
| 1957 | − optional: true | |
| 1958 | − | |
| 1959 | − '@img/sharp-darwin-arm64@0.34.5': | |
| 1960 | − optionalDependencies: | |
| 1961 | − '@img/sharp-libvips-darwin-arm64': 1.2.4 | |
| 1962 | − optional: true | |
| 1963 | − | |
| 1964 | − '@img/sharp-darwin-x64@0.34.5': | |
| 1965 | − optionalDependencies: | |
| 1966 | − '@img/sharp-libvips-darwin-x64': 1.2.4 | |
| 1967 | − optional: true | |
| 1968 | − | |
| 1969 | − '@img/sharp-libvips-darwin-arm64@1.2.4': | |
| 1970 | − optional: true | |
| 1971 | − | |
| 1972 | − '@img/sharp-libvips-darwin-x64@1.2.4': | |
| 1973 | − optional: true | |
| 1974 | − | |
| 1975 | − '@img/sharp-libvips-linux-arm64@1.2.4': | |
| 1976 | − optional: true | |
| 1977 | − | |
| 1978 | − '@img/sharp-libvips-linux-arm@1.2.4': | |
| 1979 | − optional: true | |
| 1980 | − | |
| 1981 | − '@img/sharp-libvips-linux-ppc64@1.2.4': | |
| 1982 | − optional: true | |
| 1983 | − | |
| 1984 | − '@img/sharp-libvips-linux-riscv64@1.2.4': | |
| 1985 | − optional: true | |
| 1986 | − | |
| 1987 | − '@img/sharp-libvips-linux-s390x@1.2.4': | |
| 1988 | − optional: true | |
| 1989 | − | |
| 1990 | − '@img/sharp-libvips-linux-x64@1.2.4': | |
| 1991 | − optional: true | |
| 1992 | − | |
| 1993 | − '@img/sharp-libvips-linuxmusl-arm64@1.2.4': | |
| 1994 | − optional: true | |
| 1995 | − | |
| 1996 | − '@img/sharp-libvips-linuxmusl-x64@1.2.4': | |
| 1997 | − optional: true | |
| 1998 | − | |
| 1999 | − '@img/sharp-linux-arm64@0.34.5': | |
| 2000 | − optionalDependencies: | |
| 2001 | − '@img/sharp-libvips-linux-arm64': 1.2.4 | |
| 2002 | − optional: true | |
| 2003 | − | |
| 2004 | − '@img/sharp-linux-arm@0.34.5': | |
| 2005 | − optionalDependencies: | |
| 2006 | − '@img/sharp-libvips-linux-arm': 1.2.4 | |
| 2007 | − optional: true | |
| 2008 | − | |
| 2009 | − '@img/sharp-linux-ppc64@0.34.5': | |
| 2010 | − optionalDependencies: | |
| 2011 | − '@img/sharp-libvips-linux-ppc64': 1.2.4 | |
| 2012 | − optional: true | |
| 2013 | − | |
| 2014 | − '@img/sharp-linux-riscv64@0.34.5': | |
| 2015 | − optionalDependencies: | |
| 2016 | − '@img/sharp-libvips-linux-riscv64': 1.2.4 | |
| 2017 | − optional: true | |
| 2018 | − | |
| 2019 | − '@img/sharp-linux-s390x@0.34.5': | |
| 2020 | − optionalDependencies: | |
| 2021 | − '@img/sharp-libvips-linux-s390x': 1.2.4 | |
| 2022 | − optional: true | |
| 2023 | − | |
| 2024 | − '@img/sharp-linux-x64@0.34.5': | |
| 2025 | − optionalDependencies: | |
| 2026 | − '@img/sharp-libvips-linux-x64': 1.2.4 | |
| 2027 | − optional: true | |
| 2028 | − | |
| 2029 | − '@img/sharp-linuxmusl-arm64@0.34.5': | |
| 2030 | − optionalDependencies: | |
| 2031 | − '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 | |
| 2032 | − optional: true | |
| 2033 | − | |
| 2034 | − '@img/sharp-linuxmusl-x64@0.34.5': | |
| 2035 | − optionalDependencies: | |
| 2036 | − '@img/sharp-libvips-linuxmusl-x64': 1.2.4 | |
| 2037 | − optional: true | |
| 2038 | − | |
| 2039 | − '@img/sharp-wasm32@0.34.5': | |
| 2040 | − dependencies: | |
| 2041 | − '@emnapi/runtime': 1.11.3 | |
| 2042 | − optional: true | |
| 2043 | − | |
| 2044 | − '@img/sharp-win32-arm64@0.34.5': | |
| 2045 | − optional: true | |
| 2046 | − | |
| 2047 | − '@img/sharp-win32-ia32@0.34.5': | |
| 2048 | − optional: true | |
| 2049 | − | |
| 2050 | − '@img/sharp-win32-x64@0.34.5': | |
| 2051 | − optional: true | |
| 2052 | − | |
| 2053 | − '@jridgewell/gen-mapping@0.3.13': | |
| 2054 | − dependencies: | |
| 2055 | − '@jridgewell/sourcemap-codec': 1.5.5 | |
| 2056 | − '@jridgewell/trace-mapping': 0.3.31 | |
| 2057 | − | |
| 2058 | − '@jridgewell/resolve-uri@3.1.2': {} | |
| 2059 | − | |
| 2060 | − '@jridgewell/sourcemap-codec@1.5.5': {} | |
| 2061 | − | |
| 2062 | − '@jridgewell/trace-mapping@0.3.31': | |
| 2063 | − dependencies: | |
| 2064 | − '@jridgewell/resolve-uri': 3.1.2 | |
| 2065 | − '@jridgewell/sourcemap-codec': 1.5.5 | |
| 2066 | − | |
| 2067 | − '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': | |
| 2068 | − dependencies: | |
| 2069 | − '@emnapi/core': 1.10.0 | |
| 2070 | − '@emnapi/runtime': 1.10.0 | |
| 2071 | − '@tybys/wasm-util': 0.10.3 | |
| 2072 | − optional: true | |
| 2073 | − | |
| 2074 | − '@next/env@15.5.23': {} | |
| 2075 | − | |
| 2076 | − '@next/eslint-plugin-next@15.5.23': | |
| 2077 | − dependencies: | |
| 2078 | − fast-glob: 3.3.1 | |
| 2079 | − | |
| 2080 | − '@next/swc-darwin-arm64@15.5.23': | |
| 2081 | − optional: true | |
| 2082 | − | |
| 2083 | − '@next/swc-darwin-x64@15.5.23': | |
| 2084 | − optional: true | |
| 2085 | − | |
| 2086 | − '@next/swc-linux-arm64-gnu@15.5.23': | |
| 2087 | − optional: true | |
| 2088 | − | |
| 2089 | − '@next/swc-linux-arm64-musl@15.5.23': | |
| 2090 | − optional: true | |
| 2091 | − | |
| 2092 | − '@next/swc-linux-x64-gnu@15.5.23': | |
| 2093 | − optional: true | |
| 2094 | − | |
| 2095 | − '@next/swc-linux-x64-musl@15.5.23': | |
| 2096 | − optional: true | |
| 2097 | − | |
| 2098 | − '@next/swc-win32-arm64-msvc@15.5.23': | |
| 2099 | − optional: true | |
| 2100 | − | |
| 2101 | − '@next/swc-win32-x64-msvc@15.5.23': | |
| 2102 | − optional: true | |
| 2103 | − | |
| 2104 | − '@nodelib/fs.scandir@2.1.5': | |
| 2105 | − dependencies: | |
| 2106 | − '@nodelib/fs.stat': 2.0.5 | |
| 2107 | − run-parallel: 1.2.0 | |
| 2108 | − | |
| 2109 | − '@nodelib/fs.stat@2.0.5': {} | |
| 2110 | − | |
| 2111 | − '@nodelib/fs.walk@1.2.8': | |
| 2112 | − dependencies: | |
| 2113 | − '@nodelib/fs.scandir': 2.1.5 | |
| 2114 | − fastq: 1.20.1 | |
| 2115 | − | |
| 2116 | − '@nolyfill/is-core-module@1.0.39': {} | |
| 2117 | − | |
| 2118 | − '@rtsao/scc@1.1.0': {} | |
| 2119 | − | |
| 2120 | − '@rushstack/eslint-patch@1.16.1': {} | |
| 2121 | − | |
| 2122 | − '@swc/helpers@0.5.15': | |
| 2123 | − dependencies: | |
| 2124 | − tslib: 2.8.1 | |
| 2125 | − | |
| 2126 | − '@tybys/wasm-util@0.10.3': | |
| 2127 | − dependencies: | |
| 2128 | − tslib: 2.8.1 | |
| 2129 | − optional: true | |
| 2130 | − | |
| 2131 | − '@types/json5@0.0.29': {} | |
| 2132 | − | |
| 2133 | − '@types/node@22.20.1': | |
| 2134 | − dependencies: | |
| 2135 | − undici-types: 6.21.0 | |
| 2136 | − | |
| 2137 | − '@types/react-dom@19.2.4(@types/react@19.2.18)': | |
| 2138 | − dependencies: | |
| 2139 | − '@types/react': 19.2.18 | |
| 2140 | − | |
| 2141 | − '@types/react@19.2.18': | |
| 2142 | − dependencies: | |
| 2143 | − csstype: 3.2.3 | |
| 2144 | − | |
| 2145 | − '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': | |
| 2146 | − dependencies: | |
| 2147 | − '@eslint-community/regexpp': 4.12.2 | |
| 2148 | − '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2149 | − '@typescript-eslint/scope-manager': 8.67.0 | |
| 2150 | − '@typescript-eslint/type-utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2151 | − '@typescript-eslint/utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2152 | − '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2153 | − eslint: 8.57.1 | |
| 2154 | − ignore: 7.0.6 | |
| 2155 | − natural-compare: 1.4.0 | |
| 2156 | − ts-api-utils: 2.5.0(typescript@5.9.3) | |
| 2157 | − typescript: 5.9.3 | |
| 2158 | − transitivePeerDependencies: | |
| 2159 | − - supports-color | |
| 2160 | − | |
| 2161 | − '@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3)': | |
| 2162 | − dependencies: | |
| 2163 | − '@typescript-eslint/scope-manager': 8.67.0 | |
| 2164 | − '@typescript-eslint/types': 8.67.0 | |
| 2165 | − '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) | |
| 2166 | − '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2167 | − debug: 4.4.3 | |
| 2168 | − eslint: 8.57.1 | |
| 2169 | − typescript: 5.9.3 | |
| 2170 | − transitivePeerDependencies: | |
| 2171 | − - supports-color | |
| 2172 | − | |
| 2173 | − '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': | |
| 2174 | − dependencies: | |
| 2175 | − '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) | |
| 2176 | − '@typescript-eslint/types': 8.67.0 | |
| 2177 | − debug: 4.4.3 | |
| 2178 | − typescript: 5.9.3 | |
| 2179 | − transitivePeerDependencies: | |
| 2180 | − - supports-color | |
| 2181 | − | |
| 2182 | − '@typescript-eslint/scope-manager@8.67.0': | |
| 2183 | − dependencies: | |
| 2184 | − '@typescript-eslint/types': 8.67.0 | |
| 2185 | − '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2186 | − | |
| 2187 | − '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)': | |
| 2188 | − dependencies: | |
| 2189 | − typescript: 5.9.3 | |
| 2190 | − | |
| 2191 | − '@typescript-eslint/type-utils@8.67.0(eslint@8.57.1)(typescript@5.9.3)': | |
| 2192 | − dependencies: | |
| 2193 | − '@typescript-eslint/types': 8.67.0 | |
| 2194 | − '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) | |
| 2195 | − '@typescript-eslint/utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2196 | − debug: 4.4.3 | |
| 2197 | − eslint: 8.57.1 | |
| 2198 | − ts-api-utils: 2.5.0(typescript@5.9.3) | |
| 2199 | − typescript: 5.9.3 | |
| 2200 | − transitivePeerDependencies: | |
| 2201 | − - supports-color | |
| 2202 | − | |
| 2203 | − '@typescript-eslint/types@8.67.0': {} | |
| 2204 | − | |
| 2205 | − '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': | |
| 2206 | − dependencies: | |
| 2207 | − '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3) | |
| 2208 | − '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) | |
| 2209 | − '@typescript-eslint/types': 8.67.0 | |
| 2210 | − '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2211 | − debug: 4.4.3 | |
| 2212 | − minimatch: 10.2.6 | |
| 2213 | − semver: 7.8.5 | |
| 2214 | − tinyglobby: 0.2.17 | |
| 2215 | − ts-api-utils: 2.5.0(typescript@5.9.3) | |
| 2216 | − typescript: 5.9.3 | |
| 2217 | − transitivePeerDependencies: | |
| 2218 | − - supports-color | |
| 2219 | − | |
| 2220 | − '@typescript-eslint/utils@8.67.0(eslint@8.57.1)(typescript@5.9.3)': | |
| 2221 | − dependencies: | |
| 2222 | − '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1) | |
| 2223 | − '@typescript-eslint/scope-manager': 8.67.0 | |
| 2224 | − '@typescript-eslint/types': 8.67.0 | |
| 2225 | − '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) | |
| 2226 | − eslint: 8.57.1 | |
| 2227 | − typescript: 5.9.3 | |
| 2228 | − transitivePeerDependencies: | |
| 2229 | − - supports-color | |
| 2230 | − | |
| 2231 | − '@typescript-eslint/visitor-keys@8.67.0': | |
| 2232 | − dependencies: | |
| 2233 | − '@typescript-eslint/types': 8.67.0 | |
| 2234 | − eslint-visitor-keys: 5.0.1 | |
| 2235 | − | |
| 2236 | − '@ungap/structured-clone@1.3.3': {} | |
| 2237 | − | |
| 2238 | − '@unrs/resolver-binding-android-arm-eabi@1.12.2': | |
| 2239 | − optional: true | |
| 2240 | − | |
| 2241 | − '@unrs/resolver-binding-android-arm64@1.12.2': | |
| 2242 | − optional: true | |
| 2243 | − | |
| 2244 | − '@unrs/resolver-binding-darwin-arm64@1.12.2': | |
| 2245 | − optional: true | |
| 2246 | − | |
| 2247 | − '@unrs/resolver-binding-darwin-x64@1.12.2': | |
| 2248 | − optional: true | |
| 2249 | − | |
| 2250 | − '@unrs/resolver-binding-freebsd-x64@1.12.2': | |
| 2251 | − optional: true | |
| 2252 | − | |
| 2253 | − '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': | |
| 2254 | − optional: true | |
| 2255 | − | |
| 2256 | − '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': | |
| 2257 | − optional: true | |
| 2258 | − | |
| 2259 | − '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': | |
| 2260 | − optional: true | |
| 2261 | − | |
| 2262 | − '@unrs/resolver-binding-linux-arm64-musl@1.12.2': | |
| 2263 | − optional: true | |
| 2264 | − | |
| 2265 | − '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': | |
| 2266 | − optional: true | |
| 2267 | − | |
| 2268 | − '@unrs/resolver-binding-linux-loong64-musl@1.12.2': | |
| 2269 | − optional: true | |
| 2270 | − | |
| 2271 | − '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': | |
| 2272 | − optional: true | |
| 2273 | − | |
| 2274 | − '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': | |
| 2275 | − optional: true | |
| 2276 | − | |
| 2277 | − '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': | |
| 2278 | − optional: true | |
| 2279 | − | |
| 2280 | − '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': | |
| 2281 | − optional: true | |
| 2282 | − | |
| 2283 | − '@unrs/resolver-binding-linux-x64-gnu@1.12.2': | |
| 2284 | − optional: true | |
| 2285 | − | |
| 2286 | − '@unrs/resolver-binding-linux-x64-musl@1.12.2': | |
| 2287 | − optional: true | |
| 2288 | − | |
| 2289 | − '@unrs/resolver-binding-openharmony-arm64@1.12.2': | |
| 2290 | − optional: true | |
| 2291 | − | |
| 2292 | − '@unrs/resolver-binding-wasm32-wasi@1.12.2': | |
| 2293 | − dependencies: | |
| 2294 | − '@emnapi/core': 1.10.0 | |
| 2295 | − '@emnapi/runtime': 1.10.0 | |
| 2296 | − '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) | |
| 2297 | − optional: true | |
| 2298 | − | |
| 2299 | − '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': | |
| 2300 | − optional: true | |
| 2301 | − | |
| 2302 | − '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': | |
| 2303 | − optional: true | |
| 2304 | − | |
| 2305 | − '@unrs/resolver-binding-win32-x64-msvc@1.12.2': | |
| 2306 | − optional: true | |
| 2307 | − | |
| 2308 | − acorn-jsx@5.3.2(acorn@8.18.0): | |
| 2309 | − dependencies: | |
| 2310 | − acorn: 8.18.0 | |
| 2311 | − | |
| 2312 | − acorn@8.18.0: {} | |
| 2313 | − | |
| 2314 | − ajv@6.15.0: | |
| 2315 | − dependencies: | |
| 2316 | − fast-deep-equal: 3.1.3 | |
| 2317 | − fast-json-stable-stringify: 2.1.0 | |
| 2318 | − json-schema-traverse: 0.4.1 | |
| 2319 | − uri-js: 4.4.1 | |
| 2320 | − | |
| 2321 | − ansi-regex@5.0.1: {} | |
| 2322 | − | |
| 2323 | − ansi-styles@4.3.0: | |
| 2324 | − dependencies: | |
| 2325 | − color-convert: 2.0.1 | |
| 2326 | − | |
| 2327 | − any-promise@1.3.0: {} | |
| 2328 | − | |
| 2329 | − anymatch@3.1.3: | |
| 2330 | − dependencies: | |
| 2331 | − normalize-path: 3.0.0 | |
| 2332 | − picomatch: 2.3.2 | |
| 2333 | − | |
| 2334 | − arg@5.0.2: {} | |
| 2335 | − | |
| 2336 | − argparse@2.0.1: {} | |
| 2337 | − | |
| 2338 | − aria-query@5.3.2: {} | |
| 2339 | − | |
| 2340 | − array-buffer-byte-length@1.0.2: | |
| 2341 | − dependencies: | |
| 2342 | − call-bound: 1.0.4 | |
| 2343 | − is-array-buffer: 3.0.5 | |
| 2344 | − | |
| 2345 | − array-includes@3.1.9: | |
| 2346 | − dependencies: | |
| 2347 | − call-bind: 1.0.9 | |
| 2348 | − call-bound: 1.0.4 | |
| 2349 | − define-properties: 1.2.1 | |
| 2350 | − es-abstract: 1.24.2 | |
| 2351 | − es-object-atoms: 1.1.2 | |
| 2352 | − get-intrinsic: 1.3.0 | |
| 2353 | − is-string: 1.1.1 | |
| 2354 | − math-intrinsics: 1.1.0 | |
| 2355 | − | |
| 2356 | − array.prototype.findlast@1.2.5: | |
| 2357 | − dependencies: | |
| 2358 | − call-bind: 1.0.9 | |
| 2359 | − define-properties: 1.2.1 | |
| 2360 | − es-abstract: 1.24.2 | |
| 2361 | − es-errors: 1.3.0 | |
| 2362 | − es-object-atoms: 1.1.2 | |
| 2363 | − es-shim-unscopables: 1.1.0 | |
| 2364 | − | |
| 2365 | − array.prototype.findlastindex@1.2.6: | |
| 2366 | − dependencies: | |
| 2367 | − call-bind: 1.0.9 | |
| 2368 | − call-bound: 1.0.4 | |
| 2369 | − define-properties: 1.2.1 | |
| 2370 | − es-abstract: 1.24.2 | |
| 2371 | − es-errors: 1.3.0 | |
| 2372 | − es-object-atoms: 1.1.2 | |
| 2373 | − es-shim-unscopables: 1.1.0 | |
| 2374 | − | |
| 2375 | − array.prototype.flat@1.3.3: | |
| 2376 | − dependencies: | |
| 2377 | − call-bind: 1.0.9 | |
| 2378 | − define-properties: 1.2.1 | |
| 2379 | − es-abstract: 1.24.2 | |
| 2380 | − es-shim-unscopables: 1.1.0 | |
| 2381 | − | |
| 2382 | − array.prototype.flatmap@1.3.3: | |
| 2383 | − dependencies: | |
| 2384 | − call-bind: 1.0.9 | |
| 2385 | − define-properties: 1.2.1 | |
| 2386 | − es-abstract: 1.24.2 | |
| 2387 | − es-shim-unscopables: 1.1.0 | |
| 2388 | − | |
| 2389 | − array.prototype.tosorted@1.1.4: | |
| 2390 | − dependencies: | |
| 2391 | − call-bind: 1.0.9 | |
| 2392 | − define-properties: 1.2.1 | |
| 2393 | − es-abstract: 1.24.2 | |
| 2394 | − es-errors: 1.3.0 | |
| 2395 | − es-shim-unscopables: 1.1.0 | |
| 2396 | − | |
| 2397 | − arraybuffer.prototype.slice@1.0.4: | |
| 2398 | − dependencies: | |
| 2399 | − array-buffer-byte-length: 1.0.2 | |
| 2400 | − call-bind: 1.0.9 | |
| 2401 | − define-properties: 1.2.1 | |
| 2402 | − es-abstract: 1.24.2 | |
| 2403 | − es-errors: 1.3.0 | |
| 2404 | − get-intrinsic: 1.3.0 | |
| 2405 | − is-array-buffer: 3.0.5 | |
| 2406 | − | |
| 2407 | − ast-types-flow@0.0.8: {} | |
| 2408 | − | |
| 2409 | − async-function@1.0.0: {} | |
| 2410 | − | |
| 2411 | − autoprefixer@10.5.4(postcss@8.5.26): | |
| 2412 | − dependencies: | |
| 2413 | − browserslist: 4.28.8 | |
| 2414 | − caniuse-lite: 1.0.30001809 | |
| 2415 | − fraction.js: 5.3.4 | |
| 2416 | − picocolors: 1.1.1 | |
| 2417 | − postcss: 8.5.26 | |
| 2418 | − postcss-value-parser: 4.2.0 | |
| 2419 | − | |
| 2420 | − available-typed-arrays@1.0.7: | |
| 2421 | − dependencies: | |
| 2422 | − possible-typed-array-names: 1.1.0 | |
| 2423 | − | |
| 2424 | − axe-core@4.13.0: {} | |
| 2425 | − | |
| 2426 | − axobject-query@4.1.0: {} | |
| 2427 | − | |
| 2428 | − balanced-match@1.0.2: {} | |
| 2429 | − | |
| 2430 | − balanced-match@4.0.4: {} | |
| 2431 | − | |
| 2432 | − baseline-browser-mapping@2.11.13: {} | |
| 2433 | − | |
| 2434 | − binary-extensions@2.3.0: {} | |
| 2435 | − | |
| 2436 | − brace-expansion@1.1.18: | |
| 2437 | − dependencies: | |
| 2438 | − balanced-match: 1.0.2 | |
| 2439 | − concat-map: 0.0.1 | |
| 2440 | − | |
| 2441 | − brace-expansion@5.0.9: | |
| 2442 | − dependencies: | |
| 2443 | − balanced-match: 4.0.4 | |
| 2444 | − | |
| 2445 | − braces@3.0.3: | |
| 2446 | − dependencies: | |
| 2447 | − fill-range: 7.1.1 | |
| 2448 | − | |
| 2449 | − browserslist@4.28.8: | |
| 2450 | − dependencies: | |
| 2451 | − baseline-browser-mapping: 2.11.13 | |
| 2452 | − caniuse-lite: 1.0.30001809 | |
| 2453 | − electron-to-chromium: 1.5.405 | |
| 2454 | − node-releases: 2.0.53 | |
| 2455 | − update-browserslist-db: 1.3.1(browserslist@4.28.8) | |
| 2456 | − | |
| 2457 | − call-bind-apply-helpers@1.0.2: | |
| 2458 | − dependencies: | |
| 2459 | − es-errors: 1.3.0 | |
| 2460 | − function-bind: 1.1.2 | |
| 2461 | − | |
| 2462 | − call-bind@1.0.9: | |
| 2463 | − dependencies: | |
| 2464 | − call-bind-apply-helpers: 1.0.2 | |
| 2465 | − es-define-property: 1.0.1 | |
| 2466 | − get-intrinsic: 1.3.0 | |
| 2467 | − set-function-length: 1.2.2 | |
| 2468 | − | |
| 2469 | − call-bound@1.0.4: | |
| 2470 | − dependencies: | |
| 2471 | − call-bind-apply-helpers: 1.0.2 | |
| 2472 | − get-intrinsic: 1.3.0 | |
| 2473 | − | |
| 2474 | − callsites@3.1.0: {} | |
| 2475 | − | |
| 2476 | − camelcase-css@2.0.1: {} | |
| 2477 | − | |
| 2478 | − caniuse-lite@1.0.30001809: {} | |
| 2479 | − | |
| 2480 | − chalk@4.1.2: | |
| 2481 | − dependencies: | |
| 2482 | − ansi-styles: 4.3.0 | |
| 2483 | − supports-color: 7.2.0 | |
| 2484 | − | |
| 2485 | − chokidar@3.6.0: | |
| 2486 | − dependencies: | |
| 2487 | − anymatch: 3.1.3 | |
| 2488 | − braces: 3.0.3 | |
| 2489 | − glob-parent: 5.1.2 | |
| 2490 | − is-binary-path: 2.1.0 | |
| 2491 | − is-glob: 4.0.3 | |
| 2492 | − normalize-path: 3.0.0 | |
| 2493 | − readdirp: 3.6.0 | |
| 2494 | − optionalDependencies: | |
| 2495 | − fsevents: 2.3.3 | |
| 2496 | − | |
| 2497 | − class-variance-authority@0.7.1: | |
| 2498 | − dependencies: | |
| 2499 | − clsx: 2.1.1 | |
| 2500 | − | |
| 2501 | − client-only@0.0.1: {} | |
| 2502 | − | |
| 2503 | − clsx@2.1.1: {} | |
| 2504 | − | |
| 2505 | − color-convert@2.0.1: | |
| 2506 | − dependencies: | |
| 2507 | − color-name: 1.1.4 | |
| 2508 | − | |
| 2509 | − color-name@1.1.4: {} | |
| 2510 | − | |
| 2511 | − commander@4.1.1: {} | |
| 2512 | − | |
| 2513 | − concat-map@0.0.1: {} | |
| 2514 | − | |
| 2515 | − cross-spawn@7.0.6: | |
| 2516 | − dependencies: | |
| 2517 | − path-key: 3.1.1 | |
| 2518 | − shebang-command: 2.0.0 | |
| 2519 | − which: 2.0.2 | |
| 2520 | − | |
| 2521 | − cssesc@3.0.0: {} | |
| 2522 | − | |
| 2523 | − csstype@3.2.3: {} | |
| 2524 | − | |
| 2525 | − damerau-levenshtein@1.0.8: {} | |
| 2526 | − | |
| 2527 | − data-view-buffer@1.0.2: | |
| 2528 | − dependencies: | |
| 2529 | − call-bound: 1.0.4 | |
| 2530 | − es-errors: 1.3.0 | |
| 2531 | − is-data-view: 1.0.2 | |
| 2532 | − | |
| 2533 | − data-view-byte-length@1.0.2: | |
| 2534 | − dependencies: | |
| 2535 | − call-bound: 1.0.4 | |
| 2536 | − es-errors: 1.3.0 | |
| 2537 | − is-data-view: 1.0.2 | |
| 2538 | − | |
| 2539 | − data-view-byte-offset@1.0.1: | |
| 2540 | − dependencies: | |
| 2541 | − call-bound: 1.0.4 | |
| 2542 | − es-errors: 1.3.0 | |
| 2543 | − is-data-view: 1.0.2 | |
| 2544 | − | |
| 2545 | − debug@3.2.7: | |
| 2546 | − dependencies: | |
| 2547 | − ms: 2.1.3 | |
| 2548 | − | |
| 2549 | − debug@4.4.3: | |
| 2550 | − dependencies: | |
| 2551 | − ms: 2.1.3 | |
| 2552 | − | |
| 2553 | − deep-is@0.1.4: {} | |
| 2554 | − | |
| 2555 | − define-data-property@1.1.4: | |
| 2556 | − dependencies: | |
| 2557 | − es-define-property: 1.0.1 | |
| 2558 | − es-errors: 1.3.0 | |
| 2559 | − gopd: 1.2.0 | |
| 2560 | − | |
| 2561 | − define-properties@1.2.1: | |
| 2562 | − dependencies: | |
| 2563 | − define-data-property: 1.1.4 | |
| 2564 | − has-property-descriptors: 1.0.2 | |
| 2565 | − object-keys: 1.1.1 | |
| 2566 | − | |
| 2567 | − detect-libc@2.1.2: | |
| 2568 | − optional: true | |
| 2569 | − | |
| 2570 | − didyoumean@1.2.2: {} | |
| 2571 | − | |
| 2572 | − dlv@1.1.3: {} | |
| 2573 | − | |
| 2574 | − doctrine@2.1.0: | |
| 2575 | − dependencies: | |
| 2576 | − esutils: 2.0.3 | |
| 2577 | − | |
| 2578 | − doctrine@3.0.0: | |
| 2579 | − dependencies: | |
| 2580 | − esutils: 2.0.3 | |
| 2581 | − | |
| 2582 | − dunder-proto@1.0.1: | |
| 2583 | − dependencies: | |
| 2584 | − call-bind-apply-helpers: 1.0.2 | |
| 2585 | − es-errors: 1.3.0 | |
| 2586 | − gopd: 1.2.0 | |
| 2587 | − | |
| 2588 | − electron-to-chromium@1.5.405: {} | |
| 2589 | − | |
| 2590 | − emoji-regex@9.2.2: {} | |
| 2591 | − | |
| 2592 | − es-abstract-get@1.0.0: | |
| 2593 | − dependencies: | |
| 2594 | − es-errors: 1.3.0 | |
| 2595 | − es-object-atoms: 1.1.2 | |
| 2596 | − is-callable: 1.2.7 | |
| 2597 | − object-inspect: 1.13.4 | |
| 2598 | − | |
| 2599 | − es-abstract@1.24.2: | |
| 2600 | − dependencies: | |
| 2601 | − array-buffer-byte-length: 1.0.2 | |
| 2602 | − arraybuffer.prototype.slice: 1.0.4 | |
| 2603 | − available-typed-arrays: 1.0.7 | |
| 2604 | − call-bind: 1.0.9 | |
| 2605 | − call-bound: 1.0.4 | |
| 2606 | − data-view-buffer: 1.0.2 | |
| 2607 | − data-view-byte-length: 1.0.2 | |
| 2608 | − data-view-byte-offset: 1.0.1 | |
| 2609 | − es-define-property: 1.0.1 | |
| 2610 | − es-errors: 1.3.0 | |
| 2611 | − es-object-atoms: 1.1.2 | |
| 2612 | − es-set-tostringtag: 2.1.0 | |
| 2613 | − es-to-primitive: 1.3.4 | |
| 2614 | − function.prototype.name: 1.2.0 | |
| 2615 | − get-intrinsic: 1.3.0 | |
| 2616 | − get-proto: 1.0.1 | |
| 2617 | − get-symbol-description: 1.1.0 | |
| 2618 | − globalthis: 1.0.4 | |
| 2619 | − gopd: 1.2.0 | |
| 2620 | − has-property-descriptors: 1.0.2 | |
| 2621 | − has-proto: 1.2.0 | |
| 2622 | − has-symbols: 1.1.0 | |
| 2623 | − hasown: 2.0.4 | |
| 2624 | − internal-slot: 1.1.0 | |
| 2625 | − is-array-buffer: 3.0.5 | |
| 2626 | − is-callable: 1.2.7 | |
| 2627 | − is-data-view: 1.0.2 | |
| 2628 | − is-negative-zero: 2.0.3 | |
| 2629 | − is-regex: 1.2.1 | |
| 2630 | − is-set: 2.0.3 | |
| 2631 | − is-shared-array-buffer: 1.0.4 | |
| 2632 | − is-string: 1.1.1 | |
| 2633 | − is-typed-array: 1.1.15 | |
| 2634 | − is-weakref: 1.1.1 | |
| 2635 | − math-intrinsics: 1.1.0 | |
| 2636 | − object-inspect: 1.13.4 | |
| 2637 | − object-keys: 1.1.1 | |
| 2638 | − object.assign: 4.1.7 | |
| 2639 | − own-keys: 1.0.2 | |
| 2640 | − regexp.prototype.flags: 1.5.4 | |
| 2641 | − safe-array-concat: 1.1.4 | |
| 2642 | − safe-push-apply: 1.0.0 | |
| 2643 | − safe-regex-test: 1.1.0 | |
| 2644 | − set-proto: 1.0.0 | |
| 2645 | − stop-iteration-iterator: 1.1.0 | |
| 2646 | − string.prototype.trim: 1.2.11 | |
| 2647 | − string.prototype.trimend: 1.0.10 | |
| 2648 | − string.prototype.trimstart: 1.0.8 | |
| 2649 | − typed-array-buffer: 1.0.3 | |
| 2650 | − typed-array-byte-length: 1.0.3 | |
| 2651 | − typed-array-byte-offset: 1.0.4 | |
| 2652 | − typed-array-length: 1.0.8 | |
| 2653 | − unbox-primitive: 1.1.0 | |
| 2654 | − which-typed-array: 1.1.22 | |
| 2655 | − | |
| 2656 | − es-define-property@1.0.1: {} | |
| 2657 | − | |
| 2658 | − es-errors@1.3.0: {} | |
| 2659 | − | |
| 2660 | − es-iterator-helpers@1.4.0: | |
| 2661 | − dependencies: | |
| 2662 | − call-bind: 1.0.9 | |
| 2663 | − call-bound: 1.0.4 | |
| 2664 | − define-properties: 1.2.1 | |
| 2665 | − es-abstract: 1.24.2 | |
| 2666 | − es-errors: 1.3.0 | |
| 2667 | − es-set-tostringtag: 2.1.0 | |
| 2668 | − function-bind: 1.1.2 | |
| 2669 | − get-intrinsic: 1.3.0 | |
| 2670 | − globalthis: 1.0.4 | |
| 2671 | − gopd: 1.2.0 | |
| 2672 | − has-property-descriptors: 1.0.2 | |
| 2673 | − has-proto: 1.2.0 | |
| 2674 | − has-symbols: 1.1.0 | |
| 2675 | − internal-slot: 1.1.0 | |
| 2676 | − iterator.prototype: 1.1.5 | |
| 2677 | − math-intrinsics: 1.1.0 | |
| 2678 | − | |
| 2679 | − es-object-atoms@1.1.2: | |
| 2680 | − dependencies: | |
| 2681 | − es-errors: 1.3.0 | |
| 2682 | − | |
| 2683 | − es-set-tostringtag@2.1.0: | |
| 2684 | − dependencies: | |
| 2685 | − es-errors: 1.3.0 | |
| 2686 | − get-intrinsic: 1.3.0 | |
| 2687 | − has-tostringtag: 1.0.2 | |
| 2688 | − hasown: 2.0.4 | |
| 2689 | − | |
| 2690 | − es-shim-unscopables@1.1.0: | |
| 2691 | − dependencies: | |
| 2692 | − hasown: 2.0.4 | |
| 2693 | − | |
| 2694 | − es-to-primitive@1.3.4: | |
| 2695 | − dependencies: | |
| 2696 | − es-abstract-get: 1.0.0 | |
| 2697 | − es-define-property: 1.0.1 | |
| 2698 | − es-errors: 1.3.0 | |
| 2699 | − is-callable: 1.2.7 | |
| 2700 | − is-date-object: 1.1.0 | |
| 2701 | − is-symbol: 1.1.1 | |
| 2702 | − | |
| 2703 | − escalade@3.2.0: {} | |
| 2704 | − | |
| 2705 | − escape-string-regexp@4.0.0: {} | |
| 2706 | − | |
| 2707 | − eslint-config-next@15.5.23(eslint@8.57.1)(typescript@5.9.3): | |
| 2708 | − dependencies: | |
| 2709 | − '@next/eslint-plugin-next': 15.5.23 | |
| 2710 | − '@rushstack/eslint-patch': 1.16.1 | |
| 2711 | − '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) | |
| 2712 | − '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2713 | − eslint: 8.57.1 | |
| 2714 | − eslint-import-resolver-node: 0.3.10 | |
| 2715 | − eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) | |
| 2716 | − eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) | |
| 2717 | − eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) | |
| 2718 | − eslint-plugin-react: 7.37.5(eslint@8.57.1) | |
| 2719 | − eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) | |
| 2720 | − optionalDependencies: | |
| 2721 | − typescript: 5.9.3 | |
| 2722 | − transitivePeerDependencies: | |
| 2723 | − - eslint-import-resolver-webpack | |
| 2724 | − - eslint-plugin-import-x | |
| 2725 | − - supports-color | |
| 2726 | − | |
| 2727 | − eslint-import-resolver-node@0.3.10: | |
| 2728 | − dependencies: | |
| 2729 | − debug: 3.2.7 | |
| 2730 | − is-core-module: 2.16.2 | |
| 2731 | − resolve: 2.0.0-next.7 | |
| 2732 | − transitivePeerDependencies: | |
| 2733 | − - supports-color | |
| 2734 | − | |
| 2735 | − eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): | |
| 2736 | − dependencies: | |
| 2737 | − '@nolyfill/is-core-module': 1.0.39 | |
| 2738 | − debug: 4.4.3 | |
| 2739 | − eslint: 8.57.1 | |
| 2740 | − get-tsconfig: 4.14.2 | |
| 2741 | − is-bun-module: 2.0.0 | |
| 2742 | − stable-hash: 0.0.5 | |
| 2743 | − tinyglobby: 0.2.17 | |
| 2744 | − unrs-resolver: 1.12.2 | |
| 2745 | − optionalDependencies: | |
| 2746 | − eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) | |
| 2747 | − transitivePeerDependencies: | |
| 2748 | − - supports-color | |
| 2749 | − | |
| 2750 | − eslint-module-utils@2.14.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): | |
| 2751 | − dependencies: | |
| 2752 | − debug: 3.2.7 | |
| 2753 | − optionalDependencies: | |
| 2754 | − '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2755 | − eslint: 8.57.1 | |
| 2756 | − eslint-import-resolver-node: 0.3.10 | |
| 2757 | − eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) | |
| 2758 | − transitivePeerDependencies: | |
| 2759 | − - supports-color | |
| 2760 | − | |
| 2761 | − eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): | |
| 2762 | − dependencies: | |
| 2763 | − '@rtsao/scc': 1.1.0 | |
| 2764 | − array-includes: 3.1.9 | |
| 2765 | − array.prototype.findlastindex: 1.2.6 | |
| 2766 | − array.prototype.flat: 1.3.3 | |
| 2767 | − array.prototype.flatmap: 1.3.3 | |
| 2768 | − debug: 3.2.7 | |
| 2769 | − doctrine: 2.1.0 | |
| 2770 | − eslint: 8.57.1 | |
| 2771 | − eslint-import-resolver-node: 0.3.10 | |
| 2772 | − eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) | |
| 2773 | − hasown: 2.0.4 | |
| 2774 | − is-core-module: 2.16.2 | |
| 2775 | − is-glob: 4.0.3 | |
| 2776 | − minimatch: 3.1.5 | |
| 2777 | − object.fromentries: 2.0.8 | |
| 2778 | − object.groupby: 1.0.3 | |
| 2779 | − object.values: 1.2.1 | |
| 2780 | − semver: 6.3.1 | |
| 2781 | − string.prototype.trimend: 1.0.10 | |
| 2782 | − tsconfig-paths: 3.15.0 | |
| 2783 | − optionalDependencies: | |
| 2784 | − '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2785 | − transitivePeerDependencies: | |
| 2786 | − - eslint-import-resolver-typescript | |
| 2787 | − - eslint-import-resolver-webpack | |
| 2788 | − - supports-color | |
| 2789 | − | |
| 2790 | − eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1): | |
| 2791 | − dependencies: | |
| 2792 | − aria-query: 5.3.2 | |
| 2793 | − array-includes: 3.1.9 | |
| 2794 | − array.prototype.flatmap: 1.3.3 | |
| 2795 | − ast-types-flow: 0.0.8 | |
| 2796 | − axe-core: 4.13.0 | |
| 2797 | − axobject-query: 4.1.0 | |
| 2798 | − damerau-levenshtein: 1.0.8 | |
| 2799 | − emoji-regex: 9.2.2 | |
| 2800 | − eslint: 8.57.1 | |
| 2801 | − hasown: 2.0.4 | |
| 2802 | − jsx-ast-utils: 3.3.5 | |
| 2803 | − language-tags: 1.0.9 | |
| 2804 | − minimatch: 3.1.5 | |
| 2805 | − object.fromentries: 2.0.8 | |
| 2806 | − safe-regex-test: 1.1.0 | |
| 2807 | − string.prototype.includes: 2.0.1 | |
| 2808 | − | |
| 2809 | − eslint-plugin-react-hooks@5.2.0(eslint@8.57.1): | |
| 2810 | − dependencies: | |
| 2811 | − eslint: 8.57.1 | |
| 2812 | − | |
| 2813 | − eslint-plugin-react@7.37.5(eslint@8.57.1): | |
| 2814 | − dependencies: | |
| 2815 | − array-includes: 3.1.9 | |
| 2816 | − array.prototype.findlast: 1.2.5 | |
| 2817 | − array.prototype.flatmap: 1.3.3 | |
| 2818 | − array.prototype.tosorted: 1.1.4 | |
| 2819 | − doctrine: 2.1.0 | |
| 2820 | − es-iterator-helpers: 1.4.0 | |
| 2821 | − eslint: 8.57.1 | |
| 2822 | − estraverse: 5.3.0 | |
| 2823 | − hasown: 2.0.4 | |
| 2824 | − jsx-ast-utils: 3.3.5 | |
| 2825 | − minimatch: 3.1.5 | |
| 2826 | − object.entries: 1.1.9 | |
| 2827 | − object.fromentries: 2.0.8 | |
| 2828 | − object.values: 1.2.1 | |
| 2829 | − prop-types: 15.8.1 | |
| 2830 | − resolve: 2.0.0-next.7 | |
| 2831 | − semver: 6.3.1 | |
| 2832 | − string.prototype.matchall: 4.0.12 | |
| 2833 | − string.prototype.repeat: 1.0.0 | |
| 2834 | − | |
| 2835 | − eslint-scope@7.2.2: | |
| 2836 | − dependencies: | |
| 2837 | − esrecurse: 4.3.0 | |
| 2838 | − estraverse: 5.3.0 | |
| 2839 | − | |
| 2840 | − eslint-visitor-keys@3.4.3: {} | |
| 2841 | − | |
| 2842 | − eslint-visitor-keys@5.0.1: {} | |
| 2843 | − | |
| 2844 | − eslint@8.57.1: | |
| 2845 | − dependencies: | |
| 2846 | − '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1) | |
| 2847 | − '@eslint-community/regexpp': 4.12.2 | |
| 2848 | − '@eslint/eslintrc': 2.1.4 | |
| 2849 | − '@eslint/js': 8.57.1 | |
| 2850 | − '@humanwhocodes/config-array': 0.13.0 | |
| 2851 | − '@humanwhocodes/module-importer': 1.0.1 | |
| 2852 | − '@nodelib/fs.walk': 1.2.8 | |
| 2853 | − '@ungap/structured-clone': 1.3.3 | |
| 2854 | − ajv: 6.15.0 | |
| 2855 | − chalk: 4.1.2 | |
| 2856 | − cross-spawn: 7.0.6 | |
| 2857 | − debug: 4.4.3 | |
| 2858 | − doctrine: 3.0.0 | |
| 2859 | − escape-string-regexp: 4.0.0 | |
| 2860 | − eslint-scope: 7.2.2 | |
| 2861 | − eslint-visitor-keys: 3.4.3 | |
| 2862 | − espree: 9.6.1 | |
| 2863 | − esquery: 1.7.0 | |
| 2864 | − esutils: 2.0.3 | |
| 2865 | − fast-deep-equal: 3.1.3 | |
| 2866 | − file-entry-cache: 6.0.1 | |
| 2867 | − find-up: 5.0.0 | |
| 2868 | − glob-parent: 6.0.2 | |
| 2869 | − globals: 13.24.0 | |
| 2870 | − graphemer: 1.4.0 | |
| 2871 | − ignore: 5.3.2 | |
| 2872 | − imurmurhash: 0.1.4 | |
| 2873 | − is-glob: 4.0.3 | |
| 2874 | − is-path-inside: 3.0.3 | |
| 2875 | − js-yaml: 4.3.1 | |
| 2876 | − json-stable-stringify-without-jsonify: 1.0.1 | |
| 2877 | − levn: 0.4.1 | |
| 2878 | − lodash.merge: 4.6.2 | |
| 2879 | − minimatch: 3.1.5 | |
| 2880 | − natural-compare: 1.4.0 | |
| 2881 | − optionator: 0.9.4 | |
| 2882 | − strip-ansi: 6.0.1 | |
| 2883 | − text-table: 0.2.0 | |
| 2884 | − transitivePeerDependencies: | |
| 2885 | − - supports-color | |
| 2886 | − | |
| 2887 | − espree@9.6.1: | |
| 2888 | − dependencies: | |
| 2889 | − acorn: 8.18.0 | |
| 2890 | − acorn-jsx: 5.3.2(acorn@8.18.0) | |
| 2891 | − eslint-visitor-keys: 3.4.3 | |
| 2892 | − | |
| 2893 | − esquery@1.7.0: | |
| 2894 | − dependencies: | |
| 2895 | − estraverse: 5.3.0 | |
| 2896 | − | |
| 2897 | − esrecurse@4.3.0: | |
| 2898 | − dependencies: | |
| 2899 | − estraverse: 5.3.0 | |
| 2900 | − | |
| 2901 | − estraverse@5.3.0: {} | |
| 2902 | − | |
| 2903 | − esutils@2.0.3: {} | |
| 2904 | − | |
| 2905 | − fast-deep-equal@3.1.3: {} | |
| 2906 | − | |
| 2907 | − fast-glob@3.3.1: | |
| 2908 | − dependencies: | |
| 2909 | − '@nodelib/fs.stat': 2.0.5 | |
| 2910 | − '@nodelib/fs.walk': 1.2.8 | |
| 2911 | − glob-parent: 5.1.2 | |
| 2912 | − merge2: 1.4.1 | |
| 2913 | − micromatch: 4.0.8 | |
| 2914 | − | |
| 2915 | − fast-glob@3.3.3: | |
| 2916 | − dependencies: | |
| 2917 | − '@nodelib/fs.stat': 2.0.5 | |
| 2918 | − '@nodelib/fs.walk': 1.2.8 | |
| 2919 | − glob-parent: 5.1.2 | |
| 2920 | − merge2: 1.4.1 | |
| 2921 | − micromatch: 4.0.8 | |
| 2922 | − | |
| 2923 | − fast-json-stable-stringify@2.1.0: {} | |
| 2924 | − | |
| 2925 | − fast-levenshtein@2.0.6: {} | |
| 2926 | − | |
| 2927 | − fastq@1.20.1: | |
| 2928 | − dependencies: | |
| 2929 | − reusify: 1.1.0 | |
| 2930 | − | |
| 2931 | − fdir@6.5.0(picomatch@4.0.5): | |
| 2932 | − optionalDependencies: | |
| 2933 | − picomatch: 4.0.5 | |
| 2934 | − | |
| 2935 | − file-entry-cache@6.0.1: | |
| 2936 | − dependencies: | |
| 2937 | − flat-cache: 3.2.0 | |
| 2938 | − | |
| 2939 | − fill-range@7.1.1: | |
| 2940 | − dependencies: | |
| 2941 | − to-regex-range: 5.0.1 | |
| 2942 | − | |
| 2943 | − find-up@5.0.0: | |
| 2944 | − dependencies: | |
| 2945 | − locate-path: 6.0.0 | |
| 2946 | − path-exists: 4.0.0 | |
| 2947 | − | |
| 2948 | − flat-cache@3.2.0: | |
| 2949 | − dependencies: | |
| 2950 | − flatted: 3.4.4 | |
| 2951 | − keyv: 4.5.4 | |
| 2952 | − rimraf: 3.0.2 | |
| 2953 | − | |
| 2954 | − flatted@3.4.4: {} | |
| 2955 | − | |
| 2956 | − for-each@0.3.5: | |
| 2957 | − dependencies: | |
| 2958 | − is-callable: 1.2.7 | |
| 2959 | − | |
| 2960 | − fraction.js@5.3.4: {} | |
| 2961 | − | |
| 2962 | − fs.realpath@1.0.0: {} | |
| 2963 | − | |
| 2964 | − fsevents@2.3.3: | |
| 2965 | − optional: true | |
| 2966 | − | |
| 2967 | − function-bind@1.1.2: {} | |
| 2968 | − | |
| 2969 | − function.prototype.name@1.2.0: | |
| 2970 | − dependencies: | |
| 2971 | − call-bind: 1.0.9 | |
| 2972 | − call-bound: 1.0.4 | |
| 2973 | − es-define-property: 1.0.1 | |
| 2974 | − es-errors: 1.3.0 | |
| 2975 | − functions-have-names: 1.2.3 | |
| 2976 | − has-property-descriptors: 1.0.2 | |
| 2977 | − hasown: 2.0.4 | |
| 2978 | − is-callable: 1.2.7 | |
| 2979 | − is-document.all: 1.0.0 | |
| 2980 | − | |
| 2981 | − functions-have-names@1.2.3: {} | |
| 2982 | − | |
| 2983 | − generator-function@2.0.1: {} | |
| 2984 | − | |
| 2985 | − get-intrinsic@1.3.0: | |
| 2986 | − dependencies: | |
| 2987 | − call-bind-apply-helpers: 1.0.2 | |
| 2988 | − es-define-property: 1.0.1 | |
| 2989 | − es-errors: 1.3.0 | |
| 2990 | − es-object-atoms: 1.1.2 | |
| 2991 | − function-bind: 1.1.2 | |
| 2992 | − get-proto: 1.0.1 | |
| 2993 | − gopd: 1.2.0 | |
| 2994 | − has-symbols: 1.1.0 | |
| 2995 | − hasown: 2.0.4 | |
| 2996 | − math-intrinsics: 1.1.0 | |
| 2997 | − | |
| 2998 | − get-proto@1.0.1: | |
| 2999 | − dependencies: | |
| 3000 | − dunder-proto: 1.0.1 | |
| 3001 | − es-object-atoms: 1.1.2 | |
| 3002 | − | |
| 3003 | − get-symbol-description@1.1.0: | |
| 3004 | − dependencies: | |
| 3005 | − call-bound: 1.0.4 | |
| 3006 | − es-errors: 1.3.0 | |
| 3007 | − get-intrinsic: 1.3.0 | |
| 3008 | − | |
| 3009 | − get-tsconfig@4.14.2: | |
| 3010 | − dependencies: | |
| 3011 | − resolve-pkg-maps: 1.0.0 | |
| 3012 | − | |
| 3013 | − glob-parent@5.1.2: | |
| 3014 | − dependencies: | |
| 3015 | − is-glob: 4.0.3 | |
| 3016 | − | |
| 3017 | − glob-parent@6.0.2: | |
| 3018 | − dependencies: | |
| 3019 | − is-glob: 4.0.3 | |
| 3020 | − | |
| 3021 | − glob@7.2.3: | |
| 3022 | − dependencies: | |
| 3023 | − fs.realpath: 1.0.0 | |
| 3024 | − inflight: 1.0.6 | |
| 3025 | − inherits: 2.0.4 | |
| 3026 | − minimatch: 3.1.5 | |
| 3027 | − once: 1.4.0 | |
| 3028 | − path-is-absolute: 1.0.1 | |
| 3029 | − | |
| 3030 | − globals@13.24.0: | |
| 3031 | − dependencies: | |
| 3032 | − type-fest: 0.20.2 | |
| 3033 | − | |
| 3034 | − globalthis@1.0.4: | |
| 3035 | − dependencies: | |
| 3036 | − define-properties: 1.2.1 | |
| 3037 | − gopd: 1.2.0 | |
| 3038 | − | |
| 3039 | − gopd@1.2.0: {} | |
| 3040 | − | |
| 3041 | − graphemer@1.4.0: {} | |
| 3042 | − | |
| 3043 | − has-bigints@1.1.0: {} | |
| 3044 | − | |
| 3045 | − has-flag@4.0.0: {} | |
| 3046 | − | |
| 3047 | − has-property-descriptors@1.0.2: | |
| 3048 | − dependencies: | |
| 3049 | − es-define-property: 1.0.1 | |
| 3050 | − | |
| 3051 | − has-proto@1.2.0: | |
| 3052 | − dependencies: | |
| 3053 | − dunder-proto: 1.0.1 | |
| 3054 | − | |
| 3055 | − has-symbols@1.1.0: {} | |
| 3056 | − | |
| 3057 | − has-tostringtag@1.0.2: | |
| 3058 | − dependencies: | |
| 3059 | − has-symbols: 1.1.0 | |
| 3060 | − | |
| 3061 | − hasown@2.0.4: | |
| 3062 | − dependencies: | |
| 3063 | − function-bind: 1.1.2 | |
| 3064 | − | |
| 3065 | − ignore@5.3.2: {} | |
| 3066 | − | |
| 3067 | − ignore@7.0.6: {} | |
| 3068 | − | |
| 3069 | − import-fresh@3.3.1: | |
| 3070 | − dependencies: | |
| 3071 | − parent-module: 1.0.1 | |
| 3072 | − resolve-from: 4.0.0 | |
| 3073 | − | |
| 3074 | − imurmurhash@0.1.4: {} | |
| 3075 | − | |
| 3076 | − inflight@1.0.6: | |
| 3077 | − dependencies: | |
| 3078 | − once: 1.4.0 | |
| 3079 | − wrappy: 1.0.2 | |
| 3080 | − | |
| 3081 | − inherits@2.0.4: {} | |
| 3082 | − | |
| 3083 | − internal-slot@1.1.0: | |
| 3084 | − dependencies: | |
| 3085 | − es-errors: 1.3.0 | |
| 3086 | − hasown: 2.0.4 | |
| 3087 | − side-channel: 1.1.1 | |
| 3088 | − | |
| 3089 | − is-array-buffer@3.0.5: | |
| 3090 | − dependencies: | |
| 3091 | − call-bind: 1.0.9 | |
| 3092 | − call-bound: 1.0.4 | |
| 3093 | − get-intrinsic: 1.3.0 | |
| 3094 | − | |
| 3095 | − is-async-function@2.1.1: | |
| 3096 | − dependencies: | |
| 3097 | − async-function: 1.0.0 | |
| 3098 | − call-bound: 1.0.4 | |
| 3099 | − get-proto: 1.0.1 | |
| 3100 | − has-tostringtag: 1.0.2 | |
| 3101 | − safe-regex-test: 1.1.0 | |
| 3102 | − | |
| 3103 | − is-bigint@1.1.0: | |
| 3104 | − dependencies: | |
| 3105 | − has-bigints: 1.1.0 | |
| 3106 | − | |
| 3107 | − is-binary-path@2.1.0: | |
| 3108 | − dependencies: | |
| 3109 | − binary-extensions: 2.3.0 | |
| 3110 | − | |
| 3111 | − is-boolean-object@1.2.2: | |
| 3112 | − dependencies: | |
| 3113 | − call-bound: 1.0.4 | |
| 3114 | − has-tostringtag: 1.0.2 | |
| 3115 | − | |
| 3116 | − is-bun-module@2.0.0: | |
| 3117 | − dependencies: | |
| 3118 | − semver: 7.8.5 | |
| 3119 | − | |
| 3120 | − is-callable@1.2.7: {} | |
| 3121 | − | |
| 3122 | − is-core-module@2.16.2: | |
| 3123 | − dependencies: | |
| 3124 | − hasown: 2.0.4 | |
| 3125 | − | |
| 3126 | − is-data-view@1.0.2: | |
| 3127 | − dependencies: | |
| 3128 | − call-bound: 1.0.4 | |
| 3129 | − get-intrinsic: 1.3.0 | |
| 3130 | − is-typed-array: 1.1.15 | |
| 3131 | − | |
| 3132 | − is-date-object@1.1.0: | |
| 3133 | − dependencies: | |
| 3134 | − call-bound: 1.0.4 | |
| 3135 | − has-tostringtag: 1.0.2 | |
| 3136 | − | |
| 3137 | − is-document.all@1.0.0: | |
| 3138 | − dependencies: | |
| 3139 | − call-bound: 1.0.4 | |
| 3140 | − | |
| 3141 | − is-extglob@2.1.1: {} | |
| 3142 | − | |
| 3143 | − is-finalizationregistry@1.1.1: | |
| 3144 | − dependencies: | |
| 3145 | − call-bound: 1.0.4 | |
| 3146 | − | |
| 3147 | − is-generator-function@1.1.2: | |
| 3148 | − dependencies: | |
| 3149 | − call-bound: 1.0.4 | |
| 3150 | − generator-function: 2.0.1 | |
| 3151 | − get-proto: 1.0.1 | |
| 3152 | − has-tostringtag: 1.0.2 | |
| 3153 | − safe-regex-test: 1.1.0 | |
| 3154 | − | |
| 3155 | − is-glob@4.0.3: | |
| 3156 | − dependencies: | |
| 3157 | − is-extglob: 2.1.1 | |
| 3158 | − | |
| 3159 | − is-map@2.0.3: {} | |
| 3160 | − | |
| 3161 | − is-negative-zero@2.0.3: {} | |
| 3162 | − | |
| 3163 | − is-number-object@1.1.1: | |
| 3164 | − dependencies: | |
| 3165 | − call-bound: 1.0.4 | |
| 3166 | − has-tostringtag: 1.0.2 | |
| 3167 | − | |
| 3168 | − is-number@7.0.0: {} | |
| 3169 | − | |
| 3170 | − is-path-inside@3.0.3: {} | |
| 3171 | − | |
| 3172 | − is-regex@1.2.1: | |
| 3173 | − dependencies: | |
| 3174 | − call-bound: 1.0.4 | |
| 3175 | − gopd: 1.2.0 | |
| 3176 | − has-tostringtag: 1.0.2 | |
| 3177 | − hasown: 2.0.4 | |
| 3178 | − | |
| 3179 | − is-set@2.0.3: {} | |
| 3180 | − | |
| 3181 | − is-shared-array-buffer@1.0.4: | |
| 3182 | − dependencies: | |
| 3183 | − call-bound: 1.0.4 | |
| 3184 | − | |
| 3185 | − is-string@1.1.1: | |
| 3186 | − dependencies: | |
| 3187 | − call-bound: 1.0.4 | |
| 3188 | − has-tostringtag: 1.0.2 | |
| 3189 | − | |
| 3190 | − is-symbol@1.1.1: | |
| 3191 | − dependencies: | |
| 3192 | − call-bound: 1.0.4 | |
| 3193 | − has-symbols: 1.1.0 | |
| 3194 | − safe-regex-test: 1.1.0 | |
| 3195 | − | |
| 3196 | − is-typed-array@1.1.15: | |
| 3197 | − dependencies: | |
| 3198 | − which-typed-array: 1.1.22 | |
| 3199 | − | |
| 3200 | − is-weakmap@2.0.2: {} | |
| 3201 | − | |
| 3202 | − is-weakref@1.1.1: | |
| 3203 | − dependencies: | |
| 3204 | − call-bound: 1.0.4 | |
| 3205 | − | |
| 3206 | − is-weakset@2.0.4: | |
| 3207 | − dependencies: | |
| 3208 | − call-bound: 1.0.4 | |
| 3209 | − get-intrinsic: 1.3.0 | |
| 3210 | − | |
| 3211 | − isarray@2.0.5: {} | |
| 3212 | − | |
| 3213 | − isexe@2.0.0: {} | |
| 3214 | − | |
| 3215 | − iterator.prototype@1.1.5: | |
| 3216 | − dependencies: | |
| 3217 | − define-data-property: 1.1.4 | |
| 3218 | − es-object-atoms: 1.1.2 | |
| 3219 | − get-intrinsic: 1.3.0 | |
| 3220 | − get-proto: 1.0.1 | |
| 3221 | − has-symbols: 1.1.0 | |
| 3222 | − set-function-name: 2.0.2 | |
| 3223 | − | |
| 3224 | − jiti@1.21.7: {} | |
| 3225 | − | |
| 3226 | − js-tokens@4.0.0: {} | |
| 3227 | − | |
| 3228 | − js-yaml@4.3.1: | |
| 3229 | − dependencies: | |
| 3230 | − argparse: 2.0.1 | |
| 3231 | − | |
| 3232 | − json-buffer@3.0.1: {} | |
| 3233 | − | |
| 3234 | − json-schema-traverse@0.4.1: {} | |
| 3235 | − | |
| 3236 | − json-stable-stringify-without-jsonify@1.0.1: {} | |
| 3237 | − | |
| 3238 | − json5@1.0.2: | |
| 3239 | − dependencies: | |
| 3240 | − minimist: 1.2.8 | |
| 3241 | − | |
| 3242 | − jsx-ast-utils@3.3.5: | |
| 3243 | − dependencies: | |
| 3244 | − array-includes: 3.1.9 | |
| 3245 | − array.prototype.flat: 1.3.3 | |
| 3246 | − object.assign: 4.1.7 | |
| 3247 | − object.values: 1.2.1 | |
| 3248 | − | |
| 3249 | − keyv@4.5.4: | |
| 3250 | − dependencies: | |
| 3251 | − json-buffer: 3.0.1 | |
| 3252 | − | |
| 3253 | − language-subtag-registry@0.3.23: {} | |
| 3254 | − | |
| 3255 | − language-tags@1.0.9: | |
| 3256 | − dependencies: | |
| 3257 | − language-subtag-registry: 0.3.23 | |
| 3258 | − | |
| 3259 | − levn@0.4.1: | |
| 3260 | − dependencies: | |
| 3261 | − prelude-ls: 1.2.1 | |
| 3262 | − type-check: 0.4.0 | |
| 3263 | − | |
| 3264 | − lilconfig@3.1.3: {} | |
| 3265 | − | |
| 3266 | − lines-and-columns@1.2.4: {} | |
| 3267 | − | |
| 3268 | − locate-path@6.0.0: | |
| 3269 | − dependencies: | |
| 3270 | − p-locate: 5.0.0 | |
| 3271 | − | |
| 3272 | − lodash.merge@4.6.2: {} | |
| 3273 | − | |
| 3274 | − loose-envify@1.4.0: | |
| 3275 | − dependencies: | |
| 3276 | − js-tokens: 4.0.0 | |
| 3277 | − | |
| 3278 | − math-intrinsics@1.1.0: {} | |
| 3279 | − | |
| 3280 | − merge2@1.4.1: {} | |
| 3281 | − | |
| 3282 | − micromatch@4.0.8: | |
| 3283 | − dependencies: | |
| 3284 | − braces: 3.0.3 | |
| 3285 | − picomatch: 2.3.2 | |
| 3286 | − | |
| 3287 | − minimatch@10.2.6: | |
| 3288 | − dependencies: | |
| 3289 | − brace-expansion: 5.0.9 | |
| 3290 | − | |
| 3291 | − minimatch@3.1.5: | |
| 3292 | − dependencies: | |
| 3293 | − brace-expansion: 1.1.18 | |
| 3294 | − | |
| 3295 | − minimist@1.2.8: {} | |
| 3296 | − | |
| 3297 | − ms@2.1.3: {} | |
| 3298 | − | |
| 3299 | − mz@2.7.0: | |
| 3300 | − dependencies: | |
| 3301 | − any-promise: 1.3.0 | |
| 3302 | − object-assign: 4.1.1 | |
| 3303 | − thenify-all: 1.6.0 | |
| 3304 | − | |
| 3305 | − nanoid@3.3.18: {} | |
| 3306 | − | |
| 3307 | − napi-postinstall@0.3.4: {} | |
| 3308 | − | |
| 3309 | − natural-compare@1.4.0: {} | |
| 3310 | − | |
| 3311 | − next@15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8): | |
| 3312 | − dependencies: | |
| 3313 | − '@next/env': 15.5.23 | |
| 3314 | − '@swc/helpers': 0.5.15 | |
| 3315 | − caniuse-lite: 1.0.30001809 | |
| 3316 | − postcss: 8.4.31 | |
| 3317 | − react: 19.2.8 | |
| 3318 | − react-dom: 19.2.8(react@19.2.8) | |
| 3319 | − styled-jsx: 5.1.6(react@19.2.8) | |
| 3320 | − optionalDependencies: | |
| 3321 | − '@next/swc-darwin-arm64': 15.5.23 | |
| 3322 | − '@next/swc-darwin-x64': 15.5.23 | |
| 3323 | − '@next/swc-linux-arm64-gnu': 15.5.23 | |
| 3324 | − '@next/swc-linux-arm64-musl': 15.5.23 | |
| 3325 | − '@next/swc-linux-x64-gnu': 15.5.23 | |
| 3326 | − '@next/swc-linux-x64-musl': 15.5.23 | |
| 3327 | − '@next/swc-win32-arm64-msvc': 15.5.23 | |
| 3328 | − '@next/swc-win32-x64-msvc': 15.5.23 | |
| 3329 | − sharp: 0.34.5 | |
| 3330 | − transitivePeerDependencies: | |
| 3331 | − - '@babel/core' | |
| 3332 | − - babel-plugin-macros | |
| 3333 | − | |
| 3334 | − node-exports-info@1.6.2: | |
| 3335 | − dependencies: | |
| 3336 | − array.prototype.flatmap: 1.3.3 | |
| 3337 | − es-errors: 1.3.0 | |
| 3338 | − object.entries: 1.1.9 | |
| 3339 | − semver: 6.3.1 | |
| 3340 | − | |
| 3341 | − node-releases@2.0.53: {} | |
| 3342 | − | |
| 3343 | − normalize-path@3.0.0: {} | |
| 3344 | − | |
| 3345 | − object-assign@4.1.1: {} | |
| 3346 | − | |
| 3347 | − object-hash@3.0.0: {} | |
| 3348 | − | |
| 3349 | − object-inspect@1.13.4: {} | |
| 3350 | − | |
| 3351 | − object-keys@1.1.1: {} | |
| 3352 | − | |
| 3353 | − object.assign@4.1.7: | |
| 3354 | − dependencies: | |
| 3355 | − call-bind: 1.0.9 | |
| 3356 | − call-bound: 1.0.4 | |
| 3357 | − define-properties: 1.2.1 | |
| 3358 | − es-object-atoms: 1.1.2 | |
| 3359 | − has-symbols: 1.1.0 | |
| 3360 | − object-keys: 1.1.1 | |
| 3361 | − | |
| 3362 | − object.entries@1.1.9: | |
| 3363 | − dependencies: | |
| 3364 | − call-bind: 1.0.9 | |
| 3365 | − call-bound: 1.0.4 | |
| 3366 | − define-properties: 1.2.1 | |
| 3367 | − es-object-atoms: 1.1.2 | |
| 3368 | − | |
| 3369 | − object.fromentries@2.0.8: | |
| 3370 | − dependencies: | |
| 3371 | − call-bind: 1.0.9 | |
| 3372 | − define-properties: 1.2.1 | |
| 3373 | − es-abstract: 1.24.2 | |
| 3374 | − es-object-atoms: 1.1.2 | |
| 3375 | − | |
| 3376 | − object.groupby@1.0.3: | |
| 3377 | − dependencies: | |
| 3378 | − call-bind: 1.0.9 | |
| 3379 | − define-properties: 1.2.1 | |
| 3380 | − es-abstract: 1.24.2 | |
| 3381 | − | |
| 3382 | − object.values@1.2.1: | |
| 3383 | − dependencies: | |
| 3384 | − call-bind: 1.0.9 | |
| 3385 | − call-bound: 1.0.4 | |
| 3386 | − define-properties: 1.2.1 | |
| 3387 | − es-object-atoms: 1.1.2 | |
| 3388 | − | |
| 3389 | − once@1.4.0: | |
| 3390 | − dependencies: | |
| 3391 | − wrappy: 1.0.2 | |
| 3392 | − | |
| 3393 | − optionator@0.9.4: | |
| 3394 | − dependencies: | |
| 3395 | − deep-is: 0.1.4 | |
| 3396 | − fast-levenshtein: 2.0.6 | |
| 3397 | − levn: 0.4.1 | |
| 3398 | − prelude-ls: 1.2.1 | |
| 3399 | − type-check: 0.4.0 | |
| 3400 | − word-wrap: 1.2.5 | |
| 3401 | − | |
| 3402 | − own-keys@1.0.2: | |
| 3403 | − dependencies: | |
| 3404 | − call-bound: 1.0.4 | |
| 3405 | − get-intrinsic: 1.3.0 | |
| 3406 | − object-keys: 1.1.1 | |
| 3407 | − safe-push-apply: 1.0.0 | |
| 3408 | − | |
| 3409 | − p-limit@3.1.0: | |
| 3410 | − dependencies: | |
| 3411 | − yocto-queue: 0.1.0 | |
| 3412 | − | |
| 3413 | − p-locate@5.0.0: | |
| 3414 | − dependencies: | |
| 3415 | − p-limit: 3.1.0 | |
| 3416 | − | |
| 3417 | − parent-module@1.0.1: | |
| 3418 | − dependencies: | |
| 3419 | − callsites: 3.1.0 | |
| 3420 | − | |
| 3421 | − path-exists@4.0.0: {} | |
| 3422 | − | |
| 3423 | − path-is-absolute@1.0.1: {} | |
| 3424 | − | |
| 3425 | − path-key@3.1.1: {} | |
| 3426 | − | |
| 3427 | − path-parse@1.0.7: {} | |
| 3428 | − | |
| 3429 | − picocolors@1.1.1: {} | |
| 3430 | − | |
| 3431 | − picomatch@2.3.2: {} | |
| 3432 | − | |
| 3433 | − picomatch@4.0.5: {} | |
| 3434 | − | |
| 3435 | − pify@2.3.0: {} | |
| 3436 | − | |
| 3437 | − pirates@4.0.7: {} | |
| 3438 | − | |
| 3439 | − possible-typed-array-names@1.1.0: {} | |
| 3440 | − | |
| 3441 | − postcss-import@15.1.0(postcss@8.5.26): | |
| 3442 | − dependencies: | |
| 3443 | − postcss: 8.5.26 | |
| 3444 | − postcss-value-parser: 4.2.0 | |
| 3445 | − read-cache: 1.0.0 | |
| 3446 | − resolve: 1.22.12 | |
| 3447 | − | |
| 3448 | − postcss-js@4.1.0(postcss@8.5.26): | |
| 3449 | − dependencies: | |
| 3450 | − camelcase-css: 2.0.1 | |
| 3451 | − postcss: 8.5.26 | |
| 3452 | − | |
| 3453 | − postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26): | |
| 3454 | − dependencies: | |
| 3455 | − lilconfig: 3.1.3 | |
| 3456 | − optionalDependencies: | |
| 3457 | − jiti: 1.21.7 | |
| 3458 | − postcss: 8.5.26 | |
| 3459 | − | |
| 3460 | − postcss-nested@6.2.0(postcss@8.5.26): | |
| 3461 | − dependencies: | |
| 3462 | − postcss: 8.5.26 | |
| 3463 | − postcss-selector-parser: 6.1.4 | |
| 3464 | − | |
| 3465 | − postcss-selector-parser@6.1.4: | |
| 3466 | − dependencies: | |
| 3467 | − cssesc: 3.0.0 | |
| 3468 | − util-deprecate: 1.0.2 | |
| 3469 | − | |
| 3470 | − postcss-value-parser@4.2.0: {} | |
| 3471 | − | |
| 3472 | − postcss@8.4.31: | |
| 3473 | − dependencies: | |
| 3474 | − nanoid: 3.3.18 | |
| 3475 | − picocolors: 1.1.1 | |
| 3476 | − source-map-js: 1.2.1 | |
| 3477 | − | |
| 3478 | − postcss@8.5.26: | |
| 3479 | − dependencies: | |
| 3480 | − nanoid: 3.3.18 | |
| 3481 | − picocolors: 1.1.1 | |
| 3482 | − source-map-js: 1.2.1 | |
| 3483 | − | |
| 3484 | − prelude-ls@1.2.1: {} | |
| 3485 | − | |
| 3486 | − prop-types@15.8.1: | |
| 3487 | − dependencies: | |
| 3488 | − loose-envify: 1.4.0 | |
| 3489 | − object-assign: 4.1.1 | |
| 3490 | − react-is: 16.13.1 | |
| 3491 | − | |
| 3492 | − punycode@2.3.1: {} | |
| 3493 | − | |
| 3494 | − queue-microtask@1.2.3: {} | |
| 3495 | − | |
| 3496 | − react-dom@19.2.8(react@19.2.8): | |
| 3497 | − dependencies: | |
| 3498 | − react: 19.2.8 | |
| 3499 | − scheduler: 0.27.0 | |
| 3500 | − | |
| 3501 | − react-is@16.13.1: {} | |
| 3502 | − | |
| 3503 | − react@19.2.8: {} | |
| 3504 | − | |
| 3505 | − read-cache@1.0.0: | |
| 3506 | − dependencies: | |
| 3507 | − pify: 2.3.0 | |
| 3508 | − | |
| 3509 | − readdirp@3.6.0: | |
| 3510 | − dependencies: | |
| 3511 | − picomatch: 2.3.2 | |
| 3512 | − | |
| 3513 | − reflect.getprototypeof@1.0.10: | |
| 3514 | − dependencies: | |
| 3515 | − call-bind: 1.0.9 | |
| 3516 | − define-properties: 1.2.1 | |
| 3517 | − es-abstract: 1.24.2 | |
| 3518 | − es-errors: 1.3.0 | |
| 3519 | − es-object-atoms: 1.1.2 | |
| 3520 | − get-intrinsic: 1.3.0 | |
| 3521 | − get-proto: 1.0.1 | |
| 3522 | − which-builtin-type: 1.2.1 | |
| 3523 | − | |
| 3524 | − regexp.prototype.flags@1.5.4: | |
| 3525 | − dependencies: | |
| 3526 | − call-bind: 1.0.9 | |
| 3527 | − define-properties: 1.2.1 | |
| 3528 | − es-errors: 1.3.0 | |
| 3529 | − get-proto: 1.0.1 | |
| 3530 | − gopd: 1.2.0 | |
| 3531 | − set-function-name: 2.0.2 | |
| 3532 | − | |
| 3533 | − resolve-from@4.0.0: {} | |
| 3534 | − | |
| 3535 | − resolve-pkg-maps@1.0.0: {} | |
| 3536 | − | |
| 3537 | − resolve@1.22.12: | |
| 3538 | − dependencies: | |
| 3539 | − es-errors: 1.3.0 | |
| 3540 | − is-core-module: 2.16.2 | |
| 3541 | − path-parse: 1.0.7 | |
| 3542 | − supports-preserve-symlinks-flag: 1.0.0 | |
| 3543 | − | |
| 3544 | − resolve@2.0.0-next.7: | |
| 3545 | − dependencies: | |
| 3546 | − es-errors: 1.3.0 | |
| 3547 | − is-core-module: 2.16.2 | |
| 3548 | − node-exports-info: 1.6.2 | |
| 3549 | − object-keys: 1.1.1 | |
| 3550 | − path-parse: 1.0.7 | |
| 3551 | − supports-preserve-symlinks-flag: 1.0.0 | |
| 3552 | − | |
| 3553 | − reusify@1.1.0: {} | |
| 3554 | − | |
| 3555 | − rimraf@3.0.2: | |
| 3556 | − dependencies: | |
| 3557 | − glob: 7.2.3 | |
| 3558 | − | |
| 3559 | − run-parallel@1.2.0: | |
| 3560 | − dependencies: | |
| 3561 | − queue-microtask: 1.2.3 | |
| 3562 | − | |
| 3563 | − safe-array-concat@1.1.4: | |
| 3564 | − dependencies: | |
| 3565 | − call-bind: 1.0.9 | |
| 3566 | − call-bound: 1.0.4 | |
| 3567 | − get-intrinsic: 1.3.0 | |
| 3568 | − has-symbols: 1.1.0 | |
| 3569 | − isarray: 2.0.5 | |
| 3570 | − | |
| 3571 | − safe-push-apply@1.0.0: | |
| 3572 | − dependencies: | |
| 3573 | − es-errors: 1.3.0 | |
| 3574 | − isarray: 2.0.5 | |
| 3575 | − | |
| 3576 | − safe-regex-test@1.1.0: | |
| 3577 | − dependencies: | |
| 3578 | − call-bound: 1.0.4 | |
| 3579 | − es-errors: 1.3.0 | |
| 3580 | − is-regex: 1.2.1 | |
| 3581 | − | |
| 3582 | − scheduler@0.27.0: {} | |
| 3583 | − | |
| 3584 | − semver@6.3.1: {} | |
| 3585 | − | |
| 3586 | − semver@7.8.5: {} | |
| 3587 | − | |
| 3588 | − set-function-length@1.2.2: | |
| 3589 | − dependencies: | |
| 3590 | − define-data-property: 1.1.4 | |
| 3591 | − es-errors: 1.3.0 | |
| 3592 | − function-bind: 1.1.2 | |
| 3593 | − get-intrinsic: 1.3.0 | |
| 3594 | − gopd: 1.2.0 | |
| 3595 | − has-property-descriptors: 1.0.2 | |
| 3596 | − | |
| 3597 | − set-function-name@2.0.2: | |
| 3598 | − dependencies: | |
| 3599 | − define-data-property: 1.1.4 | |
| 3600 | − es-errors: 1.3.0 | |
| 3601 | − functions-have-names: 1.2.3 | |
| 3602 | − has-property-descriptors: 1.0.2 | |
| 3603 | − | |
| 3604 | − set-proto@1.0.0: | |
| 3605 | − dependencies: | |
| 3606 | − dunder-proto: 1.0.1 | |
| 3607 | − es-errors: 1.3.0 | |
| 3608 | − es-object-atoms: 1.1.2 | |
| 3609 | − | |
| 3610 | − sharp@0.34.5: | |
| 3611 | − dependencies: | |
| 3612 | − '@img/colour': 1.1.0 | |
| 3613 | − detect-libc: 2.1.2 | |
| 3614 | − semver: 7.8.5 | |
| 3615 | − optionalDependencies: | |
| 3616 | − '@img/sharp-darwin-arm64': 0.34.5 | |
| 3617 | − '@img/sharp-darwin-x64': 0.34.5 | |
| 3618 | − '@img/sharp-libvips-darwin-arm64': 1.2.4 | |
| 3619 | − '@img/sharp-libvips-darwin-x64': 1.2.4 | |
| 3620 | − '@img/sharp-libvips-linux-arm': 1.2.4 | |
| 3621 | − '@img/sharp-libvips-linux-arm64': 1.2.4 | |
| 3622 | − '@img/sharp-libvips-linux-ppc64': 1.2.4 | |
| 3623 | − '@img/sharp-libvips-linux-riscv64': 1.2.4 | |
| 3624 | − '@img/sharp-libvips-linux-s390x': 1.2.4 | |
| 3625 | − '@img/sharp-libvips-linux-x64': 1.2.4 | |
| 3626 | − '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 | |
| 3627 | − '@img/sharp-libvips-linuxmusl-x64': 1.2.4 | |
| 3628 | − '@img/sharp-linux-arm': 0.34.5 | |
| 3629 | − '@img/sharp-linux-arm64': 0.34.5 | |
| 3630 | − '@img/sharp-linux-ppc64': 0.34.5 | |
| 3631 | − '@img/sharp-linux-riscv64': 0.34.5 | |
| 3632 | − '@img/sharp-linux-s390x': 0.34.5 | |
| 3633 | − '@img/sharp-linux-x64': 0.34.5 | |
| 3634 | − '@img/sharp-linuxmusl-arm64': 0.34.5 | |
| 3635 | − '@img/sharp-linuxmusl-x64': 0.34.5 | |
| 3636 | − '@img/sharp-wasm32': 0.34.5 | |
| 3637 | − '@img/sharp-win32-arm64': 0.34.5 | |
| 3638 | − '@img/sharp-win32-ia32': 0.34.5 | |
| 3639 | − '@img/sharp-win32-x64': 0.34.5 | |
| 3640 | − optional: true | |
| 3641 | − | |
| 3642 | − shebang-command@2.0.0: | |
| 3643 | − dependencies: | |
| 3644 | − shebang-regex: 3.0.0 | |
| 3645 | − | |
| 3646 | − shebang-regex@3.0.0: {} | |
| 3647 | − | |
| 3648 | − side-channel-list@1.0.1: | |
| 3649 | − dependencies: | |
| 3650 | − es-errors: 1.3.0 | |
| 3651 | − object-inspect: 1.13.4 | |
| 3652 | − | |
| 3653 | − side-channel-map@1.0.1: | |
| 3654 | − dependencies: | |
| 3655 | − call-bound: 1.0.4 | |
| 3656 | − es-errors: 1.3.0 | |
| 3657 | − get-intrinsic: 1.3.0 | |
| 3658 | − object-inspect: 1.13.4 | |
| 3659 | − | |
| 3660 | − side-channel-weakmap@1.0.2: | |
| 3661 | − dependencies: | |
| 3662 | − call-bound: 1.0.4 | |
| 3663 | − es-errors: 1.3.0 | |
| 3664 | − get-intrinsic: 1.3.0 | |
| 3665 | − object-inspect: 1.13.4 | |
| 3666 | − side-channel-map: 1.0.1 | |
| 3667 | − | |
| 3668 | − side-channel@1.1.1: | |
| 3669 | − dependencies: | |
| 3670 | − es-errors: 1.3.0 | |
| 3671 | − object-inspect: 1.13.4 | |
| 3672 | − side-channel-list: 1.0.1 | |
| 3673 | − side-channel-map: 1.0.1 | |
| 3674 | − side-channel-weakmap: 1.0.2 | |
| 3675 | − | |
| 3676 | − source-map-js@1.2.1: {} | |
| 3677 | − | |
| 3678 | − stable-hash@0.0.5: {} | |
| 3679 | − | |
| 3680 | − stop-iteration-iterator@1.1.0: | |
| 3681 | − dependencies: | |
| 3682 | − es-errors: 1.3.0 | |
| 3683 | − internal-slot: 1.1.0 | |
| 3684 | − | |
| 3685 | − string.prototype.includes@2.0.1: | |
| 3686 | − dependencies: | |
| 3687 | − call-bind: 1.0.9 | |
| 3688 | − define-properties: 1.2.1 | |
| 3689 | − es-abstract: 1.24.2 | |
| 3690 | − | |
| 3691 | − string.prototype.matchall@4.0.12: | |
| 3692 | − dependencies: | |
| 3693 | − call-bind: 1.0.9 | |
| 3694 | − call-bound: 1.0.4 | |
| 3695 | − define-properties: 1.2.1 | |
| 3696 | − es-abstract: 1.24.2 | |
| 3697 | − es-errors: 1.3.0 | |
| 3698 | − es-object-atoms: 1.1.2 | |
| 3699 | − get-intrinsic: 1.3.0 | |
| 3700 | − gopd: 1.2.0 | |
| 3701 | − has-symbols: 1.1.0 | |
| 3702 | − internal-slot: 1.1.0 | |
| 3703 | − regexp.prototype.flags: 1.5.4 | |
| 3704 | − set-function-name: 2.0.2 | |
| 3705 | − side-channel: 1.1.1 | |
| 3706 | − | |
| 3707 | − string.prototype.repeat@1.0.0: | |
| 3708 | − dependencies: | |
| 3709 | − define-properties: 1.2.1 | |
| 3710 | − es-abstract: 1.24.2 | |
| 3711 | − | |
| 3712 | − string.prototype.trim@1.2.11: | |
| 3713 | − dependencies: | |
| 3714 | − call-bind: 1.0.9 | |
| 3715 | − call-bound: 1.0.4 | |
| 3716 | − define-data-property: 1.1.4 | |
| 3717 | − define-properties: 1.2.1 | |
| 3718 | − es-abstract: 1.24.2 | |
| 3719 | − es-object-atoms: 1.1.2 | |
| 3720 | − has-property-descriptors: 1.0.2 | |
| 3721 | − safe-regex-test: 1.1.0 | |
| 3722 | − | |
| 3723 | − string.prototype.trimend@1.0.10: | |
| 3724 | − dependencies: | |
| 3725 | − call-bind: 1.0.9 | |
| 3726 | − call-bound: 1.0.4 | |
| 3727 | − define-properties: 1.2.1 | |
| 3728 | − es-object-atoms: 1.1.2 | |
| 3729 | − | |
| 3730 | − string.prototype.trimstart@1.0.8: | |
| 3731 | − dependencies: | |
| 3732 | − call-bind: 1.0.9 | |
| 3733 | − define-properties: 1.2.1 | |
| 3734 | − es-object-atoms: 1.1.2 | |
| 3735 | − | |
| 3736 | − strip-ansi@6.0.1: | |
| 3737 | − dependencies: | |
| 3738 | − ansi-regex: 5.0.1 | |
| 3739 | − | |
| 3740 | − strip-bom@3.0.0: {} | |
| 3741 | − | |
| 3742 | − strip-json-comments@3.1.1: {} | |
| 3743 | − | |
| 3744 | − styled-jsx@5.1.6(react@19.2.8): | |
| 3745 | − dependencies: | |
| 3746 | − client-only: 0.0.1 | |
| 3747 | − react: 19.2.8 | |
| 3748 | − | |
| 3749 | − sucrase@3.35.1: | |
| 3750 | − dependencies: | |
| 3751 | − '@jridgewell/gen-mapping': 0.3.13 | |
| 3752 | − commander: 4.1.1 | |
| 3753 | − lines-and-columns: 1.2.4 | |
| 3754 | − mz: 2.7.0 | |
| 3755 | − pirates: 4.0.7 | |
| 3756 | − tinyglobby: 0.2.17 | |
| 3757 | − ts-interface-checker: 0.1.13 | |
| 3758 | − | |
| 3759 | − supports-color@7.2.0: | |
| 3760 | − dependencies: | |
| 3761 | − has-flag: 4.0.0 | |
| 3762 | − | |
| 3763 | − supports-preserve-symlinks-flag@1.0.0: {} | |
| 3764 | − | |
| 3765 | − tailwind-merge@2.6.1: {} | |
| 3766 | − | |
| 3767 | − tailwindcss@3.4.19: | |
| 3768 | − dependencies: | |
| 3769 | − '@alloc/quick-lru': 5.2.0 | |
| 3770 | − arg: 5.0.2 | |
| 3771 | − chokidar: 3.6.0 | |
| 3772 | − didyoumean: 1.2.2 | |
| 3773 | − dlv: 1.1.3 | |
| 3774 | − fast-glob: 3.3.3 | |
| 3775 | − glob-parent: 6.0.2 | |
| 3776 | − is-glob: 4.0.3 | |
| 3777 | − jiti: 1.21.7 | |
| 3778 | − lilconfig: 3.1.3 | |
| 3779 | − micromatch: 4.0.8 | |
| 3780 | − normalize-path: 3.0.0 | |
| 3781 | − object-hash: 3.0.0 | |
| 3782 | − picocolors: 1.1.1 | |
| 3783 | − postcss: 8.5.26 | |
| 3784 | − postcss-import: 15.1.0(postcss@8.5.26) | |
| 3785 | − postcss-js: 4.1.0(postcss@8.5.26) | |
| 3786 | − postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26) | |
| 3787 | − postcss-nested: 6.2.0(postcss@8.5.26) | |
| 3788 | − postcss-selector-parser: 6.1.4 | |
| 3789 | − resolve: 1.22.12 | |
| 3790 | − sucrase: 3.35.1 | |
| 3791 | − transitivePeerDependencies: | |
| 3792 | − - tsx | |
| 3793 | − - yaml | |
| 3794 | − | |
| 3795 | − text-table@0.2.0: {} | |
| 3796 | − | |
| 3797 | − thenify-all@1.6.0: | |
| 3798 | − dependencies: | |
| 3799 | − thenify: 3.3.1 | |
| 3800 | − | |
| 3801 | − thenify@3.3.1: | |
| 3802 | − dependencies: | |
| 3803 | − any-promise: 1.3.0 | |
| 3804 | − | |
| 3805 | − tinyglobby@0.2.17: | |
| 3806 | − dependencies: | |
| 3807 | − fdir: 6.5.0(picomatch@4.0.5) | |
| 3808 | − picomatch: 4.0.5 | |
| 3809 | − | |
| 3810 | − to-regex-range@5.0.1: | |
| 3811 | − dependencies: | |
| 3812 | − is-number: 7.0.0 | |
| 3813 | − | |
| 3814 | − ts-api-utils@2.5.0(typescript@5.9.3): | |
| 3815 | − dependencies: | |
| 3816 | − typescript: 5.9.3 | |
| 3817 | − | |
| 3818 | − ts-interface-checker@0.1.13: {} | |
| 3819 | − | |
| 3820 | − tsconfig-paths@3.15.0: | |
| 3821 | − dependencies: | |
| 3822 | − '@types/json5': 0.0.29 | |
| 3823 | − json5: 1.0.2 | |
| 3824 | − minimist: 1.2.8 | |
| 3825 | − strip-bom: 3.0.0 | |
| 3826 | − | |
| 3827 | − tslib@2.8.1: {} | |
| 3828 | − | |
| 3829 | − type-check@0.4.0: | |
| 3830 | − dependencies: | |
| 3831 | − prelude-ls: 1.2.1 | |
| 3832 | − | |
| 3833 | − type-fest@0.20.2: {} | |
| 3834 | − | |
| 3835 | − typed-array-buffer@1.0.3: | |
| 3836 | − dependencies: | |
| 3837 | − call-bound: 1.0.4 | |
| 3838 | − es-errors: 1.3.0 | |
| 3839 | − is-typed-array: 1.1.15 | |
| 3840 | − | |
| 3841 | − typed-array-byte-length@1.0.3: | |
| 3842 | − dependencies: | |
| 3843 | − call-bind: 1.0.9 | |
| 3844 | − for-each: 0.3.5 | |
| 3845 | − gopd: 1.2.0 | |
| 3846 | − has-proto: 1.2.0 | |
| 3847 | − is-typed-array: 1.1.15 | |
| 3848 | − | |
| 3849 | − typed-array-byte-offset@1.0.4: | |
| 3850 | − dependencies: | |
| 3851 | − available-typed-arrays: 1.0.7 | |
| 3852 | − call-bind: 1.0.9 | |
| 3853 | − for-each: 0.3.5 | |
| 3854 | − gopd: 1.2.0 | |
| 3855 | − has-proto: 1.2.0 | |
| 3856 | − is-typed-array: 1.1.15 | |
| 3857 | − reflect.getprototypeof: 1.0.10 | |
| 3858 | − | |
| 3859 | − typed-array-length@1.0.8: | |
| 3860 | − dependencies: | |
| 3861 | − call-bind: 1.0.9 | |
| 3862 | − for-each: 0.3.5 | |
| 3863 | − gopd: 1.2.0 | |
| 3864 | − is-typed-array: 1.1.15 | |
| 3865 | − possible-typed-array-names: 1.1.0 | |
| 3866 | − reflect.getprototypeof: 1.0.10 | |
| 3867 | − | |
| 3868 | − typescript@5.9.3: {} | |
| 3869 | − | |
| 3870 | − unbox-primitive@1.1.0: | |
| 3871 | − dependencies: | |
| 3872 | − call-bound: 1.0.4 | |
| 3873 | − has-bigints: 1.1.0 | |
| 3874 | − has-symbols: 1.1.0 | |
| 3875 | − which-boxed-primitive: 1.1.1 | |
| 3876 | − | |
| 3877 | − undici-types@6.21.0: {} | |
| 3878 | − | |
| 3879 | − unrs-resolver@1.12.2: | |
| 3880 | − dependencies: | |
| 3881 | − napi-postinstall: 0.3.4 | |
| 3882 | − optionalDependencies: | |
| 3883 | − '@unrs/resolver-binding-android-arm-eabi': 1.12.2 | |
| 3884 | − '@unrs/resolver-binding-android-arm64': 1.12.2 | |
| 3885 | − '@unrs/resolver-binding-darwin-arm64': 1.12.2 | |
| 3886 | − '@unrs/resolver-binding-darwin-x64': 1.12.2 | |
| 3887 | − '@unrs/resolver-binding-freebsd-x64': 1.12.2 | |
| 3888 | − '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 | |
| 3889 | − '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 | |
| 3890 | − '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 | |
| 3891 | − '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 | |
| 3892 | − '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 | |
| 3893 | − '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 | |
| 3894 | − '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 | |
| 3895 | − '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 | |
| 3896 | − '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 | |
| 3897 | − '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 | |
| 3898 | − '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 | |
| 3899 | − '@unrs/resolver-binding-linux-x64-musl': 1.12.2 | |
| 3900 | − '@unrs/resolver-binding-openharmony-arm64': 1.12.2 | |
| 3901 | − '@unrs/resolver-binding-wasm32-wasi': 1.12.2 | |
| 3902 | − '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 | |
| 3903 | − '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 | |
| 3904 | − '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 | |
| 3905 | − | |
| 3906 | − update-browserslist-db@1.3.1(browserslist@4.28.8): | |
| 3907 | − dependencies: | |
| 3908 | − browserslist: 4.28.8 | |
| 3909 | − escalade: 3.2.0 | |
| 3910 | − picocolors: 1.1.1 | |
| 3911 | − | |
| 3912 | − uri-js@4.4.1: | |
| 3913 | − dependencies: | |
| 3914 | − punycode: 2.3.1 | |
| 3915 | − | |
| 3916 | − util-deprecate@1.0.2: {} | |
| 3917 | − | |
| 3918 | − which-boxed-primitive@1.1.1: | |
| 3919 | − dependencies: | |
| 3920 | − is-bigint: 1.1.0 | |
| 3921 | − is-boolean-object: 1.2.2 | |
| 3922 | − is-number-object: 1.1.1 | |
| 3923 | − is-string: 1.1.1 | |
| 3924 | − is-symbol: 1.1.1 | |
| 3925 | − | |
| 3926 | − which-builtin-type@1.2.1: | |
| 3927 | − dependencies: | |
| 3928 | − call-bound: 1.0.4 | |
| 3929 | − function.prototype.name: 1.2.0 | |
| 3930 | − has-tostringtag: 1.0.2 | |
| 3931 | − is-async-function: 2.1.1 | |
| 3932 | − is-date-object: 1.1.0 | |
| 3933 | − is-finalizationregistry: 1.1.1 | |
| 3934 | − is-generator-function: 1.1.2 | |
| 3935 | − is-regex: 1.2.1 | |
| 3936 | − is-weakref: 1.1.1 | |
| 3937 | − isarray: 2.0.5 | |
| 3938 | − which-boxed-primitive: 1.1.1 | |
| 3939 | − which-collection: 1.0.2 | |
| 3940 | − which-typed-array: 1.1.22 | |
| 3941 | − | |
| 3942 | − which-collection@1.0.2: | |
| 3943 | − dependencies: | |
| 3944 | − is-map: 2.0.3 | |
| 3945 | − is-set: 2.0.3 | |
| 3946 | − is-weakmap: 2.0.2 | |
| 3947 | − is-weakset: 2.0.4 | |
| 3948 | − | |
| 3949 | − which-typed-array@1.1.22: | |
| 3950 | − dependencies: | |
| 3951 | − available-typed-arrays: 1.0.7 | |
| 3952 | − call-bind: 1.0.9 | |
| 3953 | − call-bound: 1.0.4 | |
| 3954 | − for-each: 0.3.5 | |
| 3955 | − get-proto: 1.0.1 | |
| 3956 | − gopd: 1.2.0 | |
| 3957 | − has-tostringtag: 1.0.2 | |
| 3958 | − | |
| 3959 | − which@2.0.2: | |
| 3960 | − dependencies: | |
| 3961 | − isexe: 2.0.0 | |
| 3962 | − | |
| 3963 | − word-wrap@1.2.5: {} | |
| 3964 | − | |
| 3965 | − wrappy@1.0.2: {} | |
| 3966 | − | |
| 3967 | − yocto-queue@0.1.0: {} | |
deleted
M2M32brouve-ka/pnpm-workspace.yaml
+0 −8
@@ -1,8 +0,0 @@ | ||
| 1 | −# Trouve-KA — pnpm workspace | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | −packages: | |
| 5 | − - "apps/web" | |
| 6 | −onlyBuiltDependencies: | |
| 7 | − - sharp | |
| 8 | − - unrs-resolver | |
deleted
M2M32brouve-ka/pyproject.toml
+0 −85
@@ -1,85 +0,0 @@ | ||
| 1 | −# Trouve-KA — Python monorepo build configuration | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −[project] | |
| 6 | −name = "trouveka" | |
| 7 | −version = "0.1.0" | |
| 8 | −description = "Trouve-KA — moteur de recherche web indépendant, Québec-first" | |
| 9 | −requires-python = ">=3.12" | |
| 10 | −authors = [{ name = "Simon-Pierre Boucher", email = "contact@spboucher.ai" }] | |
| 11 | −dependencies = [ | |
| 12 | − "fastapi>=0.115", | |
| 13 | − "uvicorn[standard]>=0.30", | |
| 14 | − "httpx>=0.27", | |
| 15 | − "brotli>=1.1", | |
| 16 | − "selectolax>=0.3.21", | |
| 17 | − "protego>=0.3.1", | |
| 18 | − "asyncpg>=0.29", | |
| 19 | − "redis>=5.0", | |
| 20 | − "opensearch-py[async]>=2.6", | |
| 21 | − "pydantic>=2.7", | |
| 22 | − "pydantic-settings>=2.3", | |
| 23 | − "langdetect>=1.0.9", | |
| 24 | − "python-dateutil>=2.9", | |
| 25 | −] | |
| 26 | − | |
| 27 | −[project.optional-dependencies] | |
| 28 | −dev = [ | |
| 29 | − "pytest>=8", | |
| 30 | − "pytest-asyncio>=0.23", | |
| 31 | − "ruff>=0.5", | |
| 32 | −] | |
| 33 | − | |
| 34 | −[build-system] | |
| 35 | −requires = ["setuptools>=69"] | |
| 36 | −build-backend = "setuptools.build_meta" | |
| 37 | − | |
| 38 | −# The "trouveka" namespace package is assembled from the monorepo layout: | |
| 39 | −# packages/* hold shared libraries, services/* hold pipeline services, | |
| 40 | −# apps/api holds the FastAPI application (see CLAUDE.md §2). | |
| 41 | −[tool.setuptools] | |
| 42 | −packages = [ | |
| 43 | − "trouveka.config", | |
| 44 | − "trouveka.logging", | |
| 45 | − "trouveka.types", | |
| 46 | − "trouveka.shared", | |
| 47 | − "trouveka.database", | |
| 48 | − "trouveka.queue", | |
| 49 | − "trouveka.search_core", | |
| 50 | − "trouveka.crawler", | |
| 51 | − "trouveka.frontier", | |
| 52 | − "trouveka.parser", | |
| 53 | − "trouveka.classifier", | |
| 54 | − "trouveka.indexer", | |
| 55 | − "trouveka.ranking", | |
| 56 | − "trouveka.scheduler", | |
| 57 | − "trouveka.enrichment", | |
| 58 | − "trouveka.api", | |
| 59 | −] | |
| 60 | − | |
| 61 | −[tool.setuptools.package-dir] | |
| 62 | −"trouveka.config" = "packages/config" | |
| 63 | −"trouveka.logging" = "packages/logging" | |
| 64 | −"trouveka.types" = "packages/types" | |
| 65 | −"trouveka.shared" = "packages/shared" | |
| 66 | −"trouveka.database" = "packages/database" | |
| 67 | −"trouveka.queue" = "packages/queue" | |
| 68 | −"trouveka.search_core" = "packages/search-core" | |
| 69 | −"trouveka.crawler" = "services/crawler" | |
| 70 | −"trouveka.frontier" = "services/frontier" | |
| 71 | −"trouveka.parser" = "services/parser" | |
| 72 | −"trouveka.classifier" = "services/classifier" | |
| 73 | −"trouveka.indexer" = "services/indexer" | |
| 74 | −"trouveka.ranking" = "services/ranking" | |
| 75 | −"trouveka.scheduler" = "services/scheduler" | |
| 76 | −"trouveka.enrichment" = "services/enrichment" | |
| 77 | −"trouveka.api" = "apps/api" | |
| 78 | − | |
| 79 | −[tool.pytest.ini_options] | |
| 80 | −asyncio_mode = "auto" | |
| 81 | −testpaths = ["tests"] | |
| 82 | − | |
| 83 | −[tool.ruff] | |
| 84 | −line-length = 110 | |
| 85 | −target-version = "py312" | |
deleted
M2M32brouve-ka/scripts/bootstrap-seeds/seed.sh
+0 −14
@@ -1,14 +0,0 @@ | ||
| 1 | −#!/usr/bin/env bash | |
| 2 | −# Trouve-KA — chargement des seeds dans le frontier | |
| 3 | −# Author: Simon-Pierre Boucher | |
| 4 | −# Contact: contact@spboucher.ai | |
| 5 | −set -euo pipefail | |
| 6 | −cd "$(dirname "$0")/../.." | |
| 7 | − | |
| 8 | −if [ -x .venv/bin/python ]; then | |
| 9 | − PY=.venv/bin/python | |
| 10 | −else | |
| 11 | − PY=python3 | |
| 12 | −fi | |
| 13 | − | |
| 14 | −exec "$PY" -m trouveka.crawler.seed "${1:-scripts/bootstrap-seeds/seeds.txt}" | |
deleted
M2M32brouve-ka/scripts/bootstrap-seeds/seeds.txt
+0 −80
@@ -1,80 +0,0 @@ | ||
| 1 | −# Trouve-KA — seeds de démarrage (qualité > quantité, CLAUDE.md §8) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | −# Nœuds fortement connectés du web québécois. Une URL par ligne, # = commentaire. | |
| 5 | − | |
| 6 | −# --- Gouvernement du Québec --- | |
| 7 | −https://www.quebec.ca/ | |
| 8 | −https://www.assnat.qc.ca/ | |
| 9 | −https://www.revenuquebec.ca/ | |
| 10 | −https://www.ramq.gouv.qc.ca/ | |
| 11 | −https://saaq.gouv.qc.ca/ | |
| 12 | −https://www.cnesst.gouv.qc.ca/ | |
| 13 | −https://www.hydroquebec.com/ | |
| 14 | −https://www.investquebec.com/ | |
| 15 | −https://www.transitionenergetique.gouv.qc.ca/ | |
| 16 | − | |
| 17 | −# --- Municipalités --- | |
| 18 | −https://montreal.ca/ | |
| 19 | −https://www.ville.quebec.qc.ca/ | |
| 20 | −https://www.laval.ca/ | |
| 21 | −https://www.gatineau.ca/ | |
| 22 | −https://www.sherbrooke.ca/ | |
| 23 | −https://www.longueuil.quebec/ | |
| 24 | −https://www.trois-rivieres.ca/ | |
| 25 | −https://ville.saguenay.ca/ | |
| 26 | −https://www.levis.ca/ | |
| 27 | −https://www.terrebonne.ca/ | |
| 28 | −https://www.drummondville.ca/ | |
| 29 | −https://www.rimouski.ca/ | |
| 30 | − | |
| 31 | −# --- Universités et cégeps --- | |
| 32 | −https://www.ulaval.ca/ | |
| 33 | −https://www.umontreal.ca/ | |
| 34 | −https://www.mcgill.ca/ | |
| 35 | −https://uqam.ca/ | |
| 36 | −https://www.usherbrooke.ca/ | |
| 37 | −https://www.concordia.ca/ | |
| 38 | −https://www.polymtl.ca/ | |
| 39 | −https://www.etsmtl.ca/ | |
| 40 | −https://www.hec.ca/ | |
| 41 | −https://www.uqac.ca/ | |
| 42 | −https://www.uqtr.ca/ | |
| 43 | −https://www.uqar.ca/ | |
| 44 | −https://uqo.ca/ | |
| 45 | −https://www.inrs.ca/ | |
| 46 | −https://www.cegepsquebec.ca/ | |
| 47 | − | |
| 48 | −# --- Médias --- | |
| 49 | −https://www.lapresse.ca/ | |
| 50 | −https://www.ledevoir.com/ | |
| 51 | −https://www.journaldemontreal.com/ | |
| 52 | −https://www.journaldequebec.com/ | |
| 53 | −https://ici.radio-canada.ca/ | |
| 54 | −https://www.tvanouvelles.ca/ | |
| 55 | −https://www.lesoleil.com/ | |
| 56 | −https://www.ledroit.com/ | |
| 57 | −https://www.latribune.ca/ | |
| 58 | −https://www.lenouvelliste.ca/ | |
| 59 | −https://www.noovo.info/ | |
| 60 | −https://www.lactualite.com/ | |
| 61 | − | |
| 62 | −# --- Affaires, annuaires, associations --- | |
| 63 | −https://www.registreentreprises.gouv.qc.ca/ | |
| 64 | −https://www.desjardins.com/ | |
| 65 | −https://www.fccq.ca/ | |
| 66 | −https://www.ccmm.ca/ | |
| 67 | −https://www.pagesjaunes.ca/ | |
| 68 | −https://quebec.craigslist.org/ | |
| 69 | −https://www.cqcd.org/ | |
| 70 | −https://www.manufacturiersquebec.ca/ | |
| 71 | − | |
| 72 | −# --- Tourisme et culture --- | |
| 73 | −https://www.bonjourquebec.com/ | |
| 74 | −https://www.mtl.org/ | |
| 75 | −https://www.quebec-cite.com/ | |
| 76 | −https://www.tourisme-charlevoix.com/ | |
| 77 | −https://www.tourismegaspesie.com/ | |
| 78 | −https://www.sepaq.com/ | |
| 79 | −https://www.lavitrine.com/ | |
| 80 | −https://www.banq.qc.ca/ | |
deleted
M2M32brouve-ka/scripts/check-headers.py
+0 −38
@@ -1,38 +0,0 @@ | ||
| 1 | −#!/usr/bin/env python3 | |
| 2 | −# Trouve-KA — vérification CI des headers d'auteur (CLAUDE.md §0.1) | |
| 3 | −# Author: Simon-Pierre Boucher | |
| 4 | −# Contact: contact@spboucher.ai | |
| 5 | − | |
| 6 | −"""Vérifie que chaque fichier source porte le header auteur obligatoire.""" | |
| 7 | − | |
| 8 | −import pathlib | |
| 9 | −import sys | |
| 10 | − | |
| 11 | −ROOT = pathlib.Path(__file__).resolve().parents[1] | |
| 12 | −EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".mjs", ".cjs", ".sql", ".sh", ".css", ".yml", ".yaml", ".toml"} | |
| 13 | −SKIP_DIRS = {"node_modules", ".next", ".venv", "__pycache__", ".git", "dist", ".pytest_cache", ".ruff_cache"} | |
| 14 | −SKIP_FILES = {"pnpm-lock.yaml", "next-env.d.ts"} | |
| 15 | −REQUIRED = "Author: Simon-Pierre Boucher" | |
| 16 | − | |
| 17 | − | |
| 18 | −def main() -> int: | |
| 19 | − missing: list[pathlib.Path] = [] | |
| 20 | − for path in ROOT.rglob("*"): | |
| 21 | − if not path.is_file() or path.suffix not in EXTENSIONS or path.name in SKIP_FILES: | |
| 22 | − continue | |
| 23 | − if any(part in SKIP_DIRS for part in path.parts): | |
| 24 | − continue | |
| 25 | − head = path.read_text(encoding="utf-8", errors="replace")[:600] | |
| 26 | − if REQUIRED not in head: | |
| 27 | − missing.append(path.relative_to(ROOT)) | |
| 28 | − if missing: | |
| 29 | − print(f"✗ {len(missing)} fichier(s) sans header auteur :") | |
| 30 | − for p in missing: | |
| 31 | − print(f" - {p}") | |
| 32 | − return 1 | |
| 33 | − print("✓ Tous les fichiers source portent le header auteur.") | |
| 34 | − return 0 | |
| 35 | − | |
| 36 | − | |
| 37 | −if __name__ == "__main__": | |
| 38 | − sys.exit(main()) | |
deleted
M2M32brouve-ka/scripts/cleanup-garbage.py
+0 −63
@@ -1,63 +0,0 @@ | ||
| 1 | −#!/usr/bin/env python3 | |
| 2 | −# Trouve-KA — purge des documents mal décodés de l'index | |
| 3 | −# Author: Simon-Pierre Boucher | |
| 4 | −# Contact: contact@spboucher.ai | |
| 5 | − | |
| 6 | −"""Scanne l'index, supprime les documents au contenu charabia (binaire mal décodé) | |
| 7 | −et remet leurs URLs en recrawl (le fetcher corrigé les réindexera proprement). | |
| 8 | − | |
| 9 | −Usage : python -m scripts... non — exécuter depuis la racine : | |
| 10 | − .venv/bin/python scripts/cleanup-garbage.py # dev local | |
| 11 | − (sur m2m32 : docker compose run --rm --entrypoint python api /app/scripts/cleanup-garbage.py) | |
| 12 | −""" | |
| 13 | − | |
| 14 | −import asyncio | |
| 15 | − | |
| 16 | −from trouveka.config import get_settings | |
| 17 | −from trouveka.database import Database | |
| 18 | −from trouveka.parser import looks_like_garbage | |
| 19 | −from trouveka.search_core import SearchCore | |
| 20 | − | |
| 21 | − | |
| 22 | −async def main() -> None: | |
| 23 | − s = get_settings() | |
| 24 | − db = Database(s.database_url, pool_min=1, pool_max=3) | |
| 25 | − await db.connect() | |
| 26 | − search = SearchCore(s.search_url, s.search_index) | |
| 27 | − | |
| 28 | − removed = 0 | |
| 29 | − scanned = 0 | |
| 30 | − # Scroll de tout l'index par pages de 200 | |
| 31 | − body = {"query": {"match_all": {}}, "size": 200, "_source": ["url", "title", "description", "body"]} | |
| 32 | − resp = await search.client.search(index=s.search_index, body=body, scroll="2m") | |
| 33 | − scroll_id = resp.get("_scroll_id") | |
| 34 | − try: | |
| 35 | − while True: | |
| 36 | − hits = resp["hits"]["hits"] | |
| 37 | − if not hits: | |
| 38 | − break | |
| 39 | − for hit in hits: | |
| 40 | − scanned += 1 | |
| 41 | − src = hit["_source"] | |
| 42 | − text = " ".join([src.get("title") or "", src.get("description") or "", | |
| 43 | − (src.get("body") or "")[:20_000]]) | |
| 44 | − if looks_like_garbage(text, threshold=0.02): | |
| 45 | − await search.client.delete(index=s.search_index, id=hit["_id"], ignore=[404]) | |
| 46 | − await db.requeue_url(src["url"]) | |
| 47 | − await db.pool.execute( | |
| 48 | − "DELETE FROM documents WHERE url_id = (SELECT id FROM urls WHERE url = $1)", | |
| 49 | − src["url"], | |
| 50 | − ) | |
| 51 | − removed += 1 | |
| 52 | − print(f"purgé : {src['url']}") | |
| 53 | − resp = await search.client.scroll(scroll_id=scroll_id, scroll="2m") | |
| 54 | − finally: | |
| 55 | − if scroll_id: | |
| 56 | − await search.client.clear_scroll(scroll_id=scroll_id) | |
| 57 | − await search.close() | |
| 58 | − await db.close() | |
| 59 | − print(f"\n{scanned} documents scannés, {removed} purgés et remis en recrawl.") | |
| 60 | − | |
| 61 | − | |
| 62 | −if __name__ == "__main__": | |
| 63 | − asyncio.run(main()) | |
deleted
M2M32brouve-ka/scripts/eval/ranking-eval.yaml
+0 −29
@@ -1,29 +0,0 @@ | ||
| 1 | −# Trouve-KA — dataset d'évaluation du ranking (§16) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | −# Chaque cas : requête + domaines attendus dans le top-k. Mesurable, reproductible. | |
| 5 | −cases: | |
| 6 | − - query: "université québec" | |
| 7 | − expect_any_of: [uqam.ca, ulaval.ca, umontreal.ca, uqtr.ca, uqac.ca, uqar.ca, uquebec.ca, usherbrooke.ca] | |
| 8 | − top_k: 5 | |
| 9 | − - query: "plombier gatineau" | |
| 10 | − expect_any_of: [gatineau.ca, pagesjaunes.ca] | |
| 11 | − top_k: 10 | |
| 12 | − - query: "subvention thermopompe" | |
| 13 | − expect_any_of: [quebec.ca, hydroquebec.com, transitionenergetique.gouv.qc.ca] | |
| 14 | − top_k: 10 | |
| 15 | − - query: "heat pump rebate" | |
| 16 | − expect_any_of: [quebec.ca, hydroquebec.com, transitionenergetique.gouv.qc.ca] | |
| 17 | − top_k: 10 | |
| 18 | − - query: "impôt revenu" | |
| 19 | − expect_any_of: [revenuquebec.ca, quebec.ca] | |
| 20 | − top_k: 5 | |
| 21 | − - query: "tourisme gaspésie" | |
| 22 | − expect_any_of: [tourismegaspesie.com, bonjourquebec.com, quebecmaritime.ca, sepaq.com] | |
| 23 | − top_k: 5 | |
| 24 | − - query: "nouvelles montréal" | |
| 25 | − expect_any_of: [lapresse.ca, journaldemontreal.com, radio-canada.ca, ledevoir.com, tvanouvelles.ca, montreal.ca, mtl.org] | |
| 26 | − top_k: 10 | |
| 27 | − - query: "permis de conduire" | |
| 28 | − expect_any_of: [saaq.gouv.qc.ca, quebec.ca] | |
| 29 | − top_k: 5 | |
deleted
M2M32brouve-ka/scripts/eval/run-eval.py
+0 −65
@@ -1,65 +0,0 @@ | ||
| 1 | −#!/usr/bin/env python3 | |
| 2 | −# Trouve-KA — évaluation du ranking contre le dataset (§16) | |
| 3 | −# Author: Simon-Pierre Boucher | |
| 4 | −# Contact: contact@spboucher.ai | |
| 5 | − | |
| 6 | −"""Exécute le dataset d'évaluation contre l'API de recherche. | |
| 7 | − | |
| 8 | −Usage : python3 scripts/eval/run-eval.py [--api http://localhost:8080] | |
| 9 | −Un cas passe si au moins un domaine attendu apparaît dans le top-k. | |
| 10 | −L'index grandit en continu : les scores s'améliorent avec la couverture. | |
| 11 | −""" | |
| 12 | − | |
| 13 | −import argparse | |
| 14 | −import json | |
| 15 | −import pathlib | |
| 16 | −import re | |
| 17 | −import urllib.parse | |
| 18 | −import urllib.request | |
| 19 | − | |
| 20 | −HERE = pathlib.Path(__file__).parent | |
| 21 | − | |
| 22 | − | |
| 23 | −def load_cases() -> list[dict]: | |
| 24 | − """Parse minimaliste du YAML du dataset (pas de dépendance PyYAML).""" | |
| 25 | − cases, current = [], None | |
| 26 | − for line in (HERE / "ranking-eval.yaml").read_text(encoding="utf-8").splitlines(): | |
| 27 | − if m := re.match(r'\s*- query: "(.*)"', line): | |
| 28 | − current = {"query": m.group(1)} | |
| 29 | − cases.append(current) | |
| 30 | − elif m := re.match(r"\s*expect_any_of: \[(.*)\]", line): | |
| 31 | − current["expect_any_of"] = [d.strip() for d in m.group(1).split(",")] | |
| 32 | − elif m := re.match(r"\s*top_k: (\d+)", line): | |
| 33 | − current["top_k"] = int(m.group(1)) | |
| 34 | − return cases | |
| 35 | − | |
| 36 | − | |
| 37 | −def main() -> int: | |
| 38 | − ap = argparse.ArgumentParser() | |
| 39 | − ap.add_argument("--api", default="http://localhost:8080") | |
| 40 | − args = ap.parse_args() | |
| 41 | − | |
| 42 | − passed = 0 | |
| 43 | − cases = load_cases() | |
| 44 | − for case in cases: | |
| 45 | − q = urllib.parse.quote(case["query"]) | |
| 46 | − url = f"{args.api}/api/search?q={q}&limit={case['top_k']}" | |
| 47 | − try: | |
| 48 | − with urllib.request.urlopen(url, timeout=10) as resp: | |
| 49 | − data = json.load(resp) | |
| 50 | − except Exception as exc: | |
| 51 | − print(f"✗ {case['query']!r} — API injoignable : {exc}") | |
| 52 | − continue | |
| 53 | − domains = [r["domain"] for r in data["results"]] | |
| 54 | − hit = next((d for d in domains if any(d == e or d.endswith("." + e) for e in case["expect_any_of"])), None) | |
| 55 | − if hit: | |
| 56 | − passed += 1 | |
| 57 | − print(f"✓ {case['query']!r} → {hit} (rang {domains.index(hit) + 1}, {data['took_ms']} ms)") | |
| 58 | − else: | |
| 59 | − print(f"✗ {case['query']!r} → top-{case['top_k']}: {domains[:5]}") | |
| 60 | − print(f"\n{passed}/{len(cases)} cas réussis") | |
| 61 | − return 0 if passed == len(cases) else 1 | |
| 62 | − | |
| 63 | − | |
| 64 | −if __name__ == "__main__": | |
| 65 | − raise SystemExit(main()) | |
deleted
M2M32brouve-ka/scripts/health-check/check.sh
+0 −27
@@ -1,27 +0,0 @@ | ||
| 1 | −#!/usr/bin/env bash | |
| 2 | −# Trouve-KA — vérification de santé du stack | |
| 3 | −# Author: Simon-Pierre Boucher | |
| 4 | −# Contact: contact@spboucher.ai | |
| 5 | −set -uo pipefail | |
| 6 | − | |
| 7 | −API_URL="${API_URL:-http://localhost:8080}" | |
| 8 | −SEARCH_URL="${SEARCH_URL:-http://localhost:9200}" | |
| 9 | −WEB_URL="${WEB_URL:-http://localhost:3000}" | |
| 10 | −FAIL=0 | |
| 11 | − | |
| 12 | −check() { | |
| 13 | − local name="$1" url="$2" | |
| 14 | − if curl -sf --max-time 5 "$url" > /dev/null; then | |
| 15 | − echo "✓ $name ($url)" | |
| 16 | − else | |
| 17 | − echo "✗ $name ($url)" | |
| 18 | − FAIL=1 | |
| 19 | − fi | |
| 20 | −} | |
| 21 | − | |
| 22 | −check "API santé" "$API_URL/api/health" | |
| 23 | −check "API statut" "$API_URL/api/status" | |
| 24 | −check "OpenSearch" "$SEARCH_URL/_cluster/health" | |
| 25 | −check "Web" "$WEB_URL/" | |
| 26 | − | |
| 27 | −exit $FAIL | |
deleted
M2M32brouve-ka/scripts/start-crawler/start.sh
+0 −14
@@ -1,14 +0,0 @@ | ||
| 1 | −#!/usr/bin/env bash | |
| 2 | −# Trouve-KA — démarrage local d'un worker de crawl | |
| 3 | −# Author: Simon-Pierre Boucher | |
| 4 | −# Contact: contact@spboucher.ai | |
| 5 | −set -euo pipefail | |
| 6 | −cd "$(dirname "$0")/../.." | |
| 7 | − | |
| 8 | −if [ -x .venv/bin/python ]; then | |
| 9 | − PY=.venv/bin/python | |
| 10 | −else | |
| 11 | − PY=python3 | |
| 12 | −fi | |
| 13 | − | |
| 14 | −exec "$PY" -m trouveka.crawler.worker | |
deleted
M2M32brouve-ka/services/classifier/__init__.py
+0 −15
@@ -1,15 +0,0 @@ | ||
| 1 | −# Trouve-KA — classification Québec | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Détection de pertinence québécoise (CLAUDE.md §7). | |
| 6 | − | |
| 7 | −Deux scores distincts : page_quebec_score (calculé ici) et domain_quebec_score | |
| 8 | −(agrégé en base au fil des pages). Un article du NYT sur Montréal peut être | |
| 9 | −pertinent sans que le domaine le soit. | |
| 10 | −""" | |
| 11 | − | |
| 12 | −from .scorer import score_page | |
| 13 | −from .gazetteer import QUEBEC_TOPONYMS, QUEBEC_REGIONS | |
| 14 | − | |
| 15 | −__all__ = ["score_page", "QUEBEC_TOPONYMS", "QUEBEC_REGIONS"] | |
deleted
M2M32brouve-ka/services/classifier/gazetteer.py
+0 −75
@@ -1,75 +0,0 @@ | ||
| 1 | −# Trouve-KA — gazetteer québécois | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Toponymes, régions et signaux institutionnels du Québec. | |
| 6 | − | |
| 7 | −Liste de départ volontairement conservatrice : des noms non ambigus d'abord. | |
| 8 | −Les toponymes ambigus (Laval existe en France, Hull au Royaume-Uni…) portent | |
| 9 | −un poids réduit dans le scorer. | |
| 10 | −""" | |
| 11 | − | |
| 12 | −# Municipalités et lieux à fort signal (poids plein) | |
| 13 | −QUEBEC_TOPONYMS: set[str] = { | |
| 14 | − "québec", "quebec", "montréal", "montreal", "gatineau", "sherbrooke", | |
| 15 | − "trois-rivières", "trois-rivieres", "saguenay", "lévis", "levis", | |
| 16 | − "longueuil", "terrebonne", "brossard", "repentigny", "drummondville", | |
| 17 | − "saint-jérôme", "saint-jerome", "granby", "blainville", "shawinigan", | |
| 18 | − "dollard-des-ormeaux", "rimouski", "victoriaville", "saint-hyacinthe", | |
| 19 | − "boucherville", "salaberry-de-valleyfield", "vaudreuil-dorion", "châteauguay", | |
| 20 | − "chateauguay", "rouyn-noranda", "joliette", "sorel-tracy", "val-d'or", | |
| 21 | − "alma", "sept-îles", "sept-iles", "baie-comeau", "chicoutimi", "jonquière", | |
| 22 | − "jonquiere", "rivière-du-loup", "riviere-du-loup", "matane", "gaspé", "gaspe", | |
| 23 | − "magog", "thetford mines", "sainte-thérèse", "sainte-therese", "mirabel", | |
| 24 | − "beloeil", "mascouche", "varennes", "chambly", "saint-eustache", | |
| 25 | − "l'assomption", "lachute", "mont-tremblant", "sainte-adèle", "sainte-adele", | |
| 26 | − "kuujjuaq", "chibougamau", "amos", "la tuque", "cowansville", "beauharnois", | |
| 27 | − "outaouais", "estrie", "montérégie", "monteregie", "mauricie", "lanaudière", | |
| 28 | − "lanaudiere", "laurentides", "abitibi-témiscamingue", "abitibi-temiscamingue", | |
| 29 | − "bas-saint-laurent", "côte-nord", "cote-nord", "nord-du-québec", "nord-du-quebec", | |
| 30 | − "gaspésie", "gaspesie", "chaudière-appalaches", "chaudiere-appalaches", | |
| 31 | − "centre-du-québec", "centre-du-quebec", "saguenay-lac-saint-jean", | |
| 32 | − "nunavik", "îles-de-la-madeleine", "iles-de-la-madeleine", | |
| 33 | − "hochelaga", "rosemont", "ahuntsic", "verdun", "outremont", "westmount", | |
| 34 | − "plateau-mont-royal", "villeray", "anjou", "lasalle", "lachine", "dorval", | |
| 35 | − "pointe-claire", "kirkland", "beaconsfield", "côte-des-neiges", "cote-des-neiges", | |
| 36 | −} | |
| 37 | − | |
| 38 | −# Toponymes ambigus (existent ailleurs) — poids réduit | |
| 39 | −AMBIGUOUS_TOPONYMS: set[str] = { | |
| 40 | − "laval", "hull", "aylmer", "candiac", "delson", "bromont", "sutton", | |
| 41 | − "stanstead", "asbestos", "windsor", "richmond", "warwick", "kingsey", | |
| 42 | −} | |
| 43 | − | |
| 44 | −QUEBEC_REGIONS: set[str] = { | |
| 45 | − "outaouais", "estrie", "montérégie", "mauricie", "lanaudière", "laurentides", | |
| 46 | − "abitibi-témiscamingue", "bas-saint-laurent", "côte-nord", "gaspésie", | |
| 47 | − "chaudière-appalaches", "centre-du-québec", "saguenay-lac-saint-jean", "nunavik", | |
| 48 | −} | |
| 49 | − | |
| 50 | −# Institutions et organisations québécoises fortement identifiantes | |
| 51 | −QUEBEC_ORGS: set[str] = { | |
| 52 | − "hydro-québec", "hydro-quebec", "hydroquébec", "hydroquebec", | |
| 53 | − "desjardins", "gouvernement du québec", "gouvernement du quebec", | |
| 54 | − "assemblée nationale", "assemblee nationale", "revenu québec", "revenu quebec", | |
| 55 | − "saq", "sqdc", "caq", "société de transport de montréal", "stm", | |
| 56 | − "réseau de transport de la capitale", "rtc", "exo", "via rail", | |
| 57 | − "université laval", "universite laval", "université de montréal", | |
| 58 | − "universite de montreal", "mcgill", "uqam", "uqac", "uqtr", "uqar", "uqo", "uqat", | |
| 59 | − "université de sherbrooke", "universite de sherbrooke", "polytechnique montréal", | |
| 60 | − "polytechnique montreal", "hec montréal", "hec montreal", "éts", "ets", | |
| 61 | − "concordia", "inrs", "cégep", "cegep", "clsc", "chum", "chu de québec", | |
| 62 | − "chu sainte-justine", "ramq", "saaq", "cnesst", "sûreté du québec", | |
| 63 | − "surete du quebec", "loto-québec", "loto-quebec", "investissement québec", | |
| 64 | − "investissement quebec", "caisse de dépôt", "caisse de depot", "cdpq", | |
| 65 | − "radio-canada", "télé-québec", "tele-quebec", "la presse", "le devoir", | |
| 66 | − "journal de montréal", "journal de montreal", "journal de québec", | |
| 67 | − "journal de quebec", "tva", "noovo", "bonjour québec", "bonjour quebec", | |
| 68 | − "tourisme québec", "tourisme quebec", | |
| 69 | −} | |
| 70 | − | |
| 71 | −# Indicatifs téléphoniques du Québec (signal faible) | |
| 72 | −QUEBEC_AREA_CODES: set[str] = {"418", "514", "438", "450", "579", "819", "873", "367", "263", "354", "468"} | |
| 73 | − | |
| 74 | −# TLD / suffixes de domaine à signal fort | |
| 75 | −STRONG_DOMAIN_SUFFIXES: tuple[str, ...] = (".qc.ca", ".quebec", ".gouv.qc.ca", ".montreal.ca") | |
deleted
M2M32brouve-ka/services/classifier/scorer.py
+0 −115
@@ -1,115 +0,0 @@ | ||
| 1 | −# Trouve-KA — scoreur Québec | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Calcul de page_quebec_score ∈ [0, 1]. | |
| 6 | − | |
| 7 | −Combinaison de signaux hétérogènes (CLAUDE.md §7) : TLD, toponymes, codes | |
| 8 | −postaux QC, indicatifs téléphoniques, organisations connues, langue française, | |
| 9 | −mentions structurées de la province. Aucun signal seul ne suffit (un .ca seul | |
| 10 | −ne prouve rien); le score sature progressivement via une somme amortie. | |
| 11 | −""" | |
| 12 | − | |
| 13 | −import re | |
| 14 | − | |
| 15 | −from trouveka.types import ParsedPage, QuebecSignals | |
| 16 | − | |
| 17 | −from .gazetteer import ( | |
| 18 | − AMBIGUOUS_TOPONYMS, | |
| 19 | − QUEBEC_AREA_CODES, | |
| 20 | − QUEBEC_ORGS, | |
| 21 | − QUEBEC_TOPONYMS, | |
| 22 | − STRONG_DOMAIN_SUFFIXES, | |
| 23 | −) | |
| 24 | − | |
| 25 | −# Codes postaux du Québec : G, H, J en première lettre (format A1A 1A1) | |
| 26 | −_POSTAL_RE = re.compile(r"\b[GHJ]\d[A-Z]\s?\d[A-Z]\d\b", re.IGNORECASE) | |
| 27 | −_PHONE_RE = re.compile(r"(?:\+?1[\s.-]?)?\(?(\d{3})\)?[\s.-]?\d{3}[\s.-]?\d{4}\b") | |
| 28 | −_PROVINCE_RE = re.compile( | |
| 29 | − r"\b(?:province\s+(?:de\s+|du\s+)?qu[ée]bec|qu[ée]bec\s*\(qc\)|,\s*(?:qc|qu[ée]bec)\b)", | |
| 30 | − re.IGNORECASE, | |
| 31 | −) | |
| 32 | −_WORD_BOUNDARY = r"(?<![\w-]){}(?![\w-])" | |
| 33 | − | |
| 34 | − | |
| 35 | −def _count_terms(text: str, terms: set[str], cap: int = 10) -> tuple[int, list[str]]: | |
| 36 | − found: list[str] = [] | |
| 37 | − total = 0 | |
| 38 | − for term in terms: | |
| 39 | − pattern = re.compile(_WORD_BOUNDARY.format(re.escape(term)), re.IGNORECASE) | |
| 40 | − n = len(pattern.findall(text)) | |
| 41 | − if n: | |
| 42 | − found.append(term) | |
| 43 | − total += min(n, 4) # une page qui répète 200× « Montréal » n'est pas 200× plus québécoise | |
| 44 | − if total >= cap: | |
| 45 | − break | |
| 46 | − return min(total, cap), found | |
| 47 | − | |
| 48 | − | |
| 49 | −def score_page(page: ParsedPage, domain: str) -> QuebecSignals: | |
| 50 | − """Score Québec d'une page. Déterministe, sans LLM, économique (§12).""" | |
| 51 | − signals = QuebecSignals() | |
| 52 | − reasons: list[str] = [] | |
| 53 | − points = 0.0 | |
| 54 | − | |
| 55 | − text = " ".join([page.title, page.description, " ".join(page.headings), page.body[:20_000]]) | |
| 56 | − text_with_hints = text + " " + " ".join(page.structured_hints) | |
| 57 | − lower = text_with_hints.lower() | |
| 58 | − | |
| 59 | − # 1. Domaine (signal fort mais pas suffisant seul) | |
| 60 | − host = domain.lower() | |
| 61 | − if any(host.endswith(suffix) or host == suffix.lstrip(".") for suffix in STRONG_DOMAIN_SUFFIXES): | |
| 62 | − points += 4.0 | |
| 63 | − reasons.append("tld_quebec") | |
| 64 | − | |
| 65 | − # 2. Toponymes non ambigus (titre/headings pèsent plus que le corps) | |
| 66 | − head_text = " ".join([page.title, page.description, " ".join(page.headings)]) | |
| 67 | − head_hits, head_names = _count_terms(head_text, QUEBEC_TOPONYMS, cap=6) | |
| 68 | − body_hits, body_names = _count_terms(page.body[:20_000], QUEBEC_TOPONYMS, cap=8) | |
| 69 | − if head_hits: | |
| 70 | − points += 1.2 * head_hits | |
| 71 | − reasons.append("toponymes_titre") | |
| 72 | − if body_hits: | |
| 73 | − points += 0.4 * body_hits | |
| 74 | − reasons.append("toponymes_corps") | |
| 75 | − signals.locations = sorted({*head_names, *body_names})[:12] | |
| 76 | − | |
| 77 | − # 3. Toponymes ambigus — poids réduit | |
| 78 | − amb_hits, amb_names = _count_terms(lower, AMBIGUOUS_TOPONYMS, cap=3) | |
| 79 | − if amb_hits: | |
| 80 | − points += 0.15 * amb_hits | |
| 81 | − reasons.append("toponymes_ambigus") | |
| 82 | − signals.locations = sorted({*signals.locations, *amb_names})[:12] | |
| 83 | − | |
| 84 | − # 4. Codes postaux QC (signal fort : preuve d'adresse physique) | |
| 85 | − postal_hits = len(set(_POSTAL_RE.findall(text_with_hints))) | |
| 86 | − if postal_hits: | |
| 87 | − points += min(postal_hits, 3) * 1.5 | |
| 88 | − reasons.append("code_postal_qc") | |
| 89 | − | |
| 90 | − # 5. Mention structurée de la province (adresses, footers) | |
| 91 | − if _PROVINCE_RE.search(text_with_hints): | |
| 92 | − points += 1.5 | |
| 93 | − reasons.append("province_quebec") | |
| 94 | − | |
| 95 | − # 6. Indicatifs téléphoniques (signal faible) | |
| 96 | − area_codes = {m for m in _PHONE_RE.findall(text_with_hints) if m in QUEBEC_AREA_CODES} | |
| 97 | − if area_codes: | |
| 98 | − points += min(len(area_codes), 2) * 0.6 | |
| 99 | − reasons.append("indicatif_qc") | |
| 100 | − | |
| 101 | − # 7. Organisations québécoises connues | |
| 102 | − org_hits, _ = _count_terms(lower, QUEBEC_ORGS, cap=6) | |
| 103 | − if org_hits: | |
| 104 | − points += 0.8 * org_hits | |
| 105 | − reasons.append("organisations_qc") | |
| 106 | − | |
| 107 | − # 8. Langue : le français augmente la probabilité sans la prouver | |
| 108 | − if page.language == "fr": | |
| 109 | − points += 0.8 | |
| 110 | − reasons.append("francais") | |
| 111 | − | |
| 112 | − # Saturation douce : 0 pt → 0, ~3 pts → 0.5, ≥9 pts → ~0.95 | |
| 113 | − signals.score = round(points / (points + 3.0), 4) if points > 0 else 0.0 | |
| 114 | − signals.reasons = reasons | |
| 115 | − return signals | |
deleted
M2M32brouve-ka/services/crawler/__init__.py
+0 −12
@@ -1,12 +0,0 @@ | ||
| 1 | −# Trouve-KA — crawler | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Le crawler de Trouve-KA : frontier Postgres, politesse par hôte, robots.txt, | |
| 6 | −fetch HTTP sécurisé (SSRF), pièges de crawl, pipeline inline fetch→parse→score→index.""" | |
| 7 | − | |
| 8 | −from .robots import RobotsCache | |
| 9 | −from .fetcher import Fetcher | |
| 10 | −from .traps import looks_like_trap | |
| 11 | − | |
| 12 | −__all__ = ["RobotsCache", "Fetcher", "looks_like_trap"] | |
deleted
M2M32brouve-ka/services/crawler/fetcher.py
+0 −148
@@ -1,148 +0,0 @@ | ||
| 1 | −# Trouve-KA — fetcher HTTP sécurisé | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Fetch HTTP avec garde SSRF revalidée à chaque redirection, limites de taille, | |
| 6 | −gestion des content-types et cache HTTP conditionnel (ETag / Last-Modified). | |
| 7 | − | |
| 8 | −Jamais de Chromium ici (CLAUDE.md §5.10) : fetch HTTP pur. | |
| 9 | −""" | |
| 10 | − | |
| 11 | −import time | |
| 12 | −from urllib.parse import urlsplit | |
| 13 | − | |
| 14 | −import httpx | |
| 15 | − | |
| 16 | −from trouveka.config import Settings | |
| 17 | −from trouveka.shared import canonicalize_url, is_safe_url | |
| 18 | −from trouveka.types import ErrorCode, FetchResult | |
| 19 | − | |
| 20 | −ACCEPTED_CONTENT_TYPES = ("text/html", "application/xhtml+xml", "text/plain") | |
| 21 | − | |
| 22 | − | |
| 23 | −class Fetcher: | |
| 24 | − def __init__(self, client: httpx.AsyncClient, settings: Settings): | |
| 25 | − self._client = client | |
| 26 | − self._s = settings | |
| 27 | − | |
| 28 | − async def fetch( | |
| 29 | − self, url: str, *, etag: str | None = None, last_modified: str | None = None | |
| 30 | − ) -> FetchResult: | |
| 31 | − """Fetch une URL en suivant manuellement les redirections (revalidation SSRF à chaque saut).""" | |
| 32 | − start = time.monotonic() | |
| 33 | − chain: list[str] = [] | |
| 34 | − current = url | |
| 35 | − | |
| 36 | − for _hop in range(self._s.max_redirects + 1): | |
| 37 | − if not is_safe_url(current): | |
| 38 | − return self._fail(url, current, ErrorCode.SSRF_BLOCKED, start, chain) | |
| 39 | − | |
| 40 | − # Accept-Encoding volontairement absent : httpx annonce lui-même les | |
| 41 | − # encodages qu'il sait décoder (br seulement si brotli est installé). | |
| 42 | − # L'annoncer à la main fait indexer du binaire compressé (bug itum.qc.ca). | |
| 43 | − headers = { | |
| 44 | − "User-Agent": self._s.crawler_user_agent, | |
| 45 | − "Accept": "text/html,application/xhtml+xml;q=0.9,text/plain;q=0.5,*/*;q=0.1", | |
| 46 | − "Accept-Language": "fr-CA,fr;q=0.9,en-CA;q=0.8,en;q=0.7", | |
| 47 | − } | |
| 48 | − if etag and current == url: | |
| 49 | − headers["If-None-Match"] = etag | |
| 50 | − if last_modified and current == url: | |
| 51 | − headers["If-Modified-Since"] = last_modified | |
| 52 | − | |
| 53 | − try: | |
| 54 | − async with self._client.stream( | |
| 55 | − "GET", current, headers=headers, timeout=self._s.fetch_timeout, follow_redirects=False | |
| 56 | − ) as resp: | |
| 57 | − if resp.status_code in (301, 302, 303, 307, 308): | |
| 58 | − location = resp.headers.get("location") | |
| 59 | − if not location: | |
| 60 | − return self._fail(url, current, ErrorCode.HTTP_4XX, start, chain, resp.status_code) | |
| 61 | − next_url = canonicalize_url(location, base=current) | |
| 62 | − if not next_url: | |
| 63 | − return self._fail(url, current, ErrorCode.SSRF_BLOCKED, start, chain, resp.status_code) | |
| 64 | − chain.append(next_url) | |
| 65 | − current = next_url | |
| 66 | − continue | |
| 67 | − | |
| 68 | − if resp.status_code == 304: | |
| 69 | − return FetchResult( | |
| 70 | − url=url, final_url=current, status_code=304, | |
| 71 | − duration_ms=self._ms(start), redirect_chain=chain, | |
| 72 | − ) | |
| 73 | − if 400 <= resp.status_code < 500: | |
| 74 | − return self._fail(url, current, ErrorCode.HTTP_4XX, start, chain, resp.status_code) | |
| 75 | − if resp.status_code >= 500: | |
| 76 | − return self._fail(url, current, ErrorCode.HTTP_5XX, start, chain, resp.status_code) | |
| 77 | − | |
| 78 | − # X-Robots-Tag : noindex au niveau HTTP | |
| 79 | − x_robots = (resp.headers.get("x-robots-tag") or "").lower() | |
| 80 | − | |
| 81 | − raw_ct = resp.headers.get("content-type") or "" | |
| 82 | − content_type = raw_ct.split(";")[0].strip().lower() | |
| 83 | − charset = None | |
| 84 | − if "charset=" in raw_ct.lower(): | |
| 85 | − charset = raw_ct.lower().split("charset=")[-1].split(";")[0].strip(' "\'') or None | |
| 86 | − if content_type and not any(content_type.startswith(t) for t in ACCEPTED_CONTENT_TYPES): | |
| 87 | − return self._fail( | |
| 88 | − url, current, ErrorCode.UNSUPPORTED_CONTENT, start, chain, resp.status_code | |
| 89 | − ) | |
| 90 | − | |
| 91 | − declared = resp.headers.get("content-length") | |
| 92 | − if declared and int(declared) > self._s.max_response_bytes: | |
| 93 | − return self._fail(url, current, ErrorCode.TOO_LARGE, start, chain, resp.status_code) | |
| 94 | − | |
| 95 | − body = b"" | |
| 96 | − async for part in resp.aiter_bytes(): | |
| 97 | − body += part | |
| 98 | − if len(body) > self._s.max_response_bytes: | |
| 99 | − return self._fail(url, current, ErrorCode.TOO_LARGE, start, chain, resp.status_code) | |
| 100 | − | |
| 101 | − result = FetchResult( | |
| 102 | − url=url, | |
| 103 | − final_url=current, | |
| 104 | − status_code=resp.status_code, | |
| 105 | − content_type=content_type or None, | |
| 106 | − charset=charset, | |
| 107 | − body=body, | |
| 108 | − etag=resp.headers.get("etag"), | |
| 109 | − last_modified=resp.headers.get("last-modified"), | |
| 110 | − duration_ms=self._ms(start), | |
| 111 | − redirect_chain=chain, | |
| 112 | − ) | |
| 113 | − if "noindex" in x_robots: | |
| 114 | − result.error_code = ErrorCode.ROBOTS_DENIED | |
| 115 | − return result | |
| 116 | − | |
| 117 | − except httpx.TimeoutException: | |
| 118 | − return self._fail(url, current, ErrorCode.TIMEOUT, start, chain) | |
| 119 | − except httpx.ConnectError as exc: | |
| 120 | − code = ErrorCode.TLS if "SSL" in str(exc) or "certificate" in str(exc).lower() else ErrorCode.DNS | |
| 121 | − return self._fail(url, current, code, start, chain) | |
| 122 | − except httpx.HTTPError: | |
| 123 | − return self._fail(url, current, ErrorCode.CONNECTION, start, chain) | |
| 124 | − | |
| 125 | − return self._fail(url, current, ErrorCode.TOO_MANY_REDIRECTS, start, chain) | |
| 126 | − | |
| 127 | − @staticmethod | |
| 128 | − def _ms(start: float) -> int: | |
| 129 | − return int((time.monotonic() - start) * 1000) | |
| 130 | − | |
| 131 | − def _fail( | |
| 132 | − self, | |
| 133 | − url: str, | |
| 134 | − final_url: str, | |
| 135 | − code: ErrorCode, | |
| 136 | − start: float, | |
| 137 | − chain: list[str], | |
| 138 | − status: int | None = None, | |
| 139 | − ) -> FetchResult: | |
| 140 | − return FetchResult( | |
| 141 | − url=url, final_url=final_url, status_code=status, error_code=code, | |
| 142 | − duration_ms=self._ms(start), redirect_chain=chain, | |
| 143 | − ) | |
| 144 | − | |
| 145 | − | |
| 146 | −def scheme_host(url: str) -> str: | |
| 147 | − parts = urlsplit(url) | |
| 148 | − return f"{parts.scheme}://{parts.netloc}" | |
deleted
M2M32brouve-ka/services/crawler/robots.py
+0 −84
@@ -1,84 +0,0 @@ | ||
| 1 | −# Trouve-KA — gestion robots.txt | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Cache robots.txt conforme aux standards (Protego, le parseur de Scrapy). | |
| 6 | − | |
| 7 | −Les règles sont persistées en base (domains.robots_body) et rafraîchies | |
| 8 | −après 24 h. Un robots.txt introuvable (404) = tout permis; une erreur | |
| 9 | −serveur = prudence, on refuse temporairement. | |
| 10 | −""" | |
| 11 | − | |
| 12 | −from datetime import UTC, datetime, timedelta | |
| 13 | − | |
| 14 | −import httpx | |
| 15 | −from protego import Protego | |
| 16 | − | |
| 17 | −from trouveka.logging import get_logger | |
| 18 | − | |
| 19 | −log = get_logger("crawler.robots") | |
| 20 | − | |
| 21 | −ROBOTS_TTL = timedelta(hours=24) | |
| 22 | −_MAX_ROBOTS_BYTES = 512_000 | |
| 23 | − | |
| 24 | − | |
| 25 | −class RobotsCache: | |
| 26 | − def __init__(self, db, client: httpx.AsyncClient, user_agent: str): | |
| 27 | − self._db = db | |
| 28 | − self._client = client | |
| 29 | − self._ua = user_agent | |
| 30 | − self._parsers: dict[int, tuple[Protego | None, datetime]] = {} | |
| 31 | − | |
| 32 | − async def _fetch_robots(self, scheme_host: str) -> tuple[str | None, str]: | |
| 33 | − """Retourne (body, status) : ok | not_found | error.""" | |
| 34 | − try: | |
| 35 | − resp = await self._client.get( | |
| 36 | − f"{scheme_host}/robots.txt", | |
| 37 | − headers={"User-Agent": self._ua}, | |
| 38 | − timeout=15, | |
| 39 | − follow_redirects=True, | |
| 40 | − ) | |
| 41 | − except httpx.HTTPError: | |
| 42 | − return None, "error" | |
| 43 | − if resp.status_code == 200: | |
| 44 | − return resp.text[:_MAX_ROBOTS_BYTES], "ok" | |
| 45 | − if 400 <= resp.status_code < 500: | |
| 46 | − return None, "not_found" # 4xx = pas de robots → tout permis (standard) | |
| 47 | − return None, "error" | |
| 48 | − | |
| 49 | − async def get_parser(self, domain_id: int, scheme_host: str) -> tuple[Protego | None, str, int | None]: | |
| 50 | − """Parseur robots pour un domaine. Retourne (parser|None, status, crawl_delay_ms).""" | |
| 51 | − cached = self._parsers.get(domain_id) | |
| 52 | − now = datetime.now(UTC) | |
| 53 | − if cached and now - cached[1] < ROBOTS_TTL: | |
| 54 | − parser = cached[0] | |
| 55 | − return parser, "ok" if parser else "not_found", None | |
| 56 | − | |
| 57 | − row = await self._db.get_domain_by_id(domain_id) | |
| 58 | − if row and row["robots_fetched_at"] and now - row["robots_fetched_at"] < ROBOTS_TTL: | |
| 59 | − body, status = row["robots_body"], row["robots_status"] | |
| 60 | − else: | |
| 61 | − body, status = await self._fetch_robots(scheme_host) | |
| 62 | − delay_ms = None | |
| 63 | − if body: | |
| 64 | − delay = Protego.parse(body).crawl_delay(self._ua) | |
| 65 | − delay_ms = int(delay * 1000) if delay else None | |
| 66 | − await self._db.save_robots(domain_id, body, status, delay_ms) | |
| 67 | − | |
| 68 | − parser = Protego.parse(body) if body else None | |
| 69 | − self._parsers[domain_id] = (parser, now) | |
| 70 | − if len(self._parsers) > 5000: # borne mémoire du cache local | |
| 71 | − self._parsers.pop(next(iter(self._parsers))) | |
| 72 | − return parser, status, row["crawl_delay_ms"] if row else None | |
| 73 | − | |
| 74 | − async def allowed(self, domain_id: int, url: str, scheme_host: str) -> tuple[bool, float | None]: | |
| 75 | − """(autorisé, crawl_delay_secondes). Erreur robots = refus temporaire (prudence).""" | |
| 76 | − parser, status, delay_ms = await self.get_parser(domain_id, scheme_host) | |
| 77 | − if status == "error": | |
| 78 | − return False, None | |
| 79 | − if parser is None: | |
| 80 | − return True, None | |
| 81 | − delay = parser.crawl_delay(self._ua) | |
| 82 | − if delay is None and delay_ms: | |
| 83 | − delay = delay_ms / 1000 | |
| 84 | − return parser.can_fetch(url, self._ua), delay | |
deleted
M2M32brouve-ka/services/crawler/seed.py
+0 −57
@@ -1,57 +0,0 @@ | ||
| 1 | −# Trouve-KA — chargeur de seeds | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Charge les seeds dans le frontier avec priorité maximale. | |
| 6 | − | |
| 7 | −Usage : python -m trouveka.crawler.seed [chemin/vers/seeds.txt] | |
| 8 | −""" | |
| 9 | − | |
| 10 | −import asyncio | |
| 11 | −import pathlib | |
| 12 | −import sys | |
| 13 | − | |
| 14 | −from trouveka.config import get_settings | |
| 15 | −from trouveka.database import Database, run_migrations | |
| 16 | −from trouveka.logging import get_logger | |
| 17 | −from trouveka.shared import canonicalize_url, extract_domain | |
| 18 | − | |
| 19 | −log = get_logger("crawler.seed") | |
| 20 | − | |
| 21 | −DEFAULT_SEEDS = pathlib.Path(__file__).resolve().parents[2] / "scripts" / "bootstrap-seeds" / "seeds.txt" | |
| 22 | − | |
| 23 | − | |
| 24 | −async def load_seeds(path: pathlib.Path) -> int: | |
| 25 | − settings = get_settings() | |
| 26 | − applied = await run_migrations(settings.database_url) | |
| 27 | − if applied: | |
| 28 | − log.info("migrations appliquées", extra={"ctx": {"files": applied}}) | |
| 29 | − | |
| 30 | − db = Database(settings.database_url, pool_min=1, pool_max=3) | |
| 31 | − await db.connect() | |
| 32 | − added = 0 | |
| 33 | − try: | |
| 34 | − for line in path.read_text(encoding="utf-8").splitlines(): | |
| 35 | − line = line.strip() | |
| 36 | − if not line or line.startswith("#"): | |
| 37 | − continue | |
| 38 | − url = canonicalize_url(line) | |
| 39 | − domain = extract_domain(url) if url else None | |
| 40 | − if not url or not domain: | |
| 41 | − log.info("seed ignorée (URL invalide)", extra={"ctx": {"line": line}}) | |
| 42 | − continue | |
| 43 | − if await db.enqueue_url(url, domain, priority=1.0, depth=0, is_seed=True): | |
| 44 | − added += 1 | |
| 45 | − finally: | |
| 46 | − await db.close() | |
| 47 | − log.info("seeds chargées", extra={"ctx": {"added": added, "file": str(path)}}) | |
| 48 | − return added | |
| 49 | − | |
| 50 | − | |
| 51 | −def main() -> None: | |
| 52 | − path = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_SEEDS | |
| 53 | − asyncio.run(load_seeds(path)) | |
| 54 | − | |
| 55 | − | |
| 56 | −if __name__ == "__main__": | |
| 57 | − main() | |
deleted
M2M32brouve-ka/services/crawler/traps.py
+0 −59
@@ -1,59 +0,0 @@ | ||
| 1 | −# Trouve-KA — détection de pièges de crawl | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Heuristiques anti-pièges (CLAUDE.md §5.8) : calendriers infinis, session IDs, | |
| 6 | −explosions de facettes, pagination infinie, chemins répétitifs.""" | |
| 7 | − | |
| 8 | −import re | |
| 9 | −from urllib.parse import parse_qsl, urlsplit | |
| 10 | − | |
| 11 | −_SESSION_PARAMS = {"sid", "sessionid", "session_id", "phpsessid", "jsessionid", "aspsessionid", "cfid", "cftoken"} | |
| 12 | −_CALENDAR_RE = re.compile(r"/(?:19|20)\d{2}[-/](?:0?\d|1[0-2])(?:[-/](?:0?\d|[12]\d|3[01]))?/?$") | |
| 13 | −_LONG_NUMBER_RE = re.compile(r"\d{10,}") | |
| 14 | − | |
| 15 | − | |
| 16 | −def looks_like_trap( | |
| 17 | − url: str, | |
| 18 | − *, | |
| 19 | − max_query_params: int = 8, | |
| 20 | − max_path_segments: int = 12, | |
| 21 | − max_url_length: int = 1024, | |
| 22 | −) -> bool: | |
| 23 | − """True si l'URL ressemble à un piège de crawl et ne doit pas entrer au frontier.""" | |
| 24 | − if len(url) > max_url_length: | |
| 25 | − return True | |
| 26 | − try: | |
| 27 | − parts = urlsplit(url) | |
| 28 | − except ValueError: | |
| 29 | − return True | |
| 30 | − | |
| 31 | − params = parse_qsl(parts.query, keep_blank_values=True) | |
| 32 | − if len(params) > max_query_params: | |
| 33 | − return True | |
| 34 | − keys = {k.lower() for k, _ in params} | |
| 35 | − if keys & _SESSION_PARAMS: | |
| 36 | − return True | |
| 37 | − # Même paramètre répété (facettes qui explosent : ?filter=a&filter=b&filter=c…) | |
| 38 | − raw_keys = [k.lower() for k, _ in params] | |
| 39 | − if any(raw_keys.count(k) > 3 for k in set(raw_keys)): | |
| 40 | − return True | |
| 41 | − | |
| 42 | − segments = [s for s in parts.path.split("/") if s] | |
| 43 | − if len(segments) > max_path_segments: | |
| 44 | − return True | |
| 45 | − # Segment répété (boucles : /a/b/a/b/a/b) | |
| 46 | − if any(segments.count(s) > 3 for s in set(segments)): | |
| 47 | − return True | |
| 48 | − # Calendriers profonds (au-delà de l'année-mois raisonnable) : /events/2031/05/17 | |
| 49 | − if _CALENDAR_RE.search(parts.path): | |
| 50 | − year_match = re.search(r"/((?:19|20)\d{2})[-/]", parts.path) | |
| 51 | − if year_match and not (1995 <= int(year_match.group(1)) <= 2027): | |
| 52 | − return True | |
| 53 | − # Pagination excessive | |
| 54 | − for key, value in params: | |
| 55 | − if key.lower() in ("page", "p", "offset", "start") and value.isdigit() and int(value) > 500: | |
| 56 | − return True | |
| 57 | − if _LONG_NUMBER_RE.search(parts.query): | |
| 58 | − return True | |
| 59 | − return False | |
deleted
M2M32brouve-ka/services/crawler/worker.py
+0 −447
@@ -1,447 +0,0 @@ | ||
| 1 | −# Trouve-KA — worker de crawl (pipeline complet) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Worker de crawl : réclame des URLs au frontier et exécute le pipeline inline | |
| 6 | −fetch → parse → score Québec → indexation IMMÉDIATE (principe cardinal §0.3). | |
| 7 | − | |
| 8 | −Chaque page traitée avec succès est cherchable en quelques secondes. | |
| 9 | −L'enrichissement (étapes 2-3) part dans Redis Streams et ne bloque jamais. | |
| 10 | −N workers peuvent tourner en parallèle (frontier SKIP LOCKED + locks Redis par hôte). | |
| 11 | −""" | |
| 12 | − | |
| 13 | −import asyncio | |
| 14 | −import os | |
| 15 | −import random | |
| 16 | −import signal | |
| 17 | −import uuid | |
| 18 | −from datetime import UTC, datetime, timedelta | |
| 19 | − | |
| 20 | −import httpx | |
| 21 | − | |
| 22 | −from trouveka.classifier import score_page | |
| 23 | −from trouveka.config import get_settings | |
| 24 | −from trouveka.database import Database | |
| 25 | −from trouveka.frontier import compute_priority, next_recrawl_delay, retry_delay | |
| 26 | −from trouveka.indexer import build_search_document | |
| 27 | −from trouveka.logging import get_logger | |
| 28 | −from trouveka.parser import looks_like_garbage, parse_html | |
| 29 | −from trouveka.queue import Coordination | |
| 30 | −from trouveka.search_core import SearchCore | |
| 31 | −from trouveka.shared import canonicalize_url, content_hash, extract_domain | |
| 32 | −from trouveka.types import ErrorCode, Outcome | |
| 33 | − | |
| 34 | −from .fetcher import Fetcher, scheme_host | |
| 35 | −from .robots import RobotsCache | |
| 36 | −from .traps import looks_like_trap | |
| 37 | − | |
| 38 | −log = get_logger("crawler.worker") | |
| 39 | − | |
| 40 | −TRANSIENT_ERRORS = {ErrorCode.TIMEOUT, ErrorCode.HTTP_5XX, ErrorCode.CONNECTION, ErrorCode.DNS} | |
| 41 | −MAX_RETRIES = 3 | |
| 42 | − | |
| 43 | − | |
| 44 | −class CrawlerWorker: | |
| 45 | − def __init__(self) -> None: | |
| 46 | − self.s = get_settings() | |
| 47 | − self.worker_id = f"crawler-{os.getpid()}-{uuid.uuid4().hex[:6]}" | |
| 48 | − self.db = Database(self.s.database_url, pool_min=self.s.pg_pool_min, pool_max=self.s.pg_pool_max) | |
| 49 | − self.coord = Coordination(self.s.redis_url) | |
| 50 | − self.search = SearchCore(self.s.search_url, self.s.search_index) | |
| 51 | − self.client = httpx.AsyncClient( | |
| 52 | − limits=httpx.Limits( | |
| 53 | − max_connections=self.s.max_global_concurrency, | |
| 54 | − max_keepalive_connections=self.s.max_global_concurrency, | |
| 55 | − ), | |
| 56 | − http2=False, | |
| 57 | − ) | |
| 58 | − self.fetcher = Fetcher(self.client, self.s) | |
| 59 | − self.robots: RobotsCache | None = None | |
| 60 | − self.stop_event = asyncio.Event() | |
| 61 | − self._domain_cache: dict[int, dict] = {} | |
| 62 | − # Cache domaine→row pour la découverte : évite un SELECT par lien sortant | |
| 63 | − # (une page peut avoir 300 liens, souvent vers les mêmes domaines). | |
| 64 | − self._domain_by_name: dict[str, tuple[object, datetime]] = {} | |
| 65 | − | |
| 66 | − # ------------------------------------------------------------------ cycle de vie | |
| 67 | − | |
| 68 | − async def start(self) -> None: | |
| 69 | − await self.db.connect() | |
| 70 | − self.robots = RobotsCache(self.db, self.client, self.s.crawler_user_agent) | |
| 71 | − await self.search.ensure_index() | |
| 72 | − log.info("worker démarré", extra={"ctx": {"worker_id": self.worker_id}}) | |
| 73 | − | |
| 74 | − loop = asyncio.get_running_loop() | |
| 75 | − for sig in (signal.SIGINT, signal.SIGTERM): | |
| 76 | − loop.add_signal_handler(sig, self.stop_event.set) | |
| 77 | − | |
| 78 | − # Pool continu : on réclame de nouvelles URLs dès qu'un slot se libère. | |
| 79 | − # (Un gather par lot créait du head-of-line blocking : une page lente | |
| 80 | − # bloquait tout le lot avant la réclamation suivante.) | |
| 81 | − in_flight: set[asyncio.Task] = set() | |
| 82 | − idle_sleep = 1.0 | |
| 83 | − | |
| 84 | − async def run_one(item) -> None: | |
| 85 | − try: | |
| 86 | − await self.process_item(item) | |
| 87 | − except Exception: | |
| 88 | − log.exception( | |
| 89 | − "échec inattendu du pipeline", | |
| 90 | − extra={"ctx": {"url": item["url"], "worker_id": self.worker_id}}, | |
| 91 | − ) | |
| 92 | − await self.db.release_item( | |
| 93 | − item["url_id"], status="failed", error_code="parse_failed" | |
| 94 | − ) | |
| 95 | − | |
| 96 | − while not self.stop_event.is_set(): | |
| 97 | − if await self.coord.is_paused(): | |
| 98 | − await asyncio.sleep(3) | |
| 99 | − continue | |
| 100 | − free = self.s.max_global_concurrency - len(in_flight) | |
| 101 | − if free <= 0: | |
| 102 | − await asyncio.wait(in_flight, return_when=asyncio.FIRST_COMPLETED) | |
| 103 | − continue | |
| 104 | − batch = await self.db.claim_batch(self.worker_id, limit=free) | |
| 105 | − if not batch: | |
| 106 | − if in_flight: | |
| 107 | − await asyncio.wait( | |
| 108 | − in_flight, return_when=asyncio.FIRST_COMPLETED, timeout=idle_sleep | |
| 109 | − ) | |
| 110 | − else: | |
| 111 | − await asyncio.sleep(idle_sleep) | |
| 112 | − idle_sleep = min(idle_sleep * 1.5, 15) | |
| 113 | − continue | |
| 114 | − idle_sleep = 1.0 | |
| 115 | − for item in batch: | |
| 116 | − task = asyncio.create_task(run_one(item)) | |
| 117 | − in_flight.add(task) | |
| 118 | − task.add_done_callback(in_flight.discard) | |
| 119 | − | |
| 120 | − if in_flight: | |
| 121 | − await asyncio.gather(*in_flight, return_exceptions=True) | |
| 122 | − await self.shutdown() | |
| 123 | − | |
| 124 | − async def shutdown(self) -> None: | |
| 125 | − await self.client.aclose() | |
| 126 | − await self.search.close() | |
| 127 | − await self.coord.close() | |
| 128 | − await self.db.close() | |
| 129 | − log.info("worker arrêté", extra={"ctx": {"worker_id": self.worker_id}}) | |
| 130 | − | |
| 131 | − # ------------------------------------------------------------------ helpers | |
| 132 | − | |
| 133 | − async def _domain(self, domain_id: int) -> dict: | |
| 134 | − cached = self._domain_cache.get(domain_id) | |
| 135 | − if cached and (datetime.now(UTC) - cached["_at"]).total_seconds() < 60: | |
| 136 | − return cached | |
| 137 | − row = await self.db.get_domain_by_id(domain_id) | |
| 138 | − entry = dict(row) | {"_at": datetime.now(UTC)} | |
| 139 | − self._domain_cache[domain_id] = entry | |
| 140 | − if len(self._domain_cache) > 5000: | |
| 141 | − self._domain_cache.pop(next(iter(self._domain_cache))) | |
| 142 | − return entry | |
| 143 | − | |
| 144 | − async def _defer(self, url_id: int, seconds: float) -> None: | |
| 145 | − """Repousse un item sans compter d'erreur (politesse : trop tôt pour cet hôte). | |
| 146 | − | |
| 147 | − Jitter aléatoire pour désynchroniser les grappes d'URLs d'un même hôte | |
| 148 | − (sinon elles reviennent toutes en tête de file au même instant et churnent). | |
| 149 | − """ | |
| 150 | − await self.db.release_item( | |
| 151 | − url_id, | |
| 152 | − status="pending", | |
| 153 | − next_crawl_at=datetime.now(UTC) + timedelta(seconds=seconds + random.uniform(0.2, 3.0)), | |
| 154 | − ) | |
| 155 | − | |
| 156 | − async def _domain_row_by_name(self, domain: str): | |
| 157 | − """Row domaine (ou None si inconnu), avec cache TTL 60 s.""" | |
| 158 | − cached = self._domain_by_name.get(domain) | |
| 159 | − now = datetime.now(UTC) | |
| 160 | − if cached and (now - cached[1]).total_seconds() < 60: | |
| 161 | − return cached[0] | |
| 162 | − row = await self.db.get_domain(domain) | |
| 163 | − self._domain_by_name[domain] = (row, now) | |
| 164 | − if len(self._domain_by_name) > 20_000: | |
| 165 | − self._domain_by_name.pop(next(iter(self._domain_by_name))) | |
| 166 | − return row | |
| 167 | − | |
| 168 | − def _previous_delay_hours(self, item) -> float | None: | |
| 169 | − if item["last_crawled_at"] and item["next_crawl_at"]: | |
| 170 | − delta = (item["next_crawl_at"] - item["last_crawled_at"]).total_seconds() / 3600 | |
| 171 | − return max(delta, 0.1) | |
| 172 | − return None | |
| 173 | − | |
| 174 | − # ------------------------------------------------------------------ pipeline | |
| 175 | − | |
| 176 | − async def process_item(self, item) -> None: | |
| 177 | − url: str = item["url"] | |
| 178 | − url_id: int = item["url_id"] | |
| 179 | − domain_row = await self._domain(item["domain_id"]) | |
| 180 | − domain: str = domain_row["domain"] | |
| 181 | − | |
| 182 | − if domain_row["blocked"]: | |
| 183 | − await self.db.release_item(url_id, status="blocked") | |
| 184 | − return | |
| 185 | − | |
| 186 | − host = scheme_host(url) | |
| 187 | − | |
| 188 | − # robots.txt d'abord (le fetch de robots ne compte pas dans la politesse) | |
| 189 | − allowed, robots_delay = await self.robots.allowed(item["domain_id"], url, host) | |
| 190 | − if not allowed: | |
| 191 | − await self.db.record_attempt( | |
| 192 | − url_id, status_code=None, error_code=ErrorCode.ROBOTS_DENIED, | |
| 193 | − outcome=Outcome.ROBOTS_BLOCKED, | |
| 194 | − ) | |
| 195 | − await self.db.release_item(url_id, status="done", error_code=ErrorCode.ROBOTS_DENIED) | |
| 196 | − return | |
| 197 | − | |
| 198 | − # Politesse par hôte, tous workers confondus | |
| 199 | − delay = max(robots_delay or 0, self.s.default_host_delay) | |
| 200 | − if not await self.coord.acquire_host_slot(domain, delay): | |
| 201 | − await self._defer(url_id, delay + 0.5) | |
| 202 | − return | |
| 203 | − | |
| 204 | − # Cache HTTP conditionnel | |
| 205 | − existing = await self.db.get_document(url_id) | |
| 206 | − result = await self.fetcher.fetch( | |
| 207 | − url, | |
| 208 | − etag=existing["etag"] if existing else None, | |
| 209 | − last_modified=existing["last_modified"] if existing else None, | |
| 210 | − ) | |
| 211 | − | |
| 212 | − # --- 304 : inchangé | |
| 213 | − if result.status_code == 304: | |
| 214 | − await self._finish_unchanged(item, existing) | |
| 215 | − return | |
| 216 | − | |
| 217 | − # --- erreurs | |
| 218 | − if result.error_code not in (ErrorCode.OK, ErrorCode.ROBOTS_DENIED) or result.body is None: | |
| 219 | − await self._finish_error(item, result) | |
| 220 | − return | |
| 221 | − | |
| 222 | − # --- redirection vers une autre URL canonique : suivre la cible | |
| 223 | − final = canonicalize_url(result.final_url) or result.final_url | |
| 224 | − if final != url: | |
| 225 | − final_domain = extract_domain(final) | |
| 226 | − if final_domain and not looks_like_trap(final): | |
| 227 | − await self.db.enqueue_url( | |
| 228 | − final, final_domain, priority=item["priority"], depth=item["depth"], | |
| 229 | − source_url_id=url_id, max_urls_per_domain=self.s.max_urls_per_domain, | |
| 230 | − ) | |
| 231 | − await self.db.set_canonical(url_id, final) | |
| 232 | − await self.db.record_attempt( | |
| 233 | − url_id, status_code=result.status_code, error_code=ErrorCode.OK, | |
| 234 | − outcome=Outcome.REDIRECT, redirect_url=final, duration_ms=result.duration_ms, | |
| 235 | − ) | |
| 236 | − await self.db.release_item(url_id, status="done") | |
| 237 | − return | |
| 238 | − | |
| 239 | − # --- parse | |
| 240 | − try: | |
| 241 | − page = parse_html( | |
| 242 | − url, result.body, max_links=self.s.max_links_per_page, charset=result.charset | |
| 243 | − ) | |
| 244 | − except Exception: | |
| 245 | − await self.db.record_attempt( | |
| 246 | − url_id, status_code=result.status_code, error_code=ErrorCode.PARSE_FAILED, | |
| 247 | − outcome=Outcome.ERROR, duration_ms=result.duration_ms, | |
| 248 | − ) | |
| 249 | − await self.db.release_item(url_id, status="failed", error_code=ErrorCode.PARSE_FAILED) | |
| 250 | − return | |
| 251 | − | |
| 252 | − # --- garde anti-charabia : du binaire/mal décodé ne doit JAMAIS être indexé | |
| 253 | − if looks_like_garbage(page.title) or looks_like_garbage(page.body): | |
| 254 | − await self.db.record_attempt( | |
| 255 | − url_id, status_code=result.status_code, error_code=ErrorCode.PARSE_FAILED, | |
| 256 | − outcome=Outcome.ERROR, duration_ms=result.duration_ms, title=page.title[:80], | |
| 257 | − ) | |
| 258 | − await self.db.release_item(url_id, status="failed", error_code=ErrorCode.PARSE_FAILED) | |
| 259 | − return | |
| 260 | − | |
| 261 | − # --- score Québec (immédiat, déterministe) | |
| 262 | − signals = score_page(page, domain) | |
| 263 | − | |
| 264 | − # --- détection de changement | |
| 265 | − chash = content_hash(page.title, page.body) | |
| 266 | − if existing and existing["content_hash"] == chash: | |
| 267 | − await self._finish_unchanged(item, existing, status_code=result.status_code) | |
| 268 | − await self._discover_links(item, page, signals) # les liens peuvent avoir changé de priorité | |
| 269 | − return | |
| 270 | − | |
| 271 | − # --- doublon exact sur une autre URL | |
| 272 | − duplicate = await self.db.find_duplicate(chash, url_id) | |
| 273 | − if duplicate: | |
| 274 | − await self.db.record_attempt( | |
| 275 | − url_id, status_code=result.status_code, error_code=ErrorCode.DUPLICATE, | |
| 276 | − outcome=Outcome.DUPLICATE, content_hash=chash, duration_ms=result.duration_ms, | |
| 277 | − title=page.title, quebec_score=signals.score, | |
| 278 | − ) | |
| 279 | − await self.db.release_item( | |
| 280 | − url_id, status="done", | |
| 281 | − next_crawl_at=datetime.now(UTC) + timedelta(hours=self.s.max_recrawl_hours), | |
| 282 | − ) | |
| 283 | − return | |
| 284 | − | |
| 285 | − indexable = ( | |
| 286 | − not page.noindex | |
| 287 | − and result.error_code != ErrorCode.ROBOTS_DENIED # X-Robots-Tag: noindex | |
| 288 | − and signals.score >= self.s.min_quebec_score_to_index | |
| 289 | − and len(page.body) >= self.s.min_body_length | |
| 290 | − ) | |
| 291 | − | |
| 292 | − if indexable: | |
| 293 | − # ------- INDEXATION IMMÉDIATE : la page est cherchable en ~1 s ------- | |
| 294 | − canonical_target = page.canonical_url or url | |
| 295 | − doc = build_search_document( | |
| 296 | − page, signals, | |
| 297 | − domain=domain, | |
| 298 | − domain_quebec_score=float(domain_row["quebec_score"]), | |
| 299 | − authority_score=float(domain_row["authority_score"]), | |
| 300 | − ) | |
| 301 | − await self.search.index_document(doc) | |
| 302 | − await self.db.upsert_document( | |
| 303 | − url_id, | |
| 304 | − content_hash=chash, | |
| 305 | − etag=result.etag, | |
| 306 | − last_modified=result.last_modified, | |
| 307 | − title=page.title, | |
| 308 | − description=page.description, | |
| 309 | − language=page.language, | |
| 310 | − page_quebec_score=signals.score, | |
| 311 | − published_at=page.published_at, | |
| 312 | − changed=existing is not None, | |
| 313 | − ) | |
| 314 | − if page.canonical_url and page.canonical_url != url: | |
| 315 | − await self.db.set_canonical(url_id, canonical_target) | |
| 316 | − # Enrichissement asynchrone (jamais bloquant) | |
| 317 | − await self.coord.enqueue_enrichment( | |
| 318 | − {"url": canonical_target, "url_id": url_id, "domain_id": item["domain_id"], "stage": 2} | |
| 319 | − ) | |
| 320 | − outcome = Outcome.INDEXED | |
| 321 | − else: | |
| 322 | − outcome = Outcome.NOT_QUEBEC if signals.score < self.s.min_quebec_score_to_index else Outcome.UNCHANGED | |
| 323 | − | |
| 324 | − await self.db.update_domain_after_page(item["domain_id"], signals.score, page.language) | |
| 325 | − self._domain_cache.pop(item["domain_id"], None) | |
| 326 | − | |
| 327 | − await self.db.record_attempt( | |
| 328 | − url_id, status_code=result.status_code, error_code=ErrorCode.OK, | |
| 329 | − outcome=outcome, content_hash=chash, num_bytes=len(result.body), | |
| 330 | − duration_ms=result.duration_ms, title=page.title, quebec_score=signals.score, | |
| 331 | − ) | |
| 332 | − recrawl = next_recrawl_delay( | |
| 333 | − changed=True, previous_delay_hours=self._previous_delay_hours(item), | |
| 334 | − min_hours=self.s.min_recrawl_hours, max_hours=self.s.max_recrawl_hours, | |
| 335 | − default_hours=self.s.default_recrawl_hours, | |
| 336 | − ) | |
| 337 | − await self.db.release_item( | |
| 338 | − url_id, status="pending", next_crawl_at=datetime.now(UTC) + recrawl, | |
| 339 | − ) | |
| 340 | − | |
| 341 | − await self._discover_links(item, page, signals) | |
| 342 | − log.info( | |
| 343 | − "page traitée", | |
| 344 | − extra={"ctx": { | |
| 345 | − "worker_id": self.worker_id, "url_id": url_id, "domain_id": item["domain_id"], | |
| 346 | − "url": url, "outcome": str(outcome), "quebec_score": signals.score, | |
| 347 | − "links": len(page.links), "ms": result.duration_ms, | |
| 348 | − }}, | |
| 349 | − ) | |
| 350 | − | |
| 351 | − # ------------------------------------------------------------------ issues terminales | |
| 352 | − | |
| 353 | − async def _finish_unchanged(self, item, existing, status_code: int | None = 304) -> None: | |
| 354 | − recrawl = next_recrawl_delay( | |
| 355 | − changed=False, previous_delay_hours=self._previous_delay_hours(item), | |
| 356 | − min_hours=self.s.min_recrawl_hours, max_hours=self.s.max_recrawl_hours, | |
| 357 | − default_hours=self.s.default_recrawl_hours, | |
| 358 | − ) | |
| 359 | − await self.db.record_attempt( | |
| 360 | − item["url_id"], status_code=status_code, error_code=ErrorCode.OK, | |
| 361 | − outcome=Outcome.UNCHANGED, | |
| 362 | − content_hash=existing["content_hash"] if existing else None, | |
| 363 | − ) | |
| 364 | − await self.db.release_item( | |
| 365 | − item["url_id"], status="pending", | |
| 366 | − next_crawl_at=datetime.now(UTC) + recrawl, | |
| 367 | − ) | |
| 368 | − | |
| 369 | − async def _finish_error(self, item, result) -> None: | |
| 370 | − url_id = item["url_id"] | |
| 371 | − await self.db.record_attempt( | |
| 372 | − url_id, status_code=result.status_code, error_code=result.error_code, | |
| 373 | − outcome=Outcome.ERROR, duration_ms=result.duration_ms, | |
| 374 | − ) | |
| 375 | − transient = result.error_code in TRANSIENT_ERRORS | |
| 376 | − if transient and item["retries"] < MAX_RETRIES: | |
| 377 | − await self.db.release_item( | |
| 378 | − url_id, status="pending", error_code=result.error_code, | |
| 379 | − next_crawl_at=datetime.now(UTC) + retry_delay(item["retries"]), | |
| 380 | − increment_retries=True, | |
| 381 | − ) | |
| 382 | − else: | |
| 383 | − await self.db.release_item(url_id, status="failed", error_code=result.error_code) | |
| 384 | − | |
| 385 | − # ------------------------------------------------------------------ découverte | |
| 386 | − | |
| 387 | − async def _discover_links(self, item, page, signals) -> None: | |
| 388 | − """Boucle de découverte (§8) : liens sortants → scoring → frontier.""" | |
| 389 | − if item["depth"] >= self.s.max_crawl_depth: | |
| 390 | − return | |
| 391 | − domain_row = await self._domain(item["domain_id"]) | |
| 392 | − source_domain = domain_row["domain"] | |
| 393 | − source_quebec = max(float(domain_row["quebec_score"]), signals.score) | |
| 394 | − | |
| 395 | − outlink_domains: dict[int, int] = {} | |
| 396 | − enqueued = 0 | |
| 397 | − for link in page.links: | |
| 398 | − if link.nofollow or looks_like_trap( | |
| 399 | − link.url, | |
| 400 | − max_query_params=self.s.max_query_params, | |
| 401 | − max_path_segments=self.s.max_path_segments, | |
| 402 | − ): | |
| 403 | − continue | |
| 404 | − target_domain = extract_domain(link.url) | |
| 405 | − if not target_domain: | |
| 406 | − continue | |
| 407 | − same_domain = target_domain == source_domain | |
| 408 | − target_row = await self._domain_row_by_name(target_domain) | |
| 409 | − is_new = target_row is None | |
| 410 | − target_quebec = float(target_row["quebec_score"]) if target_row else 0.0 | |
| 411 | − # Un domaine découvert depuis une page québécoise hérite d'un a priori Québec | |
| 412 | − effective_quebec = target_quebec if not is_new else source_quebec * 0.7 | |
| 413 | − if same_domain: | |
| 414 | − effective_quebec = max(effective_quebec, source_quebec) | |
| 415 | − | |
| 416 | − priority = compute_priority( | |
| 417 | − domain_quebec_score=effective_quebec, | |
| 418 | − authority_score=float(target_row["authority_score"]) if target_row else 0.0, | |
| 419 | − link_signal=0.5 if not same_domain else 0.2, | |
| 420 | − is_new_domain=is_new, | |
| 421 | − depth=item["depth"] + 1, | |
| 422 | − ) | |
| 423 | − # Économie de crawl : ignorer les cibles au signal Québec quasi nul | |
| 424 | − if priority < 0.1: | |
| 425 | − continue | |
| 426 | − url_id = await self.db.enqueue_url( | |
| 427 | − link.url, target_domain, | |
| 428 | − priority=priority, depth=item["depth"] + 1, source_url_id=item["url_id"], | |
| 429 | − max_urls_per_domain=self.s.max_urls_per_domain, | |
| 430 | − ) | |
| 431 | − if url_id: | |
| 432 | − enqueued += 1 | |
| 433 | − if not same_domain: | |
| 434 | − target_id = (target_row["id"] if target_row | |
| 435 | − else (await self.db.upsert_domain(target_domain))) | |
| 436 | − outlink_domains[target_id] = outlink_domains.get(target_id, 0) + 1 | |
| 437 | − | |
| 438 | − if outlink_domains: | |
| 439 | − await self.db.record_domain_links(item["domain_id"], outlink_domains) | |
| 440 | − | |
| 441 | − | |
| 442 | −def main() -> None: | |
| 443 | − asyncio.run(CrawlerWorker().start()) | |
| 444 | − | |
| 445 | − | |
| 446 | −if __name__ == "__main__": | |
| 447 | − main() | |
deleted
M2M32brouve-ka/services/enrichment/__init__.py
+0 −14
@@ -1,14 +0,0 @@ | ||
| 1 | −# Trouve-KA — enrichissement asynchrone | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Enrichissement asynchrone des documents (étapes 2-3, CLAUDE.md §4). | |
| 6 | − | |
| 7 | −Ne bloque JAMAIS l'indexation : consomme Redis Streams et met à jour | |
| 8 | −les documents déjà cherchables (scores de domaine, fraîcheur, plus tard | |
| 9 | −embeddings et entités). | |
| 10 | −""" | |
| 11 | − | |
| 12 | −from .worker import EnrichmentWorker | |
| 13 | − | |
| 14 | −__all__ = ["EnrichmentWorker"] | |
deleted
M2M32brouve-ka/services/enrichment/worker.py
+0 −82
@@ -1,82 +0,0 @@ | ||
| 1 | −# Trouve-KA — worker d'enrichissement | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Worker d'enrichissement : consomme trouveka:enrich (Redis Streams). | |
| 6 | − | |
| 7 | −Étape 2 (implémentée) : propage domain_quebec_score et authority_score à jour | |
| 8 | −dans le document indexé (le domaine apprend au fil du crawl, les documents déjà | |
| 9 | −indexés en profitent rétroactivement). | |
| 10 | −Étapes futures : embeddings, entités, classification thématique — même canal, | |
| 11 | −même contrat : mise à jour partielle du document, jamais bloquante. | |
| 12 | −""" | |
| 13 | − | |
| 14 | −import asyncio | |
| 15 | −import os | |
| 16 | −import signal | |
| 17 | −import uuid | |
| 18 | − | |
| 19 | −from trouveka.config import get_settings | |
| 20 | −from trouveka.database import Database | |
| 21 | −from trouveka.logging import get_logger | |
| 22 | −from trouveka.queue import Coordination | |
| 23 | −from trouveka.search_core import SearchCore | |
| 24 | − | |
| 25 | −log = get_logger("enrichment") | |
| 26 | − | |
| 27 | −GROUP = "enrichers" | |
| 28 | − | |
| 29 | − | |
| 30 | −class EnrichmentWorker: | |
| 31 | − def __init__(self) -> None: | |
| 32 | − self.s = get_settings() | |
| 33 | − self.consumer = f"enrich-{os.getpid()}-{uuid.uuid4().hex[:6]}" | |
| 34 | − self.db = Database(self.s.database_url, pool_min=1, pool_max=3) | |
| 35 | − self.coord = Coordination(self.s.redis_url) | |
| 36 | − self.search = SearchCore(self.s.search_url, self.s.search_index) | |
| 37 | − self.stop_event = asyncio.Event() | |
| 38 | − | |
| 39 | − async def enrich(self, payload: dict) -> None: | |
| 40 | − domain_row = await self.db.get_domain_by_id(int(payload["domain_id"])) | |
| 41 | − if not domain_row: | |
| 42 | − return | |
| 43 | − await self.search.update_document( | |
| 44 | − payload["url"], | |
| 45 | − { | |
| 46 | − "domain_quebec_score": round(float(domain_row["quebec_score"]), 4), | |
| 47 | − "authority_score": round(float(domain_row["authority_score"]), 4), | |
| 48 | − }, | |
| 49 | − ) | |
| 50 | − | |
| 51 | − async def start(self) -> None: | |
| 52 | − await self.db.connect() | |
| 53 | − loop = asyncio.get_running_loop() | |
| 54 | − for sig in (signal.SIGINT, signal.SIGTERM): | |
| 55 | − loop.add_signal_handler(sig, self.stop_event.set) | |
| 56 | − log.info("worker d'enrichissement démarré", extra={"ctx": {"consumer": self.consumer}}) | |
| 57 | − | |
| 58 | − while not self.stop_event.is_set(): | |
| 59 | − try: | |
| 60 | − messages = await self.coord.read_enrichment(GROUP, self.consumer, count=20, block_ms=5000) | |
| 61 | − for msg_id, payload in messages: | |
| 62 | − try: | |
| 63 | − await self.enrich(payload) | |
| 64 | − except Exception: | |
| 65 | − log.exception("échec enrichissement", extra={"ctx": payload}) | |
| 66 | − finally: | |
| 67 | − await self.coord.ack_enrichment(GROUP, msg_id) | |
| 68 | − except Exception: | |
| 69 | − log.exception("erreur boucle enrichissement (on continue)") | |
| 70 | − await asyncio.sleep(2) | |
| 71 | − | |
| 72 | − await self.search.close() | |
| 73 | − await self.coord.close() | |
| 74 | − await self.db.close() | |
| 75 | − | |
| 76 | − | |
| 77 | −def main() -> None: | |
| 78 | − asyncio.run(EnrichmentWorker().start()) | |
| 79 | − | |
| 80 | − | |
| 81 | −if __name__ == "__main__": | |
| 82 | − main() | |
deleted
M2M32brouve-ka/services/frontier/__init__.py
+0 −9
@@ -1,9 +0,0 @@ | ||
| 1 | −# Trouve-KA — politique du frontier | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Politique du frontier : fonction de priorité et recrawl adaptatif (CLAUDE.md §5.3, §5.5).""" | |
| 6 | − | |
| 7 | −from .policy import compute_priority, next_recrawl_delay, retry_delay | |
| 8 | − | |
| 9 | −__all__ = ["compute_priority", "next_recrawl_delay", "retry_delay"] | |
deleted
M2M32brouve-ka/services/frontier/policy.py
+0 −65
@@ -1,65 +0,0 @@ | ||
| 1 | −# Trouve-KA — priorités et recrawl adaptatif | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Fonction de priorité du frontier et scheduling de recrawl. | |
| 6 | − | |
| 7 | −P = w_q·Q + w_a·A + w_f·F + w_l·L + w_n·N − w_d·D − w_s·S (CLAUDE.md §5.3) | |
| 8 | −Les poids sont des constantes nommées, destinées à être calibrées par mesures. | |
| 9 | −""" | |
| 10 | − | |
| 11 | −from datetime import timedelta | |
| 12 | − | |
| 13 | −# Poids de la fonction de priorité — à calibrer empiriquement, jamais figés | |
| 14 | −W_QUEBEC = 0.40 | |
| 15 | −W_AUTHORITY = 0.15 | |
| 16 | −W_FRESHNESS = 0.10 | |
| 17 | −W_LINKS = 0.10 | |
| 18 | −W_NOVELTY = 0.15 | |
| 19 | −W_DEPTH = 0.05 | |
| 20 | −W_SPAM = 0.30 | |
| 21 | − | |
| 22 | − | |
| 23 | −def compute_priority( | |
| 24 | − *, | |
| 25 | − domain_quebec_score: float, | |
| 26 | − authority_score: float = 0.0, | |
| 27 | − freshness_hint: float = 0.0, | |
| 28 | − link_signal: float = 0.0, | |
| 29 | − is_new_domain: bool = False, | |
| 30 | − depth: int = 0, | |
| 31 | − spam_signal: float = 0.0, | |
| 32 | − is_seed: bool = False, | |
| 33 | −) -> float: | |
| 34 | − """Priorité ∈ [0, 1]. Les seeds démarrent au maximum.""" | |
| 35 | − if is_seed: | |
| 36 | − return 1.0 | |
| 37 | − p = ( | |
| 38 | − W_QUEBEC * domain_quebec_score | |
| 39 | − + W_AUTHORITY * min(authority_score, 1.0) | |
| 40 | − + W_FRESHNESS * min(freshness_hint, 1.0) | |
| 41 | − + W_LINKS * min(link_signal, 1.0) | |
| 42 | − + W_NOVELTY * (1.0 if is_new_domain else 0.3) | |
| 43 | − - W_DEPTH * min(depth, 10) / 10.0 | |
| 44 | − - W_SPAM * min(spam_signal, 1.0) | |
| 45 | − ) | |
| 46 | − return max(0.0, min(1.0, round(p, 4))) | |
| 47 | − | |
| 48 | − | |
| 49 | −def next_recrawl_delay( | |
| 50 | − *, | |
| 51 | − changed: bool, | |
| 52 | − previous_delay_hours: float | None, | |
| 53 | − min_hours: float = 1.0, | |
| 54 | − max_hours: float = 24 * 30.0, | |
| 55 | − default_hours: float = 24.0, | |
| 56 | −) -> timedelta: | |
| 57 | − """Recrawl adaptatif (§5.5) : page inchangée → intervalle ×2; page volatile → intervalle ÷2.""" | |
| 58 | − prev = previous_delay_hours or default_hours | |
| 59 | − hours = max(min_hours, prev / 2.0) if changed else min(max_hours, prev * 2.0) | |
| 60 | − return timedelta(hours=hours) | |
| 61 | − | |
| 62 | − | |
| 63 | −def retry_delay(retries: int) -> timedelta: | |
| 64 | − """Backoff exponentiel plafonné pour les erreurs transitoires.""" | |
| 65 | − return timedelta(minutes=min(15 * (2**retries), 60 * 24)) | |
deleted
M2M32brouve-ka/services/indexer/__init__.py
+0 −13
@@ -1,13 +0,0 @@ | ||
| 1 | −# Trouve-KA — indexeur | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Construction et indexation immédiate des documents (étape 1, CLAUDE.md §4). | |
| 6 | − | |
| 7 | −L'indexation ne dépend JAMAIS de l'enrichissement : les champs des étapes 2-3 | |
| 8 | −(embedding, entités, autorité…) sont remplis plus tard par mise à jour partielle. | |
| 9 | −""" | |
| 10 | − | |
| 11 | −from .build import build_search_document | |
| 12 | − | |
| 13 | −__all__ = ["build_search_document"] | |
deleted
M2M32brouve-ka/services/indexer/build.py
+0 −66
@@ -1,66 +0,0 @@ | ||
| 1 | −# Trouve-KA — construction du document d'index | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Transforme une page parsée + signaux Québec en document OpenSearch (étape 1).""" | |
| 6 | − | |
| 7 | −from datetime import UTC, datetime | |
| 8 | −from typing import Any | |
| 9 | − | |
| 10 | −from trouveka.types import ParsedPage, QuebecSignals | |
| 11 | − | |
| 12 | −# Catégorisation grossière par domaine — raffinée en asynchrone (étape 2) | |
| 13 | −_GOV_SUFFIXES = (".gouv.qc.ca", ".gc.ca", ".quebec.ca") | |
| 14 | −_GOV_DOMAINS = {"quebec.ca", "canada.ca", "montreal.ca", "laval.ca", "gatineau.ca", "sherbrooke.ca"} | |
| 15 | −_NEWS_DOMAINS = { | |
| 16 | − "lapresse.ca", "ledevoir.com", "journaldemontreal.com", "journaldequebec.com", | |
| 17 | − "radio-canada.ca", "tvanouvelles.ca", "lesoleil.com", "ledroit.com", | |
| 18 | − "latribune.ca", "lenouvelliste.ca", "lequotidien.com", "lavoixdelest.ca", | |
| 19 | − "noovo.info", "24heures.ca", "montrealgazette.com", | |
| 20 | −} | |
| 21 | −_EDU_SUFFIXES = (".ulaval.ca", ".umontreal.ca", ".mcgill.ca", ".uqam.ca", ".usherbrooke.ca", | |
| 22 | − ".concordia.ca", ".polymtl.ca", ".etsmtl.ca", ".hec.ca") | |
| 23 | − | |
| 24 | − | |
| 25 | −def categorize_domain(domain: str) -> list[str]: | |
| 26 | − d = domain.lower() | |
| 27 | − cats: list[str] = [] | |
| 28 | − if d in _GOV_DOMAINS or any(d.endswith(s) for s in _GOV_SUFFIXES) or ".gouv." in d: | |
| 29 | − cats.append("government") | |
| 30 | − if d in _NEWS_DOMAINS: | |
| 31 | − cats.append("news") | |
| 32 | − if any(d.endswith(s) or d == s.lstrip(".") for s in _EDU_SUFFIXES) or d.endswith(".edu"): | |
| 33 | − cats.append("education") | |
| 34 | − return cats | |
| 35 | − | |
| 36 | − | |
| 37 | −def build_search_document( | |
| 38 | − page: ParsedPage, | |
| 39 | − signals: QuebecSignals, | |
| 40 | − *, | |
| 41 | − domain: str, | |
| 42 | − domain_quebec_score: float, | |
| 43 | − authority_score: float = 0.0, | |
| 44 | −) -> dict[str, Any]: | |
| 45 | − return { | |
| 46 | − "url": page.url, | |
| 47 | − "canonical_url": page.canonical_url or page.url, | |
| 48 | − "domain": domain, | |
| 49 | − "title": page.title, | |
| 50 | − "description": page.description, | |
| 51 | − "body": page.body[:100_000], | |
| 52 | − "headings": page.headings, | |
| 53 | − "language": page.language, | |
| 54 | − "page_quebec_score": signals.score, | |
| 55 | − "domain_quebec_score": round(domain_quebec_score, 4), | |
| 56 | − "locations": signals.locations, | |
| 57 | − "organizations": [], # enrichissement étape 2 | |
| 58 | − "people": [], # enrichissement étape 2 | |
| 59 | − "categories": categorize_domain(domain), | |
| 60 | − "published_at": page.published_at.isoformat() if page.published_at else None, | |
| 61 | − "crawled_at": datetime.now(UTC).isoformat(), | |
| 62 | − "authority_score": round(authority_score, 4), | |
| 63 | − "freshness_score": 0.0, # enrichissement étape 3 | |
| 64 | − "quality_score": 0.0, # enrichissement étape 3 | |
| 65 | − "spam_score": 0.0, # enrichissement étape 3 | |
| 66 | − } | |
deleted
M2M32brouve-ka/services/parser/__init__.py
+0 −13
@@ -1,13 +0,0 @@ | ||
| 1 | −# Trouve-KA — extraction de contenu | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Extraction de contenu des pages crawlées (CLAUDE.md §6). | |
| 6 | − | |
| 7 | −Chaque page est traitée comme non fiable : texte extrait puis assaini, | |
| 8 | −URLs re-canonicalisées, MIME types jamais crus sur parole. | |
| 9 | −""" | |
| 10 | − | |
| 11 | −from .html_parser import parse_html, decode_html, looks_like_garbage | |
| 12 | − | |
| 13 | −__all__ = ["parse_html", "decode_html", "looks_like_garbage"] | |
deleted
M2M32brouve-ka/services/parser/html_parser.py
+0 −246
@@ -1,246 +0,0 @@ | ||
| 1 | −# Trouve-KA — parseur HTML | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Extraction HTML avec selectolax (rapide, tolérant). | |
| 6 | − | |
| 7 | −Extrait : titre, meta description, corps principal (boilerplate retiré), | |
| 8 | −headings, liens + anchors + nofollow, canonical, langue, dates de publication, | |
| 9 | −auteur, indices structurés (JSON-LD/OpenGraph) pour le scoring Québec. | |
| 10 | −""" | |
| 11 | − | |
| 12 | −import json | |
| 13 | −import re | |
| 14 | −from datetime import datetime | |
| 15 | − | |
| 16 | −from dateutil import parser as dateparser | |
| 17 | −from langdetect import DetectorFactory, LangDetectException, detect | |
| 18 | −from selectolax.parser import HTMLParser | |
| 19 | − | |
| 20 | −from trouveka.shared import canonicalize_url | |
| 21 | −from trouveka.types import ExtractedLink, ParsedPage | |
| 22 | − | |
| 23 | −DetectorFactory.seed = 42 # détection de langue déterministe | |
| 24 | − | |
| 25 | −# Éléments retirés avant extraction du corps (navigation, pub, scripts…) | |
| 26 | −_STRIP_SELECTORS = ( | |
| 27 | − "script", "style", "noscript", "template", "svg", "iframe", "form", | |
| 28 | − "nav", "header", "footer", "aside", | |
| 29 | − "[role=navigation]", "[role=banner]", "[role=contentinfo]", "[role=complementary]", | |
| 30 | − "[aria-hidden=true]", ".cookie-banner", "#cookie-banner", ".cookies", "[class*=cookie-consent]", | |
| 31 | −) | |
| 32 | − | |
| 33 | −_MAIN_SELECTORS = ("main", "article", "[role=main]", "#main", "#content", ".main-content", ".content") | |
| 34 | − | |
| 35 | −_WS = re.compile(r"\s+") | |
| 36 | −_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") | |
| 37 | − | |
| 38 | − | |
| 39 | −def _clean(text: str | None) -> str: | |
| 40 | − if not text: | |
| 41 | − return "" | |
| 42 | − return _WS.sub(" ", _CTRL.sub(" ", text)).strip() | |
| 43 | − | |
| 44 | − | |
| 45 | −def _parse_date(value: str | None) -> datetime | None: | |
| 46 | − if not value: | |
| 47 | − return None | |
| 48 | − try: | |
| 49 | − dt = dateparser.parse(value) | |
| 50 | − except (ValueError, OverflowError, TypeError): | |
| 51 | − return None | |
| 52 | − if dt and dt.year >= 1990: | |
| 53 | − return dt | |
| 54 | − return None | |
| 55 | − | |
| 56 | − | |
| 57 | −def _meta(tree: HTMLParser, *names: str) -> str | None: | |
| 58 | − for name in names: | |
| 59 | − for attr in ("name", "property", "itemprop"): | |
| 60 | − node = tree.css_first(f'meta[{attr}="{name}"]') | |
| 61 | − if node: | |
| 62 | − content = node.attributes.get("content") | |
| 63 | − if content and content.strip(): | |
| 64 | − return content.strip() | |
| 65 | − return None | |
| 66 | − | |
| 67 | − | |
| 68 | −def _extract_jsonld_hints(tree: HTMLParser, max_blocks: int = 10) -> tuple[list[str], datetime | None]: | |
| 69 | − """Extrait des textes utiles des blocs JSON-LD (adresses, noms d'org) + datePublished.""" | |
| 70 | − hints: list[str] = [] | |
| 71 | − published: datetime | None = None | |
| 72 | − for node in tree.css('script[type="application/ld+json"]')[:max_blocks]: | |
| 73 | − raw = node.text() | |
| 74 | − if not raw or len(raw) > 100_000: | |
| 75 | − continue | |
| 76 | − try: | |
| 77 | − data = json.loads(raw) | |
| 78 | − except (json.JSONDecodeError, ValueError): | |
| 79 | − continue | |
| 80 | − stack = [data] | |
| 81 | − while stack: | |
| 82 | − item = stack.pop() | |
| 83 | − if isinstance(item, list): | |
| 84 | − stack.extend(item[:20]) | |
| 85 | − elif isinstance(item, dict): | |
| 86 | − for key in ("name", "legalName", "addressLocality", "addressRegion", | |
| 87 | − "postalCode", "streetAddress", "telephone"): | |
| 88 | − val = item.get(key) | |
| 89 | − if isinstance(val, str) and len(val) < 200: | |
| 90 | − hints.append(val) | |
| 91 | − if published is None: | |
| 92 | − published = _parse_date(item.get("datePublished")) or _parse_date(item.get("dateCreated")) | |
| 93 | − stack.extend(v for v in item.values() if isinstance(v, (dict, list))) | |
| 94 | − if len(hints) > 60: | |
| 95 | − break | |
| 96 | − return hints[:60], published | |
| 97 | − | |
| 98 | − | |
| 99 | −_META_CHARSET_RE = re.compile( | |
| 100 | − rb'<meta[^>]+charset=["\']?\s*([a-zA-Z0-9_-]{2,20})', re.IGNORECASE | |
| 101 | −) | |
| 102 | −_CHARSET_ALIASES = {"iso-8859-1": "cp1252", "latin-1": "cp1252", "latin1": "cp1252", "ansi": "cp1252"} | |
| 103 | − | |
| 104 | − | |
| 105 | −def decode_html(body: bytes, declared_charset: str | None = None) -> str: | |
| 106 | − """Décode le HTML : charset HTTP déclaré → meta charset → UTF-8 strict → cp1252. | |
| 107 | − | |
| 108 | − Les vieux sites québécois sont souvent en ISO-8859-1/cp1252; décoder | |
| 109 | − aveuglément en UTF-8 produit du charabia qui finirait dans l'index. | |
| 110 | − """ | |
| 111 | − candidates: list[str] = [] | |
| 112 | − if declared_charset: | |
| 113 | − candidates.append(declared_charset.lower()) | |
| 114 | − meta = _META_CHARSET_RE.search(body[:4096]) | |
| 115 | − if meta: | |
| 116 | − candidates.append(meta.group(1).decode("ascii", errors="ignore").lower()) | |
| 117 | − candidates.extend(["utf-8", "cp1252"]) | |
| 118 | − for charset in candidates: | |
| 119 | − charset = _CHARSET_ALIASES.get(charset, charset) | |
| 120 | − try: | |
| 121 | − return body.decode(charset) | |
| 122 | − except (UnicodeDecodeError, LookupError): | |
| 123 | − continue | |
| 124 | − return body.decode("utf-8", errors="replace") | |
| 125 | − | |
| 126 | − | |
| 127 | −def looks_like_garbage(text: str, *, threshold: float = 0.03) -> bool: | |
| 128 | − """True si le texte contient trop de caractères de remplacement/contrôle | |
| 129 | − (contenu binaire ou mal décodé — ne doit jamais être indexé).""" | |
| 130 | − if not text: | |
| 131 | − return False | |
| 132 | − sample = text[:20_000] | |
| 133 | − bad = sum(1 for c in sample if c == "�" or (ord(c) < 32 and c not in "\t\n\r")) | |
| 134 | − return bad / len(sample) > threshold | |
| 135 | − | |
| 136 | − | |
| 137 | −def parse_html( | |
| 138 | − url: str, html: bytes | str, *, max_links: int = 300, charset: str | None = None | |
| 139 | −) -> ParsedPage: | |
| 140 | − """Parse une page HTML en ParsedPage. Ne lève pas sur du HTML dégueulasse.""" | |
| 141 | − if isinstance(html, bytes): | |
| 142 | − html = decode_html(html, charset) | |
| 143 | − tree = HTMLParser(html) | |
| 144 | − | |
| 145 | − # Directives robots de la page | |
| 146 | − robots_meta = (_meta(tree, "robots", "googlebot") or "").lower() | |
| 147 | − noindex = "noindex" in robots_meta | |
| 148 | − nofollow_page = "nofollow" in robots_meta | |
| 149 | − | |
| 150 | − # Canonical | |
| 151 | − canonical = None | |
| 152 | − link_canonical = tree.css_first('link[rel="canonical"]') | |
| 153 | − if link_canonical: | |
| 154 | − canonical = canonicalize_url(link_canonical.attributes.get("href") or "", base=url) | |
| 155 | − | |
| 156 | − # Titre / description | |
| 157 | − title = _clean(tree.css_first("title").text() if tree.css_first("title") else None) | |
| 158 | − og_title = _meta(tree, "og:title") | |
| 159 | − if not title and og_title: | |
| 160 | − title = _clean(og_title) | |
| 161 | − description = _clean(_meta(tree, "description", "og:description") or "") | |
| 162 | − | |
| 163 | − # Langue déclarée | |
| 164 | − declared_lang = None | |
| 165 | − html_node = tree.css_first("html") | |
| 166 | − if html_node: | |
| 167 | − lang_attr = (html_node.attributes.get("lang") or "").strip().lower() | |
| 168 | − if lang_attr: | |
| 169 | − declared_lang = lang_attr[:2] | |
| 170 | − | |
| 171 | − # Dates / auteur | |
| 172 | − published = _parse_date(_meta(tree, "article:published_time", "datePublished", "date", "dc.date")) | |
| 173 | − modified = _parse_date(_meta(tree, "article:modified_time", "dateModified")) | |
| 174 | − author = _meta(tree, "author", "article:author") | |
| 175 | − | |
| 176 | − # Indices structurés (avant strip : JSON-LD est dans <script>) | |
| 177 | − structured_hints, jsonld_published = _extract_jsonld_hints(tree) | |
| 178 | − published = published or jsonld_published | |
| 179 | − | |
| 180 | − # Liens (avant strip : les liens de nav comptent pour la découverte) | |
| 181 | − links: list[ExtractedLink] = [] | |
| 182 | − seen: set[str] = set() | |
| 183 | − for node in tree.css("a[href]"): | |
| 184 | − if len(links) >= max_links: | |
| 185 | − break | |
| 186 | − href = node.attributes.get("href") or "" | |
| 187 | − if href.startswith(("#", "javascript:", "mailto:", "tel:", "data:")): | |
| 188 | − continue | |
| 189 | − normalized = canonicalize_url(href, base=url) | |
| 190 | − if not normalized or normalized in seen: | |
| 191 | − continue | |
| 192 | − seen.add(normalized) | |
| 193 | − rel = (node.attributes.get("rel") or "").lower() | |
| 194 | − links.append( | |
| 195 | − ExtractedLink( | |
| 196 | − url=normalized, | |
| 197 | − anchor=_clean(node.text())[:200], | |
| 198 | − nofollow="nofollow" in rel or nofollow_page, | |
| 199 | − ) | |
| 200 | − ) | |
| 201 | − | |
| 202 | − # Headings | |
| 203 | − headings = [_clean(n.text()) for n in tree.css("h1, h2, h3")[:40]] | |
| 204 | − headings = [h for h in headings if h] | |
| 205 | − | |
| 206 | − # Corps principal : strip du boilerplate, puis zone principale si identifiable | |
| 207 | − for selector in _STRIP_SELECTORS: | |
| 208 | − for node in tree.css(selector): | |
| 209 | − node.decompose() | |
| 210 | − body_node = None | |
| 211 | − for selector in _MAIN_SELECTORS: | |
| 212 | − body_node = tree.css_first(selector) | |
| 213 | − if body_node: | |
| 214 | − break | |
| 215 | − if body_node is None: | |
| 216 | − body_node = tree.css_first("body") or tree.root | |
| 217 | − body = _clean(body_node.text(separator=" ", deep=True))[:200_000] | |
| 218 | − | |
| 219 | − # Langue : déclaration HTML validée/complétée par détection statistique | |
| 220 | − language = declared_lang if declared_lang in ("fr", "en") else None | |
| 221 | − sample = body[:4000] or title | |
| 222 | − if sample and len(sample) > 40: | |
| 223 | − try: | |
| 224 | − detected = detect(sample) | |
| 225 | − if detected in ("fr", "en"): | |
| 226 | − # La détection l'emporte si elle contredit une déclaration douteuse | |
| 227 | − language = detected if language is None or detected != language else language | |
| 228 | − except LangDetectException: | |
| 229 | − pass | |
| 230 | − | |
| 231 | − return ParsedPage( | |
| 232 | − url=url, | |
| 233 | − canonical_url=canonical, | |
| 234 | − title=title[:500], | |
| 235 | − description=description[:1000], | |
| 236 | − body=body, | |
| 237 | − headings=headings, | |
| 238 | − language=language, | |
| 239 | − links=links, | |
| 240 | − published_at=published, | |
| 241 | − modified_at=modified, | |
| 242 | − author=_clean(author)[:200] if author else None, | |
| 243 | − noindex=noindex, | |
| 244 | − nofollow_page=nofollow_page, | |
| 245 | − structured_hints=structured_hints, | |
| 246 | − ) | |
deleted
M2M32brouve-ka/services/ranking/__init__.py
+0 −13
@@ -1,13 +0,0 @@ | ||
| 1 | −# Trouve-KA — ranking | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Ranking Québec-first (CLAUDE.md §9). | |
| 6 | − | |
| 7 | −Score(d,q) = w_b·BM25 + w_q·Québec + w_a·Autorité + w_f·Fraîcheur + w_l·Localité | |
| 8 | −Chaque composant avancé est optionnel : BM25 fonctionne même si tout le reste tombe. | |
| 9 | −""" | |
| 10 | − | |
| 11 | −from .query import analyze_query, build_search_body | |
| 12 | − | |
| 13 | −__all__ = ["analyze_query", "build_search_body"] | |
deleted
M2M32brouve-ka/services/ranking/query.py
+0 −146
@@ -1,146 +0,0 @@ | ||
| 1 | −# Trouve-KA — pipeline de requête et ranking | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Pipeline de requête : normalisation → langue → lieux → requête OpenSearch. | |
| 6 | − | |
| 7 | −BM25 (multi_match bilingue) au cœur, function_score par-dessus : | |
| 8 | −Québec, autorité, fraîcheur, localité. Poids nommés, non figés (§9). | |
| 9 | −""" | |
| 10 | − | |
| 11 | −import re | |
| 12 | −import unicodedata | |
| 13 | −from typing import Any | |
| 14 | − | |
| 15 | −from trouveka.classifier import QUEBEC_TOPONYMS | |
| 16 | − | |
| 17 | −# Poids de ranking — à calibrer avec le dataset d'évaluation (§16) | |
| 18 | −W_PAGE_QUEBEC = 1.6 | |
| 19 | −W_DOMAIN_QUEBEC = 1.0 | |
| 20 | −W_AUTHORITY = 0.8 | |
| 21 | −W_FRESHNESS = 0.5 | |
| 22 | −W_LOCALITY = 2.0 | |
| 23 | − | |
| 24 | −_FR_HINTS = {"le", "la", "les", "des", "une", "un", "du", "de", "et", "ou", "pour", | |
| 25 | − "avec", "dans", "sur", "meilleur", "meilleure", "comment", "où", "quel", "quelle"} | |
| 26 | −_EN_HINTS = {"the", "a", "an", "of", "and", "or", "for", "with", "in", "on", "best", "how", "what", "where"} | |
| 27 | − | |
| 28 | − | |
| 29 | −def _fold(text: str) -> str: | |
| 30 | − return "".join(c for c in unicodedata.normalize("NFD", text) if unicodedata.category(c) != "Mn") | |
| 31 | − | |
| 32 | − | |
| 33 | −def analyze_query(q: str) -> dict[str, Any]: | |
| 34 | − """Normalisation, détection de langue heuristique, extraction de lieux québécois.""" | |
| 35 | − normalized = re.sub(r"\s+", " ", q).strip()[:200] | |
| 36 | − tokens = [t.lower() for t in re.findall(r"[\w'-]+", normalized, re.UNICODE)] | |
| 37 | − | |
| 38 | − fr_hits = sum(1 for t in tokens if t in _FR_HINTS) | |
| 39 | − en_hits = sum(1 for t in tokens if t in _EN_HINTS) | |
| 40 | − has_accents = normalized != _fold(normalized) | |
| 41 | − language = "fr" if (fr_hits > en_hits or has_accents) else ("en" if en_hits > fr_hits else None) | |
| 42 | − | |
| 43 | − # Lieux : tokens simples + bigrammes contre le gazetteer | |
| 44 | − folded_gazetteer = {_fold(t): t for t in QUEBEC_TOPONYMS} | |
| 45 | − locations: list[str] = [] | |
| 46 | − candidates = tokens + [f"{a}-{b}" for a, b in zip(tokens, tokens[1:])] + [ | |
| 47 | − f"{a} {b}" for a, b in zip(tokens, tokens[1:]) | |
| 48 | − ] | |
| 49 | − for cand in candidates: | |
| 50 | − folded = _fold(cand) | |
| 51 | − if folded in folded_gazetteer: | |
| 52 | − locations.append(folded_gazetteer[folded]) | |
| 53 | − return {"query": normalized, "language": language, "locations": sorted(set(locations))} | |
| 54 | − | |
| 55 | − | |
| 56 | −def build_search_body( | |
| 57 | − q: str, | |
| 58 | − *, | |
| 59 | − page: int = 1, | |
| 60 | − limit: int = 10, | |
| 61 | − language: str | None = None, | |
| 62 | − category: str | None = None, | |
| 63 | − quebec_only: bool = False, | |
| 64 | − freshness: str | None = None, | |
| 65 | −) -> tuple[dict[str, Any], dict[str, Any]]: | |
| 66 | − """Construit le corps de requête OpenSearch. Retourne (body, analyse).""" | |
| 67 | − analysis = analyze_query(q) | |
| 68 | − normalized = analysis["query"] | |
| 69 | − | |
| 70 | − # BM25 bilingue : les deux analyzers interrogés, le meilleur champ gagne | |
| 71 | − text_query: dict[str, Any] = { | |
| 72 | − "multi_match": { | |
| 73 | − "query": normalized, | |
| 74 | − "type": "most_fields", | |
| 75 | − "fields": [ | |
| 76 | − "title^4", "title.en^4", | |
| 77 | − "headings^2", "headings.en^2", | |
| 78 | − "description^2", "description.en^2", | |
| 79 | − "body", "body.en", | |
| 80 | − ], | |
| 81 | − "fuzziness": "AUTO", | |
| 82 | − "prefix_length": 2, | |
| 83 | − } | |
| 84 | − } | |
| 85 | − | |
| 86 | − filters: list[dict[str, Any]] = [] | |
| 87 | − if language in ("fr", "en"): | |
| 88 | − filters.append({"term": {"language": language}}) | |
| 89 | − if category: | |
| 90 | − filters.append({"term": {"categories": category}}) | |
| 91 | − if quebec_only: | |
| 92 | − filters.append({ | |
| 93 | − "bool": { | |
| 94 | − "should": [ | |
| 95 | − {"range": {"page_quebec_score": {"gte": 0.45}}}, | |
| 96 | − {"range": {"domain_quebec_score": {"gte": 0.6}}}, | |
| 97 | − ], | |
| 98 | − "minimum_should_match": 1, | |
| 99 | − } | |
| 100 | − }) | |
| 101 | − if freshness in ("day", "week", "month", "year"): | |
| 102 | − filters.append({"range": {"crawled_at": {"gte": f"now-1{freshness[0]}/d"}}}) | |
| 103 | − | |
| 104 | − functions: list[dict[str, Any]] = [ | |
| 105 | − {"field_value_factor": {"field": "page_quebec_score", "factor": W_PAGE_QUEBEC, "missing": 0}}, | |
| 106 | − {"field_value_factor": {"field": "domain_quebec_score", "factor": W_DOMAIN_QUEBEC, "missing": 0}}, | |
| 107 | − {"field_value_factor": {"field": "authority_score", "factor": W_AUTHORITY, "missing": 0}}, | |
| 108 | − { | |
| 109 | − "gauss": {"published_at": {"origin": "now", "scale": "180d", "decay": 0.6}}, | |
| 110 | − "weight": W_FRESHNESS, | |
| 111 | − }, | |
| 112 | − ] | |
| 113 | − # Localité : « plombier Gatineau » booste les documents avec preuve géographique explicite | |
| 114 | − if analysis["locations"]: | |
| 115 | − functions.append({ | |
| 116 | − "filter": {"terms": {"locations": analysis["locations"]}}, | |
| 117 | − "weight": W_LOCALITY, | |
| 118 | − }) | |
| 119 | − | |
| 120 | − body = { | |
| 121 | − "from": max(page - 1, 0) * limit, | |
| 122 | − "size": limit, | |
| 123 | − "query": { | |
| 124 | − "function_score": { | |
| 125 | − "query": {"bool": {"must": [text_query], "filter": filters}}, | |
| 126 | − "functions": functions, | |
| 127 | − "score_mode": "sum", | |
| 128 | − "boost_mode": "sum", | |
| 129 | − } | |
| 130 | − }, | |
| 131 | − "highlight": { | |
| 132 | − "pre_tags": ["<em>"], | |
| 133 | − "post_tags": ["</em>"], | |
| 134 | − "fields": { | |
| 135 | − "body": {"fragment_size": 180, "number_of_fragments": 2}, | |
| 136 | − "description": {"fragment_size": 180, "number_of_fragments": 1}, | |
| 137 | − }, | |
| 138 | − "encoder": "html", | |
| 139 | − }, | |
| 140 | − "_source": [ | |
| 141 | − "url", "canonical_url", "domain", "title", "description", "language", | |
| 142 | − "page_quebec_score", "domain_quebec_score", "categories", "published_at", | |
| 143 | − ], | |
| 144 | − "track_total_hits": True, | |
| 145 | − } | |
| 146 | − return body, analysis | |
deleted
M2M32brouve-ka/services/scheduler/__init__.py
+0 −9
@@ -1,9 +0,0 @@ | ||
| 1 | −# Trouve-KA — scheduler | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Boucle de maintenance : items abandonnés, autorité de domaine, hygiène du frontier.""" | |
| 6 | − | |
| 7 | −from .loop import SchedulerLoop | |
| 8 | − | |
| 9 | −__all__ = ["SchedulerLoop"] | |
deleted
M2M32brouve-ka/services/scheduler/loop.py
+0 −81
@@ -1,81 +0,0 @@ | ||
| 1 | −# Trouve-KA — boucle de maintenance | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Scheduler : tâches périodiques légères. | |
| 6 | − | |
| 7 | −- Relance les items in_progress abandonnés (worker mort) — dégradation gracieuse §13. | |
| 8 | −- Recalcule l'autorité de domaine à partir du graphe de liens (inlinks pondérés | |
| 9 | − par le score Québec des domaines source) — version simple, PageRank-like plus tard. | |
| 10 | −""" | |
| 11 | − | |
| 12 | −import asyncio | |
| 13 | −import signal | |
| 14 | − | |
| 15 | −from trouveka.config import get_settings | |
| 16 | −from trouveka.database import Database | |
| 17 | −from trouveka.logging import get_logger | |
| 18 | − | |
| 19 | −log = get_logger("scheduler") | |
| 20 | − | |
| 21 | −STALE_RESET_INTERVAL = 60 # secondes | |
| 22 | −AUTHORITY_INTERVAL = 15 * 60 # secondes | |
| 23 | − | |
| 24 | − | |
| 25 | −class SchedulerLoop: | |
| 26 | − def __init__(self) -> None: | |
| 27 | − self.s = get_settings() | |
| 28 | − self.db = Database(self.s.database_url, pool_min=1, pool_max=3) | |
| 29 | − self.stop_event = asyncio.Event() | |
| 30 | − | |
| 31 | − async def recompute_authority(self) -> None: | |
| 32 | − """Autorité ∈ [0,1] : log-saturation des inlinks pondérés par le Québec-score des sources.""" | |
| 33 | − await self.db.pool.execute( | |
| 34 | − """ | |
| 35 | − WITH weighted AS ( | |
| 36 | − SELECT dl.to_domain_id AS id, | |
| 37 | − sum(least(dl.link_count, 50) * greatest(d.quebec_score, 0.1)) AS w, | |
| 38 | − count(DISTINCT dl.from_domain_id) AS in_domains | |
| 39 | − FROM domain_links dl JOIN domains d ON d.id = dl.from_domain_id | |
| 40 | − GROUP BY dl.to_domain_id | |
| 41 | − ) | |
| 42 | − UPDATE domains SET | |
| 43 | − authority_score = least(1.0, ln(1 + w.w) / ln(1 + 5000)), | |
| 44 | − inlink_domains = w.in_domains | |
| 45 | − FROM weighted w WHERE domains.id = w.id | |
| 46 | − """ | |
| 47 | − ) | |
| 48 | − log.info("autorité de domaine recalculée") | |
| 49 | − | |
| 50 | − async def start(self) -> None: | |
| 51 | − await self.db.connect() | |
| 52 | − loop = asyncio.get_running_loop() | |
| 53 | − for sig in (signal.SIGINT, signal.SIGTERM): | |
| 54 | − loop.add_signal_handler(sig, self.stop_event.set) | |
| 55 | − log.info("scheduler démarré") | |
| 56 | − | |
| 57 | − elapsed_authority = AUTHORITY_INTERVAL # premier calcul immédiat | |
| 58 | − while not self.stop_event.is_set(): | |
| 59 | − try: | |
| 60 | − reset = await self.db.reset_stale_items(older_than_minutes=30) | |
| 61 | − if reset: | |
| 62 | − log.info("items abandonnés relancés", extra={"ctx": {"count": reset}}) | |
| 63 | − if elapsed_authority >= AUTHORITY_INTERVAL: | |
| 64 | − await self.recompute_authority() | |
| 65 | − elapsed_authority = 0 | |
| 66 | − except Exception: | |
| 67 | − log.exception("erreur scheduler (on continue)") | |
| 68 | − try: | |
| 69 | − await asyncio.wait_for(self.stop_event.wait(), timeout=STALE_RESET_INTERVAL) | |
| 70 | − except TimeoutError: | |
| 71 | − pass | |
| 72 | − elapsed_authority += STALE_RESET_INTERVAL | |
| 73 | − await self.db.close() | |
| 74 | − | |
| 75 | − | |
| 76 | −def main() -> None: | |
| 77 | − asyncio.run(SchedulerLoop().start()) | |
| 78 | − | |
| 79 | − | |
| 80 | −if __name__ == "__main__": | |
| 81 | − main() | |
deleted
M2M32brouve-ka/tests/test_fetcher.py
+0 −110
@@ -1,110 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests du fetcher (web de fixtures via MockTransport) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −import functools | |
| 6 | − | |
| 7 | −import httpx | |
| 8 | −import pytest | |
| 9 | − | |
| 10 | −import trouveka.crawler.fetcher as fetcher_mod | |
| 11 | −from trouveka.config import Settings | |
| 12 | −from trouveka.crawler.fetcher import Fetcher | |
| 13 | −from trouveka.shared import is_safe_url | |
| 14 | −from trouveka.types import ErrorCode | |
| 15 | − | |
| 16 | − | |
| 17 | −@pytest.fixture(autouse=True) | |
| 18 | −def no_dns(monkeypatch): | |
| 19 | − # Les hôtes de fixtures ne résolvent pas en vrai DNS : on garde toute la | |
| 20 | − # logique SSRF (schémas, hôtes bloqués, IP littérales) sans résolution. | |
| 21 | − monkeypatch.setattr(fetcher_mod, "is_safe_url", functools.partial(is_safe_url, resolve=False)) | |
| 22 | − | |
| 23 | − | |
| 24 | −def make_fetcher(handler, **overrides) -> Fetcher: | |
| 25 | − settings = Settings(max_response_bytes=1000, max_redirects=3, fetch_timeout=5, **overrides) | |
| 26 | − client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) | |
| 27 | − return Fetcher(client, settings) | |
| 28 | − | |
| 29 | − | |
| 30 | −async def test_simple_fetch(): | |
| 31 | − def handler(request): | |
| 32 | − return httpx.Response(200, html="<html><title>ok</title></html>", | |
| 33 | − headers={"content-type": "text/html; charset=utf-8"}) | |
| 34 | − | |
| 35 | − result = await make_fetcher(handler).fetch("https://site.qc.ca/page") | |
| 36 | − assert result.status_code == 200 | |
| 37 | − assert result.error_code == ErrorCode.OK | |
| 38 | − assert b"ok" in result.body | |
| 39 | − | |
| 40 | − | |
| 41 | −async def test_redirect_followed_with_ssrf_recheck(): | |
| 42 | − def handler(request): | |
| 43 | − if request.url.path == "/a": | |
| 44 | − return httpx.Response(301, headers={"location": "/b"}) | |
| 45 | − return httpx.Response(200, html="fini", headers={"content-type": "text/html"}) | |
| 46 | − | |
| 47 | − result = await make_fetcher(handler).fetch("https://site.qc.ca/a") | |
| 48 | − assert result.status_code == 200 | |
| 49 | − assert result.final_url == "https://site.qc.ca/b" | |
| 50 | − assert result.redirect_chain == ["https://site.qc.ca/b"] | |
| 51 | − | |
| 52 | − | |
| 53 | −async def test_redirect_to_private_ip_blocked(): | |
| 54 | − def handler(request): | |
| 55 | − return httpx.Response(302, headers={"location": "http://169.254.169.254/latest/meta-data/"}) | |
| 56 | − | |
| 57 | − result = await make_fetcher(handler).fetch("https://site.qc.ca/evil") | |
| 58 | − assert result.error_code == ErrorCode.SSRF_BLOCKED | |
| 59 | − | |
| 60 | − | |
| 61 | −async def test_too_many_redirects(): | |
| 62 | − def handler(request): | |
| 63 | − n = int(request.url.path.strip("/") or 0) | |
| 64 | − return httpx.Response(301, headers={"location": f"/{n + 1}"}) | |
| 65 | − | |
| 66 | − result = await make_fetcher(handler).fetch("https://site.qc.ca/0") | |
| 67 | − assert result.error_code == ErrorCode.TOO_MANY_REDIRECTS | |
| 68 | − | |
| 69 | − | |
| 70 | −async def test_body_size_limit(): | |
| 71 | − def handler(request): | |
| 72 | − return httpx.Response(200, content=b"x" * 5000, headers={"content-type": "text/html"}) | |
| 73 | − | |
| 74 | − result = await make_fetcher(handler).fetch("https://site.qc.ca/gros") | |
| 75 | − assert result.error_code == ErrorCode.TOO_LARGE | |
| 76 | − | |
| 77 | − | |
| 78 | −async def test_unsupported_content_type(): | |
| 79 | − def handler(request): | |
| 80 | − return httpx.Response(200, content=b"%PDF-1.4", headers={"content-type": "application/pdf"}) | |
| 81 | − | |
| 82 | − result = await make_fetcher(handler).fetch("https://site.qc.ca/doc.pdf") | |
| 83 | − assert result.error_code == ErrorCode.UNSUPPORTED_CONTENT | |
| 84 | − | |
| 85 | − | |
| 86 | −async def test_conditional_304(): | |
| 87 | − def handler(request): | |
| 88 | − assert request.headers.get("if-none-match") == 'W/"abc"' | |
| 89 | − return httpx.Response(304) | |
| 90 | − | |
| 91 | − result = await make_fetcher(handler).fetch("https://site.qc.ca/page", etag='W/"abc"') | |
| 92 | − assert result.status_code == 304 | |
| 93 | − | |
| 94 | − | |
| 95 | −async def test_4xx_and_5xx_classified(): | |
| 96 | − async def run(status): | |
| 97 | − def handler(request): | |
| 98 | − return httpx.Response(status) | |
| 99 | − return await make_fetcher(handler).fetch("https://site.qc.ca/x") | |
| 100 | − | |
| 101 | − assert (await run(404)).error_code == ErrorCode.HTTP_4XX | |
| 102 | − assert (await run(503)).error_code == ErrorCode.HTTP_5XX | |
| 103 | − | |
| 104 | − | |
| 105 | −async def test_ssrf_blocked_upfront(): | |
| 106 | − def handler(request): # pragma: no cover — ne doit jamais être appelé | |
| 107 | − raise AssertionError("fetch ne doit pas partir") | |
| 108 | − | |
| 109 | − result = await make_fetcher(handler).fetch("http://127.0.0.1:8080/admin") | |
| 110 | − assert result.error_code == ErrorCode.SSRF_BLOCKED | |
deleted
M2M32brouve-ka/tests/test_frontier_policy.py
+0 −44
@@ -1,44 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests de la politique du frontier | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −from trouveka.frontier import compute_priority, next_recrawl_delay, retry_delay | |
| 6 | − | |
| 7 | − | |
| 8 | −def test_seed_priority_is_max(): | |
| 9 | − assert compute_priority(domain_quebec_score=0, is_seed=True) == 1.0 | |
| 10 | − | |
| 11 | − | |
| 12 | −def test_priority_bounded(): | |
| 13 | − assert 0.0 <= compute_priority(domain_quebec_score=1.0, authority_score=1.0, | |
| 14 | − link_signal=1.0, is_new_domain=True) <= 1.0 | |
| 15 | − assert compute_priority(domain_quebec_score=0.0, spam_signal=1.0, depth=10) == 0.0 | |
| 16 | − | |
| 17 | − | |
| 18 | −def test_quebec_dominates_priority(): | |
| 19 | − high_q = compute_priority(domain_quebec_score=0.9, depth=2) | |
| 20 | − low_q = compute_priority(domain_quebec_score=0.1, depth=2) | |
| 21 | − assert high_q > low_q + 0.2 | |
| 22 | − | |
| 23 | − | |
| 24 | −def test_recrawl_backoff_doubles_when_unchanged(): | |
| 25 | − d = next_recrawl_delay(changed=False, previous_delay_hours=24.0) | |
| 26 | − assert d.total_seconds() == 48 * 3600 | |
| 27 | − | |
| 28 | − | |
| 29 | −def test_recrawl_halves_when_changed(): | |
| 30 | − d = next_recrawl_delay(changed=True, previous_delay_hours=24.0) | |
| 31 | − assert d.total_seconds() == 12 * 3600 | |
| 32 | − | |
| 33 | − | |
| 34 | −def test_recrawl_bounded(): | |
| 35 | − fast = next_recrawl_delay(changed=True, previous_delay_hours=0.5, min_hours=1.0) | |
| 36 | − slow = next_recrawl_delay(changed=False, previous_delay_hours=10_000, max_hours=720) | |
| 37 | − assert fast.total_seconds() == 3600 | |
| 38 | − assert slow.total_seconds() == 720 * 3600 | |
| 39 | − | |
| 40 | − | |
| 41 | −def test_retry_delay_grows_and_caps(): | |
| 42 | − assert retry_delay(0).total_seconds() == 15 * 60 | |
| 43 | − assert retry_delay(1).total_seconds() == 30 * 60 | |
| 44 | − assert retry_delay(10).total_seconds() == 24 * 3600 | |
deleted
M2M32brouve-ka/tests/test_parser.py
+0 −92
@@ -1,92 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests d'extraction HTML | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −from trouveka.parser import decode_html, looks_like_garbage, parse_html | |
| 6 | − | |
| 7 | −FIXTURE = """<!DOCTYPE html> | |
| 8 | −<html lang="fr"> | |
| 9 | −<head> | |
| 10 | − <title>Plomberie XYZ — Plombier à Gatineau</title> | |
| 11 | − <meta name="description" content="Service de plomberie en Outaouais depuis 1998."> | |
| 12 | − <link rel="canonical" href="https://plomberiexyz.qc.ca/services"> | |
| 13 | − <meta property="article:published_time" content="2024-06-12T10:00:00-04:00"> | |
| 14 | − <script type="application/ld+json"> | |
| 15 | − {"@type": "Plumber", "name": "Plomberie XYZ", | |
| 16 | − "address": {"addressLocality": "Gatineau", "addressRegion": "QC", "postalCode": "J8X 3X3"}} | |
| 17 | − </script> | |
| 18 | −</head> | |
| 19 | −<body> | |
| 20 | − <nav><a href="/menu1">Menu qui ne doit pas polluer le corps</a></nav> | |
| 21 | − <main> | |
| 22 | − <h1>Plombier Gatineau</h1> | |
| 23 | − <h2>Nos services</h2> | |
| 24 | − <p>Nous desservons Gatineau et tout l'Outaouais depuis 1998.</p> | |
| 25 | − <a href="/tarifs?utm_source=nav">Tarifs</a> | |
| 26 | − <a href="https://autresite.qc.ca/page" rel="nofollow">Partenaire</a> | |
| 27 | − <a href="mailto:info@x.ca">Courriel</a> | |
| 28 | − <a href="javascript:void(0)">JS</a> | |
| 29 | − </main> | |
| 30 | − <footer>123 rue Principale, Gatineau (Québec) J8X 3X3 — 819-555-1234</footer> | |
| 31 | − <script>trackingStuff();</script> | |
| 32 | −</body> | |
| 33 | −</html>""" | |
| 34 | − | |
| 35 | − | |
| 36 | −def test_full_extraction(): | |
| 37 | − page = parse_html("https://plomberiexyz.qc.ca/services/", FIXTURE) | |
| 38 | − assert page.title == "Plomberie XYZ — Plombier à Gatineau" | |
| 39 | − assert "Outaouais" in page.description | |
| 40 | − assert page.canonical_url == "https://plomberiexyz.qc.ca/services" | |
| 41 | − assert page.language == "fr" | |
| 42 | − assert "Plombier Gatineau" in page.headings | |
| 43 | − assert page.published_at and page.published_at.year == 2024 | |
| 44 | − # Corps : contenu principal présent, nav/script exclus | |
| 45 | − assert "Nous desservons Gatineau" in page.body | |
| 46 | − assert "Menu qui ne doit pas" not in page.body | |
| 47 | − assert "trackingStuff" not in page.body | |
| 48 | − # JSON-LD → indices structurés | |
| 49 | − assert "Gatineau" in page.structured_hints | |
| 50 | − | |
| 51 | − | |
| 52 | −def test_links_normalized_and_flagged(): | |
| 53 | − page = parse_html("https://plomberiexyz.qc.ca/services/", FIXTURE) | |
| 54 | − urls = {link.url for link in page.links} | |
| 55 | − assert "https://plomberiexyz.qc.ca/tarifs" in urls # utm retiré, relatif résolu | |
| 56 | − nofollow = {link.url: link.nofollow for link in page.links} | |
| 57 | − assert nofollow["https://autresite.qc.ca/page"] is True | |
| 58 | − assert not any(u.startswith(("mailto:", "javascript:")) for u in urls) | |
| 59 | − | |
| 60 | − | |
| 61 | −def test_noindex_detected(): | |
| 62 | − html = '<html><head><meta name="robots" content="noindex, nofollow"><title>x</title></head><body>corps</body></html>' | |
| 63 | − page = parse_html("https://x.qc.ca/", html) | |
| 64 | − assert page.noindex is True | |
| 65 | − assert page.nofollow_page is True | |
| 66 | − | |
| 67 | − | |
| 68 | −def test_garbage_html_does_not_crash(): | |
| 69 | − page = parse_html("https://x.qc.ca/", b"\x00\xffPas du HTML <div <<< &&& </span>") | |
| 70 | − assert page.url == "https://x.qc.ca/" | |
| 71 | − | |
| 72 | − | |
| 73 | −def test_decode_latin1_quebec_site(): | |
| 74 | − # Vieux site québécois en ISO-8859-1 : les accents doivent survivre | |
| 75 | − body = "<html><head><meta charset=\"iso-8859-1\"><title>Éducation à Québec</title></head><body>Ministère de l'Éducation</body></html>".encode("cp1252") | |
| 76 | − assert "Éducation à Québec" in decode_html(body) | |
| 77 | − page = parse_html("https://vieux-site.qc.ca/", body) | |
| 78 | − assert page.title == "Éducation à Québec" | |
| 79 | − | |
| 80 | − | |
| 81 | −def test_decode_http_charset_priority(): | |
| 82 | − body = "<html><title>Montréal</title></html>".encode("cp1252") | |
| 83 | − assert "Montréal" in decode_html(body, "iso-8859-1") | |
| 84 | − | |
| 85 | − | |
| 86 | −def test_binary_garbage_detected(): | |
| 87 | − # Contenu binaire (ex. brotli non décodé) décodé de force → détecté comme charabia | |
| 88 | − binary = bytes(range(256)) * 40 | |
| 89 | − text = binary.decode("utf-8", errors="replace") | |
| 90 | − assert looks_like_garbage(text) | |
| 91 | − assert not looks_like_garbage("Une page normale sur le Québec, avec des accents é à ç.") | |
| 92 | − assert not looks_like_garbage("") | |
deleted
M2M32brouve-ka/tests/test_quebec_scoring.py
+0 −88
@@ -1,88 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests du scoring Québec | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −from trouveka.classifier import score_page | |
| 6 | −from trouveka.types import ParsedPage | |
| 7 | − | |
| 8 | − | |
| 9 | −def _page(**kwargs) -> ParsedPage: | |
| 10 | − defaults = {"url": "https://example.com/page", "title": "", "body": ""} | |
| 11 | − return ParsedPage(**(defaults | kwargs)) | |
| 12 | − | |
| 13 | − | |
| 14 | −def test_quebec_business_scores_high(): | |
| 15 | − page = _page( | |
| 16 | − url="https://plomberiexyz.qc.ca/services", | |
| 17 | − title="Plomberie XYZ — Plombier à Gatineau", | |
| 18 | − description="Service de plomberie résidentielle en Outaouais", | |
| 19 | − body="Plomberie XYZ dessert Gatineau et l'Outaouais. 123 rue Principale, Gatineau (Québec) J8X 3X3. " | |
| 20 | − "Appelez-nous au 819-555-1234. Urgences 24h partout au Québec.", | |
| 21 | − headings=["Plombier Gatineau", "Nos services"], | |
| 22 | − language="fr", | |
| 23 | − ) | |
| 24 | − signals = score_page(page, "plomberiexyz.qc.ca") | |
| 25 | − assert signals.score > 0.7 | |
| 26 | − assert "gatineau" in signals.locations | |
| 27 | − assert "tld_quebec" in signals.reasons | |
| 28 | − assert "code_postal_qc" in signals.reasons | |
| 29 | − | |
| 30 | − | |
| 31 | −def test_ontario_business_scores_low(): | |
| 32 | − page = _page( | |
| 33 | − url="https://ottawaplumbing.ca/", | |
| 34 | − title="Ottawa Plumbing Services", | |
| 35 | − description="Plumbing services in Ottawa, Ontario", | |
| 36 | − body="We serve Ottawa, Kanata and Nepean. 456 Bank Street, Ottawa, ON K1S 3T4. Call 613-555-9999.", | |
| 37 | − headings=["Plumbing Ottawa"], | |
| 38 | − language="en", | |
| 39 | − ) | |
| 40 | − signals = score_page(page, "ottawaplumbing.ca") | |
| 41 | − assert signals.score < 0.2 | |
| 42 | − | |
| 43 | − | |
| 44 | −def test_nyt_article_about_montreal_gets_page_signal(): | |
| 45 | − # Un article international SUR Montréal : le domaine n'est pas québécois, | |
| 46 | − # mais la page a un signal réel (page_quebec_score > 0, sans être maximal). | |
| 47 | − page = _page( | |
| 48 | − url="https://www.nytimes.com/2026/01/01/travel/montreal.html", | |
| 49 | − title="36 Hours in Montreal", | |
| 50 | − description="What to do in Montreal, Quebec's largest city", | |
| 51 | − body="Montreal is the largest city in Quebec. From the Plateau-Mont-Royal to Old Montreal, " | |
| 52 | − "the city offers poutine, festivals and more. Nearby Laval and Longueuil...", | |
| 53 | − headings=["36 Hours in Montreal"], | |
| 54 | − language="en", | |
| 55 | − ) | |
| 56 | − signals = score_page(page, "nytimes.com") | |
| 57 | − assert 0.2 < signals.score < 0.8 | |
| 58 | − assert "montreal" in signals.locations | |
| 59 | − | |
| 60 | − | |
| 61 | −def test_generic_english_page_scores_near_zero(): | |
| 62 | − page = _page( | |
| 63 | − url="https://techblog.com/post", | |
| 64 | − title="Understanding Rust lifetimes", | |
| 65 | − body="Rust lifetimes are a way to express the scope of references in your program. " * 20, | |
| 66 | − language="en", | |
| 67 | − ) | |
| 68 | − signals = score_page(page, "techblog.com") | |
| 69 | − assert signals.score < 0.1 | |
| 70 | − | |
| 71 | − | |
| 72 | −def test_french_alone_is_not_proof(): | |
| 73 | − # Une page française de France ne doit pas passer le seuil sur la langue seule | |
| 74 | − page = _page( | |
| 75 | − url="https://lemonde.fr/article", | |
| 76 | − title="Actualités françaises", | |
| 77 | − body="La France annonce de nouvelles mesures. Paris, Lyon et Marseille concernées. " * 10, | |
| 78 | − language="fr", | |
| 79 | − ) | |
| 80 | − signals = score_page(page, "lemonde.fr") | |
| 81 | − assert signals.score < 0.3 | |
| 82 | − | |
| 83 | − | |
| 84 | −def test_score_saturates_below_one(): | |
| 85 | − body = "Montréal Québec Gatineau Sherbrooke Laval " * 100 + " H2X 1Y6 G1R 4S9 514-555-0000 Hydro-Québec" | |
| 86 | − page = _page(url="https://x.qc.ca/", title="Montréal Québec", body=body, language="fr") | |
| 87 | − signals = score_page(page, "x.qc.ca") | |
| 88 | − assert 0.8 < signals.score <= 1.0 | |
deleted
M2M32brouve-ka/tests/test_ranking.py
+0 −41
@@ -1,41 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests du pipeline de requête et du ranking | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −from trouveka.ranking import analyze_query, build_search_body | |
| 6 | − | |
| 7 | − | |
| 8 | −def test_language_detection_heuristics(): | |
| 9 | − assert analyze_query("meilleur programme de subvention pour thermopompe")["language"] == "fr" | |
| 10 | − assert analyze_query("best heat pump rebate program")["language"] == "en" | |
| 11 | − | |
| 12 | − | |
| 13 | −def test_location_extraction_with_accents(): | |
| 14 | − a = analyze_query("plombier gatineau") | |
| 15 | − assert "gatineau" in a["locations"] | |
| 16 | − b = analyze_query("subvention Trois-Rivières") | |
| 17 | − assert any("trois-rivieres" in loc or "trois-rivières" in loc for loc in b["locations"]) | |
| 18 | − | |
| 19 | − | |
| 20 | −def test_body_structure_bm25_core(): | |
| 21 | − body, analysis = build_search_body("plombier Gatineau", page=2, limit=10) | |
| 22 | − assert body["from"] == 10 and body["size"] == 10 | |
| 23 | − fs = body["query"]["function_score"] | |
| 24 | − fields = fs["query"]["bool"]["must"][0]["multi_match"]["fields"] | |
| 25 | − assert "title^4" in fields and "body" in fields and "body.en" in fields | |
| 26 | − # Localité : fonction de boost présente quand un lieu est détecté | |
| 27 | − locality = [f for f in fs["functions"] if "filter" in f] | |
| 28 | − assert locality and locality[0]["filter"]["terms"]["locations"] == ["gatineau"] | |
| 29 | − | |
| 30 | − | |
| 31 | −def test_filters(): | |
| 32 | − body, _ = build_search_body("test", language="fr", category="government", quebec_only=True) | |
| 33 | − filters = body["query"]["function_score"]["query"]["bool"]["filter"] | |
| 34 | − assert {"term": {"language": "fr"}} in filters | |
| 35 | − assert {"term": {"categories": "government"}} in filters | |
| 36 | − assert any("bool" in f for f in filters) # quebec_only | |
| 37 | − | |
| 38 | − | |
| 39 | −def test_no_locality_function_without_location(): | |
| 40 | − body, _ = build_search_body("recette de tourtière") | |
| 41 | − assert not [f for f in body["query"]["function_score"]["functions"] if "filter" in f] | |
deleted
M2M32brouve-ka/tests/test_ssrf.py
+0 −39
@@ -1,39 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests de prévention SSRF | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −from trouveka.shared import is_safe_ip, is_safe_url | |
| 6 | − | |
| 7 | − | |
| 8 | −def test_blocks_loopback_and_private(): | |
| 9 | − for ip in ("127.0.0.1", "10.0.0.5", "192.168.2.10", "172.16.0.1", "0.0.0.0", "::1", "fc00::1"): | |
| 10 | − assert not is_safe_ip(ip), ip | |
| 11 | − | |
| 12 | − | |
| 13 | −def test_blocks_link_local_and_metadata(): | |
| 14 | − assert not is_safe_ip("169.254.169.254") | |
| 15 | − assert not is_safe_ip("169.254.1.1") | |
| 16 | − assert not is_safe_ip("fe80::1") | |
| 17 | − | |
| 18 | − | |
| 19 | −def test_allows_public_ips(): | |
| 20 | − assert is_safe_ip("142.226.10.10") | |
| 21 | − assert is_safe_ip("2607:f8b0::1") | |
| 22 | − | |
| 23 | − | |
| 24 | −def test_blocks_localhost_and_schemes(): | |
| 25 | − assert not is_safe_url("http://localhost/admin", resolve=False) | |
| 26 | − assert not is_safe_url("http://foo.localhost/x", resolve=False) | |
| 27 | − assert not is_safe_url("http://metadata.google.internal/", resolve=False) | |
| 28 | − assert not is_safe_url("file:///etc/passwd", resolve=False) | |
| 29 | − assert not is_safe_url("gopher://x.ca", resolve=False) | |
| 30 | − | |
| 31 | − | |
| 32 | −def test_blocks_ip_literal_urls(): | |
| 33 | − assert not is_safe_url("http://127.0.0.1:8080/", resolve=False) | |
| 34 | − assert not is_safe_url("http://169.254.169.254/latest/meta-data/", resolve=False) | |
| 35 | − assert not is_safe_url("http://[::1]/", resolve=False) | |
| 36 | − | |
| 37 | − | |
| 38 | −def test_allows_normal_url_without_resolution(): | |
| 39 | − assert is_safe_url("https://www.quebec.ca/", resolve=False) | |
deleted
M2M32brouve-ka/tests/test_traps.py
+0 −39
@@ -1,39 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests de détection de pièges | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −from trouveka.crawler.traps import looks_like_trap | |
| 6 | − | |
| 7 | − | |
| 8 | −def test_session_ids(): | |
| 9 | − assert looks_like_trap("https://x.ca/p?PHPSESSID=abc123") | |
| 10 | − assert looks_like_trap("https://x.ca/p?sid=9f8e7d") | |
| 11 | − | |
| 12 | − | |
| 13 | −def test_too_many_params(): | |
| 14 | − url = "https://x.ca/p?" + "&".join(f"f{i}={i}" for i in range(12)) | |
| 15 | − assert looks_like_trap(url) | |
| 16 | − | |
| 17 | − | |
| 18 | −def test_repeated_facet_param(): | |
| 19 | − assert looks_like_trap("https://x.ca/s?f=a&f=b&f=c&f=d&f=e") | |
| 20 | − | |
| 21 | − | |
| 22 | −def test_deep_paths_and_loops(): | |
| 23 | − assert looks_like_trap("https://x.ca/" + "/".join(["seg"] * 15)) | |
| 24 | − assert looks_like_trap("https://x.ca/a/b/a/b/a/b/a/b") | |
| 25 | − | |
| 26 | − | |
| 27 | −def test_far_future_calendar(): | |
| 28 | − assert looks_like_trap("https://x.ca/events/2085/05/17") | |
| 29 | − assert not looks_like_trap("https://x.ca/nouvelles/2024/06/12") | |
| 30 | − | |
| 31 | − | |
| 32 | −def test_excessive_pagination(): | |
| 33 | − assert looks_like_trap("https://x.ca/liste?page=9999") | |
| 34 | − assert not looks_like_trap("https://x.ca/liste?page=3") | |
| 35 | − | |
| 36 | − | |
| 37 | −def test_normal_urls_pass(): | |
| 38 | − assert not looks_like_trap("https://www.quebec.ca/services/permis?type=conduire") | |
| 39 | − assert not looks_like_trap("https://ici.radio-canada.ca/nouvelle/2024/ceci-est-un-titre") | |
deleted
M2M32brouve-ka/tests/test_urls.py
+0 −53
@@ -1,53 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests de canonicalisation d'URL | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −from trouveka.shared import canonicalize_url, display_url, extract_domain | |
| 6 | − | |
| 7 | − | |
| 8 | −def test_removes_fragment_and_default_port(): | |
| 9 | − assert canonicalize_url("https://quebec.ca:443/services#section") == "https://quebec.ca/services" | |
| 10 | − assert canonicalize_url("http://quebec.ca:80/") == "http://quebec.ca/" | |
| 11 | − | |
| 12 | − | |
| 13 | −def test_preserves_non_default_port(): | |
| 14 | − assert canonicalize_url("http://example.qc.ca:8080/x") == "http://example.qc.ca:8080/x" | |
| 15 | − | |
| 16 | − | |
| 17 | −def test_lowercases_host_but_preserves_path_case(): | |
| 18 | − assert canonicalize_url("HTTPS://WWW.Quebec.CA/Services/PDF") == "https://www.quebec.ca/Services/PDF" | |
| 19 | − | |
| 20 | − | |
| 21 | −def test_strips_tracking_params_only(): | |
| 22 | − url = "https://ledevoir.com/a?utm_source=fb&fbclid=xyz&id=42&gclid=1" | |
| 23 | − assert canonicalize_url(url) == "https://ledevoir.com/a?id=42" | |
| 24 | − | |
| 25 | − | |
| 26 | −def test_preserves_unknown_params_and_order(): | |
| 27 | − # Deux ressources distinctes ne doivent jamais fusionner : ordre préservé | |
| 28 | − assert canonicalize_url("https://x.ca/p?b=2&a=1") == "https://x.ca/p?b=2&a=1" | |
| 29 | − assert canonicalize_url("https://x.ca/p?a=1&b=2") == "https://x.ca/p?a=1&b=2" | |
| 30 | − | |
| 31 | − | |
| 32 | −def test_rejects_non_http_schemes(): | |
| 33 | − assert canonicalize_url("file:///etc/passwd") is None | |
| 34 | − assert canonicalize_url("javascript:alert(1)") is None | |
| 35 | − assert canonicalize_url("ftp://x.ca/f") is None | |
| 36 | − | |
| 37 | − | |
| 38 | −def test_relative_resolution(): | |
| 39 | − assert canonicalize_url("../b", base="https://x.ca/a/c/") == "https://x.ca/a/b" | |
| 40 | − | |
| 41 | − | |
| 42 | −def test_collapses_duplicate_slashes(): | |
| 43 | − assert canonicalize_url("https://x.ca//a///b") == "https://x.ca/a/b" | |
| 44 | − | |
| 45 | − | |
| 46 | −def test_extract_domain_strips_www(): | |
| 47 | − assert extract_domain("https://www.quebec.ca/services") == "quebec.ca" | |
| 48 | − assert extract_domain("https://ici.radio-canada.ca/n") == "ici.radio-canada.ca" | |
| 49 | − | |
| 50 | − | |
| 51 | −def test_display_url_breadcrumb(): | |
| 52 | − crumb = display_url("https://www.quebec.ca/services/permis/conduire") | |
| 53 | − assert crumb.startswith("quebec.ca › services › permis") | |
deleted
M2M32crouve-ka/.env.example
+0 −34
@@ -1,34 +0,0 @@ | ||
| 1 | −# Trouve-KA — variables d'environnement (exemple, sans secrets) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −# --- Bases de données --- | |
| 6 | −DATABASE_URL=postgresql://trouveka:trouveka@localhost:5432/trouveka | |
| 7 | −REDIS_URL=redis://localhost:6379/0 | |
| 8 | −SEARCH_URL=http://localhost:9200 | |
| 9 | − | |
| 10 | −# --- Crawler --- | |
| 11 | −CRAWLER_USER_AGENT="Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)" | |
| 12 | −CRAWLER_CONTACT_URL=https://www.trouve-ka.com/trouveka-bot | |
| 13 | −MAX_GLOBAL_CONCURRENCY=24 | |
| 14 | −MAX_PER_HOST_CONCURRENCY=2 | |
| 15 | −DEFAULT_HOST_DELAY=2.0 | |
| 16 | −MAX_RESPONSE_BYTES=3000000 | |
| 17 | −MAX_REDIRECTS=5 | |
| 18 | −FETCH_TIMEOUT=20 | |
| 19 | −MAX_CRAWL_DEPTH=8 | |
| 20 | −MAX_LINKS_PER_PAGE=300 | |
| 21 | −MAX_URLS_PER_DOMAIN=5000 | |
| 22 | − | |
| 23 | −# --- API --- | |
| 24 | −API_HOST=0.0.0.0 | |
| 25 | −API_PORT=8080 | |
| 26 | −ADMIN_TOKEN=change-me-admin-token | |
| 27 | − | |
| 28 | −# --- Web --- | |
| 29 | −PUBLIC_URL=https://www.trouve-ka.com | |
| 30 | −NGROK_DOMAIN=www.trouve-ka.com | |
| 31 | −API_URL=http://localhost:8080 | |
| 32 | − | |
| 33 | −# --- Index --- | |
| 34 | −SEARCH_INDEX=trouveka-docs | |
deleted
M2M32crouve-ka/.gitignore
+0 −13
@@ -1,13 +0,0 @@ | ||
| 1 | −node_modules/ | |
| 2 | −.next/ | |
| 3 | −dist/ | |
| 4 | −__pycache__/ | |
| 5 | −*.pyc | |
| 6 | −.venv/ | |
| 7 | −.env | |
| 8 | −.env.local | |
| 9 | −*.egg-info/ | |
| 10 | −.pytest_cache/ | |
| 11 | −.ruff_cache/ | |
| 12 | −data/ | |
| 13 | −.DS_Store | |
deleted
M2M32crouve-ka/CLAUDE.md
+0 −363
@@ -1,363 +0,0 @@ | ||
| 1 | −# CLAUDE.md — Trouve-KA | |
| 2 | − | |
| 3 | −**Projet :** Trouve-KA — *Cherche le Québec.* | |
| 4 | −**Mission :** Moteur de recherche web indépendant, Québec-first, avec son propre crawler, index, ranking, API et application web publique. | |
| 5 | − | |
| 6 | −Trouve-KA n'est **pas** un métamoteur. Aucune dépendance à Google, Bing ou Brave pour les résultats primaires. L'objectif : un index structuré et continuellement mis à jour du web québécois. | |
| 7 | − | |
| 8 | −``` | |
| 9 | −Crawler → Frontier → Fetcher → Parser → Classification Québec | |
| 10 | −→ Déduplication → Indexer → Index → Ranking → API → Web App | |
| 11 | −``` | |
| 12 | − | |
| 13 | −Le crawler fait partie du produit. Tout vit dans un seul monorepo. | |
| 14 | − | |
| 15 | −--- | |
| 16 | − | |
| 17 | −## 0. Règles non négociables | |
| 18 | − | |
| 19 | −### 0.1 Header obligatoire dans chaque fichier de code | |
| 20 | − | |
| 21 | −**Chaque fichier source** (TS, JS, Python, SQL, config exécutable, scripts) doit commencer par un header d'auteur : | |
| 22 | − | |
| 23 | −```ts | |
| 24 | −/** | |
| 25 | − * Trouve-KA — <nom du module> | |
| 26 | − * Author: Simon-Pierre Boucher | |
| 27 | − * Contact: contact@spboucher.ai | |
| 28 | − */ | |
| 29 | −``` | |
| 30 | − | |
| 31 | −```python | |
| 32 | −# Trouve-KA — <nom du module> | |
| 33 | −# Author: Simon-Pierre Boucher | |
| 34 | −# Contact: contact@spboucher.ai | |
| 35 | −``` | |
| 36 | − | |
| 37 | −Adapter la syntaxe de commentaire au langage. Aucune exception. Un lint/check CI doit vérifier la présence du header. | |
| 38 | − | |
| 39 | −### 0.2 Cible de déploiement | |
| 40 | − | |
| 41 | −- **Node de déploiement : `m2m32`** (32 Go RAM). Tout le stack (Postgres, Redis, backend de recherche, workers, API, web) doit tourner confortablement sur cette machine. | |
| 42 | −- **Exposition publique via ngrok : `www.trouve-ka.com`.** Configurer ngrok avec le domaine réservé; l'app web et l'API doivent fonctionner correctement derrière le tunnel (URLs absolues, cookies, CORS, headers `X-Forwarded-*`). | |
| 43 | −- Docker Compose est l'outil de déploiement. Pas de Kubernetes. | |
| 44 | −- Fournir un script/target `deploy:m2m32` et documenter la procédure complète (compose up + tunnel ngrok) dans le README. | |
| 45 | −- Dimensionner les défauts (concurrence, tailles de heap du moteur de recherche, connexions Postgres) pour 32 Go, avec configuration par variables d'environnement. | |
| 46 | − | |
| 47 | −### 0.3 Principe cardinal | |
| 48 | − | |
| 49 | −> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Enrichis en asynchrone.** | |
| 50 | − | |
| 51 | −Le moteur doit être utilisable **dès que le crawl démarre**. Jamais de cycle « crawler tout → construire l'index → lancer la recherche ». Chaque page traitée avec succès devient cherchable en secondes. | |
| 52 | − | |
| 53 | −``` | |
| 54 | −12:00:00 démarrage crawler | |
| 55 | −12:00:04 première page fetchée | |
| 56 | −12:00:06 score Québec calculé | |
| 57 | −12:00:07 document indexé | |
| 58 | −12:00:08 cherchable par l'utilisateur | |
| 59 | −``` | |
| 60 | − | |
| 61 | −Aucune interdiction plus importante que celle-ci : **ne jamais bloquer l'indexation sur l'enrichissement** (embeddings, entités, scoring avancé = asynchrone, mise à jour du document après coup). | |
| 62 | − | |
| 63 | −--- | |
| 64 | − | |
| 65 | −## 1. Philosophie produit | |
| 66 | − | |
| 67 | −Trouve-KA est un vrai moteur, pas : un annuaire, une liste curée, un wrapper ChatGPT, un frontend Google CSE, un dataset statique. | |
| 68 | − | |
| 69 | −Il doit de façon autonome : découvrir des sites → les crawler → comprendre le contenu → juger la pertinence québécoise → indexer → ranker → exposer via recherche → découvrir davantage → rafraîchir en continu. | |
| 70 | − | |
| 71 | −**Interdictions absolues (§ fake) :** pas de données de crawl factices, pas de compteurs simulés présentés comme réels, pas de résultats hard-codés, pas d'API de recherche placeholder. Les fixtures sont réservées aux tests. Le dev tourne sur le vrai crawler et le vrai index local. | |
| 72 | − | |
| 73 | −--- | |
| 74 | − | |
| 75 | −## 2. Architecture du dépôt | |
| 76 | − | |
| 77 | −Monorepo : | |
| 78 | − | |
| 79 | −``` | |
| 80 | −trouve-ka/ | |
| 81 | −├── apps/ | |
| 82 | −│ ├── web/ # moteur public | |
| 83 | −│ ├── api/ # search + APIs internes | |
| 84 | −│ └── admin/ # dashboard crawl/index | |
| 85 | −├── services/ | |
| 86 | −│ ├── crawler/ frontier/ parser/ classifier/ | |
| 87 | −│ ├── indexer/ ranking/ scheduler/ enrichment/ | |
| 88 | −├── packages/ | |
| 89 | −│ ├── database/ shared/ config/ logging/ | |
| 90 | −│ ├── queue/ types/ search-core/ | |
| 91 | −├── infrastructure/ | |
| 92 | −│ ├── docker/ migrations/ monitoring/ deployment/ # inclut config ngrok + m2m32 | |
| 93 | −├── scripts/ | |
| 94 | −│ ├── bootstrap-seeds/ start-crawler/ rebuild-index/ health-check/ | |
| 95 | −├── docs/ | |
| 96 | −├── CLAUDE.md README.md docker-compose.yml | |
| 97 | −``` | |
| 98 | − | |
| 99 | −Claude peut améliorer cette structure sur justification technique solide. | |
| 100 | − | |
| 101 | −--- | |
| 102 | − | |
| 103 | −## 3. Stack technologique | |
| 104 | − | |
| 105 | −| Couche | Choix | | |
| 106 | −|---|---| | |
| 107 | −| Frontend | Next.js, TypeScript, React, Tailwind, shadcn/ui | | |
| 108 | −| API | FastAPI (Python) ou backend TypeScript si ça réduit la complexité; le crawler peut rester Python même si l'API est TS | | |
| 109 | −| BD relationnelle | PostgreSQL (domaines, URLs, état de crawl, métadonnées, entités, scheduler, bookkeeping) | | |
| 110 | −| Queue | Redis + vraie file de tâches/streams (évaluer avant de choisir) | | |
| 111 | −| Recherche | À sélectionner parmi : OpenSearch, Elasticsearch, Typesense, Meilisearch, Vespa, Tantivy, Quickwit | | |
| 112 | − | |
| 113 | −Critères de sélection du backend de recherche : qualité BM25, indexation incrémentale, facettes, tolérance aux typos, ranking custom, recherche hybride, performance, scaling horizontal, **complexité opérationnelle sur un seul node m2m32**. Pour le MVP : qualité de recherche + indexation incrémentale + opérations simples. Ne pas choisir le plus facile par défaut. | |
| 114 | − | |
| 115 | −--- | |
| 116 | − | |
| 117 | −## 4. Qualité de recherche progressive | |
| 118 | − | |
| 119 | −Chaque document s'enrichit par étapes; **la disponibilité en recherche ne dépend jamais des étapes suivantes.** | |
| 120 | − | |
| 121 | −- **Étape 1 (immédiat) :** URL, titre, description, corps, headings, domaine, langue, timestamp, score Québec → index BM25. | |
| 122 | −- **Étape 2 (async) :** embedding, classification thématique, organisations, signaux de localisation, entité canonique. | |
| 123 | −- **Étape 3 (async) :** autorité de domaine, score de graphe de liens / PageRank-like, fraîcheur, qualité, spam, intention commerciale, pertinence locale. | |
| 124 | − | |
| 125 | −--- | |
| 126 | − | |
| 127 | −## 5. Crawler | |
| 128 | − | |
| 129 | −Un vrai crawler : frontier d'URLs, scheduling par domaine, robots.txt, canonicalisation, redirections, retries, cache HTTP, compression, gestion des content-types, budgets de crawl, concurrence par hôte, rate limiting, files de priorité, prévention des doublons, historique, hash de contenu, détection de changements. | |
| 130 | − | |
| 131 | −### 5.1 Identité | |
| 132 | −User-agent identifiable : `Mozilla-compatible / TrouveKABot`. Page publique `/trouveka-bot` : quoi, pourquoi, UA, contact (**contact@spboucher.ai**), comment bloquer, respect de robots.txt. **Pas de stealth, pas de rotation de proxys par défaut.** IPs stables et comportement poli. | |
| 133 | − | |
| 134 | −### 5.2 robots.txt et politesse | |
| 135 | −Parsing conforme aux standards, cache des règles, respect de `noindex` / `nofollow` / `canonical` / `X-Robots-Tag`. Politesse par origine : 1–2 requêtes concurrentes max par hôte, budgets indépendants, politiques configurables. Un gros débit global ne doit jamais agresser un site individuel. | |
| 136 | − | |
| 137 | −### 5.3 Frontier | |
| 138 | −Chaque URL : `url, domain, priority, depth, source_url, discovered_at, last_crawled_at, next_crawl_at, status`. Priorités évolutives selon : pertinence Québec, autorité, source de découverte, profondeur, importance du domaine, fraîcheur, historique de changement, succès, duplication. | |
| 139 | − | |
| 140 | −Fonction de priorité conceptuelle : | |
| 141 | −`P = w_q·Q + w_a·A + w_f·F + w_l·L + w_n·N − w_d·D − w_s·S` | |
| 142 | −(Québec, Autorité, Fraîcheur, Liens, Nouveauté, Doublon, Spam). Poids calibrés par mesures, jamais figés. | |
| 143 | − | |
| 144 | −### 5.4 Canonicalisation d'URL | |
| 145 | −Extrême prudence. Normaliser : fragments, ports par défaut, slashs, paramètres de tracking/UTM, ordre des paramètres quand approprié, http/https, www, trailing slash, tags canonical. **Ne jamais fusionner deux ressources distinctes par accident.** | |
| 146 | − | |
| 147 | −### 5.5 Recrawl adaptatif | |
| 148 | −Homepage de nouvelles → minutes; article → heures puis décroissant; page gouvernementale → quotidien; site statique → mensuel; archive inchangée → rarement. Fréquence de changement mesurée empiriquement : page inchangée → intervalle ↑; page volatile → intervalle ↓. | |
| 149 | − | |
| 150 | −### 5.6 Détection de changement et doublons | |
| 151 | −Stocker `content_hash`, etag, last-modified, `last_changed_at`. Réindexer seulement si changement. Doublons : exacts (hash) d'abord, puis SimHash/MinHash/shingling si justifié. Gérer miroirs, versions imprimables, doublons de paramètres, syndication. Conserver la provenance. | |
| 152 | − | |
| 153 | −### 5.7 Sécurité du crawl (SSRF) | |
| 154 | −Bloquer : localhost, 127.0.0.0/8, plages privées IPv4/IPv6, endpoints de métadonnées cloud, `file://`, schémas dangereux. Revalider DNS/IP. Chaque réponse a des limites : taille max, redirections max, timeout, temps de parse max, liens extraits max, profondeur max. | |
| 155 | − | |
| 156 | −### 5.8 Pièges de crawl | |
| 157 | −Détecter : calendriers infinis, session IDs, explosions de navigation à facettes, pagination infinie, paramètres aléatoires, boucles. Limites par pattern/domaine. | |
| 158 | − | |
| 159 | −### 5.9 Erreurs | |
| 160 | −Codes structurés : DNS, timeout, TLS, 4xx, 5xx, robots refusé, parse échoué, contenu non supporté, trop gros, doublon, spam, non pertinent Québec. L'échec est normal; il est traqué. | |
| 161 | − | |
| 162 | −### 5.10 Rendu navigateur | |
| 163 | −**Jamais** de Chromium par page. Défaut : fetch HTTP. Le navigateur est un fallback spécialisé par domaine — essentiel pour l'échelle et pour tenir sur m2m32. | |
| 164 | − | |
| 165 | −--- | |
| 166 | − | |
| 167 | −## 6. Extraction de contenu | |
| 168 | − | |
| 169 | −Extraire : titre, meta description, corps principal, headings, données structurées (JSON-LD, schema.org, microdata, OpenGraph), URL canonique, langue, liens + anchors, dates de publication/modification, auteur, indices d'organisation et d'adresse. | |
| 170 | − | |
| 171 | −Retirer : navigation, menus, bannières cookies, footers répétitifs, scripts, styles, pub. | |
| 172 | − | |
| 173 | −Types au départ : HTML, texte, PDF. Architecture extensible (DOCX/XLSX/PPTX, RSS/Atom) sans que l'extraction coûteuse bloque le crawl HTML. | |
| 174 | − | |
| 175 | −Traiter chaque page crawlée comme **non fiable** : sanitizer HTML, URLs, métadonnées; ne jamais faire confiance aux MIME types distants. | |
| 176 | − | |
| 177 | −--- | |
| 178 | − | |
| 179 | −## 7. Détection Québec | |
| 180 | − | |
| 181 | −L'innovation clé. Un `.ca` seul ne suffit pas. Calculer `quebec_score ∈ [0,1]` avec **deux scores distincts** : | |
| 182 | − | |
| 183 | −- `domain_quebec_score` | |
| 184 | −- `page_quebec_score` (un article du NYT sur Montréal peut être pertinent sans que le domaine le soit) | |
| 185 | − | |
| 186 | −Signaux : toponymes (Québec, Montréal, Gatineau, Sherbrooke, Trois-Rivières, Saguenay, Laval, Longueuil…), adresses postales QC, province dans les adresses structurées, indicatifs téléphoniques (signal faible), organisations québécoises connues (entreprises, municipalités, universités, médias, gouvernement), langue (le français augmente la probabilité sans la prouver), graphe de domaines (un domaine massivement lié par des domaines québécois gagne du signal), métadonnées structurées, pages contact/footer. | |
| 187 | − | |
| 188 | −--- | |
| 189 | − | |
| 190 | −## 8. Découverte | |
| 191 | − | |
| 192 | −**Qualité des seeds > quantité.** Démarrer avec des nœuds fortement connectés : gouvernement du Québec, municipalités, universités, cégeps, grands médias, annuaires d'affaires, associations professionnelles, chambres de commerce, tourisme régional, grandes entreprises. | |
| 193 | − | |
| 194 | −Boucle : crawl → extraction des liens sortants → scoring Québec des domaines cibles → ajout au frontier → répéter. Détecter automatiquement sitemaps (`/sitemap.xml`, entrées robots.txt, index de sitemaps) et flux RSS/Atom, sans confiance aveugle. | |
| 195 | − | |
| 196 | −Maintenir une base de domaines : `domain, first_seen, last_crawled, page_count, quebec_score, language_distribution, robots_status, authority_score, inlinks, outlinks, content_change_rate…` | |
| 197 | − | |
| 198 | −Construire le graphe de liens dès le crawl (Page→Page, Domain→Domain, Org→Domain…) — d'abord pour le ranking et la découverte, plus tard pour l'écosystème KA. | |
| 199 | − | |
| 200 | −--- | |
| 201 | − | |
| 202 | −## 9. Index et ranking | |
| 203 | − | |
| 204 | −Document indexé (cible) : `id, url, canonical_url, domain, title, description, body, headings, language, page_quebec_score, domain_quebec_score, locations, organizations, people, categories, published_at, crawled_at, authority_score, freshness_score, quality_score, spam_score, embedding`. | |
| 205 | − | |
| 206 | −**BM25 d'abord.** Puis fonction de ranking dédiée : | |
| 207 | − | |
| 208 | −`Score(d,q) = w_b·BM25 + w_s·Sémantique + w_q·Québec + w_a·Autorité + w_f·Fraîcheur + w_l·Localité + w_u·Qualité − w_p·Spam` | |
| 209 | − | |
| 210 | −Le ranking est un package/service indépendant, évolutif, aux poids non figés. | |
| 211 | − | |
| 212 | −**Québec-first :** pour `meilleur programme thermopompe`, Hydro-Québec, un programme gouvernemental ou une entreprise CVC québécoise doivent battre un article international générique, à pertinence égale. | |
| 213 | − | |
| 214 | −**Bilingue dès le départ :** comprendre `thermopompe` ↔ `heat pump`. Pas de traduction à l'ingestion; plus tard : embeddings multilingues, expansion de requête, dictionnaires de synonymes. | |
| 215 | − | |
| 216 | −**Pipeline de requête :** normalisation → détection de langue → correction → intention → extraction d'entités/lieux → retrieval lexical → retrieval sémantique → fusion → reranking. **Chaque composant avancé est optionnel; BM25 fonctionne même si tout le reste tombe.** | |
| 217 | − | |
| 218 | −**Localisation :** `plombier Gatineau`, `subvention Sherbrooke` boostent les documents géographiquement pertinents sur preuve géographique explicite, pas juste du keyword matching. | |
| 219 | − | |
| 220 | −--- | |
| 221 | − | |
| 222 | −## 10. Interface | |
| 223 | − | |
| 224 | −**Homepage = la boîte de recherche.** Rien d'autre d'important. | |
| 225 | − | |
| 226 | −``` | |
| 227 | − Trouve-KA | |
| 228 | − [ Rechercher... ] | |
| 229 | − Chercher | |
| 230 | − Cherche le Québec. | |
| 231 | −``` | |
| 232 | − | |
| 233 | −Résultats : favicon, titre, URL/breadcrumb, snippet, badges optionnels (Québec, Gouvernement, Entreprise…). Sensation : rapide, propre, premium, minimal, fiable. Filtres MVP sobres : Tout / Actualités / Gouvernement / Français / English / Québec seulement. | |
| 234 | − | |
| 235 | −**Compteur d'index visible** (« 18 432 pages indexées » le jour 1, c'est parfait — la croissance fait partie du produit) + page `/status` : pages/domaines indexés, débit horaire, état du crawler et de l'index. | |
| 236 | − | |
| 237 | −**Dashboard admin** : longueur des files, débits fetch/parse/index, distribution HTTP, domaines actifs, blocages robots, retries, latences, stockage, taux de doublons, taux d'acceptation Québec, flux live du crawl. | |
| 238 | − | |
| 239 | −Mobile impeccable, accessibilité complète (navigation clavier, HTML sémantique, contrastes, focus states). Design : minimal, moderne, québécois sans clichés — **pas** de fleurs de lys partout, pas de hero marketing, pas de gradients IA. | |
| 240 | − | |
| 241 | −--- | |
| 242 | − | |
| 243 | −## 11. APIs | |
| 244 | − | |
| 245 | −**Recherche :** `GET /api/search?q=&page=&limit=&language=&location=&category=&freshness=` → résultats structurés (`title, url, display_url, snippet, domain, score`, `took_ms`, `total`). Ne pas exposer le scoring interne brut en prod. | |
| 246 | − | |
| 247 | −**Contrôle du crawler (protégé, jamais public sans auth) :** pause/reprise, ajout de seeds, recrawl URL/domaine, inspection du frontier, priorités, blocage de domaine. | |
| 248 | − | |
| 249 | −**Soumission d'URL** (feature simple) : « Soumettre un site québécois » → frontier. Soumission ≠ inclusion; le crawler valide. | |
| 250 | − | |
| 251 | −--- | |
| 252 | − | |
| 253 | −## 12. Coûts, LLM et embeddings | |
| 254 | − | |
| 255 | −Le crawl doit être **économique** : bande passante, compute, stockage — pas d'API LLM ni d'API de scraping commerciale par page. Le cœur (fetch, parse, canonicalisation, langue, hash, doublons, BM25) est déterministe. Les LLM enrichissent des pages **sélectionnées** en asynchrone (catégorisation, entités complexes, classification géographique ambiguë). Embeddings : toujours en arrière-plan, jamais bloquants. | |
| 256 | − | |
| 257 | −**Stockage brut :** métadonnées → Postgres; contenu cherchable → index; HTML brut compressé optionnel → object storage. Pas de blobs HTML géants dans Postgres. | |
| 258 | − | |
| 259 | −Analytics de recherche : métriques agrégées et respectueuses de la vie privée (requête, latence, position cliquée, zéro-résultat, langue). **Les requêtes zéro-résultat sont de l'or** : les stocker pour piloter le crawl par la demande. Pas de profils utilisateurs invasifs. | |
| 260 | − | |
| 261 | −--- | |
| 262 | − | |
| 263 | −## 13. Observabilité et dégradation | |
| 264 | − | |
| 265 | −Logs structurés avec `crawl_id, url_id, domain_id, worker_id, job_id`. Métriques compatibles Prometheus/Grafana/OpenTelemetry (choix pragmatique). | |
| 266 | − | |
| 267 | −**Dégradation gracieuse obligatoire :** embeddings en panne → lexical continue; enrichissement en retard → nouvelles pages cherchables quand même; un worker crash → frontier continue; frontend redémarre → crawler continue. | |
| 268 | − | |
| 269 | −Workers scalables indépendamment (`crawler-worker × N`, `parser-worker × N`, `indexer-worker × N`) — mais **pas de complexité distribuée prématurée** : Postgres + Redis + backend de recherche + quelques workers suffisent au départ. | |
| 270 | − | |
| 271 | −--- | |
| 272 | − | |
| 273 | −## 14. Provenance, droit d'auteur, avenir | |
| 274 | − | |
| 275 | −- Toujours préserver : URL originale, canonique, timestamp de crawl, domaine source. Trouve-KA renvoie vers les éditeurs originaux — c'est un index, pas un remplacement de contenu. | |
| 276 | −- Snippets raisonnables, attribution, liens sortants. Stockage brut, indexation, cache et affichage conçus séparément. | |
| 277 | −- Futur (non-MVP mais à ne pas bloquer architecturalement) : webmaster tools, verticales (news, entreprises, gouvernement, immobilier…), historique de versions des pages, réponses IA basées **uniquement** sur l'index avec citations, écosystème KA (Person-KA, Service-KA, Entreprise-KA…) alimenté par l'extraction d'entités asynchrone. Pas de collecte de données personnelles invasives. | |
| 278 | − | |
| 279 | −--- | |
| 280 | − | |
| 281 | −## 15. Configuration et DX | |
| 282 | − | |
| 283 | −Variables d'env validées : `DATABASE_URL, REDIS_URL, SEARCH_URL, CRAWLER_USER_AGENT, CRAWLER_CONTACT_URL, MAX_GLOBAL_CONCURRENCY, DEFAULT_HOST_DELAY, MAX_RESPONSE_BYTES, NGROK_DOMAIN=www.trouve-ka.com, PUBLIC_URL=https://www.trouve-ka.com`. Fournir `.env.example` sans secrets. | |
| 284 | − | |
| 285 | −Expérience développeur cible : | |
| 286 | − | |
| 287 | −``` | |
| 288 | −git clone … && cd trouve-ka | |
| 289 | −cp .env.example .env | |
| 290 | −docker compose up -d | |
| 291 | −pnpm install && pnpm dev | |
| 292 | −pnpm crawl:seed | |
| 293 | −# → des résultats apparaissent en quelques instants sur localhost:3000 | |
| 294 | −``` | |
| 295 | − | |
| 296 | −Déploiement m2m32 : même stack via compose + tunnel ngrok vers www.trouve-ka.com, scripté et documenté. | |
| 297 | − | |
| 298 | −--- | |
| 299 | − | |
| 300 | −## 16. Tests et évaluation | |
| 301 | − | |
| 302 | −Tests unitaires sur la logique critique : normalisation d'URL, robots, scoring Québec, canonical, doublons, scheduling, prévention SSRF, extraction HTML, API de recherche. | |
| 303 | − | |
| 304 | −Tests d'intégration sur un **web de fixtures local** (plombier québécois, université montréalaise, entreprise ontarienne, doublon, redirection, chemin bloqué par robots, article FR, article EN québécois) : fixture → crawler → parser → index → résultat de recherche. | |
| 305 | − | |
| 306 | −Dataset d'évaluation du ranking (« université québec », « plombier gatineau », « subvention thermopompe »…) avec domaines attendus. Les expériences de ranking doivent être mesurables. Scripts de benchmark : fetches/s, parses/s, docs indexés/s, latence de recherche, lag de queue, RAM/CPU/disque — l'optimisation est fondée sur des preuves, pas des intuitions. | |
| 307 | − | |
| 308 | −**Cibles :** recherche p50 < 100 ms, p95 < 300 ms à l'échelle MVP; page HTML normale cherchable en secondes; UI quasi instantanée. | |
| 309 | − | |
| 310 | −--- | |
| 311 | − | |
| 312 | −## 17. Ordre d'implémentation | |
| 313 | − | |
| 314 | −1. **Fondation** — monorepo, BD, queue, backend de recherche, web, API, crawler basique. | |
| 315 | −2. **Boucle complète** — seed → fetch → parse → index → search. **Ne pas continuer tant que ça ne marche pas de bout en bout.** | |
| 316 | −3. **Découverte continue** — liens sortants, frontier, domaines, scheduler, robots. | |
| 317 | −4. **Intelligence Québec** — scores, langue, localisation, scoring de domaines. | |
| 318 | −5. **Qualité de recherche** — BM25 custom, snippets, autorité, fraîcheur, doublons, compréhension de requête. | |
| 319 | −6. **Échelle** — plus de workers, meilleures queues, recrawl, monitoring. | |
| 320 | −7. **Couche sémantique** — embeddings, hybride, reranking, entités. | |
| 321 | −8. **Écosystème KA.** | |
| 322 | − | |
| 323 | −--- | |
| 324 | − | |
| 325 | −## 18. Milestone critique (non négociable) | |
| 326 | − | |
| 327 | −La première implémentation n'est réussie que si Claude démontre : | |
| 328 | − | |
| 329 | −1. démarrage du stack; | |
| 330 | −2. seed de sites québécois; | |
| 331 | −3. le crawler découvre des URLs; | |
| 332 | −4. télécharge des pages; | |
| 333 | −5. le parser extrait du texte utile; | |
| 334 | −6. les pages reçoivent un score Québec; | |
| 335 | −7. insertion **immédiate** dans l'index; | |
| 336 | −8. l'app web publique cherche ces pages; | |
| 337 | −9. les nouvelles pages deviennent cherchables **sans rien redémarrer**; | |
| 338 | −10. le frontier continue de découvrir des sites. | |
| 339 | − | |
| 340 | −**Succès Jour 1 :** même avec 10 000 pages, l'architecture se comporte exactement comme elle le fera avec 10 000 000+. Le crawler étend sa connaissance du web québécois pendant que le moteur est déjà opérationnel sur tout ce qui a été découvert. | |
| 341 | − | |
| 342 | −--- | |
| 343 | − | |
| 344 | −## 19. Méthode de travail de Claude | |
| 345 | − | |
| 346 | −Avant tout code substantiel : analyser cette spec → inspecter le dépôt existant → identifier le réutilisable → rechercher les décisions techniques incertaines → planifier → choisir sur preuves → documenter les décisions d'architecture → **implémenter verticalement** (du logiciel qui marche, pas de l'architecture spéculative). | |
| 347 | − | |
| 348 | −**Autonomie :** décisions raisonnables sans demander en permanence. Plusieurs options valables → analyser, choisir la meilleure, documenter, avancer. Ne bloquer que si c'est réellement bloquant. | |
| 349 | − | |
| 350 | −**Standard de qualité :** code typé, documenté, modulaire, observable, testable, sécurisé, efficace, orienté production. Pas de fichiers géants ni de couplage profond. Schémas migrables (concepts propres : `domains, urls, crawl_attempts, documents, document_versions, links, frontier_items, robots_rules, entities, index_jobs`). Et bien sûr : **header auteur dans chaque fichier** (§0.1). | |
| 351 | − | |
| 352 | −**Documentation :** README excellent (quoi, architecture, quick start, crawl, indexation incrémentale, recherche, layout, env, dev, tests, déploiement m2m32 + ngrok) et docs d'architecture avec diagrammes Mermaid. | |
| 353 | − | |
| 354 | −--- | |
| 355 | − | |
| 356 | −## 20. Vision | |
| 357 | − | |
| 358 | −L'objectif n'est pas de battre Google sur « Taylor Swift » ou « iPhone ». C'est de devenir extraordinairement bon sur : entreprises, institutions, gouvernement, municipalités, services, personnes, événements, produits, immobilier, documents, nouvelles et savoir local **du Québec**. | |
| 359 | − | |
| 360 | −Chaque page crawlée doit accroître la connaissance propre de Trouve-KA — jamais générer une requête vers l'API de quelqu'un d'autre. Chaque crawl améliore la couverture, le graphe de liens, la classification Québec, l'autorité, la fraîcheur, les priorités futures. **La valeur du dataset se compose dans le temps.** | |
| 361 | − | |
| 362 | −> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.** | |
| 363 | −> Le web ne « finit » jamais. Trouve-KA non plus. | |
deleted
M2M32crouve-ka/README.md
+0 −166
@@ -1,166 +0,0 @@ | ||
| 1 | −# Trouve-KA — Cherche le Québec. | |
| 2 | − | |
| 3 | −**Moteur de recherche web indépendant, Québec-first.** Son propre crawler, son propre | |
| 4 | −index, son propre ranking, son API et son application web publique. Pas un métamoteur : | |
| 5 | −aucune dépendance à Google, Bing ou Brave pour les résultats. | |
| 6 | − | |
| 7 | −> **Crawl en continu. Indexe immédiatement. Recherche immédiatement. Améliore en asynchrone.** | |
| 8 | − | |
| 9 | −En production : **https://www.trouve-ka.com** (node m2m32 du cluster MacLustr, tunnel ngrok). | |
| 10 | − | |
| 11 | −Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 12 | − | |
| 13 | −--- | |
| 14 | − | |
| 15 | −## Ce que c'est | |
| 16 | − | |
| 17 | −``` | |
| 18 | −Crawler → Frontier → Fetcher → Parser → Classification Québec | |
| 19 | −→ Déduplication → Indexer → Index → Ranking → API → Web App | |
| 20 | −``` | |
| 21 | − | |
| 22 | −Le crawler découvre le web québécois à partir de seeds à forte autorité (gouvernement, | |
| 23 | −municipalités, universités, médias), suit les liens sortants, juge la pertinence | |
| 24 | −québécoise de chaque page (`page_quebec_score` **et** `domain_quebec_score`), | |
| 25 | −et indexe **immédiatement** : une page fetchée est cherchable en ~2-4 secondes, | |
| 26 | −pendant que le frontier continue de grandir. L'enrichissement (autorité, entités, | |
| 27 | −embeddings) arrive après, en asynchrone, sans jamais bloquer. | |
| 28 | − | |
| 29 | −Détails : [docs/architecture.md](docs/architecture.md) (diagrammes Mermaid) et | |
| 30 | −[docs/decisions.md](docs/decisions.md) (pourquoi OpenSearch, pourquoi Postgres | |
| 31 | −comme frontier, etc.). | |
| 32 | − | |
| 33 | −## Stack | |
| 34 | − | |
| 35 | −| Couche | Choix | | |
| 36 | −|---|---| | |
| 37 | −| Web | Next.js 15, TypeScript, React 19, Tailwind (composants style shadcn/ui) | | |
| 38 | −| API | FastAPI (Python 3.12) | | |
| 39 | −| Pipeline | Python 3.12 async — httpx, selectolax, Protego | | |
| 40 | −| BD relationnelle | PostgreSQL 16 (frontier, domaines, documents, graphe de liens, analytics) | | |
| 41 | −| Coordination | Redis 7 (politesse par hôte, pause, Redis Streams pour l'enrichissement) | | |
| 42 | −| Recherche | OpenSearch 2.17 (BM25 FR/EN, synonymes bilingues, function_score Québec-first) | | |
| 43 | −| Déploiement | Docker Compose sur m2m32 + ngrok (www.trouve-ka.com) | | |
| 44 | − | |
| 45 | −## Layout du monorepo | |
| 46 | − | |
| 47 | −``` | |
| 48 | −apps/web # moteur public + dashboard /admin (Next.js) | |
| 49 | −apps/api # FastAPI (trouveka.api) | |
| 50 | −services/ # crawler, frontier, parser, classifier, indexer, ranking, scheduler, enrichment | |
| 51 | −packages/ # config, database, logging, queue, search-core, shared, types | |
| 52 | −infrastructure/ # docker/, migrations/, monitoring/, deployment/ (m2m32 + ngrok) | |
| 53 | −scripts/ # bootstrap-seeds/, start-crawler/, health-check/, eval/, check-headers.py | |
| 54 | −tests/ # unitaires : canonicalisation, SSRF, robots, scoring Québec, fetcher, ranking | |
| 55 | −``` | |
| 56 | − | |
| 57 | −Le backend Python est un seul package namespace `trouveka.*` mappé sur ce layout | |
| 58 | −(voir `pyproject.toml`). | |
| 59 | − | |
| 60 | −## Démarrage rapide (dev) | |
| 61 | − | |
| 62 | −```bash | |
| 63 | −git clone … && cd trouve-ka | |
| 64 | −cp .env.example .env | |
| 65 | −docker compose up -d postgres redis opensearch # infra | |
| 66 | −uv venv --python 3.12 .venv && uv pip install -e ".[dev]" --python .venv/bin/python | |
| 67 | −pnpm install | |
| 68 | − | |
| 69 | −pnpm crawl:seed # migrations + 60+ seeds québécoises | |
| 70 | −bash scripts/start-crawler/start.sh & # le crawl démarre | |
| 71 | −.venv/bin/uvicorn trouveka.api.main:app --port 8080 & | |
| 72 | −pnpm dev # → http://localhost:3000 | |
| 73 | −# → des résultats apparaissent en quelques secondes | |
| 74 | −``` | |
| 75 | − | |
| 76 | −Ports occupés sur la machine? Surcharger dans `.env` : `PG_PORT`, `REDIS_PORT`, | |
| 77 | −`SEARCH_PORT`, `API_PORT` (et les URLs correspondantes). | |
| 78 | − | |
| 79 | −Stack complet en containers : `docker compose up -d --build` (le service `migrate` | |
| 80 | −applique les migrations, `crawler-worker` se scale avec | |
| 81 | −`docker compose up -d --scale crawler-worker=3`). | |
| 82 | − | |
| 83 | −## Comment ça marche | |
| 84 | − | |
| 85 | −### Crawl et politesse | |
| 86 | −- **Identité assumée** : UA `Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)`, | |
| 87 | − page publique [/trouveka-bot](https://www.trouve-ka.com/trouveka-bot), pas de stealth. | |
| 88 | −- **robots.txt** parsé avec Protego, cache 24 h en base; `noindex`/`nofollow`/`X-Robots-Tag` respectés. | |
| 89 | −- **Politesse par origine** : verrou Redis par hôte (défaut 2 s entre requêtes, `Crawl-delay` respecté), | |
| 90 | − quel que soit le nombre de workers. | |
| 91 | −- **Sécurité** : garde SSRF (IP privées/loopback/métadonnées cloud bloquées, revalidée à chaque | |
| 92 | − redirection), limites par réponse (3 Mo, 5 redirections, timeout 20 s), détection de pièges | |
| 93 | − (session IDs, calendriers infinis, facettes explosives, pagination sans fin). | |
| 94 | − | |
| 95 | −### Indexation incrémentale | |
| 96 | −Le worker exécute fetch→parse→score→index **inline** : `refresh_interval: 1s` côté | |
| 97 | −OpenSearch → cherchable en secondes. Détection de changement par hash de contenu + | |
| 98 | −ETag/If-Modified-Since; recrawl adaptatif (inchangé → intervalle ×2, volatil → ÷2). | |
| 99 | − | |
| 100 | −### Détection Québec | |
| 101 | −Deux scores distincts (`page` et `domaine`) calculés à partir de : TLD (.qc.ca, .quebec), | |
| 102 | −gazetteer de toponymes (pondération réduite pour les ambigus type Laval/Hull), codes | |
| 103 | −postaux G/H/J, indicatifs (418/514/438/…), organisations connues (Hydro-Québec, RAMQ, | |
| 104 | −UQAM…), mentions structurées de la province (JSON-LD), langue française (indice, pas preuve). | |
| 105 | −Déterministe et gratuit — aucun LLM dans le chemin chaud (§12). | |
| 106 | − | |
| 107 | −### Ranking | |
| 108 | −`function_score` OpenSearch : BM25 bilingue (analyzers FR + EN, synonymes | |
| 109 | −thermopompe↔heat pump à la recherche) + scores Québec + autorité de domaine | |
| 110 | −(inlinks pondérés) + fraîcheur + boost de localité (« plombier Gatineau » → documents | |
| 111 | −avec preuve géographique `locations`). Chaque composant est optionnel; BM25 tient seul. | |
| 112 | − | |
| 113 | −Évaluation mesurable : `python3 scripts/eval/run-eval.py --api http://localhost:8080` | |
| 114 | −(dataset dans `scripts/eval/ranking-eval.yaml`). | |
| 115 | − | |
| 116 | −## API | |
| 117 | − | |
| 118 | −- `GET /api/search?q=&page=&limit=&language=&category=&quebec_only=&freshness=` | |
| 119 | −- `GET /api/status` — compteurs publics (pages, domaines, débit, état du crawler) | |
| 120 | −- `POST /api/submit {"url": …}` — soumettre un site québécois (soumission ≠ inclusion) | |
| 121 | −- `GET|POST /api/admin/*` — protégé par header `X-Admin-Token` : overview, flux live, | |
| 122 | − pause/reprise, seeds, recrawl, blocage de domaine, inspection du frontier | |
| 123 | − | |
| 124 | −## Tests | |
| 125 | − | |
| 126 | −```bash | |
| 127 | −.venv/bin/python -m pytest tests/ # 54 tests : URLs, SSRF, pièges, Québec, frontier, parser, fetcher, ranking | |
| 128 | −python3 scripts/check-headers.py # header auteur obligatoire dans chaque fichier source (CI) | |
| 129 | −bash scripts/health-check/check.sh # santé du stack | |
| 130 | −``` | |
| 131 | − | |
| 132 | −## Déploiement m2m32 + ngrok (www.trouve-ka.com) | |
| 133 | − | |
| 134 | −Prérequis sur m2m32 : colima + docker + docker-compose (brew), ngrok authentifié, | |
| 135 | −domaine `www.trouve-ka.com` réservé dans le compte ngrok. | |
| 136 | − | |
| 137 | −```bash | |
| 138 | −pnpm deploy:m2m32 # = bash infrastructure/deployment/deploy-m2m32.sh | |
| 139 | −``` | |
| 140 | − | |
| 141 | −Le script : rsync du monorepo → `docker compose up -d --build` → migrations + seeds | |
| 142 | −(idempotent) → tunnel `ngrok http --url=www.trouve-ka.com 3000` → health-check. | |
| 143 | −Le web proxifie `/api/*` vers l'API interne : un seul port exposé, pas d'URL absolues | |
| 144 | −côté client, cookies/CORS sans surprise derrière le tunnel. | |
| 145 | − | |
| 146 | −Opérations courantes sur le node : | |
| 147 | − | |
| 148 | −```bash | |
| 149 | −ssh M2M32 'cd trouve-ka && docker compose logs -f crawler-worker' # crawl en direct | |
| 150 | −ssh M2M32 'cd trouve-ka && docker compose up -d --scale crawler-worker=3' | |
| 151 | −``` | |
| 152 | − | |
| 153 | −## Observabilité | |
| 154 | − | |
| 155 | −Logs JSON structurés par service, métriques réelles via `/api/status` et | |
| 156 | −`/api/admin/overview`, dashboard `/admin` (files, débits, distribution HTTP, latences | |
| 157 | −p50/p95, flux live). Voir [infrastructure/monitoring/README.md](infrastructure/monitoring/README.md). | |
| 158 | − | |
| 159 | −## Règles du dépôt | |
| 160 | − | |
| 161 | −- **Header auteur obligatoire** dans chaque fichier source (§0.1) — vérifié par | |
| 162 | − `scripts/check-headers.py`. | |
| 163 | −- **Aucune donnée factice** : pas de compteurs simulés, pas de résultats hard-codés; | |
| 164 | − les fixtures vivent dans `tests/` uniquement. | |
| 165 | −- Provenance préservée : URL originale, canonique, timestamp de crawl, domaine source. | |
| 166 | − Trouve-KA renvoie vers les éditeurs originaux. | |
deleted
M2M32crouve-ka/apps/api/__init__.py
+0 −5
@@ -1,5 +0,0 @@ | ||
| 1 | −# Trouve-KA — application API | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""API publique (recherche, statut, soumission) et API de contrôle du crawler (protégée).""" | |
deleted
M2M32crouve-ka/apps/api/main.py
+0 −297
@@ -1,297 +0,0 @@ | ||
| 1 | −# Trouve-KA — API FastAPI | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""API de Trouve-KA. | |
| 6 | − | |
| 7 | −Publique : /api/search, /api/status, /api/submit, /api/health | |
| 8 | −Protégée (X-Admin-Token) : /api/admin/* — contrôle du crawler, jamais public sans auth. | |
| 9 | − | |
| 10 | −Dégradation gracieuse : si OpenSearch tombe, /api/status répond quand même; | |
| 11 | −si Postgres tombe, la recherche répond quand même (analytics sautées). | |
| 12 | −""" | |
| 13 | − | |
| 14 | −import html | |
| 15 | −import re | |
| 16 | −import time | |
| 17 | −from contextlib import asynccontextmanager | |
| 18 | −from typing import Annotated | |
| 19 | − | |
| 20 | −from fastapi import Depends, FastAPI, Header, HTTPException, Query | |
| 21 | −from fastapi.middleware.cors import CORSMiddleware | |
| 22 | −from pydantic import BaseModel | |
| 23 | − | |
| 24 | −from trouveka.config import get_settings | |
| 25 | −from trouveka.database import Database | |
| 26 | −from trouveka.logging import get_logger | |
| 27 | −from trouveka.queue import Coordination | |
| 28 | −from trouveka.ranking import build_search_body | |
| 29 | −from trouveka.search_core import SearchCore | |
| 30 | −from trouveka.shared import canonicalize_url, display_url, extract_domain, is_http_url | |
| 31 | − | |
| 32 | −log = get_logger("api") | |
| 33 | −settings = get_settings() | |
| 34 | − | |
| 35 | −db = Database(settings.database_url, pool_min=settings.pg_pool_min, pool_max=settings.pg_pool_max) | |
| 36 | −coord = Coordination(settings.redis_url) | |
| 37 | −search = SearchCore(settings.search_url, settings.search_index) | |
| 38 | − | |
| 39 | − | |
| 40 | −@asynccontextmanager | |
| 41 | −async def lifespan(_app: FastAPI): | |
| 42 | − await db.connect() | |
| 43 | − await search.ensure_index() | |
| 44 | − yield | |
| 45 | − await search.close() | |
| 46 | − await coord.close() | |
| 47 | − await db.close() | |
| 48 | − | |
| 49 | − | |
| 50 | −app = FastAPI(title="Trouve-KA API", version="0.1.0", lifespan=lifespan) | |
| 51 | − | |
| 52 | −# Derrière ngrok (www.trouve-ka.com), le web app proxifie /api : CORS permissif inutile | |
| 53 | −# en prod, mais pratique en dev local (web sur :3000, API sur :8080). | |
| 54 | −app.add_middleware( | |
| 55 | − CORSMiddleware, | |
| 56 | − allow_origins=["http://localhost:3000", settings.public_url], | |
| 57 | − allow_methods=["GET", "POST"], | |
| 58 | − allow_headers=["*", "X-Admin-Token"], | |
| 59 | −) | |
| 60 | − | |
| 61 | − | |
| 62 | −def require_admin(x_admin_token: Annotated[str | None, Header()] = None) -> None: | |
| 63 | − if not x_admin_token or x_admin_token != settings.admin_token: | |
| 64 | − raise HTTPException(status_code=401, detail="Jeton admin invalide") | |
| 65 | − | |
| 66 | − | |
| 67 | −_TAG_RE = re.compile(r"<(?!/?em>)[^>]*>") | |
| 68 | − | |
| 69 | − | |
| 70 | −def _safe_snippet(fragments: list[str]) -> str: | |
| 71 | − """Ne laisse passer que <em>/</em> (highlight); tout le reste est échappé par OpenSearch.""" | |
| 72 | − return _TAG_RE.sub("", " … ".join(fragments))[:400] | |
| 73 | − | |
| 74 | − | |
| 75 | −BADGE_LABELS = {"government": "Gouvernement", "news": "Actualités", "education": "Éducation"} | |
| 76 | − | |
| 77 | − | |
| 78 | −# ---------------------------------------------------------------------- publique | |
| 79 | − | |
| 80 | −@app.get("/api/health") | |
| 81 | −async def health(): | |
| 82 | − return {"ok": True, "search_ok": await search.ping()} | |
| 83 | − | |
| 84 | − | |
| 85 | −@app.get("/api/search") | |
| 86 | −async def api_search( | |
| 87 | − q: str = Query(..., min_length=1, max_length=200), | |
| 88 | − page: int = Query(1, ge=1, le=100), | |
| 89 | − limit: int = Query(10, ge=1, le=50), | |
| 90 | − language: str | None = Query(None, pattern="^(fr|en)$"), | |
| 91 | − location: str | None = None, | |
| 92 | − category: str | None = Query(None, max_length=40), | |
| 93 | − quebec_only: bool = False, | |
| 94 | − freshness: str | None = Query(None, pattern="^(day|week|month|year)$"), | |
| 95 | −): | |
| 96 | − started = time.monotonic() | |
| 97 | − query_text = f"{q} {location}" if location else q | |
| 98 | − body, analysis = build_search_body( | |
| 99 | − query_text, page=page, limit=limit, language=language, | |
| 100 | − category=category, quebec_only=quebec_only, freshness=freshness, | |
| 101 | − ) | |
| 102 | − try: | |
| 103 | − res = await search.search(body) | |
| 104 | − except Exception: | |
| 105 | − log.exception("recherche échouée", extra={"ctx": {"q": q}}) | |
| 106 | − raise HTTPException(status_code=503, detail="Le moteur de recherche est temporairement indisponible") | |
| 107 | − | |
| 108 | − took_ms = int((time.monotonic() - started) * 1000) | |
| 109 | − results = [] | |
| 110 | − for hit in res["hits"]["hits"]: | |
| 111 | − src = hit["_source"] | |
| 112 | − highlight = hit.get("highlight", {}) | |
| 113 | − fragments = highlight.get("body") or highlight.get("description") or [] | |
| 114 | − snippet = _safe_snippet(fragments) if fragments else html.escape(src.get("description") or "")[:400] | |
| 115 | − badges = [BADGE_LABELS[c] for c in src.get("categories", []) if c in BADGE_LABELS] | |
| 116 | − if src.get("page_quebec_score", 0) >= 0.45 or src.get("domain_quebec_score", 0) >= 0.6: | |
| 117 | − badges.insert(0, "Québec") | |
| 118 | − results.append({ | |
| 119 | − "title": src.get("title") or src["url"], | |
| 120 | − "url": src["url"], | |
| 121 | − "display_url": display_url(src["url"]), | |
| 122 | − "snippet": snippet, | |
| 123 | − "domain": src["domain"], | |
| 124 | − "language": src.get("language"), | |
| 125 | − "quebec_score": src.get("page_quebec_score", 0), | |
| 126 | − "badges": badges, | |
| 127 | − "published_at": src.get("published_at"), | |
| 128 | − }) | |
| 129 | − | |
| 130 | − total = res["hits"]["total"]["value"] | |
| 131 | − # Analytics agrégées, respectueuses de la vie privée — jamais bloquantes | |
| 132 | − try: | |
| 133 | − await db.record_search_query(q, analysis["language"], total, took_ms) | |
| 134 | − except Exception: | |
| 135 | − log.exception("analytics de recherche sautées") | |
| 136 | − | |
| 137 | − return {"query": q, "total": total, "took_ms": took_ms, "page": page, "limit": limit, "results": results} | |
| 138 | − | |
| 139 | − | |
| 140 | −@app.get("/api/status") | |
| 141 | −async def api_status(): | |
| 142 | − snapshot: dict = {} | |
| 143 | − try: | |
| 144 | − snapshot = await db.status_snapshot() | |
| 145 | − except Exception: | |
| 146 | − log.exception("statut PG indisponible") | |
| 147 | − search_ok = await search.ping() | |
| 148 | − try: | |
| 149 | − paused = await coord.is_paused() | |
| 150 | − except Exception: | |
| 151 | − paused = False | |
| 152 | − return { | |
| 153 | − "pages_indexed": snapshot.get("pages_indexed", 0), | |
| 154 | − "domains_count": snapshot.get("domains_count", 0), | |
| 155 | − "indexed_last_hour": snapshot.get("indexed_last_hour", 0), | |
| 156 | − "fetched_last_hour": snapshot.get("fetched_last_hour", 0), | |
| 157 | − "errors_last_hour": snapshot.get("errors_last_hour", 0), | |
| 158 | − "frontier_pending": snapshot.get("frontier_pending", 0), | |
| 159 | − "frontier_in_progress": snapshot.get("frontier_in_progress", 0), | |
| 160 | − "crawler_state": "paused" if paused else "running", | |
| 161 | − "search_ok": search_ok, | |
| 162 | − } | |
| 163 | − | |
| 164 | − | |
| 165 | −@app.get("/api/live") | |
| 166 | −async def api_live(): | |
| 167 | − """Dernière page visitée par TrouveKABot — alimente le flux temps réel du footer. | |
| 168 | − | |
| 169 | − Public mais volontairement minimal : domaine + URL + horodatage, rien d'interne. | |
| 170 | − """ | |
| 171 | − try: | |
| 172 | − events = await db.recent_events(1) | |
| 173 | − except Exception: | |
| 174 | − return {"event": None} | |
| 175 | − if not events: | |
| 176 | − return {"event": None} | |
| 177 | − e = events[0] | |
| 178 | − return { | |
| 179 | − "event": { | |
| 180 | − "at": e["at"], | |
| 181 | − "url": e["url"], | |
| 182 | − "domain": extract_domain(e["url"]), | |
| 183 | − "outcome": e["outcome"], | |
| 184 | − } | |
| 185 | − } | |
| 186 | − | |
| 187 | − | |
| 188 | −class SubmitBody(BaseModel): | |
| 189 | − url: str | |
| 190 | − | |
| 191 | − | |
| 192 | −@app.post("/api/submit") | |
| 193 | −async def api_submit(payload: SubmitBody): | |
| 194 | − """Soumettre un site québécois. Soumission ≠ inclusion : le crawler valide.""" | |
| 195 | − if not is_http_url(payload.url): | |
| 196 | − raise HTTPException(status_code=422, detail="URL invalide (http/https seulement)") | |
| 197 | − url = canonicalize_url(payload.url) | |
| 198 | − domain = extract_domain(url) if url else None | |
| 199 | − if not url or not domain: | |
| 200 | − raise HTTPException(status_code=422, detail="URL invalide") | |
| 201 | − await db.add_submission(url) | |
| 202 | − await db.enqueue_url(url, domain, priority=0.7, depth=0) | |
| 203 | − return { | |
| 204 | − "accepted": True, | |
| 205 | − "message": "Merci! Le site sera visité par TrouveKABot. La soumission ne garantit pas l'inclusion.", | |
| 206 | − } | |
| 207 | − | |
| 208 | − | |
| 209 | −# ---------------------------------------------------------------------- admin | |
| 210 | − | |
| 211 | −class SeedsBody(BaseModel): | |
| 212 | − urls: list[str] | |
| 213 | − | |
| 214 | − | |
| 215 | −class RecrawlBody(BaseModel): | |
| 216 | − url: str | None = None | |
| 217 | − domain: str | None = None | |
| 218 | − | |
| 219 | − | |
| 220 | −class DomainBody(BaseModel): | |
| 221 | − domain: str | |
| 222 | − | |
| 223 | − | |
| 224 | −@app.get("/api/admin/overview", dependencies=[Depends(require_admin)]) | |
| 225 | −async def admin_overview(): | |
| 226 | − overview = await db.admin_overview() | |
| 227 | − overview["paused"] = await coord.is_paused() | |
| 228 | − try: | |
| 229 | − overview["enrich_backlog"] = await coord.enrich_backlog() | |
| 230 | − except Exception: | |
| 231 | − overview["enrich_backlog"] = None | |
| 232 | − return overview | |
| 233 | − | |
| 234 | − | |
| 235 | −@app.get("/api/admin/recent", dependencies=[Depends(require_admin)]) | |
| 236 | −async def admin_recent(limit: int = Query(50, ge=1, le=200)): | |
| 237 | − return {"events": await db.recent_events(limit)} | |
| 238 | − | |
| 239 | − | |
| 240 | −@app.post("/api/admin/pause", dependencies=[Depends(require_admin)]) | |
| 241 | −async def admin_pause(): | |
| 242 | − await coord.pause_crawler() | |
| 243 | − return {"paused": True} | |
| 244 | − | |
| 245 | − | |
| 246 | −@app.post("/api/admin/resume", dependencies=[Depends(require_admin)]) | |
| 247 | −async def admin_resume(): | |
| 248 | − await coord.resume_crawler() | |
| 249 | − return {"paused": False} | |
| 250 | − | |
| 251 | − | |
| 252 | −@app.post("/api/admin/seeds", dependencies=[Depends(require_admin)]) | |
| 253 | −async def admin_seeds(payload: SeedsBody): | |
| 254 | − added = 0 | |
| 255 | − for raw in payload.urls[:500]: | |
| 256 | − url = canonicalize_url(raw.strip()) | |
| 257 | − domain = extract_domain(url) if url else None | |
| 258 | − if url and domain: | |
| 259 | − if await db.enqueue_url(url, domain, priority=1.0, depth=0, is_seed=True): | |
| 260 | − added += 1 | |
| 261 | − return {"added": added} | |
| 262 | − | |
| 263 | − | |
| 264 | −@app.post("/api/admin/recrawl", dependencies=[Depends(require_admin)]) | |
| 265 | −async def admin_recrawl(payload: RecrawlBody): | |
| 266 | − if payload.url: | |
| 267 | − url = canonicalize_url(payload.url) | |
| 268 | − ok = await db.requeue_url(url) if url else False | |
| 269 | − return {"requeued": 1 if ok else 0} | |
| 270 | − if payload.domain: | |
| 271 | − return {"requeued": await db.requeue_domain(payload.domain.lower())} | |
| 272 | − raise HTTPException(status_code=422, detail="url ou domain requis") | |
| 273 | − | |
| 274 | − | |
| 275 | −@app.post("/api/admin/domains/block", dependencies=[Depends(require_admin)]) | |
| 276 | −async def admin_block_domain(payload: DomainBody): | |
| 277 | − await db.block_domain(payload.domain.lower()) | |
| 278 | − return {"blocked": payload.domain.lower()} | |
| 279 | − | |
| 280 | − | |
| 281 | −@app.get("/api/admin/frontier", dependencies=[Depends(require_admin)]) | |
| 282 | −async def admin_frontier( | |
| 283 | − domain: str | None = None, | |
| 284 | − status: str | None = Query(None, pattern="^(pending|in_progress|done|failed|blocked)$"), | |
| 285 | − limit: int = Query(100, ge=1, le=500), | |
| 286 | −): | |
| 287 | − return {"items": await db.frontier_inspect(domain, status, limit)} | |
| 288 | − | |
| 289 | − | |
| 290 | −def main() -> None: | |
| 291 | − import uvicorn | |
| 292 | − | |
| 293 | − uvicorn.run("trouveka.api.main:app", host=settings.api_host, port=settings.api_port, workers=1) | |
| 294 | − | |
| 295 | − | |
| 296 | −if __name__ == "__main__": | |
| 297 | − main() | |
deleted
M2M32crouve-ka/docker-compose.yml
+0 −149
@@ -1,149 +0,0 @@ | ||
| 1 | −# Trouve-KA — stack Docker Compose (dev local et déploiement m2m32) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | −# | |
| 5 | −# Dimensionné pour m2m32 (32 Go RAM) : OpenSearch 2 Go de heap, Postgres léger, | |
| 6 | −# workers scalables via `docker compose up -d --scale crawler-worker=3`. | |
| 7 | − | |
| 8 | −services: | |
| 9 | − postgres: | |
| 10 | − image: postgres:16-alpine | |
| 11 | − environment: | |
| 12 | − POSTGRES_USER: trouveka | |
| 13 | − POSTGRES_PASSWORD: trouveka | |
| 14 | − POSTGRES_DB: trouveka | |
| 15 | − ports: | |
| 16 | − - "${PG_PORT:-5432}:5432" | |
| 17 | − volumes: | |
| 18 | − - pgdata:/var/lib/postgresql/data | |
| 19 | − healthcheck: | |
| 20 | − test: ["CMD-SHELL", "pg_isready -U trouveka"] | |
| 21 | − interval: 5s | |
| 22 | − timeout: 3s | |
| 23 | − retries: 20 | |
| 24 | − | |
| 25 | − redis: | |
| 26 | − image: redis:7-alpine | |
| 27 | − ports: | |
| 28 | − - "${REDIS_PORT:-6379}:6379" | |
| 29 | − volumes: | |
| 30 | − - redisdata:/data | |
| 31 | − command: ["redis-server", "--appendonly", "yes"] | |
| 32 | − healthcheck: | |
| 33 | − test: ["CMD", "redis-cli", "ping"] | |
| 34 | − interval: 5s | |
| 35 | − timeout: 3s | |
| 36 | − retries: 20 | |
| 37 | − | |
| 38 | − opensearch: | |
| 39 | − image: opensearchproject/opensearch:2.17.1 | |
| 40 | − environment: | |
| 41 | − discovery.type: single-node | |
| 42 | − plugins.security.disabled: "true" | |
| 43 | − OPENSEARCH_JAVA_OPTS: "-Xms2g -Xmx2g" | |
| 44 | − OPENSEARCH_INITIAL_ADMIN_PASSWORD: "TrouveKA!Local1" | |
| 45 | − bootstrap.memory_lock: "true" | |
| 46 | − ulimits: | |
| 47 | − memlock: | |
| 48 | − soft: -1 | |
| 49 | − hard: -1 | |
| 50 | − ports: | |
| 51 | − - "${SEARCH_PORT:-9200}:9200" | |
| 52 | − volumes: | |
| 53 | − - osdata:/usr/share/opensearch/data | |
| 54 | − healthcheck: | |
| 55 | − test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"] | |
| 56 | − interval: 10s | |
| 57 | − timeout: 5s | |
| 58 | − retries: 30 | |
| 59 | − | |
| 60 | − api: | |
| 61 | − build: | |
| 62 | − context: . | |
| 63 | − dockerfile: infrastructure/docker/backend.Dockerfile | |
| 64 | − command: ["uvicorn", "trouveka.api.main:app", "--host", "0.0.0.0", "--port", "8080"] | |
| 65 | − environment: &backend_env | |
| 66 | − DATABASE_URL: postgresql://trouveka:trouveka@postgres:5432/trouveka | |
| 67 | − REDIS_URL: redis://redis:6379/0 | |
| 68 | − SEARCH_URL: http://opensearch:9200 | |
| 69 | − ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me-admin-token} | |
| 70 | − PUBLIC_URL: ${PUBLIC_URL:-https://www.trouve-ka.com} | |
| 71 | − MAX_GLOBAL_CONCURRENCY: ${MAX_GLOBAL_CONCURRENCY:-24} | |
| 72 | − DEFAULT_HOST_DELAY: ${DEFAULT_HOST_DELAY:-2.0} | |
| 73 | − MAX_RESPONSE_BYTES: ${MAX_RESPONSE_BYTES:-3000000} | |
| 74 | − ports: | |
| 75 | − - "8080:8080" | |
| 76 | − depends_on: | |
| 77 | − postgres: | |
| 78 | − condition: service_healthy | |
| 79 | − redis: | |
| 80 | − condition: service_healthy | |
| 81 | − opensearch: | |
| 82 | − condition: service_healthy | |
| 83 | − restart: unless-stopped | |
| 84 | − | |
| 85 | − migrate: | |
| 86 | − build: | |
| 87 | − context: . | |
| 88 | − dockerfile: infrastructure/docker/backend.Dockerfile | |
| 89 | − command: ["python", "-c", "import asyncio; from trouveka.database import run_migrations; from trouveka.config import get_settings; print(asyncio.run(run_migrations(get_settings().database_url)))"] | |
| 90 | − environment: *backend_env | |
| 91 | − depends_on: | |
| 92 | − postgres: | |
| 93 | − condition: service_healthy | |
| 94 | − restart: "no" | |
| 95 | − | |
| 96 | − crawler-worker: | |
| 97 | − build: | |
| 98 | − context: . | |
| 99 | − dockerfile: infrastructure/docker/backend.Dockerfile | |
| 100 | − command: ["python", "-m", "trouveka.crawler.worker"] | |
| 101 | − environment: *backend_env | |
| 102 | − depends_on: | |
| 103 | − migrate: | |
| 104 | − condition: service_completed_successfully | |
| 105 | − opensearch: | |
| 106 | − condition: service_healthy | |
| 107 | − redis: | |
| 108 | − condition: service_healthy | |
| 109 | − restart: unless-stopped | |
| 110 | − | |
| 111 | − enrichment-worker: | |
| 112 | − build: | |
| 113 | − context: . | |
| 114 | − dockerfile: infrastructure/docker/backend.Dockerfile | |
| 115 | − command: ["python", "-m", "trouveka.enrichment.worker"] | |
| 116 | − environment: *backend_env | |
| 117 | − depends_on: | |
| 118 | − migrate: | |
| 119 | − condition: service_completed_successfully | |
| 120 | − restart: unless-stopped | |
| 121 | − | |
| 122 | − scheduler: | |
| 123 | − build: | |
| 124 | − context: . | |
| 125 | − dockerfile: infrastructure/docker/backend.Dockerfile | |
| 126 | − command: ["python", "-m", "trouveka.scheduler.loop"] | |
| 127 | − environment: *backend_env | |
| 128 | − depends_on: | |
| 129 | − migrate: | |
| 130 | − condition: service_completed_successfully | |
| 131 | − restart: unless-stopped | |
| 132 | − | |
| 133 | − web: | |
| 134 | − build: | |
| 135 | − context: . | |
| 136 | − dockerfile: infrastructure/docker/web.Dockerfile | |
| 137 | − environment: | |
| 138 | − API_URL: http://api:8080 | |
| 139 | − PORT: "3000" | |
| 140 | − ports: | |
| 141 | − - "3000:3000" | |
| 142 | − depends_on: | |
| 143 | − - api | |
| 144 | − restart: unless-stopped | |
| 145 | − | |
| 146 | −volumes: | |
| 147 | − pgdata: | |
| 148 | − redisdata: | |
| 149 | − osdata: | |
deleted
M2M32crouve-ka/docs/architecture.md
+0 −106
@@ -1,106 +0,0 @@ | ||
| 1 | −# Trouve-KA — Architecture | |
| 2 | − | |
| 3 | −Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −## Vue d'ensemble | |
| 6 | − | |
| 7 | −```mermaid | |
| 8 | −flowchart LR | |
| 9 | − subgraph Découverte | |
| 10 | − SEEDS[Seeds §8] --> FRONTIER | |
| 11 | − SUBMIT[Soumissions /soumettre] --> FRONTIER | |
| 12 | − LINKS[Liens sortants] --> FRONTIER | |
| 13 | − end | |
| 14 | − FRONTIER[(Frontier<br/>Postgres)] -->|claim SKIP LOCKED| CW[crawler-worker × N] | |
| 15 | − CW -->|politesse SET NX PX| REDIS[(Redis)] | |
| 16 | − CW --> ROBOTS[robots.txt cache] | |
| 17 | − CW --> FETCH[Fetcher HTTP<br/>garde SSRF] | |
| 18 | − FETCH --> PARSE[Parser selectolax] | |
| 19 | − PARSE --> QC[Classification Québec<br/>page_quebec_score] | |
| 20 | − QC --> DEDUP[Hash contenu / doublons] | |
| 21 | − DEDUP -->|indexation IMMÉDIATE| OS[(OpenSearch<br/>trouveka-docs)] | |
| 22 | − DEDUP --> PG[(Postgres<br/>documents, domaines, liens)] | |
| 23 | − CW -->|jamais bloquant| STREAM[Redis Stream<br/>trouveka:enrich] | |
| 24 | − STREAM --> EW[enrichment-worker] | |
| 25 | − EW -->|update partiel| OS | |
| 26 | − SCHED[scheduler] -->|items abandonnés,<br/>autorité de domaine| PG | |
| 27 | − OS --> API[API FastAPI] | |
| 28 | − PG --> API | |
| 29 | − API --> WEB[Next.js web<br/>+ /admin] | |
| 30 | − WEB --> NGROK[ngrok<br/>www.trouve-ka.com] | |
| 31 | −``` | |
| 32 | − | |
| 33 | −## Principe cardinal (§0.3) | |
| 34 | − | |
| 35 | −```mermaid | |
| 36 | −sequenceDiagram | |
| 37 | − participant F as Frontier | |
| 38 | − participant W as crawler-worker | |
| 39 | − participant O as OpenSearch | |
| 40 | − participant U as Utilisateur | |
| 41 | − F->>W: claim URL (t+0s) | |
| 42 | − W->>W: fetch + parse + score Québec (t+2s) | |
| 43 | − W->>O: index_document (t+3s) | |
| 44 | − Note over O: refresh_interval 1s | |
| 45 | − U->>O: recherche (t+4s) — la page est déjà cherchable | |
| 46 | − W--)W: enrichissement async (étapes 2-3, plus tard) | |
| 47 | −``` | |
| 48 | − | |
| 49 | −Jamais de cycle « crawler tout → indexer → chercher ». L'enrichissement met à jour | |
| 50 | −des documents **déjà cherchables** (update partiel), il ne conditionne rien. | |
| 51 | − | |
| 52 | −## Cycle de vie d'une URL | |
| 53 | − | |
| 54 | −```mermaid | |
| 55 | −stateDiagram-v2 | |
| 56 | − [*] --> pending: découverte (seed, lien, soumission) | |
| 57 | − pending --> in_progress: claim (priorité DESC, SKIP LOCKED) | |
| 58 | − in_progress --> pending: politesse (trop tôt pour cet hôte) | |
| 59 | − in_progress --> pending: succès → next_crawl_at adaptatif | |
| 60 | − in_progress --> pending: erreur transitoire (retry backoff ≤3) | |
| 61 | − in_progress --> done: robots refusé / redirection / doublon | |
| 62 | − in_progress --> failed: erreur permanente ou retries épuisés | |
| 63 | − in_progress --> blocked: domaine bloqué | |
| 64 | − pending --> in_progress: recrawl (fréquence mesurée §5.5) | |
| 65 | −``` | |
| 66 | − | |
| 67 | −## Scores (§4, §7, §9) | |
| 68 | − | |
| 69 | −| Étape | Quand | Champs | | |
| 70 | −|---|---|---| | |
| 71 | −| 1 — immédiat | pipeline inline | titre, corps, headings, langue, `page_quebec_score`, `locations`, catégories grossières | | |
| 72 | −| 2 — async | enrichment-worker | `domain_quebec_score` à jour, `authority_score` propagé (plus tard : embeddings, entités) | | |
| 73 | −| 3 — async | scheduler | autorité de domaine depuis `domain_links` (inlinks pondérés Québec) | | |
| 74 | − | |
| 75 | −Ranking à la requête : `function_score = BM25 (multi_match FR/EN + synonymes) + | |
| 76 | −w·page_quebec + w·domain_quebec + w·autorité + gauss(published_at) + boost localité` | |
| 77 | −(désactivable composant par composant — BM25 reste seul debout si tout tombe, §9). | |
| 78 | − | |
| 79 | −## Dégradation gracieuse (§13) | |
| 80 | − | |
| 81 | −- OpenSearch en panne → l'API répond 503 sur /search, /status reste up; le crawler continue d'alimenter Postgres? Non : l'indexation échoue → l'item est relâché en retry; le frontier survit. | |
| 82 | −- Redis en panne → politesse locale impossible : le worker s'arrête proprement; Postgres intact. | |
| 83 | −- Un worker crash → `scheduler.reset_stale_items` relance ses URLs après 30 min. | |
| 84 | −- Enrichissement en retard → aucune conséquence sur la recherche (backlog visible dans /admin). | |
| 85 | − | |
| 86 | −## Déploiement m2m32 (§0.2) | |
| 87 | − | |
| 88 | −```mermaid | |
| 89 | −flowchart LR | |
| 90 | − NG[ngrok www.trouve-ka.com] --> WEB3000[web :3000] | |
| 91 | − WEB3000 -->|rewrite /api/*| API8080[api :8080] | |
| 92 | − subgraph m2m32[Docker Compose sur m2m32 — 32 Go] | |
| 93 | − WEB3000 | |
| 94 | − API8080 | |
| 95 | − PG5432[postgres] | |
| 96 | − RD[redis] | |
| 97 | − OS9200[opensearch 2 Go heap] | |
| 98 | − CWX[crawler-worker × N] | |
| 99 | − EN[enrichment-worker] | |
| 100 | − SC[scheduler] | |
| 101 | − end | |
| 102 | −``` | |
| 103 | − | |
| 104 | −Un seul port exposé publiquement (3000 via ngrok). Budgets mémoire : OpenSearch 2 Go | |
| 105 | −de heap (~3 Go RSS), Postgres < 1 Go, workers Python ~100-200 Mo chacun, web ~150 Mo — | |
| 106 | −large marge sur 32 Go, scalable par `docker compose up -d --scale crawler-worker=3`. | |
deleted
M2M32crouve-ka/docs/decisions.md
+0 −43
@@ -1,43 +0,0 @@ | ||
| 1 | −# Trouve-KA — Décisions d'architecture | |
| 2 | − | |
| 3 | −Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −## D1 — Backend de recherche : OpenSearch 2.x | |
| 6 | − | |
| 7 | −**Candidats évalués :** OpenSearch, Elasticsearch, Typesense, Meilisearch, Vespa, Tantivy, Quickwit (CLAUDE.md §3). | |
| 8 | − | |
| 9 | −**Choix : OpenSearch 2.17**, parce que : | |
| 10 | −- **BM25 réel et paramétrable** par champ (multi_match, boosts), critère n°1 du MVP. | |
| 11 | −- **Indexation incrémentale** near-real-time : `refresh_interval: 1s` → une page indexée est cherchable en ~1 s (principe cardinal §0.3). | |
| 12 | −- **`function_score`** : la fonction de ranking Québec-first custom (§9) s'exprime nativement, poids ajustables sans réindexation. | |
| 13 | −- **Analyzers FR/EN** + `synonym_graph` search-time (bilinguisme sans traduction à l'ingestion). | |
| 14 | −- **Highlighting** natif pour les snippets, **facettes** pour les filtres, **k-NN natif** pour l'hybride sémantique futur (§17.7) sans changer de moteur. | |
| 15 | −- Licence Apache 2.0, image ARM64, tourne en 2 Go de heap sur un seul node m2m32. | |
| 16 | − | |
| 17 | −**Rejetés :** Meilisearch/Typesense (ranking custom trop rigide pour `Score(d,q)` pondéré; c'était le choix « facile » interdit par la spec), Tantivy (librairie Rust, il faudrait construire le serveur), Quickwit (orienté logs append-only, pas de mise à jour de documents), Vespa (excellent mais complexité opérationnelle démesurée pour un node unique), Elasticsearch (équivalent fonctionnel d'OpenSearch, licence moins permissive). | |
| 18 | − | |
| 19 | −## D2 — Backend Python unique (FastAPI + pipeline) | |
| 20 | − | |
| 21 | −Crawler, parser, classifier, indexer, ranking, scheduler, enrichment et API partagent un seul langage (Python 3.12) et un seul packaging (`pyproject.toml` racine, namespace `trouveka.*` mappé sur `packages/` et `services/`). Réduit la complexité (§3 : « le crawler peut rester Python même si l'API est TS » — ici tout le backend est Python, seul le web est TS). | |
| 22 | − | |
| 23 | −## D3 — Frontier dans Postgres, coordination dans Redis | |
| 24 | − | |
| 25 | −- **Frontier = Postgres** : état relationnel (priorités, retries, scheduling), claims multi-workers via `FOR UPDATE SKIP LOCKED` — N workers sans coordinateur. | |
| 26 | −- **Redis** : politesse par hôte (`SET NX PX`, un fetch par hôte par fenêtre, tous workers confondus), drapeau pause, **Redis Streams** pour l'enrichissement asynchrone (consumer groups, ack explicite). | |
| 27 | −- Pas de Celery/RQ : la file d'enrichissement est un stream nu, observable (`XLEN`), sans dépendance lourde. | |
| 28 | − | |
| 29 | −## D4 — Pipeline inline (fetch→parse→score→index) dans le crawler-worker | |
| 30 | − | |
| 31 | −Le chemin rapide est exécuté inline par le worker : c'est la garantie la plus simple du « cherchable en secondes ». La scalabilité passe par le nombre de workers (`--scale crawler-worker=N`), pas par une séparation prématurée fetch/parse/index (§13 : pas de complexité distribuée prématurée). Les étapes 2-3 (§4) passent par le stream d'enrichissement et ne bloquent jamais. | |
| 32 | − | |
| 33 | −## D5 — apps/admin fusionné dans apps/web (route /admin) | |
| 34 | − | |
| 35 | −Un seul runtime Next.js sur m2m32 au lieu de deux (~150 Mo RSS économisés), même design system, même proxy API. La séparation reste possible plus tard (le dashboard est un groupe de composants isolés sous `components/admin/`). Justification prévue par §2 (« Claude peut améliorer cette structure sur justification technique solide »). | |
| 36 | − | |
| 37 | −## D6 — Graphe de liens au niveau domaine d'abord | |
| 38 | − | |
| 39 | −`domain_links` agrégé (from, to, count) plutôt que page→page : suffisant pour l'autorité de domaine et la découverte au MVP, volume contrôlé. Le graphe page→page viendra quand le ranking l'exigera (§8). | |
| 40 | − | |
| 41 | −## D7 — Réseau : le web proxifie /api | |
| 42 | − | |
| 43 | −Next.js rewrite `/api/*` → API interne. Derrière ngrok (www.trouve-ka.com), un seul port exposé (3000), pas d'URL absolues côté client, pas de CORS en prod. | |
deleted
M2M32crouve-ka/infrastructure/deployment/deploy-m2m32.sh
+0 −38
@@ -1,38 +0,0 @@ | ||
| 1 | −#!/usr/bin/env bash | |
| 2 | −# Trouve-KA — déploiement sur le node m2m32 (cluster MacLustr) + tunnel ngrok | |
| 3 | −# Author: Simon-Pierre Boucher | |
| 4 | −# Contact: contact@spboucher.ai | |
| 5 | −# | |
| 6 | −# Prérequis sur m2m32 : Docker (ou colima) + docker compose, ngrok authentifié. | |
| 7 | −# Le domaine www.trouve-ka.com doit être réservé dans le compte ngrok. | |
| 8 | −set -euo pipefail | |
| 9 | − | |
| 10 | −NODE="${NODE:-M2M32}" | |
| 11 | −REMOTE_DIR="~/trouve-ka" | |
| 12 | −NGROK_DOMAIN="${NGROK_DOMAIN:-www.trouve-ka.com}" | |
| 13 | − | |
| 14 | −cd "$(dirname "$0")/../.." | |
| 15 | − | |
| 16 | −echo "→ Synchronisation du code vers $NODE:$REMOTE_DIR" | |
| 17 | −rsync -az --delete \ | |
| 18 | − --exclude node_modules --exclude .next --exclude .venv --exclude __pycache__ \ | |
| 19 | − --exclude .git --exclude data --exclude '*.egg-info' \ | |
| 20 | − ./ "$NODE:$REMOTE_DIR/" | |
| 21 | − | |
| 22 | −echo "→ Build et démarrage du stack sur $NODE" | |
| 23 | −ssh "$NODE" "cd $REMOTE_DIR && docker compose up -d --build" | |
| 24 | − | |
| 25 | −echo "→ Chargement des seeds (idempotent)" | |
| 26 | −ssh "$NODE" "cd $REMOTE_DIR && docker compose run --rm migrate && \ | |
| 27 | − docker compose run --rm --entrypoint python crawler-worker -m trouveka.crawler.seed scripts/bootstrap-seeds/seeds.txt" | |
| 28 | − | |
| 29 | −echo "→ Tunnel ngrok ($NGROK_DOMAIN → :3000)" | |
| 30 | −ssh "$NODE" "pgrep -f 'ngrok http.*$NGROK_DOMAIN' > /dev/null || \ | |
| 31 | − nohup ngrok http --url=$NGROK_DOMAIN 3000 > ~/trouve-ka-ngrok.log 2>&1 &" | |
| 32 | − | |
| 33 | −echo "→ Vérification de santé" | |
| 34 | −sleep 5 | |
| 35 | −ssh "$NODE" "cd $REMOTE_DIR && bash scripts/health-check/check.sh" || true | |
| 36 | − | |
| 37 | −echo "✓ Déploiement terminé : https://$NGROK_DOMAIN" | |
| 38 | −echo " N'oublie pas de mettre à jour ~/Desktop/cluster-skill/cluster-deployments.json" | |
deleted
M2M32crouve-ka/infrastructure/docker/backend.Dockerfile
+0 −20
@@ -1,20 +0,0 @@ | ||
| 1 | −# Trouve-KA — image backend Python (API, crawler, enrichissement, scheduler) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −FROM python:3.12-slim | |
| 6 | − | |
| 7 | −WORKDIR /app | |
| 8 | − | |
| 9 | −COPY pyproject.toml ./ | |
| 10 | −COPY packages ./packages | |
| 11 | −COPY services ./services | |
| 12 | −COPY apps/api ./apps/api | |
| 13 | −COPY infrastructure/migrations ./infrastructure/migrations | |
| 14 | −COPY scripts ./scripts | |
| 15 | − | |
| 16 | −RUN pip install --no-cache-dir . | |
| 17 | − | |
| 18 | −ENV PYTHONUNBUFFERED=1 | |
| 19 | − | |
| 20 | −CMD ["uvicorn", "trouveka.api.main:app", "--host", "0.0.0.0", "--port", "8080"] | |
deleted
M2M32crouve-ka/infrastructure/docker/web.Dockerfile
+0 −26
@@ -1,26 +0,0 @@ | ||
| 1 | −# Trouve-KA — image web Next.js | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −FROM node:22-alpine AS builder | |
| 6 | −WORKDIR /repo | |
| 7 | −RUN corepack enable | |
| 8 | −COPY package.json pnpm-workspace.yaml ./ | |
| 9 | −COPY apps/web/package.json apps/web/ | |
| 10 | −# sharp et unrs-resolver ont des scripts de build napi légitimes requis par Next | |
| 11 | −RUN pnpm install --frozen-lockfile=false --dangerously-allow-all-builds | |
| 12 | −COPY apps/web apps/web | |
| 13 | −# Les rewrites Next.js sont compilés au build : l'URL interne de l'API doit être fixée ici | |
| 14 | −ARG API_URL=http://api:8080 | |
| 15 | −ENV API_URL=${API_URL} | |
| 16 | −RUN pnpm --filter @trouveka/web build | |
| 17 | − | |
| 18 | −FROM node:22-alpine | |
| 19 | −WORKDIR /repo | |
| 20 | −RUN corepack enable | |
| 21 | −ENV NODE_ENV=production | |
| 22 | −COPY --from=builder /repo/package.json /repo/pnpm-workspace.yaml ./ | |
| 23 | −COPY --from=builder /repo/node_modules ./node_modules | |
| 24 | −COPY --from=builder /repo/apps/web ./apps/web | |
| 25 | −EXPOSE 3000 | |
| 26 | −CMD ["pnpm", "--filter", "@trouveka/web", "start"] | |
deleted
M2M32crouve-ka/infrastructure/migrations/001_init.sql
+0 −124
@@ -1,152 +0,0 @@ | ||
| 1 | − | |
| 2 | −BEGIN; | |
| 3 | − | |
| 4 | −CREATE TABLE IF NOT EXISTS schema_migrations ( | |
| 5 | − version INTEGER PRIMARY KEY, | |
| 6 | − applied_at TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 7 | −); | |
| 8 | − | |
| 9 | −CREATE TABLE IF NOT EXISTS domains ( | |
| 10 | − id BIGSERIAL PRIMARY KEY, | |
| 11 | − domain TEXT NOT NULL UNIQUE, | |
| 12 | − first_seen TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 13 | − last_crawled_at TIMESTAMPTZ, | |
| 14 | − robots_fetched_at TIMESTAMPTZ, | |
| 15 | − robots_body TEXT, | |
| 16 | − robots_status TEXT, -- ok | not_found | error | forbidden | |
| 17 | − crawl_delay_ms INTEGER, -- délai imposé par robots.txt (Crawl-delay) | |
| 18 | − quebec_score REAL NOT NULL DEFAULT 0, | |
| 19 | − authority_score REAL NOT NULL DEFAULT 0, | |
| 20 | − page_count INTEGER NOT NULL DEFAULT 0, | |
| 21 | − inlink_domains INTEGER NOT NULL DEFAULT 0, | |
| 22 | − outlink_domains INTEGER NOT NULL DEFAULT 0, | |
| 23 | − language_stats JSONB NOT NULL DEFAULT '{}'::jsonb, | |
| 24 | − content_change_rate REAL, | |
| 25 | − blocked BOOLEAN NOT NULL DEFAULT FALSE, | |
| 26 | − is_seed BOOLEAN NOT NULL DEFAULT FALSE | |
| 27 | −); | |
| 28 | −CREATE INDEX IF NOT EXISTS idx_domains_quebec ON domains (quebec_score DESC); | |
| 29 | − | |
| 30 | −CREATE TABLE IF NOT EXISTS urls ( | |
| 31 | − id BIGSERIAL PRIMARY KEY, | |
| 32 | − url TEXT NOT NULL UNIQUE, | |
| 33 | − canonical_url TEXT, | |
| 34 | − domain_id BIGINT NOT NULL REFERENCES domains(id), | |
| 35 | − first_seen TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 36 | −); | |
| 37 | −CREATE INDEX IF NOT EXISTS idx_urls_domain ON urls (domain_id); | |
| 38 | − | |
| 39 | −CREATE TABLE IF NOT EXISTS frontier_items ( | |
| 40 | − id BIGSERIAL PRIMARY KEY, | |
| 41 | − url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id), | |
| 42 | − priority REAL NOT NULL DEFAULT 0.5, | |
| 43 | − depth INTEGER NOT NULL DEFAULT 0, | |
| 44 | − source_url_id BIGINT REFERENCES urls(id), | |
| 45 | − discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 46 | − last_crawled_at TIMESTAMPTZ, | |
| 47 | − next_crawl_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 48 | − status TEXT NOT NULL DEFAULT 'pending', -- pending|in_progress|done|failed|blocked | |
| 49 | − retries INTEGER NOT NULL DEFAULT 0, | |
| 50 | − error_code TEXT, | |
| 51 | − locked_by TEXT, | |
| 52 | − locked_at TIMESTAMPTZ | |
| 53 | −); | |
| 54 | −CREATE INDEX IF NOT EXISTS idx_frontier_ready | |
| 55 | − ON frontier_items (next_crawl_at, priority DESC) | |
| 56 | − WHERE status = 'pending'; | |
| 57 | −CREATE INDEX IF NOT EXISTS idx_frontier_status ON frontier_items (status); | |
| 58 | − | |
| 59 | −CREATE TABLE IF NOT EXISTS crawl_attempts ( | |
| 60 | − id BIGSERIAL PRIMARY KEY, | |
| 61 | − url_id BIGINT NOT NULL REFERENCES urls(id), | |
| 62 | − fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 63 | − status_code INTEGER, | |
| 64 | − error_code TEXT, -- dns|timeout|tls|http_4xx|http_5xx|robots_denied|parse_failed| | |
| 65 | − -- unsupported_content|too_large|duplicate|spam|not_quebec|ssrf_blocked|ok | |
| 66 | − outcome TEXT NOT NULL, -- indexed|duplicate|error|not_quebec|robots_blocked|redirect|unchanged | |
| 67 | − content_hash TEXT, | |
| 68 | − bytes INTEGER, | |
| 69 | − duration_ms INTEGER, | |
| 70 | − redirect_url TEXT, | |
| 71 | − title TEXT, | |
| 72 | − quebec_score REAL | |
| 73 | −); | |
| 74 | −CREATE INDEX IF NOT EXISTS idx_attempts_time ON crawl_attempts (fetched_at DESC); | |
| 75 | −CREATE INDEX IF NOT EXISTS idx_attempts_url ON crawl_attempts (url_id); | |
| 76 | − | |
| 77 | −CREATE TABLE IF NOT EXISTS documents ( | |
| 78 | − id BIGSERIAL PRIMARY KEY, | |
| 79 | − url_id BIGINT NOT NULL UNIQUE REFERENCES urls(id), | |
| 80 | − content_hash TEXT NOT NULL, | |
| 81 | − etag TEXT, | |
| 82 | − last_modified TEXT, | |
| 83 | − title TEXT, | |
| 84 | − description TEXT, | |
| 85 | − language TEXT, | |
| 86 | − page_quebec_score REAL NOT NULL DEFAULT 0, | |
| 87 | − published_at TIMESTAMPTZ, | |
| 88 | − last_changed_at TIMESTAMPTZ, | |
| 89 | − first_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 90 | − last_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 91 | − enrichment_stage INTEGER NOT NULL DEFAULT 1 | |
| 92 | −); | |
| 93 | −CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents (content_hash); | |
| 94 | − | |
| 95 | −CREATE TABLE IF NOT EXISTS domain_links ( | |
| 96 | − from_domain_id BIGINT NOT NULL REFERENCES domains(id), | |
| 97 | − to_domain_id BIGINT NOT NULL REFERENCES domains(id), | |
| 98 | − link_count INTEGER NOT NULL DEFAULT 1, | |
| 99 | − updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 100 | − PRIMARY KEY (from_domain_id, to_domain_id) | |
| 101 | −); | |
| 102 | −CREATE INDEX IF NOT EXISTS idx_domain_links_to ON domain_links (to_domain_id); | |
| 103 | − | |
| 104 | −CREATE TABLE IF NOT EXISTS search_queries ( | |
| 105 | − id BIGSERIAL PRIMARY KEY, | |
| 106 | − query TEXT NOT NULL, | |
| 107 | − language TEXT, | |
| 108 | − results_total INTEGER NOT NULL, | |
| 109 | − took_ms INTEGER NOT NULL, | |
| 110 | − zero_result BOOLEAN NOT NULL DEFAULT FALSE, | |
| 111 | − created_at TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 112 | −); | |
| 113 | −CREATE INDEX IF NOT EXISTS idx_queries_zero ON search_queries (created_at) WHERE zero_result; | |
| 114 | − | |
| 115 | −CREATE TABLE IF NOT EXISTS submissions ( | |
| 116 | − id BIGSERIAL PRIMARY KEY, | |
| 117 | − url TEXT NOT NULL, | |
| 118 | − submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 119 | − status TEXT NOT NULL DEFAULT 'queued' -- queued|accepted|rejected | |
| 120 | −); | |
| 121 | − | |
| 122 | −INSERT INTO schema_migrations (version) VALUES (1) ON CONFLICT DO NOTHING; | |
| 123 | − | |
| 124 | −COMMIT; | |
deleted
M2M32crouve-ka/infrastructure/migrations/002_frontier_priority_index.sql
+0 −11
@@ -1,17 +0,0 @@ | ||
| 1 | −-- | |
| 2 | − | |
| 3 | −BEGIN; | |
| 4 | − | |
| 5 | −CREATE INDEX IF NOT EXISTS idx_frontier_priority | |
| 6 | − ON frontier_items (priority DESC, next_crawl_at) | |
| 7 | − WHERE status = 'pending'; | |
| 8 | − | |
| 9 | −INSERT INTO schema_migrations (version) VALUES (2) ON CONFLICT DO NOTHING; | |
| 10 | − | |
| 11 | −COMMIT; | |
deleted
M2M32crouve-ka/infrastructure/monitoring/README.md
+0 −22
@@ -1,22 +0,0 @@ | ||
| 1 | −# Trouve-KA — Observabilité | |
| 2 | − | |
| 3 | −Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −## En place (MVP) | |
| 6 | − | |
| 7 | −- **Logs structurés JSON** (une ligne par événement) sur stdout de chaque service, | |
| 8 | − avec contexte `worker_id, url_id, domain_id, url, outcome, quebec_score, ms` | |
| 9 | − (`packages/logging`). Consultables via `docker compose logs -f crawler-worker`. | |
| 10 | −- **Métriques opérationnelles** exposées par l'API : | |
| 11 | − - `GET /api/status` (public) : pages indexées, domaines, débits horaires, état du crawler. | |
| 12 | − - `GET /api/admin/overview` (protégé) : files par statut, débits, distribution HTTP, | |
| 13 | − latences fetch p50/p95, top domaines, erreurs récentes, backlog d'enrichissement. | |
| 14 | − - Toutes calculées sur les vraies tables (`crawl_attempts`, `frontier_items`) — | |
| 15 | − aucun compteur simulé (§ fake). | |
| 16 | −- **Dashboard admin** : `/admin` sur le web (flux live du crawl, contrôles pause/seeds/blocage). | |
| 17 | − | |
| 18 | −## Prochaine étape (post-MVP) | |
| 19 | − | |
| 20 | −Exporter ces mêmes agrégats au format Prometheus (`/metrics`) et brancher | |
| 21 | −Grafana — les requêtes SQL de `Database.admin_overview()` sont la source de vérité, | |
| 22 | −l'export est un simple reformatage. Choix pragmatique conforme au CLAUDE.md §13. | |
deleted
M2M32crouve-ka/package.json
+0 −19
@@ -1,19 +0,0 @@ | ||
| 1 | −{ | |
| 2 | − "name": "trouve-ka", | |
| 3 | − "private": true, | |
| 4 | − "version": "0.1.0", | |
| 5 | − "packageManager": "pnpm@11.1.2", | |
| 6 | − "author": "Simon-Pierre Boucher <contact@spboucher.ai>", | |
| 7 | − "description": "Trouve-KA — Cherche le Québec. Moteur de recherche web indépendant, Québec-first.", | |
| 8 | − "pnpm": { | |
| 9 | − "onlyBuiltDependencies": ["sharp", "unrs-resolver"] | |
| 10 | − }, | |
| 11 | − "scripts": { | |
| 12 | − "dev": "pnpm --filter @trouveka/web dev", | |
| 13 | − "build": "pnpm --filter @trouveka/web build", | |
| 14 | − "start": "pnpm --filter @trouveka/web start", | |
| 15 | − "crawl:seed": "bash scripts/bootstrap-seeds/seed.sh", | |
| 16 | − "check:headers": "python3 scripts/check-headers.py", | |
| 17 | − "deploy:m2m32": "bash infrastructure/deployment/deploy-m2m32.sh" | |
| 18 | − } | |
| 19 | −} | |
deleted
M2M32crouve-ka/packages/config/__init__.py
+0 −67
@@ -1,67 +0,0 @@ | ||
| 1 | −# Trouve-KA — configuration validée par variables d'environnement | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Configuration centrale de Trouve-KA. | |
| 6 | − | |
| 7 | −Toutes les valeurs sont surchargeables par variable d'environnement et | |
| 8 | −dimensionnées par défaut pour le node de déploiement m2m32 (32 Go RAM). | |
| 9 | −""" | |
| 10 | − | |
| 11 | −from functools import lru_cache | |
| 12 | − | |
| 13 | −from pydantic_settings import BaseSettings, SettingsConfigDict | |
| 14 | − | |
| 15 | − | |
| 16 | −class Settings(BaseSettings): | |
| 17 | − model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") | |
| 18 | − | |
| 19 | − # Bases de données | |
| 20 | − database_url: str = "postgresql://trouveka:trouveka@localhost:5432/trouveka" | |
| 21 | − redis_url: str = "redis://localhost:6379/0" | |
| 22 | − search_url: str = "http://localhost:9200" | |
| 23 | − search_index: str = "trouveka-docs" | |
| 24 | − | |
| 25 | − # Crawler — identité et politesse | |
| 26 | − crawler_user_agent: str = ( | |
| 27 | − "Mozilla/5.0 (compatible; TrouveKABot/0.1; +https://www.trouve-ka.com/trouveka-bot)" | |
| 28 | − ) | |
| 29 | − crawler_contact_url: str = "https://www.trouve-ka.com/trouveka-bot" | |
| 30 | − max_global_concurrency: int = 24 | |
| 31 | − max_per_host_concurrency: int = 2 | |
| 32 | − default_host_delay: float = 2.0 # secondes entre deux requêtes vers un même hôte | |
| 33 | − | |
| 34 | − # Crawler — limites de sécurité (chaque réponse a des limites) | |
| 35 | − max_response_bytes: int = 3_000_000 | |
| 36 | − max_redirects: int = 5 | |
| 37 | − fetch_timeout: float = 20.0 | |
| 38 | − max_crawl_depth: int = 8 | |
| 39 | − max_links_per_page: int = 300 | |
| 40 | − max_urls_per_domain: int = 5_000 | |
| 41 | − max_query_params: int = 8 | |
| 42 | − max_path_segments: int = 12 | |
| 43 | − | |
| 44 | − # Recrawl adaptatif (bornes, en heures) | |
| 45 | − min_recrawl_hours: float = 1.0 | |
| 46 | − max_recrawl_hours: float = 24 * 30.0 | |
| 47 | − default_recrawl_hours: float = 24.0 | |
| 48 | − | |
| 49 | − # Indexation | |
| 50 | − min_quebec_score_to_index: float = 0.15 | |
| 51 | − min_body_length: int = 80 | |
| 52 | − | |
| 53 | − # API | |
| 54 | − api_host: str = "0.0.0.0" | |
| 55 | − api_port: int = 8080 | |
| 56 | − admin_token: str = "change-me-admin-token" | |
| 57 | − public_url: str = "https://www.trouve-ka.com" | |
| 58 | − ngrok_domain: str = "www.trouve-ka.com" | |
| 59 | − | |
| 60 | − # Postgres pool (dimensionné pour 32 Go / plusieurs workers) | |
| 61 | − pg_pool_min: int = 2 | |
| 62 | − pg_pool_max: int = 10 | |
| 63 | − | |
| 64 | − | |
| 65 | −@lru_cache | |
| 66 | −def get_settings() -> Settings: | |
| 67 | − return Settings() | |
deleted
M2M32crouve-ka/packages/database/__init__.py
+0 −10
@@ -1,10 +0,0 @@ | ||
| 1 | −# Trouve-KA — accès PostgreSQL (pool asyncpg + dépôts) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Couche d'accès PostgreSQL : pool asyncpg, migrations, dépôts frontier/domaines/documents.""" | |
| 6 | − | |
| 7 | −from .db import Database | |
| 8 | −from .migrate import run_migrations | |
| 9 | − | |
| 10 | −__all__ = ["Database", "run_migrations"] | |
deleted
M2M32crouve-ka/packages/database/db.py
+0 −522
@@ -1,522 +0,0 @@ | ||
| 1 | −# Trouve-KA — dépôts PostgreSQL | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Dépôts PostgreSQL de Trouve-KA. | |
| 6 | − | |
| 7 | −Le frontier vit dans Postgres : les workers réclament des lots d'URLs via | |
| 8 | −FOR UPDATE SKIP LOCKED, ce qui permet N workers concurrents sans coordination | |
| 9 | −externe (CLAUDE.md §13 : pas de complexité distribuée prématurée). | |
| 10 | −""" | |
| 11 | − | |
| 12 | −from datetime import UTC, datetime, timedelta | |
| 13 | −from typing import Any | |
| 14 | − | |
| 15 | −import asyncpg | |
| 16 | − | |
| 17 | − | |
| 18 | −def _dsn(url: str) -> str: | |
| 19 | − # asyncpg accepte postgresql:// mais pas postgresql+asyncpg:// | |
| 20 | − return url.replace("postgresql+asyncpg://", "postgresql://") | |
| 21 | − | |
| 22 | − | |
| 23 | −class Database: | |
| 24 | − def __init__(self, database_url: str, *, pool_min: int = 2, pool_max: int = 10): | |
| 25 | − self._url = _dsn(database_url) | |
| 26 | − self._pool_min = pool_min | |
| 27 | − self._pool_max = pool_max | |
| 28 | − self.pool: asyncpg.Pool | None = None | |
| 29 | − | |
| 30 | − async def connect(self) -> None: | |
| 31 | − self.pool = await asyncpg.create_pool( | |
| 32 | − self._url, min_size=self._pool_min, max_size=self._pool_max, command_timeout=30 | |
| 33 | − ) | |
| 34 | − | |
| 35 | − async def close(self) -> None: | |
| 36 | − if self.pool: | |
| 37 | − await self.pool.close() | |
| 38 | − self.pool = None | |
| 39 | − | |
| 40 | − # ------------------------------------------------------------------ domaines | |
| 41 | − | |
| 42 | − async def upsert_domain(self, domain: str, *, is_seed: bool = False) -> int: | |
| 43 | − row = await self.pool.fetchrow( | |
| 44 | − """ | |
| 45 | − INSERT INTO domains (domain, is_seed) VALUES ($1, $2) | |
| 46 | − ON CONFLICT (domain) DO UPDATE SET is_seed = domains.is_seed OR EXCLUDED.is_seed | |
| 47 | − RETURNING id | |
| 48 | − """, | |
| 49 | − domain, | |
| 50 | − is_seed, | |
| 51 | − ) | |
| 52 | − return row["id"] | |
| 53 | − | |
| 54 | − async def get_domain(self, domain: str) -> asyncpg.Record | None: | |
| 55 | − return await self.pool.fetchrow("SELECT * FROM domains WHERE domain = $1", domain) | |
| 56 | − | |
| 57 | − async def get_domain_by_id(self, domain_id: int) -> asyncpg.Record | None: | |
| 58 | − return await self.pool.fetchrow("SELECT * FROM domains WHERE id = $1", domain_id) | |
| 59 | − | |
| 60 | − async def save_robots( | |
| 61 | − self, domain_id: int, body: str | None, status: str, crawl_delay_ms: int | None | |
| 62 | − ) -> None: | |
| 63 | − await self.pool.execute( | |
| 64 | − """ | |
| 65 | − UPDATE domains SET robots_body = $2, robots_status = $3, | |
| 66 | − robots_fetched_at = now(), crawl_delay_ms = $4 | |
| 67 | − WHERE id = $1 | |
| 68 | − """, | |
| 69 | − domain_id, | |
| 70 | − body, | |
| 71 | − status, | |
| 72 | − crawl_delay_ms, | |
| 73 | − ) | |
| 74 | − | |
| 75 | − async def block_domain(self, domain: str) -> None: | |
| 76 | − await self.pool.execute( | |
| 77 | − "UPDATE domains SET blocked = TRUE WHERE domain = $1", domain | |
| 78 | − ) | |
| 79 | − await self.pool.execute( | |
| 80 | − """ | |
| 81 | − UPDATE frontier_items SET status = 'blocked' | |
| 82 | − WHERE status IN ('pending', 'in_progress') | |
| 83 | − AND url_id IN (SELECT u.id FROM urls u JOIN domains d ON d.id = u.domain_id | |
| 84 | − WHERE d.domain = $1) | |
| 85 | − """, | |
| 86 | − domain, | |
| 87 | − ) | |
| 88 | − | |
| 89 | − async def update_domain_after_page( | |
| 90 | − self, domain_id: int, page_quebec_score: float, language: str | None | |
| 91 | − ) -> None: | |
| 92 | − """Met à jour le score Québec du domaine (moyenne mobile) et ses stats de langue.""" | |
| 93 | − lang = language or "unknown" | |
| 94 | − await self.pool.execute( | |
| 95 | − """ | |
| 96 | − UPDATE domains SET | |
| 97 | − page_count = page_count + 1, | |
| 98 | − last_crawled_at = now(), | |
| 99 | − quebec_score = (quebec_score * page_count + $2) / (page_count + 1), | |
| 100 | − language_stats = jsonb_set( | |
| 101 | − language_stats, ARRAY[$3], | |
| 102 | − (COALESCE(language_stats->>$3, '0')::int + 1)::text::jsonb) | |
| 103 | − WHERE id = $1 | |
| 104 | − """, | |
| 105 | − domain_id, | |
| 106 | − page_quebec_score, | |
| 107 | − lang, | |
| 108 | − ) | |
| 109 | − | |
| 110 | − async def record_domain_links(self, from_domain_id: int, to_domain_counts: dict[int, int]) -> None: | |
| 111 | − if not to_domain_counts: | |
| 112 | − return | |
| 113 | − await self.pool.executemany( | |
| 114 | − """ | |
| 115 | − INSERT INTO domain_links (from_domain_id, to_domain_id, link_count) | |
| 116 | − VALUES ($1, $2, $3) | |
| 117 | − ON CONFLICT (from_domain_id, to_domain_id) | |
| 118 | − DO UPDATE SET link_count = domain_links.link_count + EXCLUDED.link_count, | |
| 119 | − updated_at = now() | |
| 120 | − """, | |
| 121 | − [(from_domain_id, to_id, n) for to_id, n in to_domain_counts.items() if to_id != from_domain_id], | |
| 122 | − ) | |
| 123 | − | |
| 124 | − # ------------------------------------------------------------------ frontier | |
| 125 | − | |
| 126 | − async def enqueue_url( | |
| 127 | − self, | |
| 128 | − url: str, | |
| 129 | − domain: str, | |
| 130 | − *, | |
| 131 | − priority: float = 0.5, | |
| 132 | − depth: int = 0, | |
| 133 | − source_url_id: int | None = None, | |
| 134 | − is_seed: bool = False, | |
| 135 | − max_urls_per_domain: int = 5000, | |
| 136 | − ) -> int | None: | |
| 137 | − """Ajoute une URL au frontier si inconnue. Retourne url_id si ajoutée, None sinon.""" | |
| 138 | − async with self.pool.acquire() as conn: | |
| 139 | − async with conn.transaction(): | |
| 140 | − domain_id = await conn.fetchval( | |
| 141 | − """ | |
| 142 | − INSERT INTO domains (domain, is_seed) VALUES ($1, $2) | |
| 143 | − ON CONFLICT (domain) DO UPDATE SET is_seed = domains.is_seed OR EXCLUDED.is_seed | |
| 144 | − RETURNING id | |
| 145 | − """, | |
| 146 | − domain, | |
| 147 | − is_seed, | |
| 148 | − ) | |
| 149 | − dom = await conn.fetchrow( | |
| 150 | − "SELECT blocked, page_count FROM domains WHERE id = $1", domain_id | |
| 151 | − ) | |
| 152 | − if dom["blocked"]: | |
| 153 | − return None | |
| 154 | − if not is_seed and dom["page_count"] >= max_urls_per_domain: | |
| 155 | − return None | |
| 156 | − url_id = await conn.fetchval( | |
| 157 | − """ | |
| 158 | − INSERT INTO urls (url, domain_id) VALUES ($1, $2) | |
| 159 | − ON CONFLICT (url) DO NOTHING RETURNING id | |
| 160 | − """, | |
| 161 | − url, | |
| 162 | − domain_id, | |
| 163 | − ) | |
| 164 | − if url_id is None: | |
| 165 | − return None # URL déjà connue : pas de doublon dans le frontier | |
| 166 | − await conn.execute( | |
| 167 | − """ | |
| 168 | − INSERT INTO frontier_items (url_id, priority, depth, source_url_id) | |
| 169 | − VALUES ($1, $2, $3, $4) ON CONFLICT (url_id) DO NOTHING | |
| 170 | − """, | |
| 171 | − url_id, | |
| 172 | − priority, | |
| 173 | − depth, | |
| 174 | − source_url_id, | |
| 175 | − ) | |
| 176 | − return url_id | |
| 177 | − | |
| 178 | − async def claim_batch(self, worker_id: str, limit: int = 20) -> list[asyncpg.Record]: | |
| 179 | − """Réclame un lot d'URLs prêtes à crawler (SKIP LOCKED, multi-workers sûr).""" | |
| 180 | − return await self.pool.fetch( | |
| 181 | − """ | |
| 182 | − WITH claimed AS ( | |
| 183 | − SELECT f.id FROM frontier_items f | |
| 184 | − WHERE f.status = 'pending' AND f.next_crawl_at <= now() | |
| 185 | − ORDER BY f.priority DESC, f.next_crawl_at | |
| 186 | − LIMIT $2 | |
| 187 | − FOR UPDATE SKIP LOCKED | |
| 188 | − ) | |
| 189 | − UPDATE frontier_items f SET status = 'in_progress', locked_by = $1, locked_at = now() | |
| 190 | − FROM claimed | |
| 191 | − WHERE f.id = claimed.id | |
| 192 | − RETURNING f.id, f.url_id, f.priority, f.depth, f.retries, | |
| 193 | − f.last_crawled_at, f.next_crawl_at, | |
| 194 | − (SELECT url FROM urls WHERE id = f.url_id) AS url, | |
| 195 | − (SELECT domain_id FROM urls WHERE id = f.url_id) AS domain_id | |
| 196 | − """, | |
| 197 | − worker_id, | |
| 198 | − limit, | |
| 199 | − ) | |
| 200 | − | |
| 201 | − async def release_item( | |
| 202 | − self, | |
| 203 | − url_id: int, | |
| 204 | − *, | |
| 205 | − status: str, | |
| 206 | − error_code: str | None = None, | |
| 207 | − next_crawl_at: datetime | None = None, | |
| 208 | − priority: float | None = None, | |
| 209 | − increment_retries: bool = False, | |
| 210 | − ) -> None: | |
| 211 | − await self.pool.execute( | |
| 212 | − """ | |
| 213 | − UPDATE frontier_items SET | |
| 214 | − status = $2, | |
| 215 | − error_code = $3, | |
| 216 | − last_crawled_at = now(), | |
| 217 | − next_crawl_at = COALESCE($4, next_crawl_at), | |
| 218 | − priority = COALESCE($5, priority), | |
| 219 | − retries = retries + CASE WHEN $6 THEN 1 ELSE 0 END, | |
| 220 | − locked_by = NULL, locked_at = NULL | |
| 221 | − WHERE url_id = $1 | |
| 222 | − """, | |
| 223 | − url_id, | |
| 224 | − status, | |
| 225 | − error_code, | |
| 226 | − next_crawl_at, | |
| 227 | − priority, | |
| 228 | − increment_retries, | |
| 229 | − ) | |
| 230 | − | |
| 231 | − async def reset_stale_items(self, older_than_minutes: int = 30) -> int: | |
| 232 | − """Relance les items in_progress abandonnés (worker mort). Retourne le nombre.""" | |
| 233 | − result = await self.pool.execute( | |
| 234 | − """ | |
| 235 | − UPDATE frontier_items SET status = 'pending', locked_by = NULL, locked_at = NULL | |
| 236 | − WHERE status = 'in_progress' AND locked_at < now() - ($1 || ' minutes')::interval | |
| 237 | − """, | |
| 238 | − str(older_than_minutes), | |
| 239 | − ) | |
| 240 | − return int(result.split()[-1]) | |
| 241 | − | |
| 242 | − async def requeue_url(self, url: str) -> bool: | |
| 243 | − result = await self.pool.execute( | |
| 244 | − """ | |
| 245 | − UPDATE frontier_items SET status = 'pending', next_crawl_at = now(), retries = 0 | |
| 246 | − WHERE url_id = (SELECT id FROM urls WHERE url = $1) | |
| 247 | − """, | |
| 248 | − url, | |
| 249 | − ) | |
| 250 | − return result.endswith("1") | |
| 251 | − | |
| 252 | − async def requeue_domain(self, domain: str) -> int: | |
| 253 | − result = await self.pool.execute( | |
| 254 | − """ | |
| 255 | − UPDATE frontier_items SET status = 'pending', next_crawl_at = now() | |
| 256 | − WHERE status IN ('done', 'failed') | |
| 257 | − AND url_id IN (SELECT u.id FROM urls u JOIN domains d ON d.id = u.domain_id | |
| 258 | − WHERE d.domain = $1) | |
| 259 | − """, | |
| 260 | − domain, | |
| 261 | − ) | |
| 262 | − return int(result.split()[-1]) | |
| 263 | − | |
| 264 | − # ------------------------------------------------------------------ crawl / documents | |
| 265 | − | |
| 266 | − async def record_attempt( | |
| 267 | − self, | |
| 268 | − url_id: int, | |
| 269 | − *, | |
| 270 | − status_code: int | None, | |
| 271 | − error_code: str, | |
| 272 | − outcome: str, | |
| 273 | − content_hash: str | None = None, | |
| 274 | − num_bytes: int | None = None, | |
| 275 | − duration_ms: int | None = None, | |
| 276 | − redirect_url: str | None = None, | |
| 277 | − title: str | None = None, | |
| 278 | − quebec_score: float | None = None, | |
| 279 | − ) -> None: | |
| 280 | − await self.pool.execute( | |
| 281 | − """ | |
| 282 | − INSERT INTO crawl_attempts | |
| 283 | − (url_id, status_code, error_code, outcome, content_hash, bytes, | |
| 284 | − duration_ms, redirect_url, title, quebec_score) | |
| 285 | − VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) | |
| 286 | − """, | |
| 287 | − url_id, | |
| 288 | − status_code, | |
| 289 | − error_code, | |
| 290 | − outcome, | |
| 291 | − content_hash, | |
| 292 | − num_bytes, | |
| 293 | − duration_ms, | |
| 294 | − redirect_url, | |
| 295 | − title, | |
| 296 | − quebec_score, | |
| 297 | − ) | |
| 298 | − | |
| 299 | − async def get_document(self, url_id: int) -> asyncpg.Record | None: | |
| 300 | − return await self.pool.fetchrow("SELECT * FROM documents WHERE url_id = $1", url_id) | |
| 301 | − | |
| 302 | − async def find_duplicate(self, chash: str, url_id: int) -> asyncpg.Record | None: | |
| 303 | − """Doublon exact : même hash de contenu sur une autre URL.""" | |
| 304 | − return await self.pool.fetchrow( | |
| 305 | − "SELECT * FROM documents WHERE content_hash = $1 AND url_id != $2 LIMIT 1", | |
| 306 | − chash, | |
| 307 | − url_id, | |
| 308 | − ) | |
| 309 | − | |
| 310 | − async def upsert_document( | |
| 311 | − self, | |
| 312 | − url_id: int, | |
| 313 | − *, | |
| 314 | − content_hash: str, | |
| 315 | − etag: str | None, | |
| 316 | − last_modified: str | None, | |
| 317 | − title: str, | |
| 318 | − description: str, | |
| 319 | − language: str | None, | |
| 320 | − page_quebec_score: float, | |
| 321 | − published_at: datetime | None, | |
| 322 | − changed: bool, | |
| 323 | − ) -> int: | |
| 324 | − row = await self.pool.fetchrow( | |
| 325 | − """ | |
| 326 | − INSERT INTO documents | |
| 327 | − (url_id, content_hash, etag, last_modified, title, description, language, | |
| 328 | − page_quebec_score, published_at, last_changed_at) | |
| 329 | − VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) | |
| 330 | − ON CONFLICT (url_id) DO UPDATE SET | |
| 331 | − content_hash = EXCLUDED.content_hash, | |
| 332 | − etag = EXCLUDED.etag, | |
| 333 | − last_modified = EXCLUDED.last_modified, | |
| 334 | − title = EXCLUDED.title, | |
| 335 | − description = EXCLUDED.description, | |
| 336 | − language = EXCLUDED.language, | |
| 337 | − page_quebec_score = EXCLUDED.page_quebec_score, | |
| 338 | − published_at = EXCLUDED.published_at, | |
| 339 | − last_indexed_at = now(), | |
| 340 | − last_changed_at = CASE WHEN $10 THEN now() ELSE documents.last_changed_at END | |
| 341 | − RETURNING id | |
| 342 | − """, | |
| 343 | − url_id, | |
| 344 | − content_hash, | |
| 345 | − etag, | |
| 346 | − last_modified, | |
| 347 | − title, | |
| 348 | − description, | |
| 349 | − language, | |
| 350 | − page_quebec_score, | |
| 351 | − published_at, | |
| 352 | − changed, | |
| 353 | − ) | |
| 354 | − return row["id"] | |
| 355 | − | |
| 356 | − async def set_canonical(self, url_id: int, canonical_url: str) -> None: | |
| 357 | − await self.pool.execute( | |
| 358 | − "UPDATE urls SET canonical_url = $2 WHERE id = $1", url_id, canonical_url | |
| 359 | − ) | |
| 360 | − | |
| 361 | − # ------------------------------------------------------------------ analytics / statut | |
| 362 | − | |
| 363 | − async def record_search_query( | |
| 364 | − self, query: str, language: str | None, total: int, took_ms: int | |
| 365 | − ) -> None: | |
| 366 | − await self.pool.execute( | |
| 367 | − """ | |
| 368 | − INSERT INTO search_queries (query, language, results_total, took_ms, zero_result) | |
| 369 | − VALUES ($1, $2, $3, $4, $3 = 0) | |
| 370 | − """, | |
| 371 | − query[:500], | |
| 372 | − language, | |
| 373 | − total, | |
| 374 | − took_ms, | |
| 375 | − ) | |
| 376 | − | |
| 377 | − async def add_submission(self, url: str) -> None: | |
| 378 | − await self.pool.execute("INSERT INTO submissions (url) VALUES ($1)", url) | |
| 379 | − | |
| 380 | − async def status_snapshot(self) -> dict[str, Any]: | |
| 381 | − since = datetime.now(UTC) - timedelta(hours=1) | |
| 382 | − frontier = { | |
| 383 | − r["status"]: r["n"] | |
| 384 | − for r in await self.pool.fetch( | |
| 385 | − "SELECT status, count(*)::int AS n FROM frontier_items GROUP BY status" | |
| 386 | − ) | |
| 387 | − } | |
| 388 | − row = await self.pool.fetchrow( | |
| 389 | − """ | |
| 390 | − SELECT | |
| 391 | − (SELECT count(*)::int FROM documents) AS docs, | |
| 392 | − (SELECT count(*)::int FROM domains WHERE page_count > 0) AS domains, | |
| 393 | − (SELECT count(*)::int FROM documents WHERE last_indexed_at >= $1) AS indexed_1h, | |
| 394 | − (SELECT count(*)::int FROM crawl_attempts WHERE fetched_at >= $1) AS fetched_1h, | |
| 395 | − (SELECT count(*)::int FROM crawl_attempts | |
| 396 | − WHERE fetched_at >= $1 AND outcome = 'error') AS errors_1h | |
| 397 | − """, | |
| 398 | − since, | |
| 399 | − ) | |
| 400 | − return { | |
| 401 | − "pages_indexed": row["docs"], | |
| 402 | − "domains_count": row["domains"], | |
| 403 | − "indexed_last_hour": row["indexed_1h"], | |
| 404 | − "fetched_last_hour": row["fetched_1h"], | |
| 405 | − "errors_last_hour": row["errors_1h"], | |
| 406 | − "frontier_pending": frontier.get("pending", 0), | |
| 407 | − "frontier_in_progress": frontier.get("in_progress", 0), | |
| 408 | − "frontier": frontier, | |
| 409 | − } | |
| 410 | − | |
| 411 | − async def admin_overview(self) -> dict[str, Any]: | |
| 412 | − since = datetime.now(UTC) - timedelta(hours=1) | |
| 413 | − base = await self.status_snapshot() | |
| 414 | − http_status = { | |
| 415 | − str(r["status_code"]): r["n"] | |
| 416 | − for r in await self.pool.fetch( | |
| 417 | − """ | |
| 418 | − SELECT status_code, count(*)::int AS n FROM crawl_attempts | |
| 419 | − WHERE fetched_at >= $1 AND status_code IS NOT NULL | |
| 420 | − GROUP BY status_code ORDER BY n DESC | |
| 421 | − """, | |
| 422 | − since, | |
| 423 | − ) | |
| 424 | − } | |
| 425 | − outcomes = { | |
| 426 | − r["outcome"]: r["n"] | |
| 427 | − for r in await self.pool.fetch( | |
| 428 | − "SELECT outcome, count(*)::int AS n FROM crawl_attempts WHERE fetched_at >= $1 GROUP BY outcome", | |
| 429 | − since, | |
| 430 | − ) | |
| 431 | − } | |
| 432 | − top_domains = [ | |
| 433 | − dict(r) | |
| 434 | − for r in await self.pool.fetch( | |
| 435 | − """ | |
| 436 | − SELECT domain, page_count AS pages, round(quebec_score::numeric, 3)::float AS quebec_score | |
| 437 | − FROM domains WHERE page_count > 0 | |
| 438 | − ORDER BY page_count DESC LIMIT 20 | |
| 439 | − """ | |
| 440 | − ) | |
| 441 | − ] | |
| 442 | − recent_errors = [ | |
| 443 | − {"url": r["url"], "error_code": r["error_code"], "at": r["fetched_at"].isoformat()} | |
| 444 | − for r in await self.pool.fetch( | |
| 445 | − """ | |
| 446 | − SELECT u.url, a.error_code, a.fetched_at | |
| 447 | − FROM crawl_attempts a JOIN urls u ON u.id = a.url_id | |
| 448 | − WHERE a.outcome = 'error' ORDER BY a.fetched_at DESC LIMIT 20 | |
| 449 | − """ | |
| 450 | − ) | |
| 451 | − ] | |
| 452 | − latency = await self.pool.fetchrow( | |
| 453 | − """ | |
| 454 | − SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms) AS p50, | |
| 455 | − percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95 | |
| 456 | − FROM crawl_attempts WHERE fetched_at >= $1 AND duration_ms IS NOT NULL | |
| 457 | − """, | |
| 458 | − since, | |
| 459 | − ) | |
| 460 | − return { | |
| 461 | − "frontier": { | |
| 462 | − "pending": base["frontier"].get("pending", 0), | |
| 463 | − "in_progress": base["frontier"].get("in_progress", 0), | |
| 464 | − "done": base["frontier"].get("done", 0), | |
| 465 | − "failed": base["frontier"].get("failed", 0), | |
| 466 | − "blocked": base["frontier"].get("blocked", 0), | |
| 467 | − }, | |
| 468 | − "rates": { | |
| 469 | − "fetched_1h": base["fetched_last_hour"], | |
| 470 | − "indexed_1h": outcomes.get("indexed", 0), | |
| 471 | − "errors_1h": outcomes.get("error", 0), | |
| 472 | − "robots_blocked_1h": outcomes.get("robots_blocked", 0), | |
| 473 | − "duplicates_1h": outcomes.get("duplicate", 0), | |
| 474 | − "parsed_1h": outcomes.get("indexed", 0) + outcomes.get("not_quebec", 0), | |
| 475 | − }, | |
| 476 | − "http_status": http_status, | |
| 477 | − "top_domains": top_domains, | |
| 478 | − "recent_errors": recent_errors, | |
| 479 | − "latency": { | |
| 480 | − "fetch_p50_ms": round(latency["p50"]) if latency and latency["p50"] else None, | |
| 481 | − "fetch_p95_ms": round(latency["p95"]) if latency and latency["p95"] else None, | |
| 482 | − }, | |
| 483 | − } | |
| 484 | − | |
| 485 | − async def recent_events(self, limit: int = 50) -> list[dict[str, Any]]: | |
| 486 | − rows = await self.pool.fetch( | |
| 487 | − """ | |
| 488 | − SELECT a.fetched_at, u.url, a.status_code, a.outcome, a.quebec_score, a.title | |
| 489 | − FROM crawl_attempts a JOIN urls u ON u.id = a.url_id | |
| 490 | − ORDER BY a.fetched_at DESC LIMIT $1 | |
| 491 | − """, | |
| 492 | − min(limit, 200), | |
| 493 | − ) | |
| 494 | − return [ | |
| 495 | − { | |
| 496 | − "at": r["fetched_at"].isoformat(), | |
| 497 | − "url": r["url"], | |
| 498 | − "status": r["status_code"], | |
| 499 | − "outcome": r["outcome"], | |
| 500 | − "quebec_score": r["quebec_score"], | |
| 501 | − "title": r["title"], | |
| 502 | − } | |
| 503 | − for r in rows | |
| 504 | − ] | |
| 505 | − | |
| 506 | − async def frontier_inspect( | |
| 507 | − self, domain: str | None = None, status: str | None = None, limit: int = 100 | |
| 508 | − ) -> list[dict[str, Any]]: | |
| 509 | − rows = await self.pool.fetch( | |
| 510 | − """ | |
| 511 | − SELECT u.url, f.priority, f.depth, f.status, f.retries, f.next_crawl_at, f.error_code | |
| 512 | − FROM frontier_items f JOIN urls u ON u.id = f.url_id | |
| 513 | − JOIN domains d ON d.id = u.domain_id | |
| 514 | − WHERE ($1::text IS NULL OR d.domain = $1) | |
| 515 | − AND ($2::text IS NULL OR f.status = $2) | |
| 516 | − ORDER BY f.priority DESC LIMIT $3 | |
| 517 | − """, | |
| 518 | − domain, | |
| 519 | − status, | |
| 520 | − min(limit, 500), | |
| 521 | − ) | |
| 522 | − return [dict(r) | {"next_crawl_at": r["next_crawl_at"].isoformat()} for r in rows] | |
deleted
M2M32crouve-ka/packages/database/migrate.py
+0 −46
@@ -1,46 +0,0 @@ | ||
| 1 | −# Trouve-KA — exécution des migrations SQL | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Applique les migrations SQL de infrastructure/migrations dans l'ordre.""" | |
| 6 | − | |
| 7 | −import os | |
| 8 | −import pathlib | |
| 9 | −import re | |
| 10 | − | |
| 11 | −import asyncpg | |
| 12 | − | |
| 13 | − | |
| 14 | −def _find_migrations_dir() -> pathlib.Path: | |
| 15 | − """Résout le dossier de migrations : env, cwd (container /app), ou racine du dépôt (editable).""" | |
| 16 | − candidates = [ | |
| 17 | − os.environ.get("TROUVEKA_MIGRATIONS_DIR"), | |
| 18 | − pathlib.Path.cwd() / "infrastructure" / "migrations", | |
| 19 | − pathlib.Path(__file__).resolve().parents[2] / "infrastructure" / "migrations", | |
| 20 | − ] | |
| 21 | − for cand in candidates: | |
| 22 | − if cand and pathlib.Path(cand).is_dir(): | |
| 23 | − return pathlib.Path(cand) | |
| 24 | − raise FileNotFoundError("dossier infrastructure/migrations introuvable") | |
| 25 | − | |
| 26 | − | |
| 27 | −async def run_migrations(database_url: str) -> list[str]: | |
| 28 | − """Applique les migrations manquantes. Retourne la liste des fichiers appliqués.""" | |
| 29 | − migrations_dir = _find_migrations_dir() | |
| 30 | − conn = await asyncpg.connect(database_url) | |
| 31 | − applied: list[str] = [] | |
| 32 | − try: | |
| 33 | − await conn.execute( | |
| 34 | − "CREATE TABLE IF NOT EXISTS schema_migrations (" | |
| 35 | − " version INTEGER PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())" | |
| 36 | − ) | |
| 37 | − done = {r["version"] for r in await conn.fetch("SELECT version FROM schema_migrations")} | |
| 38 | − for path in sorted(migrations_dir.glob("*.sql")): | |
| 39 | − match = re.match(r"^(\d+)_", path.name) | |
| 40 | − if not match or int(match.group(1)) in done: | |
| 41 | − continue | |
| 42 | − await conn.execute(path.read_text(encoding="utf-8")) | |
| 43 | − applied.append(path.name) | |
| 44 | − finally: | |
| 45 | − await conn.close() | |
| 46 | − return applied | |
deleted
M2M32crouve-ka/packages/logging/__init__.py
+0 −39
@@ -1,39 +0,0 @@ | ||
| 1 | −# Trouve-KA — logging structuré JSON | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Logs structurés (JSON une ligne) avec contexte crawl_id/url_id/domain_id/worker_id.""" | |
| 6 | − | |
| 7 | −import json | |
| 8 | −import logging | |
| 9 | −import sys | |
| 10 | −import time | |
| 11 | −from typing import Any | |
| 12 | − | |
| 13 | − | |
| 14 | −class JsonFormatter(logging.Formatter): | |
| 15 | − def format(self, record: logging.LogRecord) -> str: | |
| 16 | − payload: dict[str, Any] = { | |
| 17 | − "ts": round(time.time(), 3), | |
| 18 | − "level": record.levelname.lower(), | |
| 19 | − "logger": record.name, | |
| 20 | − "msg": record.getMessage(), | |
| 21 | − } | |
| 22 | − extra = getattr(record, "ctx", None) | |
| 23 | − if isinstance(extra, dict): | |
| 24 | − payload.update(extra) | |
| 25 | − if record.exc_info and record.exc_info[0] is not None: | |
| 26 | − payload["exc"] = self.formatException(record.exc_info) | |
| 27 | − return json.dumps(payload, ensure_ascii=False, default=str) | |
| 28 | − | |
| 29 | − | |
| 30 | −def get_logger(name: str) -> logging.Logger: | |
| 31 | − """Retourne un logger JSON prêt à l'emploi. Utiliser `log.info("msg", extra={"ctx": {...}})`.""" | |
| 32 | − logger = logging.getLogger(name) | |
| 33 | − if not logger.handlers: | |
| 34 | − handler = logging.StreamHandler(sys.stdout) | |
| 35 | − handler.setFormatter(JsonFormatter()) | |
| 36 | − logger.addHandler(handler) | |
| 37 | − logger.setLevel(logging.INFO) | |
| 38 | − logger.propagate = False | |
| 39 | − return logger | |
deleted
M2M32crouve-ka/packages/queue/__init__.py
+0 −71
@@ -1,71 +0,0 @@ | ||
| 1 | −# Trouve-KA — coordination Redis (politesse, pause, enrichissement) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Coordination inter-workers via Redis. | |
| 6 | − | |
| 7 | −- Politesse par hôte : lock SET NX PX — un seul fetch par hôte par fenêtre, | |
| 8 | − quel que soit le nombre de workers. | |
| 9 | −- Pause globale du crawler : simple clé drapeau. | |
| 10 | −- Enrichissement asynchrone : Redis Stream (jamais bloquant pour l'indexation). | |
| 11 | −""" | |
| 12 | − | |
| 13 | −import json | |
| 14 | −from typing import Any | |
| 15 | − | |
| 16 | −import redis.asyncio as aioredis | |
| 17 | − | |
| 18 | −PAUSE_KEY = "trouveka:crawler:paused" | |
| 19 | −HOST_LOCK_PREFIX = "trouveka:host-lock:" | |
| 20 | −ENRICH_STREAM = "trouveka:enrich" | |
| 21 | − | |
| 22 | − | |
| 23 | −class Coordination: | |
| 24 | − def __init__(self, redis_url: str): | |
| 25 | − self.redis: aioredis.Redis = aioredis.from_url(redis_url, decode_responses=True) | |
| 26 | − | |
| 27 | − async def close(self) -> None: | |
| 28 | − await self.redis.aclose() | |
| 29 | − | |
| 30 | − # ------------------------------------------------------------- politesse | |
| 31 | − async def acquire_host_slot(self, host: str, delay_seconds: float) -> bool: | |
| 32 | − """Réserve le droit de fetcher cet hôte. False = trop tôt, repasser plus tard.""" | |
| 33 | − px = max(int(delay_seconds * 1000), 100) | |
| 34 | − return bool(await self.redis.set(HOST_LOCK_PREFIX + host, "1", nx=True, px=px)) | |
| 35 | − | |
| 36 | − # ------------------------------------------------------------- pause | |
| 37 | − async def pause_crawler(self) -> None: | |
| 38 | − await self.redis.set(PAUSE_KEY, "1") | |
| 39 | − | |
| 40 | − async def resume_crawler(self) -> None: | |
| 41 | − await self.redis.delete(PAUSE_KEY) | |
| 42 | − | |
| 43 | − async def is_paused(self) -> bool: | |
| 44 | − return await self.redis.exists(PAUSE_KEY) == 1 | |
| 45 | − | |
| 46 | − # ------------------------------------------------------------- enrichissement | |
| 47 | − async def enqueue_enrichment(self, payload: dict[str, Any]) -> None: | |
| 48 | − await self.redis.xadd(ENRICH_STREAM, {"data": json.dumps(payload, default=str)}, maxlen=100_000) | |
| 49 | − | |
| 50 | − async def read_enrichment( | |
| 51 | − self, group: str, consumer: str, count: int = 10, block_ms: int = 5000 | |
| 52 | − ) -> list[tuple[str, dict[str, Any]]]: | |
| 53 | − try: | |
| 54 | − await self.redis.xgroup_create(ENRICH_STREAM, group, id="0", mkstream=True) | |
| 55 | − except aioredis.ResponseError as exc: | |
| 56 | − if "BUSYGROUP" not in str(exc): | |
| 57 | − raise | |
| 58 | − entries = await self.redis.xreadgroup( | |
| 59 | − group, consumer, {ENRICH_STREAM: ">"}, count=count, block=block_ms | |
| 60 | − ) | |
| 61 | − out: list[tuple[str, dict[str, Any]]] = [] | |
| 62 | − for _stream, items in entries or []: | |
| 63 | − for msg_id, fields in items: | |
| 64 | − out.append((msg_id, json.loads(fields["data"]))) | |
| 65 | − return out | |
| 66 | − | |
| 67 | − async def ack_enrichment(self, group: str, msg_id: str) -> None: | |
| 68 | − await self.redis.xack(ENRICH_STREAM, group, msg_id) | |
| 69 | − | |
| 70 | − async def enrich_backlog(self) -> int: | |
| 71 | − return await self.redis.xlen(ENRICH_STREAM) | |
deleted
M2M32crouve-ka/packages/search-core/__init__.py
+0 −10
@@ -1,10 +0,0 @@ | ||
| 1 | −# Trouve-KA — couche OpenSearch (client, mapping, indexation) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Couche OpenSearch : client async, mapping bilingue FR/EN, indexation immédiate.""" | |
| 6 | − | |
| 7 | −from .client import SearchCore | |
| 8 | −from .mapping import INDEX_SETTINGS | |
| 9 | − | |
| 10 | −__all__ = ["SearchCore", "INDEX_SETTINGS"] | |
deleted
M2M32crouve-ka/packages/search-core/client.py
+0 −66
@@ -1,66 +0,0 @@ | ||
| 1 | −# Trouve-KA — client OpenSearch | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Client OpenSearch async : création d'index, indexation immédiate, recherche, statut.""" | |
| 6 | − | |
| 7 | −import hashlib | |
| 8 | −from typing import Any | |
| 9 | − | |
| 10 | −from opensearchpy import AsyncOpenSearch | |
| 11 | −from opensearchpy.exceptions import NotFoundError | |
| 12 | − | |
| 13 | −from .mapping import INDEX_SETTINGS | |
| 14 | − | |
| 15 | − | |
| 16 | −def doc_id_for_url(url: str) -> str: | |
| 17 | − """ID de document stable dérivé de l'URL canonique.""" | |
| 18 | − return hashlib.sha256(url.encode("utf-8")).hexdigest()[:32] | |
| 19 | − | |
| 20 | − | |
| 21 | −class SearchCore: | |
| 22 | − def __init__(self, search_url: str, index: str): | |
| 23 | − self.index = index | |
| 24 | − self.client = AsyncOpenSearch(hosts=[search_url], timeout=15, max_retries=2, retry_on_timeout=True) | |
| 25 | − | |
| 26 | − async def close(self) -> None: | |
| 27 | − await self.client.close() | |
| 28 | − | |
| 29 | − async def ensure_index(self) -> None: | |
| 30 | − if not await self.client.indices.exists(index=self.index): | |
| 31 | − await self.client.indices.create(index=self.index, body=INDEX_SETTINGS) | |
| 32 | − | |
| 33 | − async def index_document(self, doc: dict[str, Any]) -> str: | |
| 34 | − """Indexation immédiate d'un document (cherchable au prochain refresh, ~1 s).""" | |
| 35 | − _id = doc_id_for_url(doc["canonical_url"] or doc["url"]) | |
| 36 | − await self.client.index(index=self.index, id=_id, body=doc) | |
| 37 | − return _id | |
| 38 | − | |
| 39 | − async def update_document(self, url: str, partial: dict[str, Any]) -> None: | |
| 40 | − """Mise à jour partielle (enrichissement asynchrone, étapes 2-3).""" | |
| 41 | − try: | |
| 42 | − await self.client.update(index=self.index, id=doc_id_for_url(url), body={"doc": partial}) | |
| 43 | − except NotFoundError: | |
| 44 | − pass | |
| 45 | − | |
| 46 | − async def delete_document(self, url: str) -> None: | |
| 47 | − try: | |
| 48 | − await self.client.delete(index=self.index, id=doc_id_for_url(url)) | |
| 49 | − except NotFoundError: | |
| 50 | − pass | |
| 51 | − | |
| 52 | − async def search(self, body: dict[str, Any]) -> dict[str, Any]: | |
| 53 | − return await self.client.search(index=self.index, body=body) | |
| 54 | − | |
| 55 | − async def count(self) -> int: | |
| 56 | − try: | |
| 57 | − res = await self.client.count(index=self.index) | |
| 58 | − return res["count"] | |
| 59 | − except NotFoundError: | |
| 60 | − return 0 | |
| 61 | − | |
| 62 | − async def ping(self) -> bool: | |
| 63 | − try: | |
| 64 | − return await self.client.ping() | |
| 65 | − except Exception: | |
| 66 | − return False | |
deleted
M2M32crouve-ka/packages/search-core/mapping.py
+0 −124
@@ -1,124 +0,0 @@ | ||
| 1 | −# Trouve-KA — mapping OpenSearch bilingue | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Mapping et settings de l'index documents. | |
| 6 | − | |
| 7 | −- Analyzers français ET anglais sur les champs texte (bilingue dès le départ). | |
| 8 | −- Filtre de synonymes bilingues (thermopompe ↔ heat pump) appliqué à la recherche | |
| 9 | − seulement : pas de traduction à l'ingestion. | |
| 10 | −- Refresh 1 s : une page indexée est cherchable en ~1 seconde (principe cardinal). | |
| 11 | −""" | |
| 12 | − | |
| 13 | −# Synonymes bilingues de départ — extensibles sans réindexation (search-time) | |
| 14 | −BILINGUAL_SYNONYMS = [ | |
| 15 | − "thermopompe, heat pump", | |
| 16 | − "subvention, grant, subsidy", | |
| 17 | − "emploi, job, employment", | |
| 18 | − "entreprise, company, business", | |
| 19 | − "université, university", | |
| 20 | − "cégep, cegep, college", | |
| 21 | − "garderie, daycare, cpe", | |
| 22 | − "impôt, tax, taxes", | |
| 23 | − "santé, health", | |
| 24 | − "plombier, plumber", | |
| 25 | − "électricien, electrician", | |
| 26 | − "déneigement, snow removal", | |
| 27 | − "assurance, insurance", | |
| 28 | − "logement, housing, apartment", | |
| 29 | − "ville, city, municipalité, municipality", | |
| 30 | − "gouvernement, government", | |
| 31 | − "permis, permit, license, licence", | |
| 32 | −] | |
| 33 | − | |
| 34 | −INDEX_SETTINGS = { | |
| 35 | − "settings": { | |
| 36 | − "number_of_shards": 1, | |
| 37 | − "number_of_replicas": 0, | |
| 38 | − "refresh_interval": "1s", | |
| 39 | − "analysis": { | |
| 40 | − "filter": { | |
| 41 | − "french_elision": { | |
| 42 | − "type": "elision", | |
| 43 | − "articles_case": True, | |
| 44 | − "articles": ["l", "m", "t", "qu", "n", "s", "j", "d", "c", "jusqu", "quoiqu", "lorsqu", "puisqu"], | |
| 45 | − }, | |
| 46 | − "french_stop": {"type": "stop", "stopwords": "_french_"}, | |
| 47 | − "french_stemmer": {"type": "stemmer", "language": "light_french"}, | |
| 48 | − "english_stop": {"type": "stop", "stopwords": "_english_"}, | |
| 49 | − "english_stemmer": {"type": "stemmer", "language": "light_english"}, | |
| 50 | − "bilingual_synonyms": { | |
| 51 | − "type": "synonym_graph", | |
| 52 | − "lenient": True, | |
| 53 | − "synonyms": BILINGUAL_SYNONYMS, | |
| 54 | − }, | |
| 55 | − }, | |
| 56 | − "analyzer": { | |
| 57 | − "fr_text": { | |
| 58 | − "tokenizer": "standard", | |
| 59 | − "filter": ["french_elision", "lowercase", "asciifolding", "french_stop", "french_stemmer"], | |
| 60 | − }, | |
| 61 | − "fr_search": { | |
| 62 | − "tokenizer": "standard", | |
| 63 | − "filter": ["french_elision", "lowercase", "asciifolding", "bilingual_synonyms", | |
| 64 | − "french_stop", "french_stemmer"], | |
| 65 | − }, | |
| 66 | − "en_text": { | |
| 67 | − "tokenizer": "standard", | |
| 68 | − "filter": ["lowercase", "asciifolding", "english_stop", "english_stemmer"], | |
| 69 | − }, | |
| 70 | − "en_search": { | |
| 71 | − "tokenizer": "standard", | |
| 72 | − "filter": ["lowercase", "asciifolding", "bilingual_synonyms", | |
| 73 | − "english_stop", "english_stemmer"], | |
| 74 | − }, | |
| 75 | − }, | |
| 76 | − }, | |
| 77 | − }, | |
| 78 | − "mappings": { | |
| 79 | − "properties": { | |
| 80 | − "url": {"type": "keyword"}, | |
| 81 | − "canonical_url": {"type": "keyword"}, | |
| 82 | − "domain": {"type": "keyword"}, | |
| 83 | − "title": { | |
| 84 | − "type": "text", | |
| 85 | − "analyzer": "fr_text", | |
| 86 | − "search_analyzer": "fr_search", | |
| 87 | − "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 88 | − }, | |
| 89 | − "description": { | |
| 90 | − "type": "text", | |
| 91 | − "analyzer": "fr_text", | |
| 92 | − "search_analyzer": "fr_search", | |
| 93 | − "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 94 | − }, | |
| 95 | − "body": { | |
| 96 | − "type": "text", | |
| 97 | − "analyzer": "fr_text", | |
| 98 | − "search_analyzer": "fr_search", | |
| 99 | − "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 100 | − }, | |
| 101 | − "headings": { | |
| 102 | − "type": "text", | |
| 103 | − "analyzer": "fr_text", | |
| 104 | − "search_analyzer": "fr_search", | |
| 105 | − "fields": {"en": {"type": "text", "analyzer": "en_text", "search_analyzer": "en_search"}}, | |
| 106 | − }, | |
| 107 | − "language": {"type": "keyword"}, | |
| 108 | − "page_quebec_score": {"type": "float"}, | |
| 109 | − "domain_quebec_score": {"type": "float"}, | |
| 110 | − "locations": {"type": "keyword"}, | |
| 111 | − "organizations": {"type": "keyword"}, | |
| 112 | − "people": {"type": "keyword"}, | |
| 113 | − "categories": {"type": "keyword"}, | |
| 114 | − "published_at": {"type": "date"}, | |
| 115 | − "crawled_at": {"type": "date"}, | |
| 116 | − "authority_score": {"type": "float"}, | |
| 117 | − "freshness_score": {"type": "float"}, | |
| 118 | − "quality_score": {"type": "float"}, | |
| 119 | − "spam_score": {"type": "float"}, | |
| 120 | − # embedding (dense_vector/knn) ajouté à l'étape sémantique (§17.7), | |
| 121 | − # le mapping est extensible sans réindexation pour un nouveau champ. | |
| 122 | − } | |
| 123 | − }, | |
| 124 | −} | |
deleted
M2M32crouve-ka/packages/shared/__init__.py
+0 −20
@@ -1,20 +0,0 @@ | ||
| 1 | −# Trouve-KA — utilitaires partagés | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Utilitaires partagés : canonicalisation d'URL, garde SSRF, hachage de contenu.""" | |
| 6 | − | |
| 7 | −from .urls import canonicalize_url, display_url, extract_domain, is_http_url | |
| 8 | −from .ssrf import is_safe_url, is_safe_ip | |
| 9 | −from .hashing import content_hash, text_fingerprint | |
| 10 | − | |
| 11 | −__all__ = [ | |
| 12 | − "canonicalize_url", | |
| 13 | − "display_url", | |
| 14 | − "extract_domain", | |
| 15 | − "is_http_url", | |
| 16 | − "is_safe_url", | |
| 17 | − "is_safe_ip", | |
| 18 | − "content_hash", | |
| 19 | − "text_fingerprint", | |
| 20 | −] | |
deleted
M2M32crouve-ka/packages/shared/hashing.py
+0 −25
@@ -1,25 +0,0 @@ | ||
| 1 | −# Trouve-KA — hachage de contenu et détection de changement | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Hachage de contenu pour la déduplication exacte et la détection de changement. | |
| 6 | − | |
| 7 | −Le hash est calculé sur le texte extrait normalisé (pas le HTML brut) pour | |
| 8 | −ignorer le bruit de balisage (nonces, timestamps de rendu, etc.). | |
| 9 | −""" | |
| 10 | − | |
| 11 | −import hashlib | |
| 12 | −import re | |
| 13 | − | |
| 14 | −_WHITESPACE = re.compile(r"\s+") | |
| 15 | − | |
| 16 | − | |
| 17 | −def text_fingerprint(text: str) -> str: | |
| 18 | − """Texte normalisé pour hachage : espaces réduits, minuscules.""" | |
| 19 | − return _WHITESPACE.sub(" ", text).strip().lower() | |
| 20 | − | |
| 21 | − | |
| 22 | −def content_hash(title: str, body: str) -> str: | |
| 23 | − """SHA-256 du contenu textuel normalisé (titre + corps).""" | |
| 24 | − payload = text_fingerprint(title) + "\n" + text_fingerprint(body) | |
| 25 | − return hashlib.sha256(payload.encode("utf-8")).hexdigest() | |
deleted
M2M32crouve-ka/packages/shared/ssrf.py
+0 −75
@@ -1,75 +0,0 @@ | ||
| 1 | −# Trouve-KA — prévention SSRF | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Garde SSRF du crawler (CLAUDE.md §5.7). | |
| 6 | − | |
| 7 | −Bloque localhost, plages privées IPv4/IPv6, link-local, métadonnées cloud, | |
| 8 | −schémas non-HTTP. La résolution DNS est revalidée à chaque saut de redirection | |
| 9 | −par l'appelant (fetcher). | |
| 10 | −""" | |
| 11 | − | |
| 12 | −import ipaddress | |
| 13 | −import socket | |
| 14 | −from urllib.parse import urlsplit | |
| 15 | − | |
| 16 | −BLOCKED_HOSTS = { | |
| 17 | − "localhost", | |
| 18 | − "metadata.google.internal", | |
| 19 | − "metadata.gke.internal", | |
| 20 | −} | |
| 21 | − | |
| 22 | −# Endpoint de métadonnées AWS/GCP/Azure/OpenStack | |
| 23 | −METADATA_IPS = {"169.254.169.254", "fd00:ec2::254"} | |
| 24 | − | |
| 25 | − | |
| 26 | −def is_safe_ip(ip_str: str) -> bool: | |
| 27 | − try: | |
| 28 | − ip = ipaddress.ip_address(ip_str) | |
| 29 | − except ValueError: | |
| 30 | − return False | |
| 31 | − if ip_str in METADATA_IPS: | |
| 32 | − return False | |
| 33 | − return not ( | |
| 34 | − ip.is_private | |
| 35 | − or ip.is_loopback | |
| 36 | − or ip.is_link_local | |
| 37 | − or ip.is_multicast | |
| 38 | − or ip.is_reserved | |
| 39 | − or ip.is_unspecified | |
| 40 | − ) | |
| 41 | − | |
| 42 | − | |
| 43 | −def resolve_host(host: str) -> list[str]: | |
| 44 | − """Résout un hôte en IPs (IPv4+IPv6). Lève socket.gaierror si introuvable.""" | |
| 45 | − infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP) | |
| 46 | − return list({info[4][0] for info in infos}) | |
| 47 | − | |
| 48 | − | |
| 49 | −def is_safe_url(url: str, *, resolve: bool = True) -> bool: | |
| 50 | − """Vérifie qu'une URL est sûre à fetcher (schéma, hôte, IPs résolues).""" | |
| 51 | − try: | |
| 52 | − parts = urlsplit(url) | |
| 53 | − except ValueError: | |
| 54 | − return False | |
| 55 | − if parts.scheme.lower() not in ("http", "https"): | |
| 56 | − return False | |
| 57 | − host = parts.hostname | |
| 58 | − if not host: | |
| 59 | − return False | |
| 60 | − host = host.lower().strip(".") | |
| 61 | − if host in BLOCKED_HOSTS or host.endswith(".localhost") or host.endswith(".internal"): | |
| 62 | − return False | |
| 63 | − # Hôte littéral IP | |
| 64 | − try: | |
| 65 | − ipaddress.ip_address(host) | |
| 66 | − return is_safe_ip(host) | |
| 67 | − except ValueError: | |
| 68 | − pass | |
| 69 | − if not resolve: | |
| 70 | − return True | |
| 71 | − try: | |
| 72 | − ips = resolve_host(host) | |
| 73 | − except (socket.gaierror, OSError): | |
| 74 | − return False | |
| 75 | − return bool(ips) and all(is_safe_ip(ip) for ip in ips) | |
deleted
M2M32crouve-ka/packages/shared/urls.py
+0 −103
@@ -1,103 +0,0 @@ | ||
| 1 | −# Trouve-KA — canonicalisation d'URL | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Canonicalisation d'URL prudente. | |
| 6 | − | |
| 7 | −Règle cardinale (CLAUDE.md §5.4) : ne JAMAIS fusionner deux ressources | |
| 8 | −distinctes par accident. On normalise seulement ce qui est sûr : | |
| 9 | −fragments, ports par défaut, casse de l'hôte, paramètres de tracking connus, | |
| 10 | −encodage. On ne touche ni à la casse du chemin ni aux paramètres inconnus. | |
| 11 | −""" | |
| 12 | − | |
| 13 | −from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit, quote, unquote | |
| 14 | − | |
| 15 | −# Paramètres de tracking sûrs à retirer (jamais porteurs de contenu) | |
| 16 | −TRACKING_PARAMS = { | |
| 17 | − "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id", | |
| 18 | − "fbclid", "gclid", "gclsrc", "dclid", "msclkid", "twclid", "igshid", | |
| 19 | − "mc_cid", "mc_eid", "_ga", "_gl", "yclid", "wbraid", "gbraid", | |
| 20 | − "ref_src", "cmpid", "s_kwcid", "spm", | |
| 21 | −} | |
| 22 | − | |
| 23 | −DEFAULT_PORTS = {"http": 80, "https": 443} | |
| 24 | − | |
| 25 | − | |
| 26 | −def is_http_url(url: str) -> bool: | |
| 27 | − try: | |
| 28 | − scheme = urlsplit(url).scheme.lower() | |
| 29 | − except ValueError: | |
| 30 | − return False | |
| 31 | − return scheme in ("http", "https") | |
| 32 | − | |
| 33 | − | |
| 34 | −def canonicalize_url(url: str, base: str | None = None) -> str | None: | |
| 35 | − """Normalise une URL. Retourne None si l'URL n'est pas crawlable (schéma non http).""" | |
| 36 | − url = url.strip() | |
| 37 | − if not url: | |
| 38 | − return None | |
| 39 | − if base: | |
| 40 | − from urllib.parse import urljoin | |
| 41 | − | |
| 42 | − url = urljoin(base, url) | |
| 43 | − try: | |
| 44 | − parts = urlsplit(url) | |
| 45 | − except ValueError: | |
| 46 | − return None | |
| 47 | − scheme = parts.scheme.lower() | |
| 48 | − if scheme not in ("http", "https"): | |
| 49 | − return None | |
| 50 | − host = parts.hostname | |
| 51 | − if not host: | |
| 52 | − return None | |
| 53 | − host = host.strip(".").lower() | |
| 54 | − try: | |
| 55 | − host = host.encode("idna").decode("ascii") if any(ord(c) > 127 for c in host) else host | |
| 56 | − except UnicodeError: | |
| 57 | − return None | |
| 58 | − | |
| 59 | − port = parts.port | |
| 60 | − netloc = host | |
| 61 | − if port and port != DEFAULT_PORTS.get(scheme): | |
| 62 | − netloc = f"{host}:{port}" | |
| 63 | − | |
| 64 | − # Chemin : ré-encoder proprement sans changer la sémantique (casse préservée) | |
| 65 | − path = quote(unquote(parts.path or "/"), safe="/%:@!$&'()*+,;=~-._") | |
| 66 | − # Slashs multiples consécutifs → un seul (sûr pour HTTP) | |
| 67 | − while "//" in path: | |
| 68 | − path = path.replace("//", "/") | |
| 69 | − if not path: | |
| 70 | − path = "/" | |
| 71 | − | |
| 72 | − # Query : retirer uniquement les paramètres de tracking connus; préserver l'ordre | |
| 73 | − query = "" | |
| 74 | − if parts.query: | |
| 75 | − kept = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) | |
| 76 | − if k.lower() not in TRACKING_PARAMS] | |
| 77 | − query = urlencode(kept) | |
| 78 | − | |
| 79 | − # Fragment : toujours retiré (jamais envoyé au serveur) | |
| 80 | − return urlunsplit((scheme, netloc, path, query, "")) | |
| 81 | − | |
| 82 | − | |
| 83 | −def extract_domain(url: str) -> str | None: | |
| 84 | − """Domaine enregistrable approximatif : hôte sans le préfixe www.""" | |
| 85 | − try: | |
| 86 | − host = urlsplit(url).hostname | |
| 87 | − except ValueError: | |
| 88 | − return None | |
| 89 | − if not host: | |
| 90 | − return None | |
| 91 | − host = host.lower().strip(".") | |
| 92 | − return host[4:] if host.startswith("www.") else host | |
| 93 | − | |
| 94 | − | |
| 95 | −def display_url(url: str, max_len: int = 80) -> str: | |
| 96 | − """URL d'affichage façon breadcrumb : quebec.ca › services › permis.""" | |
| 97 | − parts = urlsplit(url) | |
| 98 | − host = (parts.hostname or "").removeprefix("www.") | |
| 99 | − segments = [unquote(s) for s in parts.path.split("/") if s] | |
| 100 | − crumb = " › ".join([host, *segments[:3]]) | |
| 101 | − if len(crumb) > max_len: | |
| 102 | − crumb = crumb[: max_len - 1] + "…" | |
| 103 | − return crumb | |
deleted
M2M32crouve-ka/packages/types/__init__.py
+0 −103
@@ -1,103 +0,0 @@ | ||
| 1 | −# Trouve-KA — types partagés du pipeline | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Modèles Pydantic partagés entre crawler, parser, classifier, indexer et API.""" | |
| 6 | − | |
| 7 | −from datetime import datetime | |
| 8 | −from enum import StrEnum | |
| 9 | − | |
| 10 | −from pydantic import BaseModel, Field | |
| 11 | − | |
| 12 | − | |
| 13 | −class ErrorCode(StrEnum): | |
| 14 | − DNS = "dns" | |
| 15 | − TIMEOUT = "timeout" | |
| 16 | − TLS = "tls" | |
| 17 | − HTTP_4XX = "http_4xx" | |
| 18 | − HTTP_5XX = "http_5xx" | |
| 19 | − ROBOTS_DENIED = "robots_denied" | |
| 20 | − PARSE_FAILED = "parse_failed" | |
| 21 | − UNSUPPORTED_CONTENT = "unsupported_content" | |
| 22 | − TOO_LARGE = "too_large" | |
| 23 | − DUPLICATE = "duplicate" | |
| 24 | − SPAM = "spam" | |
| 25 | − NOT_QUEBEC = "not_quebec" | |
| 26 | − SSRF_BLOCKED = "ssrf_blocked" | |
| 27 | − TOO_MANY_REDIRECTS = "too_many_redirects" | |
| 28 | − CONNECTION = "connection" | |
| 29 | − OK = "ok" | |
| 30 | − | |
| 31 | − | |
| 32 | −class Outcome(StrEnum): | |
| 33 | − INDEXED = "indexed" | |
| 34 | − DUPLICATE = "duplicate" | |
| 35 | − UNCHANGED = "unchanged" | |
| 36 | − ERROR = "error" | |
| 37 | − NOT_QUEBEC = "not_quebec" | |
| 38 | − ROBOTS_BLOCKED = "robots_blocked" | |
| 39 | − REDIRECT = "redirect" | |
| 40 | − | |
| 41 | − | |
| 42 | −class FetchResult(BaseModel): | |
| 43 | − url: str | |
| 44 | − final_url: str | |
| 45 | − status_code: int | None = None | |
| 46 | − error_code: ErrorCode = ErrorCode.OK | |
| 47 | − content_type: str | None = None | |
| 48 | − charset: str | None = None | |
| 49 | − body: bytes | None = None | |
| 50 | − etag: str | None = None | |
| 51 | − last_modified: str | None = None | |
| 52 | − duration_ms: int = 0 | |
| 53 | − redirect_chain: list[str] = Field(default_factory=list) | |
| 54 | − | |
| 55 | − | |
| 56 | −class ExtractedLink(BaseModel): | |
| 57 | − url: str | |
| 58 | − anchor: str = "" | |
| 59 | − nofollow: bool = False | |
| 60 | − | |
| 61 | − | |
| 62 | −class ParsedPage(BaseModel): | |
| 63 | − url: str | |
| 64 | − canonical_url: str | None = None | |
| 65 | − title: str = "" | |
| 66 | − description: str = "" | |
| 67 | − body: str = "" | |
| 68 | − headings: list[str] = Field(default_factory=list) | |
| 69 | − language: str | None = None | |
| 70 | − links: list[ExtractedLink] = Field(default_factory=list) | |
| 71 | − published_at: datetime | None = None | |
| 72 | − modified_at: datetime | None = None | |
| 73 | − author: str | None = None | |
| 74 | − noindex: bool = False | |
| 75 | − nofollow_page: bool = False | |
| 76 | − structured_hints: list[str] = Field(default_factory=list) # textes JSON-LD/OG utiles au scoring | |
| 77 | − | |
| 78 | − | |
| 79 | −class QuebecSignals(BaseModel): | |
| 80 | − score: float = 0.0 | |
| 81 | − locations: list[str] = Field(default_factory=list) | |
| 82 | − reasons: list[str] = Field(default_factory=list) | |
| 83 | − | |
| 84 | − | |
| 85 | −class SearchResult(BaseModel): | |
| 86 | − title: str | |
| 87 | − url: str | |
| 88 | − display_url: str | |
| 89 | − snippet: str | |
| 90 | − domain: str | |
| 91 | − language: str | None = None | |
| 92 | − quebec_score: float = 0.0 | |
| 93 | − badges: list[str] = Field(default_factory=list) | |
| 94 | − published_at: datetime | None = None | |
| 95 | − | |
| 96 | − | |
| 97 | −class SearchResponse(BaseModel): | |
| 98 | − query: str | |
| 99 | − total: int | |
| 100 | − took_ms: int | |
| 101 | − page: int | |
| 102 | − limit: int | |
| 103 | − results: list[SearchResult] | |
deleted
M2M32crouve-ka/pnpm-lock.yaml
+0 −3967
@@ -1,3967 +0,0 @@ | ||
| 1 | −lockfileVersion: '9.0' | |
| 2 | − | |
| 3 | −settings: | |
| 4 | − autoInstallPeers: true | |
| 5 | − excludeLinksFromLockfile: false | |
| 6 | − | |
| 7 | −importers: | |
| 8 | − | |
| 9 | − .: {} | |
| 10 | − | |
| 11 | − apps/web: | |
| 12 | − dependencies: | |
| 13 | − class-variance-authority: | |
| 14 | − specifier: ^0.7.1 | |
| 15 | − version: 0.7.1 | |
| 16 | − clsx: | |
| 17 | − specifier: ^2.1.1 | |
| 18 | − version: 2.1.1 | |
| 19 | − next: | |
| 20 | − specifier: ^15.3.5 | |
| 21 | − version: 15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8) | |
| 22 | − react: | |
| 23 | − specifier: ^19.1.0 | |
| 24 | − version: 19.2.8 | |
| 25 | − react-dom: | |
| 26 | − specifier: ^19.1.0 | |
| 27 | − version: 19.2.8(react@19.2.8) | |
| 28 | − tailwind-merge: | |
| 29 | − specifier: ^2.6.0 | |
| 30 | − version: 2.6.1 | |
| 31 | − devDependencies: | |
| 32 | − '@types/node': | |
| 33 | − specifier: ^22.15.0 | |
| 34 | − version: 22.20.1 | |
| 35 | − '@types/react': | |
| 36 | − specifier: ^19.1.0 | |
| 37 | − version: 19.2.18 | |
| 38 | − '@types/react-dom': | |
| 39 | − specifier: ^19.1.0 | |
| 40 | − version: 19.2.4(@types/react@19.2.18) | |
| 41 | − autoprefixer: | |
| 42 | − specifier: ^10.4.21 | |
| 43 | − version: 10.5.4(postcss@8.5.26) | |
| 44 | − eslint: | |
| 45 | − specifier: ^8.57.1 | |
| 46 | − version: 8.57.1 | |
| 47 | − eslint-config-next: | |
| 48 | − specifier: ^15.3.5 | |
| 49 | − version: 15.5.23(eslint@8.57.1)(typescript@5.9.3) | |
| 50 | − postcss: | |
| 51 | − specifier: ^8.5.3 | |
| 52 | − version: 8.5.26 | |
| 53 | − tailwindcss: | |
| 54 | − specifier: ^3.4.17 | |
| 55 | − version: 3.4.19 | |
| 56 | − typescript: | |
| 57 | − specifier: ^5.8.3 | |
| 58 | − version: 5.9.3 | |
| 59 | − | |
| 60 | −packages: | |
| 61 | − | |
| 62 | − '@alloc/quick-lru@5.2.0': | |
| 63 | − resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} | |
| 64 | − engines: {node: '>=10'} | |
| 65 | − | |
| 66 | − '@emnapi/core@1.10.0': | |
| 67 | − resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} | |
| 68 | − | |
| 69 | − '@emnapi/runtime@1.10.0': | |
| 70 | − resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} | |
| 71 | − | |
| 72 | − '@emnapi/runtime@1.11.3': | |
| 73 | − resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} | |
| 74 | − | |
| 75 | − '@emnapi/wasi-threads@1.2.1': | |
| 76 | − resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} | |
| 77 | − | |
| 78 | − '@eslint-community/eslint-utils@4.10.1': | |
| 79 | − resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} | |
| 80 | − engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 81 | − peerDependencies: | |
| 82 | − eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 | |
| 83 | − | |
| 84 | − '@eslint-community/regexpp@4.12.2': | |
| 85 | − resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} | |
| 86 | − engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} | |
| 87 | − | |
| 88 | − '@eslint/eslintrc@2.1.4': | |
| 89 | − resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} | |
| 90 | − engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 91 | − | |
| 92 | − '@eslint/js@8.57.1': | |
| 93 | − resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} | |
| 94 | − engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 95 | − | |
| 96 | − '@humanwhocodes/config-array@0.13.0': | |
| 97 | − resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} | |
| 98 | − engines: {node: '>=10.10.0'} | |
| 99 | − deprecated: Use @eslint/config-array instead | |
| 100 | − | |
| 101 | − '@humanwhocodes/module-importer@1.0.1': | |
| 102 | − resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} | |
| 103 | − engines: {node: '>=12.22'} | |
| 104 | − | |
| 105 | − '@humanwhocodes/object-schema@2.0.3': | |
| 106 | − resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} | |
| 107 | − deprecated: Use @eslint/object-schema instead | |
| 108 | − | |
| 109 | − '@img/colour@1.1.0': | |
| 110 | − resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} | |
| 111 | − engines: {node: '>=18'} | |
| 112 | − | |
| 113 | − '@img/sharp-darwin-arm64@0.34.5': | |
| 114 | − resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} | |
| 115 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 116 | − cpu: [arm64] | |
| 117 | − os: [darwin] | |
| 118 | − | |
| 119 | − '@img/sharp-darwin-x64@0.34.5': | |
| 120 | − resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} | |
| 121 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 122 | − cpu: [x64] | |
| 123 | − os: [darwin] | |
| 124 | − | |
| 125 | − '@img/sharp-libvips-darwin-arm64@1.2.4': | |
| 126 | − resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} | |
| 127 | − cpu: [arm64] | |
| 128 | − os: [darwin] | |
| 129 | − | |
| 130 | − '@img/sharp-libvips-darwin-x64@1.2.4': | |
| 131 | − resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} | |
| 132 | − cpu: [x64] | |
| 133 | − os: [darwin] | |
| 134 | − | |
| 135 | − '@img/sharp-libvips-linux-arm64@1.2.4': | |
| 136 | − resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} | |
| 137 | − cpu: [arm64] | |
| 138 | − os: [linux] | |
| 139 | − libc: [glibc] | |
| 140 | − | |
| 141 | − '@img/sharp-libvips-linux-arm@1.2.4': | |
| 142 | − resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} | |
| 143 | − cpu: [arm] | |
| 144 | − os: [linux] | |
| 145 | − libc: [glibc] | |
| 146 | − | |
| 147 | − '@img/sharp-libvips-linux-ppc64@1.2.4': | |
| 148 | − resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} | |
| 149 | − cpu: [ppc64] | |
| 150 | − os: [linux] | |
| 151 | − libc: [glibc] | |
| 152 | − | |
| 153 | − '@img/sharp-libvips-linux-riscv64@1.2.4': | |
| 154 | − resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} | |
| 155 | − cpu: [riscv64] | |
| 156 | − os: [linux] | |
| 157 | − libc: [glibc] | |
| 158 | − | |
| 159 | − '@img/sharp-libvips-linux-s390x@1.2.4': | |
| 160 | − resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} | |
| 161 | − cpu: [s390x] | |
| 162 | − os: [linux] | |
| 163 | − libc: [glibc] | |
| 164 | − | |
| 165 | − '@img/sharp-libvips-linux-x64@1.2.4': | |
| 166 | − resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} | |
| 167 | − cpu: [x64] | |
| 168 | − os: [linux] | |
| 169 | − libc: [glibc] | |
| 170 | − | |
| 171 | − '@img/sharp-libvips-linuxmusl-arm64@1.2.4': | |
| 172 | − resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} | |
| 173 | − cpu: [arm64] | |
| 174 | − os: [linux] | |
| 175 | − libc: [musl] | |
| 176 | − | |
| 177 | − '@img/sharp-libvips-linuxmusl-x64@1.2.4': | |
| 178 | − resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} | |
| 179 | − cpu: [x64] | |
| 180 | − os: [linux] | |
| 181 | − libc: [musl] | |
| 182 | − | |
| 183 | − '@img/sharp-linux-arm64@0.34.5': | |
| 184 | − resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} | |
| 185 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 186 | − cpu: [arm64] | |
| 187 | − os: [linux] | |
| 188 | − libc: [glibc] | |
| 189 | − | |
| 190 | − '@img/sharp-linux-arm@0.34.5': | |
| 191 | − resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} | |
| 192 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 193 | − cpu: [arm] | |
| 194 | − os: [linux] | |
| 195 | − libc: [glibc] | |
| 196 | − | |
| 197 | − '@img/sharp-linux-ppc64@0.34.5': | |
| 198 | − resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} | |
| 199 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 200 | − cpu: [ppc64] | |
| 201 | − os: [linux] | |
| 202 | − libc: [glibc] | |
| 203 | − | |
| 204 | − '@img/sharp-linux-riscv64@0.34.5': | |
| 205 | − resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} | |
| 206 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 207 | − cpu: [riscv64] | |
| 208 | − os: [linux] | |
| 209 | − libc: [glibc] | |
| 210 | − | |
| 211 | − '@img/sharp-linux-s390x@0.34.5': | |
| 212 | − resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} | |
| 213 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 214 | − cpu: [s390x] | |
| 215 | − os: [linux] | |
| 216 | − libc: [glibc] | |
| 217 | − | |
| 218 | − '@img/sharp-linux-x64@0.34.5': | |
| 219 | − resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} | |
| 220 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 221 | − cpu: [x64] | |
| 222 | − os: [linux] | |
| 223 | − libc: [glibc] | |
| 224 | − | |
| 225 | − '@img/sharp-linuxmusl-arm64@0.34.5': | |
| 226 | − resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} | |
| 227 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 228 | − cpu: [arm64] | |
| 229 | − os: [linux] | |
| 230 | − libc: [musl] | |
| 231 | − | |
| 232 | − '@img/sharp-linuxmusl-x64@0.34.5': | |
| 233 | − resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} | |
| 234 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 235 | − cpu: [x64] | |
| 236 | − os: [linux] | |
| 237 | − libc: [musl] | |
| 238 | − | |
| 239 | − '@img/sharp-wasm32@0.34.5': | |
| 240 | − resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} | |
| 241 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 242 | − cpu: [wasm32] | |
| 243 | − | |
| 244 | − '@img/sharp-win32-arm64@0.34.5': | |
| 245 | − resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} | |
| 246 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 247 | − cpu: [arm64] | |
| 248 | − os: [win32] | |
| 249 | − | |
| 250 | − '@img/sharp-win32-ia32@0.34.5': | |
| 251 | − resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} | |
| 252 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 253 | − cpu: [ia32] | |
| 254 | − os: [win32] | |
| 255 | − | |
| 256 | − '@img/sharp-win32-x64@0.34.5': | |
| 257 | − resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} | |
| 258 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 259 | − cpu: [x64] | |
| 260 | − os: [win32] | |
| 261 | − | |
| 262 | − '@jridgewell/gen-mapping@0.3.13': | |
| 263 | − resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} | |
| 264 | − | |
| 265 | − '@jridgewell/resolve-uri@3.1.2': | |
| 266 | − resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} | |
| 267 | − engines: {node: '>=6.0.0'} | |
| 268 | − | |
| 269 | − '@jridgewell/sourcemap-codec@1.5.5': | |
| 270 | − resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} | |
| 271 | − | |
| 272 | − '@jridgewell/trace-mapping@0.3.31': | |
| 273 | − resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} | |
| 274 | − | |
| 275 | − '@napi-rs/wasm-runtime@1.2.3': | |
| 276 | − resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} | |
| 277 | − engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} | |
| 278 | − peerDependencies: | |
| 279 | − '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 | |
| 280 | − '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 | |
| 281 | − | |
| 282 | − '@next/env@15.5.23': | |
| 283 | − resolution: {integrity: sha512-Mv3Z9hVbFcPnoLevsZ6rnX1TBtyHb5E17yN7HTPDXSXxeNsGBjUFrdbjRXKKXIOhfth7/cg6Ay7PZ2UFawaWsQ==} | |
| 284 | − | |
| 285 | − '@next/eslint-plugin-next@15.5.23': | |
| 286 | − resolution: {integrity: sha512-0KnCFpiWVIsbwBhByZ0uIcjYM5xqGrFzN2eOPbwru/wuy5Z1dmA+3gP+PRbi4gl1Ny7an66BqAM/NHkX/50rbw==} | |
| 287 | − | |
| 288 | − '@next/swc-darwin-arm64@15.5.23': | |
| 289 | − resolution: {integrity: sha512-SrEwOROH/rhA03F59hHtdhgtfZMWGzr5duDBWgRQt2rS3mJhqMKOcnNx6txOd0/i3E3D3uFKYFvyHsEiwQxzag==} | |
| 290 | − engines: {node: '>= 10'} | |
| 291 | − cpu: [arm64] | |
| 292 | − os: [darwin] | |
| 293 | − | |
| 294 | − '@next/swc-darwin-x64@15.5.23': | |
| 295 | − resolution: {integrity: sha512-f0FpFbG2EhDCuptBGcfrLcYMDuQAhe6m1QA4VVfXFrIBoFXvXt/olGbBkYkloKlXQtmhuzvtdYyuu/6zf07GIg==} | |
| 296 | − engines: {node: '>= 10'} | |
| 297 | − cpu: [x64] | |
| 298 | − os: [darwin] | |
| 299 | − | |
| 300 | − '@next/swc-linux-arm64-gnu@15.5.23': | |
| 301 | − resolution: {integrity: sha512-WlNtfepUXKX2u2ZsJZ8c3c8+tJSRZqsYzoMwLOY72A8ucKCCgxgNhiePA3qzFYahVWrwcQd8jOeJmBinc+VFVQ==} | |
| 302 | − engines: {node: '>= 10'} | |
| 303 | − cpu: [arm64] | |
| 304 | − os: [linux] | |
| 305 | − libc: [glibc] | |
| 306 | − | |
| 307 | − '@next/swc-linux-arm64-musl@15.5.23': | |
| 308 | − resolution: {integrity: sha512-W/6qKk7UG93mg14PmQC+2urt69MIdwTBLNQ6MJyeC4wOCIHCjz+VfgssvS1pK7mgYBtLC1g6VKNoHD9xB0WWGg==} | |
| 309 | − engines: {node: '>= 10'} | |
| 310 | − cpu: [arm64] | |
| 311 | − os: [linux] | |
| 312 | − libc: [musl] | |
| 313 | − | |
| 314 | − '@next/swc-linux-x64-gnu@15.5.23': | |
| 315 | − resolution: {integrity: sha512-vzefI32mi6VMk96RaTAyxApgfGbiFzQBXVsekEjsDv1fr48mlABTWx0sUYhaYCBHWqCalxmz3DxbxFcbFvzNtw==} | |
| 316 | − engines: {node: '>= 10'} | |
| 317 | − cpu: [x64] | |
| 318 | − os: [linux] | |
| 319 | − libc: [glibc] | |
| 320 | − | |
| 321 | − '@next/swc-linux-x64-musl@15.5.23': | |
| 322 | − resolution: {integrity: sha512-qppK/3dTGOTI+aoWWBZc3DshFIhrzgL8guATlaN9V6M1QJxbkP/rhEZ22tdICsQ/2WWXopMZ2Jokzj2u3uKY3Q==} | |
| 323 | − engines: {node: '>= 10'} | |
| 324 | − cpu: [x64] | |
| 325 | − os: [linux] | |
| 326 | − libc: [musl] | |
| 327 | − | |
| 328 | − '@next/swc-win32-arm64-msvc@15.5.23': | |
| 329 | − resolution: {integrity: sha512-Wc29KFOdT7XBcII3Vtmw7aoU8Uk3Mes/FNJfhFeSHdYBFJWMcR/DsI8U9BCPUhq/uycsUVuqSKGthW15tLsigA==} | |
| 330 | − engines: {node: '>= 10'} | |
| 331 | − cpu: [arm64] | |
| 332 | − os: [win32] | |
| 333 | − | |
| 334 | − '@next/swc-win32-x64-msvc@15.5.23': | |
| 335 | − resolution: {integrity: sha512-/C7wRW4fa9s/PKA18zGPPpVmx8ycgVpP8yOxro4gzGTzjPJdscbAP3ODeFvgiIovxD176Z2J/SXO9t8PJKHLeQ==} | |
| 336 | − engines: {node: '>= 10'} | |
| 337 | − cpu: [x64] | |
| 338 | − os: [win32] | |
| 339 | − | |
| 340 | − '@nodelib/fs.scandir@2.1.5': | |
| 341 | − resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} | |
| 342 | − engines: {node: '>= 8'} | |
| 343 | − | |
| 344 | − '@nodelib/fs.stat@2.0.5': | |
| 345 | − resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} | |
| 346 | − engines: {node: '>= 8'} | |
| 347 | − | |
| 348 | − '@nodelib/fs.walk@1.2.8': | |
| 349 | − resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} | |
| 350 | − engines: {node: '>= 8'} | |
| 351 | − | |
| 352 | − '@nolyfill/is-core-module@1.0.39': | |
| 353 | − resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} | |
| 354 | − engines: {node: '>=12.4.0'} | |
| 355 | − | |
| 356 | − '@rtsao/scc@1.1.0': | |
| 357 | − resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} | |
| 358 | − | |
| 359 | − '@rushstack/eslint-patch@1.16.1': | |
| 360 | − resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} | |
| 361 | − | |
| 362 | − '@swc/helpers@0.5.15': | |
| 363 | − resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} | |
| 364 | − | |
| 365 | − '@tybys/wasm-util@0.10.3': | |
| 366 | − resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} | |
| 367 | − | |
| 368 | − '@types/json5@0.0.29': | |
| 369 | − resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} | |
| 370 | − | |
| 371 | − '@types/node@22.20.1': | |
| 372 | − resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} | |
| 373 | − | |
| 374 | − '@types/react-dom@19.2.4': | |
| 375 | − resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} | |
| 376 | − peerDependencies: | |
| 377 | − '@types/react': ^19.2.0 | |
| 378 | − | |
| 379 | − '@types/react@19.2.18': | |
| 380 | − resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} | |
| 381 | − | |
| 382 | − '@typescript-eslint/eslint-plugin@8.67.0': | |
| 383 | − resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} | |
| 384 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 385 | − peerDependencies: | |
| 386 | − '@typescript-eslint/parser': ^8.67.0 | |
| 387 | − eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 388 | − typescript: '>=4.8.4 <6.1.0' | |
| 389 | − | |
| 390 | − '@typescript-eslint/parser@8.67.0': | |
| 391 | − resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} | |
| 392 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 393 | − peerDependencies: | |
| 394 | − eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 395 | − typescript: '>=4.8.4 <6.1.0' | |
| 396 | − | |
| 397 | − '@typescript-eslint/project-service@8.67.0': | |
| 398 | − resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} | |
| 399 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 400 | − peerDependencies: | |
| 401 | − typescript: '>=4.8.4 <6.1.0' | |
| 402 | − | |
| 403 | − '@typescript-eslint/scope-manager@8.67.0': | |
| 404 | − resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} | |
| 405 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 406 | − | |
| 407 | − '@typescript-eslint/tsconfig-utils@8.67.0': | |
| 408 | − resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} | |
| 409 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 410 | − peerDependencies: | |
| 411 | − typescript: '>=4.8.4 <6.1.0' | |
| 412 | − | |
| 413 | − '@typescript-eslint/type-utils@8.67.0': | |
| 414 | − resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} | |
| 415 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 416 | − peerDependencies: | |
| 417 | − eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 418 | − typescript: '>=4.8.4 <6.1.0' | |
| 419 | − | |
| 420 | − '@typescript-eslint/types@8.67.0': | |
| 421 | − resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} | |
| 422 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 423 | − | |
| 424 | − '@typescript-eslint/typescript-estree@8.67.0': | |
| 425 | − resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} | |
| 426 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 427 | − peerDependencies: | |
| 428 | − typescript: '>=4.8.4 <6.1.0' | |
| 429 | − | |
| 430 | − '@typescript-eslint/utils@8.67.0': | |
| 431 | − resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} | |
| 432 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 433 | − peerDependencies: | |
| 434 | − eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 435 | − typescript: '>=4.8.4 <6.1.0' | |
| 436 | − | |
| 437 | − '@typescript-eslint/visitor-keys@8.67.0': | |
| 438 | − resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} | |
| 439 | − engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 440 | − | |
| 441 | − '@ungap/structured-clone@1.3.3': | |
| 442 | − resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} | |
| 443 | − | |
| 444 | − '@unrs/resolver-binding-android-arm-eabi@1.12.2': | |
| 445 | − resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} | |
| 446 | − cpu: [arm] | |
| 447 | − os: [android] | |
| 448 | − | |
| 449 | − '@unrs/resolver-binding-android-arm64@1.12.2': | |
| 450 | − resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} | |
| 451 | − cpu: [arm64] | |
| 452 | − os: [android] | |
| 453 | − | |
| 454 | − '@unrs/resolver-binding-darwin-arm64@1.12.2': | |
| 455 | − resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} | |
| 456 | − cpu: [arm64] | |
| 457 | − os: [darwin] | |
| 458 | − | |
| 459 | − '@unrs/resolver-binding-darwin-x64@1.12.2': | |
| 460 | − resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} | |
| 461 | − cpu: [x64] | |
| 462 | − os: [darwin] | |
| 463 | − | |
| 464 | − '@unrs/resolver-binding-freebsd-x64@1.12.2': | |
| 465 | − resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} | |
| 466 | − cpu: [x64] | |
| 467 | − os: [freebsd] | |
| 468 | − | |
| 469 | − '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': | |
| 470 | − resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} | |
| 471 | − cpu: [arm] | |
| 472 | − os: [linux] | |
| 473 | − | |
| 474 | − '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': | |
| 475 | − resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} | |
| 476 | − cpu: [arm] | |
| 477 | − os: [linux] | |
| 478 | − | |
| 479 | − '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': | |
| 480 | − resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} | |
| 481 | − cpu: [arm64] | |
| 482 | − os: [linux] | |
| 483 | − libc: [glibc] | |
| 484 | − | |
| 485 | − '@unrs/resolver-binding-linux-arm64-musl@1.12.2': | |
| 486 | − resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} | |
| 487 | − cpu: [arm64] | |
| 488 | − os: [linux] | |
| 489 | − libc: [musl] | |
| 490 | − | |
| 491 | − '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': | |
| 492 | − resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} | |
| 493 | − cpu: [loong64] | |
| 494 | − os: [linux] | |
| 495 | − libc: [glibc] | |
| 496 | − | |
| 497 | − '@unrs/resolver-binding-linux-loong64-musl@1.12.2': | |
| 498 | − resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} | |
| 499 | − cpu: [loong64] | |
| 500 | − os: [linux] | |
| 501 | − libc: [musl] | |
| 502 | − | |
| 503 | − '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': | |
| 504 | − resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} | |
| 505 | − cpu: [ppc64] | |
| 506 | − os: [linux] | |
| 507 | − libc: [glibc] | |
| 508 | − | |
| 509 | − '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': | |
| 510 | − resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} | |
| 511 | − cpu: [riscv64] | |
| 512 | − os: [linux] | |
| 513 | − libc: [glibc] | |
| 514 | − | |
| 515 | − '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': | |
| 516 | − resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} | |
| 517 | − cpu: [riscv64] | |
| 518 | − os: [linux] | |
| 519 | − libc: [musl] | |
| 520 | − | |
| 521 | − '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': | |
| 522 | − resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} | |
| 523 | − cpu: [s390x] | |
| 524 | − os: [linux] | |
| 525 | − libc: [glibc] | |
| 526 | − | |
| 527 | − '@unrs/resolver-binding-linux-x64-gnu@1.12.2': | |
| 528 | − resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} | |
| 529 | − cpu: [x64] | |
| 530 | − os: [linux] | |
| 531 | − libc: [glibc] | |
| 532 | − | |
| 533 | − '@unrs/resolver-binding-linux-x64-musl@1.12.2': | |
| 534 | − resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} | |
| 535 | − cpu: [x64] | |
| 536 | − os: [linux] | |
| 537 | − libc: [musl] | |
| 538 | − | |
| 539 | − '@unrs/resolver-binding-openharmony-arm64@1.12.2': | |
| 540 | − resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} | |
| 541 | − cpu: [arm64] | |
| 542 | − os: [openharmony] | |
| 543 | − | |
| 544 | − '@unrs/resolver-binding-wasm32-wasi@1.12.2': | |
| 545 | − resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} | |
| 546 | − engines: {node: '>=14.0.0'} | |
| 547 | − cpu: [wasm32] | |
| 548 | − | |
| 549 | − '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': | |
| 550 | − resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} | |
| 551 | − cpu: [arm64] | |
| 552 | − os: [win32] | |
| 553 | − | |
| 554 | − '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': | |
| 555 | − resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} | |
| 556 | − cpu: [ia32] | |
| 557 | − os: [win32] | |
| 558 | − | |
| 559 | − '@unrs/resolver-binding-win32-x64-msvc@1.12.2': | |
| 560 | − resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} | |
| 561 | − cpu: [x64] | |
| 562 | − os: [win32] | |
| 563 | − | |
| 564 | − acorn-jsx@5.3.2: | |
| 565 | − resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} | |
| 566 | − peerDependencies: | |
| 567 | − acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 | |
| 568 | − | |
| 569 | − acorn@8.18.0: | |
| 570 | − resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} | |
| 571 | − engines: {node: '>=0.4.0'} | |
| 572 | − hasBin: true | |
| 573 | − | |
| 574 | − ajv@6.15.0: | |
| 575 | − resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} | |
| 576 | − | |
| 577 | − ansi-regex@5.0.1: | |
| 578 | − resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} | |
| 579 | − engines: {node: '>=8'} | |
| 580 | − | |
| 581 | − ansi-styles@4.3.0: | |
| 582 | − resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} | |
| 583 | − engines: {node: '>=8'} | |
| 584 | − | |
| 585 | − any-promise@1.3.0: | |
| 586 | − resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} | |
| 587 | − | |
| 588 | − anymatch@3.1.3: | |
| 589 | − resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} | |
| 590 | − engines: {node: '>= 8'} | |
| 591 | − | |
| 592 | − arg@5.0.2: | |
| 593 | − resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} | |
| 594 | − | |
| 595 | − argparse@2.0.1: | |
| 596 | − resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} | |
| 597 | − | |
| 598 | − aria-query@5.3.2: | |
| 599 | − resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} | |
| 600 | − engines: {node: '>= 0.4'} | |
| 601 | − | |
| 602 | − array-buffer-byte-length@1.0.2: | |
| 603 | − resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} | |
| 604 | − engines: {node: '>= 0.4'} | |
| 605 | − | |
| 606 | − array-includes@3.1.9: | |
| 607 | − resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} | |
| 608 | − engines: {node: '>= 0.4'} | |
| 609 | − | |
| 610 | − array.prototype.findlast@1.2.5: | |
| 611 | − resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} | |
| 612 | − engines: {node: '>= 0.4'} | |
| 613 | − | |
| 614 | − array.prototype.findlastindex@1.2.6: | |
| 615 | − resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} | |
| 616 | − engines: {node: '>= 0.4'} | |
| 617 | − | |
| 618 | − array.prototype.flat@1.3.3: | |
| 619 | − resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} | |
| 620 | − engines: {node: '>= 0.4'} | |
| 621 | − | |
| 622 | − array.prototype.flatmap@1.3.3: | |
| 623 | − resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} | |
| 624 | − engines: {node: '>= 0.4'} | |
| 625 | − | |
| 626 | − array.prototype.tosorted@1.1.4: | |
| 627 | − resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} | |
| 628 | − engines: {node: '>= 0.4'} | |
| 629 | − | |
| 630 | − arraybuffer.prototype.slice@1.0.4: | |
| 631 | − resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} | |
| 632 | − engines: {node: '>= 0.4'} | |
| 633 | − | |
| 634 | − ast-types-flow@0.0.8: | |
| 635 | − resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} | |
| 636 | − | |
| 637 | − async-function@1.0.0: | |
| 638 | − resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} | |
| 639 | − engines: {node: '>= 0.4'} | |
| 640 | − | |
| 641 | − autoprefixer@10.5.4: | |
| 642 | − resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} | |
| 643 | − engines: {node: ^10 || ^12 || >=14} | |
| 644 | − hasBin: true | |
| 645 | − peerDependencies: | |
| 646 | − postcss: ^8.1.0 | |
| 647 | − | |
| 648 | − available-typed-arrays@1.0.7: | |
| 649 | − resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} | |
| 650 | − engines: {node: '>= 0.4'} | |
| 651 | − | |
| 652 | − axe-core@4.13.0: | |
| 653 | − resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} | |
| 654 | − engines: {node: '>=4'} | |
| 655 | − | |
| 656 | − axobject-query@4.1.0: | |
| 657 | − resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} | |
| 658 | − engines: {node: '>= 0.4'} | |
| 659 | − | |
| 660 | − balanced-match@1.0.2: | |
| 661 | − resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} | |
| 662 | − | |
| 663 | − balanced-match@4.0.4: | |
| 664 | − resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} | |
| 665 | − engines: {node: 18 || 20 || >=22} | |
| 666 | − | |
| 667 | − baseline-browser-mapping@2.11.13: | |
| 668 | − resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} | |
| 669 | − engines: {node: '>=6.0.0'} | |
| 670 | − hasBin: true | |
| 671 | − | |
| 672 | − binary-extensions@2.3.0: | |
| 673 | − resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} | |
| 674 | − engines: {node: '>=8'} | |
| 675 | − | |
| 676 | − brace-expansion@1.1.18: | |
| 677 | − resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} | |
| 678 | − | |
| 679 | − brace-expansion@5.0.9: | |
| 680 | − resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} | |
| 681 | − engines: {node: 20 || >=22} | |
| 682 | − | |
| 683 | − braces@3.0.3: | |
| 684 | − resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} | |
| 685 | − engines: {node: '>=8'} | |
| 686 | − | |
| 687 | − browserslist@4.28.8: | |
| 688 | − resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} | |
| 689 | − engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} | |
| 690 | − hasBin: true | |
| 691 | − | |
| 692 | − call-bind-apply-helpers@1.0.2: | |
| 693 | − resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} | |
| 694 | − engines: {node: '>= 0.4'} | |
| 695 | − | |
| 696 | − call-bind@1.0.9: | |
| 697 | − resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} | |
| 698 | − engines: {node: '>= 0.4'} | |
| 699 | − | |
| 700 | − call-bound@1.0.4: | |
| 701 | − resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} | |
| 702 | − engines: {node: '>= 0.4'} | |
| 703 | − | |
| 704 | − callsites@3.1.0: | |
| 705 | − resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} | |
| 706 | − engines: {node: '>=6'} | |
| 707 | − | |
| 708 | − camelcase-css@2.0.1: | |
| 709 | − resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} | |
| 710 | − engines: {node: '>= 6'} | |
| 711 | − | |
| 712 | − caniuse-lite@1.0.30001809: | |
| 713 | − resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} | |
| 714 | − | |
| 715 | − chalk@4.1.2: | |
| 716 | − resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} | |
| 717 | − engines: {node: '>=10'} | |
| 718 | − | |
| 719 | − chokidar@3.6.0: | |
| 720 | − resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} | |
| 721 | − engines: {node: '>= 8.10.0'} | |
| 722 | − | |
| 723 | − class-variance-authority@0.7.1: | |
| 724 | − resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} | |
| 725 | − | |
| 726 | − client-only@0.0.1: | |
| 727 | − resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} | |
| 728 | − | |
| 729 | − clsx@2.1.1: | |
| 730 | − resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} | |
| 731 | − engines: {node: '>=6'} | |
| 732 | − | |
| 733 | − color-convert@2.0.1: | |
| 734 | − resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} | |
| 735 | − engines: {node: '>=7.0.0'} | |
| 736 | − | |
| 737 | − color-name@1.1.4: | |
| 738 | − resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} | |
| 739 | − | |
| 740 | − commander@4.1.1: | |
| 741 | − resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} | |
| 742 | − engines: {node: '>= 6'} | |
| 743 | − | |
| 744 | − concat-map@0.0.1: | |
| 745 | − resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} | |
| 746 | − | |
| 747 | − cross-spawn@7.0.6: | |
| 748 | − resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} | |
| 749 | − engines: {node: '>= 8'} | |
| 750 | − | |
| 751 | − cssesc@3.0.0: | |
| 752 | − resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} | |
| 753 | − engines: {node: '>=4'} | |
| 754 | − hasBin: true | |
| 755 | − | |
| 756 | − csstype@3.2.3: | |
| 757 | − resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} | |
| 758 | − | |
| 759 | − damerau-levenshtein@1.0.8: | |
| 760 | − resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} | |
| 761 | − | |
| 762 | − data-view-buffer@1.0.2: | |
| 763 | − resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} | |
| 764 | − engines: {node: '>= 0.4'} | |
| 765 | − | |
| 766 | − data-view-byte-length@1.0.2: | |
| 767 | − resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} | |
| 768 | − engines: {node: '>= 0.4'} | |
| 769 | − | |
| 770 | − data-view-byte-offset@1.0.1: | |
| 771 | − resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} | |
| 772 | − engines: {node: '>= 0.4'} | |
| 773 | − | |
| 774 | − debug@3.2.7: | |
| 775 | − resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} | |
| 776 | − peerDependencies: | |
| 777 | − supports-color: '*' | |
| 778 | − peerDependenciesMeta: | |
| 779 | − supports-color: | |
| 780 | − optional: true | |
| 781 | − | |
| 782 | − debug@4.4.3: | |
| 783 | − resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} | |
| 784 | − engines: {node: '>=6.0'} | |
| 785 | − peerDependencies: | |
| 786 | − supports-color: '*' | |
| 787 | − peerDependenciesMeta: | |
| 788 | − supports-color: | |
| 789 | − optional: true | |
| 790 | − | |
| 791 | − deep-is@0.1.4: | |
| 792 | − resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} | |
| 793 | − | |
| 794 | − define-data-property@1.1.4: | |
| 795 | − resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} | |
| 796 | − engines: {node: '>= 0.4'} | |
| 797 | − | |
| 798 | − define-properties@1.2.1: | |
| 799 | − resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} | |
| 800 | − engines: {node: '>= 0.4'} | |
| 801 | − | |
| 802 | − detect-libc@2.1.2: | |
| 803 | − resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} | |
| 804 | − engines: {node: '>=8'} | |
| 805 | − | |
| 806 | − didyoumean@1.2.2: | |
| 807 | − resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} | |
| 808 | − | |
| 809 | − dlv@1.1.3: | |
| 810 | − resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} | |
| 811 | − | |
| 812 | − doctrine@2.1.0: | |
| 813 | − resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} | |
| 814 | − engines: {node: '>=0.10.0'} | |
| 815 | − | |
| 816 | − doctrine@3.0.0: | |
| 817 | − resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} | |
| 818 | − engines: {node: '>=6.0.0'} | |
| 819 | − | |
| 820 | − dunder-proto@1.0.1: | |
| 821 | − resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} | |
| 822 | − engines: {node: '>= 0.4'} | |
| 823 | − | |
| 824 | − electron-to-chromium@1.5.405: | |
| 825 | − resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==} | |
| 826 | − | |
| 827 | − emoji-regex@9.2.2: | |
| 828 | − resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} | |
| 829 | − | |
| 830 | − es-abstract-get@1.0.0: | |
| 831 | − resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} | |
| 832 | − engines: {node: '>= 0.4'} | |
| 833 | − | |
| 834 | − es-abstract@1.24.2: | |
| 835 | − resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} | |
| 836 | − engines: {node: '>= 0.4'} | |
| 837 | − | |
| 838 | − es-define-property@1.0.1: | |
| 839 | − resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} | |
| 840 | − engines: {node: '>= 0.4'} | |
| 841 | − | |
| 842 | − es-errors@1.3.0: | |
| 843 | − resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} | |
| 844 | − engines: {node: '>= 0.4'} | |
| 845 | − | |
| 846 | − es-iterator-helpers@1.4.0: | |
| 847 | − resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} | |
| 848 | − engines: {node: '>= 0.4'} | |
| 849 | − | |
| 850 | − es-object-atoms@1.1.2: | |
| 851 | − resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} | |
| 852 | − engines: {node: '>= 0.4'} | |
| 853 | − | |
| 854 | − es-set-tostringtag@2.1.0: | |
| 855 | − resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} | |
| 856 | − engines: {node: '>= 0.4'} | |
| 857 | − | |
| 858 | − es-shim-unscopables@1.1.0: | |
| 859 | − resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} | |
| 860 | − engines: {node: '>= 0.4'} | |
| 861 | − | |
| 862 | − es-to-primitive@1.3.4: | |
| 863 | − resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} | |
| 864 | − engines: {node: '>= 0.4'} | |
| 865 | − | |
| 866 | − escalade@3.2.0: | |
| 867 | − resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} | |
| 868 | − engines: {node: '>=6'} | |
| 869 | − | |
| 870 | − escape-string-regexp@4.0.0: | |
| 871 | − resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} | |
| 872 | − engines: {node: '>=10'} | |
| 873 | − | |
| 874 | − eslint-config-next@15.5.23: | |
| 875 | − resolution: {integrity: sha512-z4WcTXNqFHwMG4V8WHb2xrlEPJvwarZa+H/6CR28vxr53icRnQzGXviO11p748BwrMZGl52itdPRzZfzYo0SKw==} | |
| 876 | − peerDependencies: | |
| 877 | − eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 | |
| 878 | − typescript: '>=3.3.1' | |
| 879 | − peerDependenciesMeta: | |
| 880 | − typescript: | |
| 881 | − optional: true | |
| 882 | − | |
| 883 | − eslint-import-resolver-node@0.3.10: | |
| 884 | − resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} | |
| 885 | − | |
| 886 | − eslint-import-resolver-typescript@3.10.1: | |
| 887 | − resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} | |
| 888 | − engines: {node: ^14.18.0 || >=16.0.0} | |
| 889 | − peerDependencies: | |
| 890 | − eslint: '*' | |
| 891 | − eslint-plugin-import: '*' | |
| 892 | − eslint-plugin-import-x: '*' | |
| 893 | − peerDependenciesMeta: | |
| 894 | − eslint-plugin-import: | |
| 895 | − optional: true | |
| 896 | − eslint-plugin-import-x: | |
| 897 | − optional: true | |
| 898 | − | |
| 899 | − eslint-module-utils@2.14.0: | |
| 900 | − resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} | |
| 901 | − engines: {node: '>=4'} | |
| 902 | − peerDependencies: | |
| 903 | − '@typescript-eslint/parser': '*' | |
| 904 | − eslint: '*' | |
| 905 | − eslint-import-resolver-node: '*' | |
| 906 | − eslint-import-resolver-typescript: '*' | |
| 907 | − eslint-import-resolver-webpack: '*' | |
| 908 | − peerDependenciesMeta: | |
| 909 | − '@typescript-eslint/parser': | |
| 910 | − optional: true | |
| 911 | − eslint: | |
| 912 | − optional: true | |
| 913 | − eslint-import-resolver-node: | |
| 914 | − optional: true | |
| 915 | − eslint-import-resolver-typescript: | |
| 916 | − optional: true | |
| 917 | − eslint-import-resolver-webpack: | |
| 918 | − optional: true | |
| 919 | − | |
| 920 | − eslint-plugin-import@2.32.0: | |
| 921 | − resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} | |
| 922 | − engines: {node: '>=4'} | |
| 923 | − peerDependencies: | |
| 924 | − '@typescript-eslint/parser': '*' | |
| 925 | − eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 | |
| 926 | − peerDependenciesMeta: | |
| 927 | − '@typescript-eslint/parser': | |
| 928 | − optional: true | |
| 929 | − | |
| 930 | − eslint-plugin-jsx-a11y@6.10.2: | |
| 931 | − resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} | |
| 932 | − engines: {node: '>=4.0'} | |
| 933 | − peerDependencies: | |
| 934 | − eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 | |
| 935 | − | |
| 936 | − eslint-plugin-react-hooks@5.2.0: | |
| 937 | − resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} | |
| 938 | − engines: {node: '>=10'} | |
| 939 | − peerDependencies: | |
| 940 | − eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 | |
| 941 | − | |
| 942 | − eslint-plugin-react@7.37.5: | |
| 943 | − resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} | |
| 944 | − engines: {node: '>=4'} | |
| 945 | − peerDependencies: | |
| 946 | − eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 | |
| 947 | − | |
| 948 | − eslint-scope@7.2.2: | |
| 949 | − resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} | |
| 950 | − engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 951 | − | |
| 952 | − eslint-visitor-keys@3.4.3: | |
| 953 | − resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} | |
| 954 | − engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 955 | − | |
| 956 | − eslint-visitor-keys@5.0.1: | |
| 957 | − resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} | |
| 958 | − engines: {node: ^20.19.0 || ^22.13.0 || >=24} | |
| 959 | − | |
| 960 | − eslint@8.57.1: | |
| 961 | − resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} | |
| 962 | − engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 963 | − deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. | |
| 964 | − hasBin: true | |
| 965 | − | |
| 966 | − espree@9.6.1: | |
| 967 | − resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} | |
| 968 | − engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 969 | − | |
| 970 | − esquery@1.7.0: | |
| 971 | − resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} | |
| 972 | − engines: {node: '>=0.10'} | |
| 973 | − | |
| 974 | − esrecurse@4.3.0: | |
| 975 | − resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} | |
| 976 | − engines: {node: '>=4.0'} | |
| 977 | − | |
| 978 | − estraverse@5.3.0: | |
| 979 | − resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} | |
| 980 | − engines: {node: '>=4.0'} | |
| 981 | − | |
| 982 | − esutils@2.0.3: | |
| 983 | − resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} | |
| 984 | − engines: {node: '>=0.10.0'} | |
| 985 | − | |
| 986 | − fast-deep-equal@3.1.3: | |
| 987 | − resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} | |
| 988 | − | |
| 989 | − fast-glob@3.3.1: | |
| 990 | − resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} | |
| 991 | − engines: {node: '>=8.6.0'} | |
| 992 | − | |
| 993 | − fast-glob@3.3.3: | |
| 994 | − resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} | |
| 995 | − engines: {node: '>=8.6.0'} | |
| 996 | − | |
| 997 | − fast-json-stable-stringify@2.1.0: | |
| 998 | − resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} | |
| 999 | − | |
| 1000 | − fast-levenshtein@2.0.6: | |
| 1001 | − resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} | |
| 1002 | − | |
| 1003 | − fastq@1.20.1: | |
| 1004 | − resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} | |
| 1005 | − | |
| 1006 | − fdir@6.5.0: | |
| 1007 | − resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} | |
| 1008 | − engines: {node: '>=12.0.0'} | |
| 1009 | − peerDependencies: | |
| 1010 | − picomatch: ^3 || ^4 | |
| 1011 | − peerDependenciesMeta: | |
| 1012 | − picomatch: | |
| 1013 | − optional: true | |
| 1014 | − | |
| 1015 | − file-entry-cache@6.0.1: | |
| 1016 | − resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} | |
| 1017 | − engines: {node: ^10.12.0 || >=12.0.0} | |
| 1018 | − | |
| 1019 | − fill-range@7.1.1: | |
| 1020 | − resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} | |
| 1021 | − engines: {node: '>=8'} | |
| 1022 | − | |
| 1023 | − find-up@5.0.0: | |
| 1024 | − resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} | |
| 1025 | − engines: {node: '>=10'} | |
| 1026 | − | |
| 1027 | − flat-cache@3.2.0: | |
| 1028 | − resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} | |
| 1029 | − engines: {node: ^10.12.0 || >=12.0.0} | |
| 1030 | − | |
| 1031 | − flatted@3.4.4: | |
| 1032 | − resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} | |
| 1033 | − | |
| 1034 | − for-each@0.3.5: | |
| 1035 | − resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} | |
| 1036 | − engines: {node: '>= 0.4'} | |
| 1037 | − | |
| 1038 | − fraction.js@5.3.4: | |
| 1039 | − resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} | |
| 1040 | − | |
| 1041 | − fs.realpath@1.0.0: | |
| 1042 | − resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} | |
| 1043 | − | |
| 1044 | − fsevents@2.3.3: | |
| 1045 | − resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} | |
| 1046 | − engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} | |
| 1047 | − os: [darwin] | |
| 1048 | − | |
| 1049 | − function-bind@1.1.2: | |
| 1050 | − resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} | |
| 1051 | − | |
| 1052 | − function.prototype.name@1.2.0: | |
| 1053 | − resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} | |
| 1054 | − engines: {node: '>= 0.4'} | |
| 1055 | − | |
| 1056 | − functions-have-names@1.2.3: | |
| 1057 | − resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} | |
| 1058 | − | |
| 1059 | − generator-function@2.0.1: | |
| 1060 | − resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} | |
| 1061 | − engines: {node: '>= 0.4'} | |
| 1062 | − | |
| 1063 | − get-intrinsic@1.3.0: | |
| 1064 | − resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} | |
| 1065 | − engines: {node: '>= 0.4'} | |
| 1066 | − | |
| 1067 | − get-proto@1.0.1: | |
| 1068 | − resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} | |
| 1069 | − engines: {node: '>= 0.4'} | |
| 1070 | − | |
| 1071 | − get-symbol-description@1.1.0: | |
| 1072 | − resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} | |
| 1073 | − engines: {node: '>= 0.4'} | |
| 1074 | − | |
| 1075 | − get-tsconfig@4.14.2: | |
| 1076 | − resolution: {integrity: sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==} | |
| 1077 | − | |
| 1078 | − glob-parent@5.1.2: | |
| 1079 | − resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} | |
| 1080 | − engines: {node: '>= 6'} | |
| 1081 | − | |
| 1082 | − glob-parent@6.0.2: | |
| 1083 | − resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} | |
| 1084 | − engines: {node: '>=10.13.0'} | |
| 1085 | − | |
| 1086 | − glob@7.2.3: | |
| 1087 | − resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} | |
| 1088 | − deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me | |
| 1089 | − | |
| 1090 | − globals@13.24.0: | |
| 1091 | − resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} | |
| 1092 | − engines: {node: '>=8'} | |
| 1093 | − | |
| 1094 | − globalthis@1.0.4: | |
| 1095 | − resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} | |
| 1096 | − engines: {node: '>= 0.4'} | |
| 1097 | − | |
| 1098 | − gopd@1.2.0: | |
| 1099 | − resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} | |
| 1100 | − engines: {node: '>= 0.4'} | |
| 1101 | − | |
| 1102 | − graphemer@1.4.0: | |
| 1103 | − resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} | |
| 1104 | − | |
| 1105 | − has-bigints@1.1.0: | |
| 1106 | − resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} | |
| 1107 | − engines: {node: '>= 0.4'} | |
| 1108 | − | |
| 1109 | − has-flag@4.0.0: | |
| 1110 | − resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} | |
| 1111 | − engines: {node: '>=8'} | |
| 1112 | − | |
| 1113 | − has-property-descriptors@1.0.2: | |
| 1114 | − resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} | |
| 1115 | − | |
| 1116 | − has-proto@1.2.0: | |
| 1117 | − resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} | |
| 1118 | − engines: {node: '>= 0.4'} | |
| 1119 | − | |
| 1120 | − has-symbols@1.1.0: | |
| 1121 | − resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} | |
| 1122 | − engines: {node: '>= 0.4'} | |
| 1123 | − | |
| 1124 | − has-tostringtag@1.0.2: | |
| 1125 | − resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} | |
| 1126 | − engines: {node: '>= 0.4'} | |
| 1127 | − | |
| 1128 | − hasown@2.0.4: | |
| 1129 | − resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} | |
| 1130 | − engines: {node: '>= 0.4'} | |
| 1131 | − | |
| 1132 | − ignore@5.3.2: | |
| 1133 | − resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} | |
| 1134 | − engines: {node: '>= 4'} | |
| 1135 | − | |
| 1136 | − ignore@7.0.6: | |
| 1137 | − resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} | |
| 1138 | − engines: {node: '>= 4'} | |
| 1139 | − | |
| 1140 | − import-fresh@3.3.1: | |
| 1141 | − resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} | |
| 1142 | − engines: {node: '>=6'} | |
| 1143 | − | |
| 1144 | − imurmurhash@0.1.4: | |
| 1145 | − resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} | |
| 1146 | − engines: {node: '>=0.8.19'} | |
| 1147 | − | |
| 1148 | − inflight@1.0.6: | |
| 1149 | − resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} | |
| 1150 | − deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. | |
| 1151 | − | |
| 1152 | − inherits@2.0.4: | |
| 1153 | − resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} | |
| 1154 | − | |
| 1155 | − internal-slot@1.1.0: | |
| 1156 | − resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} | |
| 1157 | − engines: {node: '>= 0.4'} | |
| 1158 | − | |
| 1159 | − is-array-buffer@3.0.5: | |
| 1160 | − resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} | |
| 1161 | − engines: {node: '>= 0.4'} | |
| 1162 | − | |
| 1163 | − is-async-function@2.1.1: | |
| 1164 | − resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} | |
| 1165 | − engines: {node: '>= 0.4'} | |
| 1166 | − | |
| 1167 | − is-bigint@1.1.0: | |
| 1168 | − resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} | |
| 1169 | − engines: {node: '>= 0.4'} | |
| 1170 | − | |
| 1171 | − is-binary-path@2.1.0: | |
| 1172 | − resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} | |
| 1173 | − engines: {node: '>=8'} | |
| 1174 | − | |
| 1175 | − is-boolean-object@1.2.2: | |
| 1176 | − resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} | |
| 1177 | − engines: {node: '>= 0.4'} | |
| 1178 | − | |
| 1179 | − is-bun-module@2.0.0: | |
| 1180 | − resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} | |
| 1181 | − | |
| 1182 | − is-callable@1.2.7: | |
| 1183 | − resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} | |
| 1184 | − engines: {node: '>= 0.4'} | |
| 1185 | − | |
| 1186 | − is-core-module@2.16.2: | |
| 1187 | − resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} | |
| 1188 | − engines: {node: '>= 0.4'} | |
| 1189 | − | |
| 1190 | − is-data-view@1.0.2: | |
| 1191 | − resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} | |
| 1192 | − engines: {node: '>= 0.4'} | |
| 1193 | − | |
| 1194 | − is-date-object@1.1.0: | |
| 1195 | − resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} | |
| 1196 | − engines: {node: '>= 0.4'} | |
| 1197 | − | |
| 1198 | − is-document.all@1.0.0: | |
| 1199 | − resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} | |
| 1200 | − engines: {node: '>= 0.4'} | |
| 1201 | − | |
| 1202 | − is-extglob@2.1.1: | |
| 1203 | − resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} | |
| 1204 | − engines: {node: '>=0.10.0'} | |
| 1205 | − | |
| 1206 | − is-finalizationregistry@1.1.1: | |
| 1207 | − resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} | |
| 1208 | − engines: {node: '>= 0.4'} | |
| 1209 | − | |
| 1210 | − is-generator-function@1.1.2: | |
| 1211 | − resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} | |
| 1212 | − engines: {node: '>= 0.4'} | |
| 1213 | − | |
| 1214 | − is-glob@4.0.3: | |
| 1215 | − resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} | |
| 1216 | − engines: {node: '>=0.10.0'} | |
| 1217 | − | |
| 1218 | − is-map@2.0.3: | |
| 1219 | − resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} | |
| 1220 | − engines: {node: '>= 0.4'} | |
| 1221 | − | |
| 1222 | − is-negative-zero@2.0.3: | |
| 1223 | − resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} | |
| 1224 | − engines: {node: '>= 0.4'} | |
| 1225 | − | |
| 1226 | − is-number-object@1.1.1: | |
| 1227 | − resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} | |
| 1228 | − engines: {node: '>= 0.4'} | |
| 1229 | − | |
| 1230 | − is-number@7.0.0: | |
| 1231 | − resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} | |
| 1232 | − engines: {node: '>=0.12.0'} | |
| 1233 | − | |
| 1234 | − is-path-inside@3.0.3: | |
| 1235 | − resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} | |
| 1236 | − engines: {node: '>=8'} | |
| 1237 | − | |
| 1238 | − is-regex@1.2.1: | |
| 1239 | − resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} | |
| 1240 | − engines: {node: '>= 0.4'} | |
| 1241 | − | |
| 1242 | − is-set@2.0.3: | |
| 1243 | − resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} | |
| 1244 | − engines: {node: '>= 0.4'} | |
| 1245 | − | |
| 1246 | − is-shared-array-buffer@1.0.4: | |
| 1247 | − resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} | |
| 1248 | − engines: {node: '>= 0.4'} | |
| 1249 | − | |
| 1250 | − is-string@1.1.1: | |
| 1251 | − resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} | |
| 1252 | − engines: {node: '>= 0.4'} | |
| 1253 | − | |
| 1254 | − is-symbol@1.1.1: | |
| 1255 | − resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} | |
| 1256 | − engines: {node: '>= 0.4'} | |
| 1257 | − | |
| 1258 | − is-typed-array@1.1.15: | |
| 1259 | − resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} | |
| 1260 | − engines: {node: '>= 0.4'} | |
| 1261 | − | |
| 1262 | − is-weakmap@2.0.2: | |
| 1263 | − resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} | |
| 1264 | − engines: {node: '>= 0.4'} | |
| 1265 | − | |
| 1266 | − is-weakref@1.1.1: | |
| 1267 | − resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} | |
| 1268 | − engines: {node: '>= 0.4'} | |
| 1269 | − | |
| 1270 | − is-weakset@2.0.4: | |
| 1271 | − resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} | |
| 1272 | − engines: {node: '>= 0.4'} | |
| 1273 | − | |
| 1274 | − isarray@2.0.5: | |
| 1275 | − resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} | |
| 1276 | − | |
| 1277 | − isexe@2.0.0: | |
| 1278 | − resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} | |
| 1279 | − | |
| 1280 | − iterator.prototype@1.1.5: | |
| 1281 | − resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} | |
| 1282 | − engines: {node: '>= 0.4'} | |
| 1283 | − | |
| 1284 | − jiti@1.21.7: | |
| 1285 | − resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} | |
| 1286 | − hasBin: true | |
| 1287 | − | |
| 1288 | − js-tokens@4.0.0: | |
| 1289 | − resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} | |
| 1290 | − | |
| 1291 | − js-yaml@4.3.1: | |
| 1292 | − resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} | |
| 1293 | − hasBin: true | |
| 1294 | − | |
| 1295 | − json-buffer@3.0.1: | |
| 1296 | − resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} | |
| 1297 | − | |
| 1298 | − json-schema-traverse@0.4.1: | |
| 1299 | − resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} | |
| 1300 | − | |
| 1301 | − json-stable-stringify-without-jsonify@1.0.1: | |
| 1302 | − resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} | |
| 1303 | − | |
| 1304 | − json5@1.0.2: | |
| 1305 | − resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} | |
| 1306 | − hasBin: true | |
| 1307 | − | |
| 1308 | − jsx-ast-utils@3.3.5: | |
| 1309 | − resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} | |
| 1310 | − engines: {node: '>=4.0'} | |
| 1311 | − | |
| 1312 | − keyv@4.5.4: | |
| 1313 | − resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} | |
| 1314 | − | |
| 1315 | − language-subtag-registry@0.3.23: | |
| 1316 | − resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} | |
| 1317 | − | |
| 1318 | − language-tags@1.0.9: | |
| 1319 | − resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} | |
| 1320 | − engines: {node: '>=0.10'} | |
| 1321 | − | |
| 1322 | − levn@0.4.1: | |
| 1323 | − resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} | |
| 1324 | − engines: {node: '>= 0.8.0'} | |
| 1325 | − | |
| 1326 | − lilconfig@3.1.3: | |
| 1327 | − resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} | |
| 1328 | − engines: {node: '>=14'} | |
| 1329 | − | |
| 1330 | − lines-and-columns@1.2.4: | |
| 1331 | − resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} | |
| 1332 | − | |
| 1333 | − locate-path@6.0.0: | |
| 1334 | − resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} | |
| 1335 | − engines: {node: '>=10'} | |
| 1336 | − | |
| 1337 | − lodash.merge@4.6.2: | |
| 1338 | − resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} | |
| 1339 | − | |
| 1340 | − loose-envify@1.4.0: | |
| 1341 | − resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} | |
| 1342 | − hasBin: true | |
| 1343 | − | |
| 1344 | − math-intrinsics@1.1.0: | |
| 1345 | − resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} | |
| 1346 | − engines: {node: '>= 0.4'} | |
| 1347 | − | |
| 1348 | − merge2@1.4.1: | |
| 1349 | − resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} | |
| 1350 | − engines: {node: '>= 8'} | |
| 1351 | − | |
| 1352 | − micromatch@4.0.8: | |
| 1353 | − resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} | |
| 1354 | − engines: {node: '>=8.6'} | |
| 1355 | − | |
| 1356 | − minimatch@10.2.6: | |
| 1357 | − resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} | |
| 1358 | − engines: {node: 18 || 20 || >=22} | |
| 1359 | − | |
| 1360 | − minimatch@3.1.5: | |
| 1361 | − resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} | |
| 1362 | − | |
| 1363 | − minimist@1.2.8: | |
| 1364 | − resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} | |
| 1365 | − | |
| 1366 | − ms@2.1.3: | |
| 1367 | − resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} | |
| 1368 | − | |
| 1369 | − mz@2.7.0: | |
| 1370 | − resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} | |
| 1371 | − | |
| 1372 | − nanoid@3.3.18: | |
| 1373 | − resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} | |
| 1374 | − engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} | |
| 1375 | − hasBin: true | |
| 1376 | − | |
| 1377 | − napi-postinstall@0.3.4: | |
| 1378 | − resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} | |
| 1379 | − engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} | |
| 1380 | − hasBin: true | |
| 1381 | − | |
| 1382 | − natural-compare@1.4.0: | |
| 1383 | − resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} | |
| 1384 | − | |
| 1385 | − next@15.5.23: | |
| 1386 | − resolution: {integrity: sha512-Gvd2WKgvxIXCGotxcI1im/Uf3rS3J3oZGw0g/uskg6AVBZhyE3aAbujkYWzS3xLmEPEtTLfkaVQUKK0KMTSIkA==} | |
| 1387 | − engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} | |
| 1388 | − hasBin: true | |
| 1389 | − peerDependencies: | |
| 1390 | − '@opentelemetry/api': ^1.1.0 | |
| 1391 | − '@playwright/test': ^1.51.1 | |
| 1392 | − babel-plugin-react-compiler: '*' | |
| 1393 | − react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 | |
| 1394 | − react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 | |
| 1395 | − sass: ^1.3.0 | |
| 1396 | − peerDependenciesMeta: | |
| 1397 | − '@opentelemetry/api': | |
| 1398 | − optional: true | |
| 1399 | − '@playwright/test': | |
| 1400 | − optional: true | |
| 1401 | − babel-plugin-react-compiler: | |
| 1402 | − optional: true | |
| 1403 | − sass: | |
| 1404 | − optional: true | |
| 1405 | − | |
| 1406 | − node-exports-info@1.6.2: | |
| 1407 | − resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} | |
| 1408 | − engines: {node: '>= 0.4'} | |
| 1409 | − | |
| 1410 | − node-releases@2.0.53: | |
| 1411 | − resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} | |
| 1412 | − engines: {node: '>=18'} | |
| 1413 | − | |
| 1414 | − normalize-path@3.0.0: | |
| 1415 | − resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} | |
| 1416 | − engines: {node: '>=0.10.0'} | |
| 1417 | − | |
| 1418 | − object-assign@4.1.1: | |
| 1419 | − resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} | |
| 1420 | − engines: {node: '>=0.10.0'} | |
| 1421 | − | |
| 1422 | − object-hash@3.0.0: | |
| 1423 | − resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} | |
| 1424 | − engines: {node: '>= 6'} | |
| 1425 | − | |
| 1426 | − object-inspect@1.13.4: | |
| 1427 | − resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} | |
| 1428 | − engines: {node: '>= 0.4'} | |
| 1429 | − | |
| 1430 | − object-keys@1.1.1: | |
| 1431 | − resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} | |
| 1432 | − engines: {node: '>= 0.4'} | |
| 1433 | − | |
| 1434 | − object.assign@4.1.7: | |
| 1435 | − resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} | |
| 1436 | − engines: {node: '>= 0.4'} | |
| 1437 | − | |
| 1438 | − object.entries@1.1.9: | |
| 1439 | − resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} | |
| 1440 | − engines: {node: '>= 0.4'} | |
| 1441 | − | |
| 1442 | − object.fromentries@2.0.8: | |
| 1443 | − resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} | |
| 1444 | − engines: {node: '>= 0.4'} | |
| 1445 | − | |
| 1446 | − object.groupby@1.0.3: | |
| 1447 | − resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} | |
| 1448 | − engines: {node: '>= 0.4'} | |
| 1449 | − | |
| 1450 | − object.values@1.2.1: | |
| 1451 | − resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} | |
| 1452 | − engines: {node: '>= 0.4'} | |
| 1453 | − | |
| 1454 | − once@1.4.0: | |
| 1455 | − resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} | |
| 1456 | − | |
| 1457 | − optionator@0.9.4: | |
| 1458 | − resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} | |
| 1459 | − engines: {node: '>= 0.8.0'} | |
| 1460 | − | |
| 1461 | − own-keys@1.0.2: | |
| 1462 | − resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} | |
| 1463 | − engines: {node: '>= 0.4'} | |
| 1464 | − | |
| 1465 | − p-limit@3.1.0: | |
| 1466 | − resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} | |
| 1467 | − engines: {node: '>=10'} | |
| 1468 | − | |
| 1469 | − p-locate@5.0.0: | |
| 1470 | − resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} | |
| 1471 | − engines: {node: '>=10'} | |
| 1472 | − | |
| 1473 | − parent-module@1.0.1: | |
| 1474 | − resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} | |
| 1475 | − engines: {node: '>=6'} | |
| 1476 | − | |
| 1477 | − path-exists@4.0.0: | |
| 1478 | − resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} | |
| 1479 | − engines: {node: '>=8'} | |
| 1480 | − | |
| 1481 | − path-is-absolute@1.0.1: | |
| 1482 | − resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} | |
| 1483 | − engines: {node: '>=0.10.0'} | |
| 1484 | − | |
| 1485 | − path-key@3.1.1: | |
| 1486 | − resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} | |
| 1487 | − engines: {node: '>=8'} | |
| 1488 | − | |
| 1489 | − path-parse@1.0.7: | |
| 1490 | − resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} | |
| 1491 | − | |
| 1492 | − picocolors@1.1.1: | |
| 1493 | − resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} | |
| 1494 | − | |
| 1495 | − picomatch@2.3.2: | |
| 1496 | − resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} | |
| 1497 | − engines: {node: '>=8.6'} | |
| 1498 | − | |
| 1499 | − picomatch@4.0.5: | |
| 1500 | − resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} | |
| 1501 | − engines: {node: '>=12'} | |
| 1502 | − | |
| 1503 | − pify@2.3.0: | |
| 1504 | − resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} | |
| 1505 | − engines: {node: '>=0.10.0'} | |
| 1506 | − | |
| 1507 | − pirates@4.0.7: | |
| 1508 | − resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} | |
| 1509 | − engines: {node: '>= 6'} | |
| 1510 | − | |
| 1511 | − possible-typed-array-names@1.1.0: | |
| 1512 | − resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} | |
| 1513 | − engines: {node: '>= 0.4'} | |
| 1514 | − | |
| 1515 | − postcss-import@15.1.0: | |
| 1516 | − resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} | |
| 1517 | − engines: {node: '>=14.0.0'} | |
| 1518 | − peerDependencies: | |
| 1519 | − postcss: ^8.0.0 | |
| 1520 | − | |
| 1521 | − postcss-js@4.1.0: | |
| 1522 | − resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} | |
| 1523 | − engines: {node: ^12 || ^14 || >= 16} | |
| 1524 | − peerDependencies: | |
| 1525 | − postcss: ^8.4.21 | |
| 1526 | − | |
| 1527 | − postcss-load-config@6.0.1: | |
| 1528 | − resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} | |
| 1529 | − engines: {node: '>= 18'} | |
| 1530 | − peerDependencies: | |
| 1531 | − jiti: '>=1.21.0' | |
| 1532 | − postcss: '>=8.0.9' | |
| 1533 | − tsx: ^4.8.1 | |
| 1534 | − yaml: ^2.4.2 | |
| 1535 | − peerDependenciesMeta: | |
| 1536 | − jiti: | |
| 1537 | − optional: true | |
| 1538 | − postcss: | |
| 1539 | − optional: true | |
| 1540 | − tsx: | |
| 1541 | − optional: true | |
| 1542 | − yaml: | |
| 1543 | − optional: true | |
| 1544 | − | |
| 1545 | − postcss-nested@6.2.0: | |
| 1546 | − resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} | |
| 1547 | − engines: {node: '>=12.0'} | |
| 1548 | − peerDependencies: | |
| 1549 | − postcss: ^8.2.14 | |
| 1550 | − | |
| 1551 | − postcss-selector-parser@6.1.4: | |
| 1552 | − resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} | |
| 1553 | − engines: {node: '>=4'} | |
| 1554 | − | |
| 1555 | − postcss-value-parser@4.2.0: | |
| 1556 | − resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} | |
| 1557 | − | |
| 1558 | − postcss@8.4.31: | |
| 1559 | − resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} | |
| 1560 | − engines: {node: ^10 || ^12 || >=14} | |
| 1561 | − | |
| 1562 | − postcss@8.5.26: | |
| 1563 | − resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} | |
| 1564 | − engines: {node: ^10 || ^12 || >=14} | |
| 1565 | − | |
| 1566 | − prelude-ls@1.2.1: | |
| 1567 | − resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} | |
| 1568 | − engines: {node: '>= 0.8.0'} | |
| 1569 | − | |
| 1570 | − prop-types@15.8.1: | |
| 1571 | − resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} | |
| 1572 | − | |
| 1573 | − punycode@2.3.1: | |
| 1574 | − resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} | |
| 1575 | − engines: {node: '>=6'} | |
| 1576 | − | |
| 1577 | − queue-microtask@1.2.3: | |
| 1578 | − resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} | |
| 1579 | − | |
| 1580 | − react-dom@19.2.8: | |
| 1581 | − resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} | |
| 1582 | − peerDependencies: | |
| 1583 | − react: ^19.2.8 | |
| 1584 | − | |
| 1585 | − react-is@16.13.1: | |
| 1586 | − resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} | |
| 1587 | − | |
| 1588 | − react@19.2.8: | |
| 1589 | − resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} | |
| 1590 | − engines: {node: '>=0.10.0'} | |
| 1591 | − | |
| 1592 | − read-cache@1.0.0: | |
| 1593 | − resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} | |
| 1594 | − | |
| 1595 | − readdirp@3.6.0: | |
| 1596 | − resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} | |
| 1597 | − engines: {node: '>=8.10.0'} | |
| 1598 | − | |
| 1599 | − reflect.getprototypeof@1.0.10: | |
| 1600 | − resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} | |
| 1601 | − engines: {node: '>= 0.4'} | |
| 1602 | − | |
| 1603 | − regexp.prototype.flags@1.5.4: | |
| 1604 | − resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} | |
| 1605 | − engines: {node: '>= 0.4'} | |
| 1606 | − | |
| 1607 | − resolve-from@4.0.0: | |
| 1608 | − resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} | |
| 1609 | − engines: {node: '>=4'} | |
| 1610 | − | |
| 1611 | − resolve-pkg-maps@1.0.0: | |
| 1612 | − resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} | |
| 1613 | − | |
| 1614 | − resolve@1.22.12: | |
| 1615 | − resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} | |
| 1616 | − engines: {node: '>= 0.4'} | |
| 1617 | − hasBin: true | |
| 1618 | − | |
| 1619 | − resolve@2.0.0-next.7: | |
| 1620 | − resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} | |
| 1621 | − engines: {node: '>= 0.4'} | |
| 1622 | − hasBin: true | |
| 1623 | − | |
| 1624 | − reusify@1.1.0: | |
| 1625 | − resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} | |
| 1626 | − engines: {iojs: '>=1.0.0', node: '>=0.10.0'} | |
| 1627 | − | |
| 1628 | − rimraf@3.0.2: | |
| 1629 | − resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} | |
| 1630 | − deprecated: Rimraf versions prior to v4 are no longer supported | |
| 1631 | − hasBin: true | |
| 1632 | − | |
| 1633 | − run-parallel@1.2.0: | |
| 1634 | − resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} | |
| 1635 | − | |
| 1636 | − safe-array-concat@1.1.4: | |
| 1637 | − resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} | |
| 1638 | − engines: {node: '>=0.4'} | |
| 1639 | − | |
| 1640 | − safe-push-apply@1.0.0: | |
| 1641 | − resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} | |
| 1642 | − engines: {node: '>= 0.4'} | |
| 1643 | − | |
| 1644 | − safe-regex-test@1.1.0: | |
| 1645 | − resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} | |
| 1646 | − engines: {node: '>= 0.4'} | |
| 1647 | − | |
| 1648 | − scheduler@0.27.0: | |
| 1649 | − resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} | |
| 1650 | − | |
| 1651 | − semver@6.3.1: | |
| 1652 | − resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} | |
| 1653 | − hasBin: true | |
| 1654 | − | |
| 1655 | − semver@7.8.5: | |
| 1656 | − resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} | |
| 1657 | − engines: {node: '>=10'} | |
| 1658 | − hasBin: true | |
| 1659 | − | |
| 1660 | − set-function-length@1.2.2: | |
| 1661 | − resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} | |
| 1662 | − engines: {node: '>= 0.4'} | |
| 1663 | − | |
| 1664 | − set-function-name@2.0.2: | |
| 1665 | − resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} | |
| 1666 | − engines: {node: '>= 0.4'} | |
| 1667 | − | |
| 1668 | − set-proto@1.0.0: | |
| 1669 | − resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} | |
| 1670 | − engines: {node: '>= 0.4'} | |
| 1671 | − | |
| 1672 | − sharp@0.34.5: | |
| 1673 | − resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} | |
| 1674 | − engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | |
| 1675 | − | |
| 1676 | − shebang-command@2.0.0: | |
| 1677 | − resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} | |
| 1678 | − engines: {node: '>=8'} | |
| 1679 | − | |
| 1680 | − shebang-regex@3.0.0: | |
| 1681 | − resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} | |
| 1682 | − engines: {node: '>=8'} | |
| 1683 | − | |
| 1684 | − side-channel-list@1.0.1: | |
| 1685 | − resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} | |
| 1686 | − engines: {node: '>= 0.4'} | |
| 1687 | − | |
| 1688 | − side-channel-map@1.0.1: | |
| 1689 | − resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} | |
| 1690 | − engines: {node: '>= 0.4'} | |
| 1691 | − | |
| 1692 | − side-channel-weakmap@1.0.2: | |
| 1693 | − resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} | |
| 1694 | − engines: {node: '>= 0.4'} | |
| 1695 | − | |
| 1696 | − side-channel@1.1.1: | |
| 1697 | − resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} | |
| 1698 | − engines: {node: '>= 0.4'} | |
| 1699 | − | |
| 1700 | − source-map-js@1.2.1: | |
| 1701 | − resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} | |
| 1702 | − engines: {node: '>=0.10.0'} | |
| 1703 | − | |
| 1704 | − stable-hash@0.0.5: | |
| 1705 | − resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} | |
| 1706 | − | |
| 1707 | − stop-iteration-iterator@1.1.0: | |
| 1708 | − resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} | |
| 1709 | − engines: {node: '>= 0.4'} | |
| 1710 | − | |
| 1711 | − string.prototype.includes@2.0.1: | |
| 1712 | − resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} | |
| 1713 | − engines: {node: '>= 0.4'} | |
| 1714 | − | |
| 1715 | − string.prototype.matchall@4.0.12: | |
| 1716 | − resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} | |
| 1717 | − engines: {node: '>= 0.4'} | |
| 1718 | − | |
| 1719 | − string.prototype.repeat@1.0.0: | |
| 1720 | − resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} | |
| 1721 | − | |
| 1722 | − string.prototype.trim@1.2.11: | |
| 1723 | − resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} | |
| 1724 | − engines: {node: '>= 0.4'} | |
| 1725 | − | |
| 1726 | − string.prototype.trimend@1.0.10: | |
| 1727 | − resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} | |
| 1728 | − engines: {node: '>= 0.4'} | |
| 1729 | − | |
| 1730 | − string.prototype.trimstart@1.0.8: | |
| 1731 | − resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} | |
| 1732 | − engines: {node: '>= 0.4'} | |
| 1733 | − | |
| 1734 | − strip-ansi@6.0.1: | |
| 1735 | − resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} | |
| 1736 | − engines: {node: '>=8'} | |
| 1737 | − | |
| 1738 | − strip-bom@3.0.0: | |
| 1739 | − resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} | |
| 1740 | − engines: {node: '>=4'} | |
| 1741 | − | |
| 1742 | − strip-json-comments@3.1.1: | |
| 1743 | − resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} | |
| 1744 | − engines: {node: '>=8'} | |
| 1745 | − | |
| 1746 | − styled-jsx@5.1.6: | |
| 1747 | − resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} | |
| 1748 | − engines: {node: '>= 12.0.0'} | |
| 1749 | − peerDependencies: | |
| 1750 | − '@babel/core': '*' | |
| 1751 | − babel-plugin-macros: '*' | |
| 1752 | − react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' | |
| 1753 | − peerDependenciesMeta: | |
| 1754 | − '@babel/core': | |
| 1755 | − optional: true | |
| 1756 | − babel-plugin-macros: | |
| 1757 | − optional: true | |
| 1758 | − | |
| 1759 | − sucrase@3.35.1: | |
| 1760 | − resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} | |
| 1761 | − engines: {node: '>=16 || 14 >=14.17'} | |
| 1762 | − hasBin: true | |
| 1763 | − | |
| 1764 | − supports-color@7.2.0: | |
| 1765 | − resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} | |
| 1766 | − engines: {node: '>=8'} | |
| 1767 | − | |
| 1768 | − supports-preserve-symlinks-flag@1.0.0: | |
| 1769 | − resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} | |
| 1770 | − engines: {node: '>= 0.4'} | |
| 1771 | − | |
| 1772 | − tailwind-merge@2.6.1: | |
| 1773 | − resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} | |
| 1774 | − | |
| 1775 | − tailwindcss@3.4.19: | |
| 1776 | − resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} | |
| 1777 | − engines: {node: '>=14.0.0'} | |
| 1778 | − hasBin: true | |
| 1779 | − | |
| 1780 | − text-table@0.2.0: | |
| 1781 | − resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} | |
| 1782 | − | |
| 1783 | − thenify-all@1.6.0: | |
| 1784 | − resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} | |
| 1785 | − engines: {node: '>=0.8'} | |
| 1786 | − | |
| 1787 | − thenify@3.3.1: | |
| 1788 | − resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} | |
| 1789 | − | |
| 1790 | − tinyglobby@0.2.17: | |
| 1791 | − resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} | |
| 1792 | − engines: {node: '>=12.0.0'} | |
| 1793 | − | |
| 1794 | − to-regex-range@5.0.1: | |
| 1795 | − resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} | |
| 1796 | − engines: {node: '>=8.0'} | |
| 1797 | − | |
| 1798 | − ts-api-utils@2.5.0: | |
| 1799 | − resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} | |
| 1800 | − engines: {node: '>=18.12'} | |
| 1801 | − peerDependencies: | |
| 1802 | − typescript: '>=4.8.4' | |
| 1803 | − | |
| 1804 | − ts-interface-checker@0.1.13: | |
| 1805 | − resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} | |
| 1806 | − | |
| 1807 | − tsconfig-paths@3.15.0: | |
| 1808 | − resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} | |
| 1809 | − | |
| 1810 | − tslib@2.8.1: | |
| 1811 | − resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} | |
| 1812 | − | |
| 1813 | − type-check@0.4.0: | |
| 1814 | − resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} | |
| 1815 | − engines: {node: '>= 0.8.0'} | |
| 1816 | − | |
| 1817 | − type-fest@0.20.2: | |
| 1818 | − resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} | |
| 1819 | − engines: {node: '>=10'} | |
| 1820 | − | |
| 1821 | − typed-array-buffer@1.0.3: | |
| 1822 | − resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} | |
| 1823 | − engines: {node: '>= 0.4'} | |
| 1824 | − | |
| 1825 | − typed-array-byte-length@1.0.3: | |
| 1826 | − resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} | |
| 1827 | − engines: {node: '>= 0.4'} | |
| 1828 | − | |
| 1829 | − typed-array-byte-offset@1.0.4: | |
| 1830 | − resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} | |
| 1831 | − engines: {node: '>= 0.4'} | |
| 1832 | − | |
| 1833 | − typed-array-length@1.0.8: | |
| 1834 | − resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} | |
| 1835 | − engines: {node: '>= 0.4'} | |
| 1836 | − | |
| 1837 | − typescript@5.9.3: | |
| 1838 | − resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} | |
| 1839 | − engines: {node: '>=14.17'} | |
| 1840 | − hasBin: true | |
| 1841 | − | |
| 1842 | − unbox-primitive@1.1.0: | |
| 1843 | − resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} | |
| 1844 | − engines: {node: '>= 0.4'} | |
| 1845 | − | |
| 1846 | − undici-types@6.21.0: | |
| 1847 | − resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} | |
| 1848 | − | |
| 1849 | − unrs-resolver@1.12.2: | |
| 1850 | − resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} | |
| 1851 | − | |
| 1852 | − update-browserslist-db@1.3.1: | |
| 1853 | − resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} | |
| 1854 | − hasBin: true | |
| 1855 | − peerDependencies: | |
| 1856 | − browserslist: '>= 4.21.0' | |
| 1857 | − | |
| 1858 | − uri-js@4.4.1: | |
| 1859 | − resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} | |
| 1860 | − | |
| 1861 | − util-deprecate@1.0.2: | |
| 1862 | − resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} | |
| 1863 | − | |
| 1864 | − which-boxed-primitive@1.1.1: | |
| 1865 | − resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} | |
| 1866 | − engines: {node: '>= 0.4'} | |
| 1867 | − | |
| 1868 | − which-builtin-type@1.2.1: | |
| 1869 | − resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} | |
| 1870 | − engines: {node: '>= 0.4'} | |
| 1871 | − | |
| 1872 | − which-collection@1.0.2: | |
| 1873 | − resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} | |
| 1874 | − engines: {node: '>= 0.4'} | |
| 1875 | − | |
| 1876 | − which-typed-array@1.1.22: | |
| 1877 | − resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} | |
| 1878 | − engines: {node: '>= 0.4'} | |
| 1879 | − | |
| 1880 | − which@2.0.2: | |
| 1881 | − resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} | |
| 1882 | − engines: {node: '>= 8'} | |
| 1883 | − hasBin: true | |
| 1884 | − | |
| 1885 | − word-wrap@1.2.5: | |
| 1886 | − resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} | |
| 1887 | − engines: {node: '>=0.10.0'} | |
| 1888 | − | |
| 1889 | − wrappy@1.0.2: | |
| 1890 | − resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} | |
| 1891 | − | |
| 1892 | − yocto-queue@0.1.0: | |
| 1893 | − resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} | |
| 1894 | − engines: {node: '>=10'} | |
| 1895 | − | |
| 1896 | −snapshots: | |
| 1897 | − | |
| 1898 | − '@alloc/quick-lru@5.2.0': {} | |
| 1899 | − | |
| 1900 | − '@emnapi/core@1.10.0': | |
| 1901 | − dependencies: | |
| 1902 | − '@emnapi/wasi-threads': 1.2.1 | |
| 1903 | − tslib: 2.8.1 | |
| 1904 | − optional: true | |
| 1905 | − | |
| 1906 | − '@emnapi/runtime@1.10.0': | |
| 1907 | − dependencies: | |
| 1908 | − tslib: 2.8.1 | |
| 1909 | − optional: true | |
| 1910 | − | |
| 1911 | − '@emnapi/runtime@1.11.3': | |
| 1912 | − dependencies: | |
| 1913 | − tslib: 2.8.1 | |
| 1914 | − optional: true | |
| 1915 | − | |
| 1916 | − '@emnapi/wasi-threads@1.2.1': | |
| 1917 | − dependencies: | |
| 1918 | − tslib: 2.8.1 | |
| 1919 | − optional: true | |
| 1920 | − | |
| 1921 | − '@eslint-community/eslint-utils@4.10.1(eslint@8.57.1)': | |
| 1922 | − dependencies: | |
| 1923 | − eslint: 8.57.1 | |
| 1924 | − eslint-visitor-keys: 3.4.3 | |
| 1925 | − | |
| 1926 | − '@eslint-community/regexpp@4.12.2': {} | |
| 1927 | − | |
| 1928 | − '@eslint/eslintrc@2.1.4': | |
| 1929 | − dependencies: | |
| 1930 | − ajv: 6.15.0 | |
| 1931 | − debug: 4.4.3 | |
| 1932 | − espree: 9.6.1 | |
| 1933 | − globals: 13.24.0 | |
| 1934 | − ignore: 5.3.2 | |
| 1935 | − import-fresh: 3.3.1 | |
| 1936 | − js-yaml: 4.3.1 | |
| 1937 | − minimatch: 3.1.5 | |
| 1938 | − strip-json-comments: 3.1.1 | |
| 1939 | − transitivePeerDependencies: | |
| 1940 | − - supports-color | |
| 1941 | − | |
| 1942 | − '@eslint/js@8.57.1': {} | |
| 1943 | − | |
| 1944 | − '@humanwhocodes/config-array@0.13.0': | |
| 1945 | − dependencies: | |
| 1946 | − '@humanwhocodes/object-schema': 2.0.3 | |
| 1947 | − debug: 4.4.3 | |
| 1948 | − minimatch: 3.1.5 | |
| 1949 | − transitivePeerDependencies: | |
| 1950 | − - supports-color | |
| 1951 | − | |
| 1952 | − '@humanwhocodes/module-importer@1.0.1': {} | |
| 1953 | − | |
| 1954 | − '@humanwhocodes/object-schema@2.0.3': {} | |
| 1955 | − | |
| 1956 | − '@img/colour@1.1.0': | |
| 1957 | − optional: true | |
| 1958 | − | |
| 1959 | − '@img/sharp-darwin-arm64@0.34.5': | |
| 1960 | − optionalDependencies: | |
| 1961 | − '@img/sharp-libvips-darwin-arm64': 1.2.4 | |
| 1962 | − optional: true | |
| 1963 | − | |
| 1964 | − '@img/sharp-darwin-x64@0.34.5': | |
| 1965 | − optionalDependencies: | |
| 1966 | − '@img/sharp-libvips-darwin-x64': 1.2.4 | |
| 1967 | − optional: true | |
| 1968 | − | |
| 1969 | − '@img/sharp-libvips-darwin-arm64@1.2.4': | |
| 1970 | − optional: true | |
| 1971 | − | |
| 1972 | − '@img/sharp-libvips-darwin-x64@1.2.4': | |
| 1973 | − optional: true | |
| 1974 | − | |
| 1975 | − '@img/sharp-libvips-linux-arm64@1.2.4': | |
| 1976 | − optional: true | |
| 1977 | − | |
| 1978 | − '@img/sharp-libvips-linux-arm@1.2.4': | |
| 1979 | − optional: true | |
| 1980 | − | |
| 1981 | − '@img/sharp-libvips-linux-ppc64@1.2.4': | |
| 1982 | − optional: true | |
| 1983 | − | |
| 1984 | − '@img/sharp-libvips-linux-riscv64@1.2.4': | |
| 1985 | − optional: true | |
| 1986 | − | |
| 1987 | − '@img/sharp-libvips-linux-s390x@1.2.4': | |
| 1988 | − optional: true | |
| 1989 | − | |
| 1990 | − '@img/sharp-libvips-linux-x64@1.2.4': | |
| 1991 | − optional: true | |
| 1992 | − | |
| 1993 | − '@img/sharp-libvips-linuxmusl-arm64@1.2.4': | |
| 1994 | − optional: true | |
| 1995 | − | |
| 1996 | − '@img/sharp-libvips-linuxmusl-x64@1.2.4': | |
| 1997 | − optional: true | |
| 1998 | − | |
| 1999 | − '@img/sharp-linux-arm64@0.34.5': | |
| 2000 | − optionalDependencies: | |
| 2001 | − '@img/sharp-libvips-linux-arm64': 1.2.4 | |
| 2002 | − optional: true | |
| 2003 | − | |
| 2004 | − '@img/sharp-linux-arm@0.34.5': | |
| 2005 | − optionalDependencies: | |
| 2006 | − '@img/sharp-libvips-linux-arm': 1.2.4 | |
| 2007 | − optional: true | |
| 2008 | − | |
| 2009 | − '@img/sharp-linux-ppc64@0.34.5': | |
| 2010 | − optionalDependencies: | |
| 2011 | − '@img/sharp-libvips-linux-ppc64': 1.2.4 | |
| 2012 | − optional: true | |
| 2013 | − | |
| 2014 | − '@img/sharp-linux-riscv64@0.34.5': | |
| 2015 | − optionalDependencies: | |
| 2016 | − '@img/sharp-libvips-linux-riscv64': 1.2.4 | |
| 2017 | − optional: true | |
| 2018 | − | |
| 2019 | − '@img/sharp-linux-s390x@0.34.5': | |
| 2020 | − optionalDependencies: | |
| 2021 | − '@img/sharp-libvips-linux-s390x': 1.2.4 | |
| 2022 | − optional: true | |
| 2023 | − | |
| 2024 | − '@img/sharp-linux-x64@0.34.5': | |
| 2025 | − optionalDependencies: | |
| 2026 | − '@img/sharp-libvips-linux-x64': 1.2.4 | |
| 2027 | − optional: true | |
| 2028 | − | |
| 2029 | − '@img/sharp-linuxmusl-arm64@0.34.5': | |
| 2030 | − optionalDependencies: | |
| 2031 | − '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 | |
| 2032 | − optional: true | |
| 2033 | − | |
| 2034 | − '@img/sharp-linuxmusl-x64@0.34.5': | |
| 2035 | − optionalDependencies: | |
| 2036 | − '@img/sharp-libvips-linuxmusl-x64': 1.2.4 | |
| 2037 | − optional: true | |
| 2038 | − | |
| 2039 | − '@img/sharp-wasm32@0.34.5': | |
| 2040 | − dependencies: | |
| 2041 | − '@emnapi/runtime': 1.11.3 | |
| 2042 | − optional: true | |
| 2043 | − | |
| 2044 | − '@img/sharp-win32-arm64@0.34.5': | |
| 2045 | − optional: true | |
| 2046 | − | |
| 2047 | − '@img/sharp-win32-ia32@0.34.5': | |
| 2048 | − optional: true | |
| 2049 | − | |
| 2050 | − '@img/sharp-win32-x64@0.34.5': | |
| 2051 | − optional: true | |
| 2052 | − | |
| 2053 | − '@jridgewell/gen-mapping@0.3.13': | |
| 2054 | − dependencies: | |
| 2055 | − '@jridgewell/sourcemap-codec': 1.5.5 | |
| 2056 | − '@jridgewell/trace-mapping': 0.3.31 | |
| 2057 | − | |
| 2058 | − '@jridgewell/resolve-uri@3.1.2': {} | |
| 2059 | − | |
| 2060 | − '@jridgewell/sourcemap-codec@1.5.5': {} | |
| 2061 | − | |
| 2062 | − '@jridgewell/trace-mapping@0.3.31': | |
| 2063 | − dependencies: | |
| 2064 | − '@jridgewell/resolve-uri': 3.1.2 | |
| 2065 | − '@jridgewell/sourcemap-codec': 1.5.5 | |
| 2066 | − | |
| 2067 | − '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': | |
| 2068 | − dependencies: | |
| 2069 | − '@emnapi/core': 1.10.0 | |
| 2070 | − '@emnapi/runtime': 1.10.0 | |
| 2071 | − '@tybys/wasm-util': 0.10.3 | |
| 2072 | − optional: true | |
| 2073 | − | |
| 2074 | − '@next/env@15.5.23': {} | |
| 2075 | − | |
| 2076 | − '@next/eslint-plugin-next@15.5.23': | |
| 2077 | − dependencies: | |
| 2078 | − fast-glob: 3.3.1 | |
| 2079 | − | |
| 2080 | − '@next/swc-darwin-arm64@15.5.23': | |
| 2081 | − optional: true | |
| 2082 | − | |
| 2083 | − '@next/swc-darwin-x64@15.5.23': | |
| 2084 | − optional: true | |
| 2085 | − | |
| 2086 | − '@next/swc-linux-arm64-gnu@15.5.23': | |
| 2087 | − optional: true | |
| 2088 | − | |
| 2089 | − '@next/swc-linux-arm64-musl@15.5.23': | |
| 2090 | − optional: true | |
| 2091 | − | |
| 2092 | − '@next/swc-linux-x64-gnu@15.5.23': | |
| 2093 | − optional: true | |
| 2094 | − | |
| 2095 | − '@next/swc-linux-x64-musl@15.5.23': | |
| 2096 | − optional: true | |
| 2097 | − | |
| 2098 | − '@next/swc-win32-arm64-msvc@15.5.23': | |
| 2099 | − optional: true | |
| 2100 | − | |
| 2101 | − '@next/swc-win32-x64-msvc@15.5.23': | |
| 2102 | − optional: true | |
| 2103 | − | |
| 2104 | − '@nodelib/fs.scandir@2.1.5': | |
| 2105 | − dependencies: | |
| 2106 | − '@nodelib/fs.stat': 2.0.5 | |
| 2107 | − run-parallel: 1.2.0 | |
| 2108 | − | |
| 2109 | − '@nodelib/fs.stat@2.0.5': {} | |
| 2110 | − | |
| 2111 | − '@nodelib/fs.walk@1.2.8': | |
| 2112 | − dependencies: | |
| 2113 | − '@nodelib/fs.scandir': 2.1.5 | |
| 2114 | − fastq: 1.20.1 | |
| 2115 | − | |
| 2116 | − '@nolyfill/is-core-module@1.0.39': {} | |
| 2117 | − | |
| 2118 | − '@rtsao/scc@1.1.0': {} | |
| 2119 | − | |
| 2120 | − '@rushstack/eslint-patch@1.16.1': {} | |
| 2121 | − | |
| 2122 | − '@swc/helpers@0.5.15': | |
| 2123 | − dependencies: | |
| 2124 | − tslib: 2.8.1 | |
| 2125 | − | |
| 2126 | − '@tybys/wasm-util@0.10.3': | |
| 2127 | − dependencies: | |
| 2128 | − tslib: 2.8.1 | |
| 2129 | − optional: true | |
| 2130 | − | |
| 2131 | − '@types/json5@0.0.29': {} | |
| 2132 | − | |
| 2133 | − '@types/node@22.20.1': | |
| 2134 | − dependencies: | |
| 2135 | − undici-types: 6.21.0 | |
| 2136 | − | |
| 2137 | − '@types/react-dom@19.2.4(@types/react@19.2.18)': | |
| 2138 | − dependencies: | |
| 2139 | − '@types/react': 19.2.18 | |
| 2140 | − | |
| 2141 | − '@types/react@19.2.18': | |
| 2142 | − dependencies: | |
| 2143 | − csstype: 3.2.3 | |
| 2144 | − | |
| 2145 | − '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': | |
| 2146 | − dependencies: | |
| 2147 | − '@eslint-community/regexpp': 4.12.2 | |
| 2148 | − '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2149 | − '@typescript-eslint/scope-manager': 8.67.0 | |
| 2150 | − '@typescript-eslint/type-utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2151 | − '@typescript-eslint/utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2152 | − '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2153 | − eslint: 8.57.1 | |
| 2154 | − ignore: 7.0.6 | |
| 2155 | − natural-compare: 1.4.0 | |
| 2156 | − ts-api-utils: 2.5.0(typescript@5.9.3) | |
| 2157 | − typescript: 5.9.3 | |
| 2158 | − transitivePeerDependencies: | |
| 2159 | − - supports-color | |
| 2160 | − | |
| 2161 | − '@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3)': | |
| 2162 | − dependencies: | |
| 2163 | − '@typescript-eslint/scope-manager': 8.67.0 | |
| 2164 | − '@typescript-eslint/types': 8.67.0 | |
| 2165 | − '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) | |
| 2166 | − '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2167 | − debug: 4.4.3 | |
| 2168 | − eslint: 8.57.1 | |
| 2169 | − typescript: 5.9.3 | |
| 2170 | − transitivePeerDependencies: | |
| 2171 | − - supports-color | |
| 2172 | − | |
| 2173 | − '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': | |
| 2174 | − dependencies: | |
| 2175 | − '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) | |
| 2176 | − '@typescript-eslint/types': 8.67.0 | |
| 2177 | − debug: 4.4.3 | |
| 2178 | − typescript: 5.9.3 | |
| 2179 | − transitivePeerDependencies: | |
| 2180 | − - supports-color | |
| 2181 | − | |
| 2182 | − '@typescript-eslint/scope-manager@8.67.0': | |
| 2183 | − dependencies: | |
| 2184 | − '@typescript-eslint/types': 8.67.0 | |
| 2185 | − '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2186 | − | |
| 2187 | − '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)': | |
| 2188 | − dependencies: | |
| 2189 | − typescript: 5.9.3 | |
| 2190 | − | |
| 2191 | − '@typescript-eslint/type-utils@8.67.0(eslint@8.57.1)(typescript@5.9.3)': | |
| 2192 | − dependencies: | |
| 2193 | − '@typescript-eslint/types': 8.67.0 | |
| 2194 | − '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) | |
| 2195 | − '@typescript-eslint/utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2196 | − debug: 4.4.3 | |
| 2197 | − eslint: 8.57.1 | |
| 2198 | − ts-api-utils: 2.5.0(typescript@5.9.3) | |
| 2199 | − typescript: 5.9.3 | |
| 2200 | − transitivePeerDependencies: | |
| 2201 | − - supports-color | |
| 2202 | − | |
| 2203 | − '@typescript-eslint/types@8.67.0': {} | |
| 2204 | − | |
| 2205 | − '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': | |
| 2206 | − dependencies: | |
| 2207 | − '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3) | |
| 2208 | − '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) | |
| 2209 | − '@typescript-eslint/types': 8.67.0 | |
| 2210 | − '@typescript-eslint/visitor-keys': 8.67.0 | |
| 2211 | − debug: 4.4.3 | |
| 2212 | − minimatch: 10.2.6 | |
| 2213 | − semver: 7.8.5 | |
| 2214 | − tinyglobby: 0.2.17 | |
| 2215 | − ts-api-utils: 2.5.0(typescript@5.9.3) | |
| 2216 | − typescript: 5.9.3 | |
| 2217 | − transitivePeerDependencies: | |
| 2218 | − - supports-color | |
| 2219 | − | |
| 2220 | − '@typescript-eslint/utils@8.67.0(eslint@8.57.1)(typescript@5.9.3)': | |
| 2221 | − dependencies: | |
| 2222 | − '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1) | |
| 2223 | − '@typescript-eslint/scope-manager': 8.67.0 | |
| 2224 | − '@typescript-eslint/types': 8.67.0 | |
| 2225 | − '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) | |
| 2226 | − eslint: 8.57.1 | |
| 2227 | − typescript: 5.9.3 | |
| 2228 | − transitivePeerDependencies: | |
| 2229 | − - supports-color | |
| 2230 | − | |
| 2231 | − '@typescript-eslint/visitor-keys@8.67.0': | |
| 2232 | − dependencies: | |
| 2233 | − '@typescript-eslint/types': 8.67.0 | |
| 2234 | − eslint-visitor-keys: 5.0.1 | |
| 2235 | − | |
| 2236 | − '@ungap/structured-clone@1.3.3': {} | |
| 2237 | − | |
| 2238 | − '@unrs/resolver-binding-android-arm-eabi@1.12.2': | |
| 2239 | − optional: true | |
| 2240 | − | |
| 2241 | − '@unrs/resolver-binding-android-arm64@1.12.2': | |
| 2242 | − optional: true | |
| 2243 | − | |
| 2244 | − '@unrs/resolver-binding-darwin-arm64@1.12.2': | |
| 2245 | − optional: true | |
| 2246 | − | |
| 2247 | − '@unrs/resolver-binding-darwin-x64@1.12.2': | |
| 2248 | − optional: true | |
| 2249 | − | |
| 2250 | − '@unrs/resolver-binding-freebsd-x64@1.12.2': | |
| 2251 | − optional: true | |
| 2252 | − | |
| 2253 | − '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': | |
| 2254 | − optional: true | |
| 2255 | − | |
| 2256 | − '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': | |
| 2257 | − optional: true | |
| 2258 | − | |
| 2259 | − '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': | |
| 2260 | − optional: true | |
| 2261 | − | |
| 2262 | − '@unrs/resolver-binding-linux-arm64-musl@1.12.2': | |
| 2263 | − optional: true | |
| 2264 | − | |
| 2265 | − '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': | |
| 2266 | − optional: true | |
| 2267 | − | |
| 2268 | − '@unrs/resolver-binding-linux-loong64-musl@1.12.2': | |
| 2269 | − optional: true | |
| 2270 | − | |
| 2271 | − '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': | |
| 2272 | − optional: true | |
| 2273 | − | |
| 2274 | − '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': | |
| 2275 | − optional: true | |
| 2276 | − | |
| 2277 | − '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': | |
| 2278 | − optional: true | |
| 2279 | − | |
| 2280 | − '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': | |
| 2281 | − optional: true | |
| 2282 | − | |
| 2283 | − '@unrs/resolver-binding-linux-x64-gnu@1.12.2': | |
| 2284 | − optional: true | |
| 2285 | − | |
| 2286 | − '@unrs/resolver-binding-linux-x64-musl@1.12.2': | |
| 2287 | − optional: true | |
| 2288 | − | |
| 2289 | − '@unrs/resolver-binding-openharmony-arm64@1.12.2': | |
| 2290 | − optional: true | |
| 2291 | − | |
| 2292 | − '@unrs/resolver-binding-wasm32-wasi@1.12.2': | |
| 2293 | − dependencies: | |
| 2294 | − '@emnapi/core': 1.10.0 | |
| 2295 | − '@emnapi/runtime': 1.10.0 | |
| 2296 | − '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) | |
| 2297 | − optional: true | |
| 2298 | − | |
| 2299 | − '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': | |
| 2300 | − optional: true | |
| 2301 | − | |
| 2302 | − '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': | |
| 2303 | − optional: true | |
| 2304 | − | |
| 2305 | − '@unrs/resolver-binding-win32-x64-msvc@1.12.2': | |
| 2306 | − optional: true | |
| 2307 | − | |
| 2308 | − acorn-jsx@5.3.2(acorn@8.18.0): | |
| 2309 | − dependencies: | |
| 2310 | − acorn: 8.18.0 | |
| 2311 | − | |
| 2312 | − acorn@8.18.0: {} | |
| 2313 | − | |
| 2314 | − ajv@6.15.0: | |
| 2315 | − dependencies: | |
| 2316 | − fast-deep-equal: 3.1.3 | |
| 2317 | − fast-json-stable-stringify: 2.1.0 | |
| 2318 | − json-schema-traverse: 0.4.1 | |
| 2319 | − uri-js: 4.4.1 | |
| 2320 | − | |
| 2321 | − ansi-regex@5.0.1: {} | |
| 2322 | − | |
| 2323 | − ansi-styles@4.3.0: | |
| 2324 | − dependencies: | |
| 2325 | − color-convert: 2.0.1 | |
| 2326 | − | |
| 2327 | − any-promise@1.3.0: {} | |
| 2328 | − | |
| 2329 | − anymatch@3.1.3: | |
| 2330 | − dependencies: | |
| 2331 | − normalize-path: 3.0.0 | |
| 2332 | − picomatch: 2.3.2 | |
| 2333 | − | |
| 2334 | − arg@5.0.2: {} | |
| 2335 | − | |
| 2336 | − argparse@2.0.1: {} | |
| 2337 | − | |
| 2338 | − aria-query@5.3.2: {} | |
| 2339 | − | |
| 2340 | − array-buffer-byte-length@1.0.2: | |
| 2341 | − dependencies: | |
| 2342 | − call-bound: 1.0.4 | |
| 2343 | − is-array-buffer: 3.0.5 | |
| 2344 | − | |
| 2345 | − array-includes@3.1.9: | |
| 2346 | − dependencies: | |
| 2347 | − call-bind: 1.0.9 | |
| 2348 | − call-bound: 1.0.4 | |
| 2349 | − define-properties: 1.2.1 | |
| 2350 | − es-abstract: 1.24.2 | |
| 2351 | − es-object-atoms: 1.1.2 | |
| 2352 | − get-intrinsic: 1.3.0 | |
| 2353 | − is-string: 1.1.1 | |
| 2354 | − math-intrinsics: 1.1.0 | |
| 2355 | − | |
| 2356 | − array.prototype.findlast@1.2.5: | |
| 2357 | − dependencies: | |
| 2358 | − call-bind: 1.0.9 | |
| 2359 | − define-properties: 1.2.1 | |
| 2360 | − es-abstract: 1.24.2 | |
| 2361 | − es-errors: 1.3.0 | |
| 2362 | − es-object-atoms: 1.1.2 | |
| 2363 | − es-shim-unscopables: 1.1.0 | |
| 2364 | − | |
| 2365 | − array.prototype.findlastindex@1.2.6: | |
| 2366 | − dependencies: | |
| 2367 | − call-bind: 1.0.9 | |
| 2368 | − call-bound: 1.0.4 | |
| 2369 | − define-properties: 1.2.1 | |
| 2370 | − es-abstract: 1.24.2 | |
| 2371 | − es-errors: 1.3.0 | |
| 2372 | − es-object-atoms: 1.1.2 | |
| 2373 | − es-shim-unscopables: 1.1.0 | |
| 2374 | − | |
| 2375 | − array.prototype.flat@1.3.3: | |
| 2376 | − dependencies: | |
| 2377 | − call-bind: 1.0.9 | |
| 2378 | − define-properties: 1.2.1 | |
| 2379 | − es-abstract: 1.24.2 | |
| 2380 | − es-shim-unscopables: 1.1.0 | |
| 2381 | − | |
| 2382 | − array.prototype.flatmap@1.3.3: | |
| 2383 | − dependencies: | |
| 2384 | − call-bind: 1.0.9 | |
| 2385 | − define-properties: 1.2.1 | |
| 2386 | − es-abstract: 1.24.2 | |
| 2387 | − es-shim-unscopables: 1.1.0 | |
| 2388 | − | |
| 2389 | − array.prototype.tosorted@1.1.4: | |
| 2390 | − dependencies: | |
| 2391 | − call-bind: 1.0.9 | |
| 2392 | − define-properties: 1.2.1 | |
| 2393 | − es-abstract: 1.24.2 | |
| 2394 | − es-errors: 1.3.0 | |
| 2395 | − es-shim-unscopables: 1.1.0 | |
| 2396 | − | |
| 2397 | − arraybuffer.prototype.slice@1.0.4: | |
| 2398 | − dependencies: | |
| 2399 | − array-buffer-byte-length: 1.0.2 | |
| 2400 | − call-bind: 1.0.9 | |
| 2401 | − define-properties: 1.2.1 | |
| 2402 | − es-abstract: 1.24.2 | |
| 2403 | − es-errors: 1.3.0 | |
| 2404 | − get-intrinsic: 1.3.0 | |
| 2405 | − is-array-buffer: 3.0.5 | |
| 2406 | − | |
| 2407 | − ast-types-flow@0.0.8: {} | |
| 2408 | − | |
| 2409 | − async-function@1.0.0: {} | |
| 2410 | − | |
| 2411 | − autoprefixer@10.5.4(postcss@8.5.26): | |
| 2412 | − dependencies: | |
| 2413 | − browserslist: 4.28.8 | |
| 2414 | − caniuse-lite: 1.0.30001809 | |
| 2415 | − fraction.js: 5.3.4 | |
| 2416 | − picocolors: 1.1.1 | |
| 2417 | − postcss: 8.5.26 | |
| 2418 | − postcss-value-parser: 4.2.0 | |
| 2419 | − | |
| 2420 | − available-typed-arrays@1.0.7: | |
| 2421 | − dependencies: | |
| 2422 | − possible-typed-array-names: 1.1.0 | |
| 2423 | − | |
| 2424 | − axe-core@4.13.0: {} | |
| 2425 | − | |
| 2426 | − axobject-query@4.1.0: {} | |
| 2427 | − | |
| 2428 | − balanced-match@1.0.2: {} | |
| 2429 | − | |
| 2430 | − balanced-match@4.0.4: {} | |
| 2431 | − | |
| 2432 | − baseline-browser-mapping@2.11.13: {} | |
| 2433 | − | |
| 2434 | − binary-extensions@2.3.0: {} | |
| 2435 | − | |
| 2436 | − brace-expansion@1.1.18: | |
| 2437 | − dependencies: | |
| 2438 | − balanced-match: 1.0.2 | |
| 2439 | − concat-map: 0.0.1 | |
| 2440 | − | |
| 2441 | − brace-expansion@5.0.9: | |
| 2442 | − dependencies: | |
| 2443 | − balanced-match: 4.0.4 | |
| 2444 | − | |
| 2445 | − braces@3.0.3: | |
| 2446 | − dependencies: | |
| 2447 | − fill-range: 7.1.1 | |
| 2448 | − | |
| 2449 | − browserslist@4.28.8: | |
| 2450 | − dependencies: | |
| 2451 | − baseline-browser-mapping: 2.11.13 | |
| 2452 | − caniuse-lite: 1.0.30001809 | |
| 2453 | − electron-to-chromium: 1.5.405 | |
| 2454 | − node-releases: 2.0.53 | |
| 2455 | − update-browserslist-db: 1.3.1(browserslist@4.28.8) | |
| 2456 | − | |
| 2457 | − call-bind-apply-helpers@1.0.2: | |
| 2458 | − dependencies: | |
| 2459 | − es-errors: 1.3.0 | |
| 2460 | − function-bind: 1.1.2 | |
| 2461 | − | |
| 2462 | − call-bind@1.0.9: | |
| 2463 | − dependencies: | |
| 2464 | − call-bind-apply-helpers: 1.0.2 | |
| 2465 | − es-define-property: 1.0.1 | |
| 2466 | − get-intrinsic: 1.3.0 | |
| 2467 | − set-function-length: 1.2.2 | |
| 2468 | − | |
| 2469 | − call-bound@1.0.4: | |
| 2470 | − dependencies: | |
| 2471 | − call-bind-apply-helpers: 1.0.2 | |
| 2472 | − get-intrinsic: 1.3.0 | |
| 2473 | − | |
| 2474 | − callsites@3.1.0: {} | |
| 2475 | − | |
| 2476 | − camelcase-css@2.0.1: {} | |
| 2477 | − | |
| 2478 | − caniuse-lite@1.0.30001809: {} | |
| 2479 | − | |
| 2480 | − chalk@4.1.2: | |
| 2481 | − dependencies: | |
| 2482 | − ansi-styles: 4.3.0 | |
| 2483 | − supports-color: 7.2.0 | |
| 2484 | − | |
| 2485 | − chokidar@3.6.0: | |
| 2486 | − dependencies: | |
| 2487 | − anymatch: 3.1.3 | |
| 2488 | − braces: 3.0.3 | |
| 2489 | − glob-parent: 5.1.2 | |
| 2490 | − is-binary-path: 2.1.0 | |
| 2491 | − is-glob: 4.0.3 | |
| 2492 | − normalize-path: 3.0.0 | |
| 2493 | − readdirp: 3.6.0 | |
| 2494 | − optionalDependencies: | |
| 2495 | − fsevents: 2.3.3 | |
| 2496 | − | |
| 2497 | − class-variance-authority@0.7.1: | |
| 2498 | − dependencies: | |
| 2499 | − clsx: 2.1.1 | |
| 2500 | − | |
| 2501 | − client-only@0.0.1: {} | |
| 2502 | − | |
| 2503 | − clsx@2.1.1: {} | |
| 2504 | − | |
| 2505 | − color-convert@2.0.1: | |
| 2506 | − dependencies: | |
| 2507 | − color-name: 1.1.4 | |
| 2508 | − | |
| 2509 | − color-name@1.1.4: {} | |
| 2510 | − | |
| 2511 | − commander@4.1.1: {} | |
| 2512 | − | |
| 2513 | − concat-map@0.0.1: {} | |
| 2514 | − | |
| 2515 | − cross-spawn@7.0.6: | |
| 2516 | − dependencies: | |
| 2517 | − path-key: 3.1.1 | |
| 2518 | − shebang-command: 2.0.0 | |
| 2519 | − which: 2.0.2 | |
| 2520 | − | |
| 2521 | − cssesc@3.0.0: {} | |
| 2522 | − | |
| 2523 | − csstype@3.2.3: {} | |
| 2524 | − | |
| 2525 | − damerau-levenshtein@1.0.8: {} | |
| 2526 | − | |
| 2527 | − data-view-buffer@1.0.2: | |
| 2528 | − dependencies: | |
| 2529 | − call-bound: 1.0.4 | |
| 2530 | − es-errors: 1.3.0 | |
| 2531 | − is-data-view: 1.0.2 | |
| 2532 | − | |
| 2533 | − data-view-byte-length@1.0.2: | |
| 2534 | − dependencies: | |
| 2535 | − call-bound: 1.0.4 | |
| 2536 | − es-errors: 1.3.0 | |
| 2537 | − is-data-view: 1.0.2 | |
| 2538 | − | |
| 2539 | − data-view-byte-offset@1.0.1: | |
| 2540 | − dependencies: | |
| 2541 | − call-bound: 1.0.4 | |
| 2542 | − es-errors: 1.3.0 | |
| 2543 | − is-data-view: 1.0.2 | |
| 2544 | − | |
| 2545 | − debug@3.2.7: | |
| 2546 | − dependencies: | |
| 2547 | − ms: 2.1.3 | |
| 2548 | − | |
| 2549 | − debug@4.4.3: | |
| 2550 | − dependencies: | |
| 2551 | − ms: 2.1.3 | |
| 2552 | − | |
| 2553 | − deep-is@0.1.4: {} | |
| 2554 | − | |
| 2555 | − define-data-property@1.1.4: | |
| 2556 | − dependencies: | |
| 2557 | − es-define-property: 1.0.1 | |
| 2558 | − es-errors: 1.3.0 | |
| 2559 | − gopd: 1.2.0 | |
| 2560 | − | |
| 2561 | − define-properties@1.2.1: | |
| 2562 | − dependencies: | |
| 2563 | − define-data-property: 1.1.4 | |
| 2564 | − has-property-descriptors: 1.0.2 | |
| 2565 | − object-keys: 1.1.1 | |
| 2566 | − | |
| 2567 | − detect-libc@2.1.2: | |
| 2568 | − optional: true | |
| 2569 | − | |
| 2570 | − didyoumean@1.2.2: {} | |
| 2571 | − | |
| 2572 | − dlv@1.1.3: {} | |
| 2573 | − | |
| 2574 | − doctrine@2.1.0: | |
| 2575 | − dependencies: | |
| 2576 | − esutils: 2.0.3 | |
| 2577 | − | |
| 2578 | − doctrine@3.0.0: | |
| 2579 | − dependencies: | |
| 2580 | − esutils: 2.0.3 | |
| 2581 | − | |
| 2582 | − dunder-proto@1.0.1: | |
| 2583 | − dependencies: | |
| 2584 | − call-bind-apply-helpers: 1.0.2 | |
| 2585 | − es-errors: 1.3.0 | |
| 2586 | − gopd: 1.2.0 | |
| 2587 | − | |
| 2588 | − electron-to-chromium@1.5.405: {} | |
| 2589 | − | |
| 2590 | − emoji-regex@9.2.2: {} | |
| 2591 | − | |
| 2592 | − es-abstract-get@1.0.0: | |
| 2593 | − dependencies: | |
| 2594 | − es-errors: 1.3.0 | |
| 2595 | − es-object-atoms: 1.1.2 | |
| 2596 | − is-callable: 1.2.7 | |
| 2597 | − object-inspect: 1.13.4 | |
| 2598 | − | |
| 2599 | − es-abstract@1.24.2: | |
| 2600 | − dependencies: | |
| 2601 | − array-buffer-byte-length: 1.0.2 | |
| 2602 | − arraybuffer.prototype.slice: 1.0.4 | |
| 2603 | − available-typed-arrays: 1.0.7 | |
| 2604 | − call-bind: 1.0.9 | |
| 2605 | − call-bound: 1.0.4 | |
| 2606 | − data-view-buffer: 1.0.2 | |
| 2607 | − data-view-byte-length: 1.0.2 | |
| 2608 | − data-view-byte-offset: 1.0.1 | |
| 2609 | − es-define-property: 1.0.1 | |
| 2610 | − es-errors: 1.3.0 | |
| 2611 | − es-object-atoms: 1.1.2 | |
| 2612 | − es-set-tostringtag: 2.1.0 | |
| 2613 | − es-to-primitive: 1.3.4 | |
| 2614 | − function.prototype.name: 1.2.0 | |
| 2615 | − get-intrinsic: 1.3.0 | |
| 2616 | − get-proto: 1.0.1 | |
| 2617 | − get-symbol-description: 1.1.0 | |
| 2618 | − globalthis: 1.0.4 | |
| 2619 | − gopd: 1.2.0 | |
| 2620 | − has-property-descriptors: 1.0.2 | |
| 2621 | − has-proto: 1.2.0 | |
| 2622 | − has-symbols: 1.1.0 | |
| 2623 | − hasown: 2.0.4 | |
| 2624 | − internal-slot: 1.1.0 | |
| 2625 | − is-array-buffer: 3.0.5 | |
| 2626 | − is-callable: 1.2.7 | |
| 2627 | − is-data-view: 1.0.2 | |
| 2628 | − is-negative-zero: 2.0.3 | |
| 2629 | − is-regex: 1.2.1 | |
| 2630 | − is-set: 2.0.3 | |
| 2631 | − is-shared-array-buffer: 1.0.4 | |
| 2632 | − is-string: 1.1.1 | |
| 2633 | − is-typed-array: 1.1.15 | |
| 2634 | − is-weakref: 1.1.1 | |
| 2635 | − math-intrinsics: 1.1.0 | |
| 2636 | − object-inspect: 1.13.4 | |
| 2637 | − object-keys: 1.1.1 | |
| 2638 | − object.assign: 4.1.7 | |
| 2639 | − own-keys: 1.0.2 | |
| 2640 | − regexp.prototype.flags: 1.5.4 | |
| 2641 | − safe-array-concat: 1.1.4 | |
| 2642 | − safe-push-apply: 1.0.0 | |
| 2643 | − safe-regex-test: 1.1.0 | |
| 2644 | − set-proto: 1.0.0 | |
| 2645 | − stop-iteration-iterator: 1.1.0 | |
| 2646 | − string.prototype.trim: 1.2.11 | |
| 2647 | − string.prototype.trimend: 1.0.10 | |
| 2648 | − string.prototype.trimstart: 1.0.8 | |
| 2649 | − typed-array-buffer: 1.0.3 | |
| 2650 | − typed-array-byte-length: 1.0.3 | |
| 2651 | − typed-array-byte-offset: 1.0.4 | |
| 2652 | − typed-array-length: 1.0.8 | |
| 2653 | − unbox-primitive: 1.1.0 | |
| 2654 | − which-typed-array: 1.1.22 | |
| 2655 | − | |
| 2656 | − es-define-property@1.0.1: {} | |
| 2657 | − | |
| 2658 | − es-errors@1.3.0: {} | |
| 2659 | − | |
| 2660 | − es-iterator-helpers@1.4.0: | |
| 2661 | − dependencies: | |
| 2662 | − call-bind: 1.0.9 | |
| 2663 | − call-bound: 1.0.4 | |
| 2664 | − define-properties: 1.2.1 | |
| 2665 | − es-abstract: 1.24.2 | |
| 2666 | − es-errors: 1.3.0 | |
| 2667 | − es-set-tostringtag: 2.1.0 | |
| 2668 | − function-bind: 1.1.2 | |
| 2669 | − get-intrinsic: 1.3.0 | |
| 2670 | − globalthis: 1.0.4 | |
| 2671 | − gopd: 1.2.0 | |
| 2672 | − has-property-descriptors: 1.0.2 | |
| 2673 | − has-proto: 1.2.0 | |
| 2674 | − has-symbols: 1.1.0 | |
| 2675 | − internal-slot: 1.1.0 | |
| 2676 | − iterator.prototype: 1.1.5 | |
| 2677 | − math-intrinsics: 1.1.0 | |
| 2678 | − | |
| 2679 | − es-object-atoms@1.1.2: | |
| 2680 | − dependencies: | |
| 2681 | − es-errors: 1.3.0 | |
| 2682 | − | |
| 2683 | − es-set-tostringtag@2.1.0: | |
| 2684 | − dependencies: | |
| 2685 | − es-errors: 1.3.0 | |
| 2686 | − get-intrinsic: 1.3.0 | |
| 2687 | − has-tostringtag: 1.0.2 | |
| 2688 | − hasown: 2.0.4 | |
| 2689 | − | |
| 2690 | − es-shim-unscopables@1.1.0: | |
| 2691 | − dependencies: | |
| 2692 | − hasown: 2.0.4 | |
| 2693 | − | |
| 2694 | − es-to-primitive@1.3.4: | |
| 2695 | − dependencies: | |
| 2696 | − es-abstract-get: 1.0.0 | |
| 2697 | − es-define-property: 1.0.1 | |
| 2698 | − es-errors: 1.3.0 | |
| 2699 | − is-callable: 1.2.7 | |
| 2700 | − is-date-object: 1.1.0 | |
| 2701 | − is-symbol: 1.1.1 | |
| 2702 | − | |
| 2703 | − escalade@3.2.0: {} | |
| 2704 | − | |
| 2705 | − escape-string-regexp@4.0.0: {} | |
| 2706 | − | |
| 2707 | − eslint-config-next@15.5.23(eslint@8.57.1)(typescript@5.9.3): | |
| 2708 | − dependencies: | |
| 2709 | − '@next/eslint-plugin-next': 15.5.23 | |
| 2710 | − '@rushstack/eslint-patch': 1.16.1 | |
| 2711 | − '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) | |
| 2712 | − '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2713 | − eslint: 8.57.1 | |
| 2714 | − eslint-import-resolver-node: 0.3.10 | |
| 2715 | − eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) | |
| 2716 | − eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) | |
| 2717 | − eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) | |
| 2718 | − eslint-plugin-react: 7.37.5(eslint@8.57.1) | |
| 2719 | − eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) | |
| 2720 | − optionalDependencies: | |
| 2721 | − typescript: 5.9.3 | |
| 2722 | − transitivePeerDependencies: | |
| 2723 | − - eslint-import-resolver-webpack | |
| 2724 | − - eslint-plugin-import-x | |
| 2725 | − - supports-color | |
| 2726 | − | |
| 2727 | − eslint-import-resolver-node@0.3.10: | |
| 2728 | − dependencies: | |
| 2729 | − debug: 3.2.7 | |
| 2730 | − is-core-module: 2.16.2 | |
| 2731 | − resolve: 2.0.0-next.7 | |
| 2732 | − transitivePeerDependencies: | |
| 2733 | − - supports-color | |
| 2734 | − | |
| 2735 | − eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): | |
| 2736 | − dependencies: | |
| 2737 | − '@nolyfill/is-core-module': 1.0.39 | |
| 2738 | − debug: 4.4.3 | |
| 2739 | − eslint: 8.57.1 | |
| 2740 | − get-tsconfig: 4.14.2 | |
| 2741 | − is-bun-module: 2.0.0 | |
| 2742 | − stable-hash: 0.0.5 | |
| 2743 | − tinyglobby: 0.2.17 | |
| 2744 | − unrs-resolver: 1.12.2 | |
| 2745 | − optionalDependencies: | |
| 2746 | − eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) | |
| 2747 | − transitivePeerDependencies: | |
| 2748 | − - supports-color | |
| 2749 | − | |
| 2750 | − eslint-module-utils@2.14.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): | |
| 2751 | − dependencies: | |
| 2752 | − debug: 3.2.7 | |
| 2753 | − optionalDependencies: | |
| 2754 | − '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2755 | − eslint: 8.57.1 | |
| 2756 | − eslint-import-resolver-node: 0.3.10 | |
| 2757 | − eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) | |
| 2758 | − transitivePeerDependencies: | |
| 2759 | − - supports-color | |
| 2760 | − | |
| 2761 | − eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): | |
| 2762 | − dependencies: | |
| 2763 | − '@rtsao/scc': 1.1.0 | |
| 2764 | − array-includes: 3.1.9 | |
| 2765 | − array.prototype.findlastindex: 1.2.6 | |
| 2766 | − array.prototype.flat: 1.3.3 | |
| 2767 | − array.prototype.flatmap: 1.3.3 | |
| 2768 | − debug: 3.2.7 | |
| 2769 | − doctrine: 2.1.0 | |
| 2770 | − eslint: 8.57.1 | |
| 2771 | − eslint-import-resolver-node: 0.3.10 | |
| 2772 | − eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) | |
| 2773 | − hasown: 2.0.4 | |
| 2774 | − is-core-module: 2.16.2 | |
| 2775 | − is-glob: 4.0.3 | |
| 2776 | − minimatch: 3.1.5 | |
| 2777 | − object.fromentries: 2.0.8 | |
| 2778 | − object.groupby: 1.0.3 | |
| 2779 | − object.values: 1.2.1 | |
| 2780 | − semver: 6.3.1 | |
| 2781 | − string.prototype.trimend: 1.0.10 | |
| 2782 | − tsconfig-paths: 3.15.0 | |
| 2783 | − optionalDependencies: | |
| 2784 | − '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3) | |
| 2785 | − transitivePeerDependencies: | |
| 2786 | − - eslint-import-resolver-typescript | |
| 2787 | − - eslint-import-resolver-webpack | |
| 2788 | − - supports-color | |
| 2789 | − | |
| 2790 | − eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1): | |
| 2791 | − dependencies: | |
| 2792 | − aria-query: 5.3.2 | |
| 2793 | − array-includes: 3.1.9 | |
| 2794 | − array.prototype.flatmap: 1.3.3 | |
| 2795 | − ast-types-flow: 0.0.8 | |
| 2796 | − axe-core: 4.13.0 | |
| 2797 | − axobject-query: 4.1.0 | |
| 2798 | − damerau-levenshtein: 1.0.8 | |
| 2799 | − emoji-regex: 9.2.2 | |
| 2800 | − eslint: 8.57.1 | |
| 2801 | − hasown: 2.0.4 | |
| 2802 | − jsx-ast-utils: 3.3.5 | |
| 2803 | − language-tags: 1.0.9 | |
| 2804 | − minimatch: 3.1.5 | |
| 2805 | − object.fromentries: 2.0.8 | |
| 2806 | − safe-regex-test: 1.1.0 | |
| 2807 | − string.prototype.includes: 2.0.1 | |
| 2808 | − | |
| 2809 | − eslint-plugin-react-hooks@5.2.0(eslint@8.57.1): | |
| 2810 | − dependencies: | |
| 2811 | − eslint: 8.57.1 | |
| 2812 | − | |
| 2813 | − eslint-plugin-react@7.37.5(eslint@8.57.1): | |
| 2814 | − dependencies: | |
| 2815 | − array-includes: 3.1.9 | |
| 2816 | − array.prototype.findlast: 1.2.5 | |
| 2817 | − array.prototype.flatmap: 1.3.3 | |
| 2818 | − array.prototype.tosorted: 1.1.4 | |
| 2819 | − doctrine: 2.1.0 | |
| 2820 | − es-iterator-helpers: 1.4.0 | |
| 2821 | − eslint: 8.57.1 | |
| 2822 | − estraverse: 5.3.0 | |
| 2823 | − hasown: 2.0.4 | |
| 2824 | − jsx-ast-utils: 3.3.5 | |
| 2825 | − minimatch: 3.1.5 | |
| 2826 | − object.entries: 1.1.9 | |
| 2827 | − object.fromentries: 2.0.8 | |
| 2828 | − object.values: 1.2.1 | |
| 2829 | − prop-types: 15.8.1 | |
| 2830 | − resolve: 2.0.0-next.7 | |
| 2831 | − semver: 6.3.1 | |
| 2832 | − string.prototype.matchall: 4.0.12 | |
| 2833 | − string.prototype.repeat: 1.0.0 | |
| 2834 | − | |
| 2835 | − eslint-scope@7.2.2: | |
| 2836 | − dependencies: | |
| 2837 | − esrecurse: 4.3.0 | |
| 2838 | − estraverse: 5.3.0 | |
| 2839 | − | |
| 2840 | − eslint-visitor-keys@3.4.3: {} | |
| 2841 | − | |
| 2842 | − eslint-visitor-keys@5.0.1: {} | |
| 2843 | − | |
| 2844 | − eslint@8.57.1: | |
| 2845 | − dependencies: | |
| 2846 | − '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1) | |
| 2847 | − '@eslint-community/regexpp': 4.12.2 | |
| 2848 | − '@eslint/eslintrc': 2.1.4 | |
| 2849 | − '@eslint/js': 8.57.1 | |
| 2850 | − '@humanwhocodes/config-array': 0.13.0 | |
| 2851 | − '@humanwhocodes/module-importer': 1.0.1 | |
| 2852 | − '@nodelib/fs.walk': 1.2.8 | |
| 2853 | − '@ungap/structured-clone': 1.3.3 | |
| 2854 | − ajv: 6.15.0 | |
| 2855 | − chalk: 4.1.2 | |
| 2856 | − cross-spawn: 7.0.6 | |
| 2857 | − debug: 4.4.3 | |
| 2858 | − doctrine: 3.0.0 | |
| 2859 | − escape-string-regexp: 4.0.0 | |
| 2860 | − eslint-scope: 7.2.2 | |
| 2861 | − eslint-visitor-keys: 3.4.3 | |
| 2862 | − espree: 9.6.1 | |
| 2863 | − esquery: 1.7.0 | |
| 2864 | − esutils: 2.0.3 | |
| 2865 | − fast-deep-equal: 3.1.3 | |
| 2866 | − file-entry-cache: 6.0.1 | |
| 2867 | − find-up: 5.0.0 | |
| 2868 | − glob-parent: 6.0.2 | |
| 2869 | − globals: 13.24.0 | |
| 2870 | − graphemer: 1.4.0 | |
| 2871 | − ignore: 5.3.2 | |
| 2872 | − imurmurhash: 0.1.4 | |
| 2873 | − is-glob: 4.0.3 | |
| 2874 | − is-path-inside: 3.0.3 | |
| 2875 | − js-yaml: 4.3.1 | |
| 2876 | − json-stable-stringify-without-jsonify: 1.0.1 | |
| 2877 | − levn: 0.4.1 | |
| 2878 | − lodash.merge: 4.6.2 | |
| 2879 | − minimatch: 3.1.5 | |
| 2880 | − natural-compare: 1.4.0 | |
| 2881 | − optionator: 0.9.4 | |
| 2882 | − strip-ansi: 6.0.1 | |
| 2883 | − text-table: 0.2.0 | |
| 2884 | − transitivePeerDependencies: | |
| 2885 | − - supports-color | |
| 2886 | − | |
| 2887 | − espree@9.6.1: | |
| 2888 | − dependencies: | |
| 2889 | − acorn: 8.18.0 | |
| 2890 | − acorn-jsx: 5.3.2(acorn@8.18.0) | |
| 2891 | − eslint-visitor-keys: 3.4.3 | |
| 2892 | − | |
| 2893 | − esquery@1.7.0: | |
| 2894 | − dependencies: | |
| 2895 | − estraverse: 5.3.0 | |
| 2896 | − | |
| 2897 | − esrecurse@4.3.0: | |
| 2898 | − dependencies: | |
| 2899 | − estraverse: 5.3.0 | |
| 2900 | − | |
| 2901 | − estraverse@5.3.0: {} | |
| 2902 | − | |
| 2903 | − esutils@2.0.3: {} | |
| 2904 | − | |
| 2905 | − fast-deep-equal@3.1.3: {} | |
| 2906 | − | |
| 2907 | − fast-glob@3.3.1: | |
| 2908 | − dependencies: | |
| 2909 | − '@nodelib/fs.stat': 2.0.5 | |
| 2910 | − '@nodelib/fs.walk': 1.2.8 | |
| 2911 | − glob-parent: 5.1.2 | |
| 2912 | − merge2: 1.4.1 | |
| 2913 | − micromatch: 4.0.8 | |
| 2914 | − | |
| 2915 | − fast-glob@3.3.3: | |
| 2916 | − dependencies: | |
| 2917 | − '@nodelib/fs.stat': 2.0.5 | |
| 2918 | − '@nodelib/fs.walk': 1.2.8 | |
| 2919 | − glob-parent: 5.1.2 | |
| 2920 | − merge2: 1.4.1 | |
| 2921 | − micromatch: 4.0.8 | |
| 2922 | − | |
| 2923 | − fast-json-stable-stringify@2.1.0: {} | |
| 2924 | − | |
| 2925 | − fast-levenshtein@2.0.6: {} | |
| 2926 | − | |
| 2927 | − fastq@1.20.1: | |
| 2928 | − dependencies: | |
| 2929 | − reusify: 1.1.0 | |
| 2930 | − | |
| 2931 | − fdir@6.5.0(picomatch@4.0.5): | |
| 2932 | − optionalDependencies: | |
| 2933 | − picomatch: 4.0.5 | |
| 2934 | − | |
| 2935 | − file-entry-cache@6.0.1: | |
| 2936 | − dependencies: | |
| 2937 | − flat-cache: 3.2.0 | |
| 2938 | − | |
| 2939 | − fill-range@7.1.1: | |
| 2940 | − dependencies: | |
| 2941 | − to-regex-range: 5.0.1 | |
| 2942 | − | |
| 2943 | − find-up@5.0.0: | |
| 2944 | − dependencies: | |
| 2945 | − locate-path: 6.0.0 | |
| 2946 | − path-exists: 4.0.0 | |
| 2947 | − | |
| 2948 | − flat-cache@3.2.0: | |
| 2949 | − dependencies: | |
| 2950 | − flatted: 3.4.4 | |
| 2951 | − keyv: 4.5.4 | |
| 2952 | − rimraf: 3.0.2 | |
| 2953 | − | |
| 2954 | − flatted@3.4.4: {} | |
| 2955 | − | |
| 2956 | − for-each@0.3.5: | |
| 2957 | − dependencies: | |
| 2958 | − is-callable: 1.2.7 | |
| 2959 | − | |
| 2960 | − fraction.js@5.3.4: {} | |
| 2961 | − | |
| 2962 | − fs.realpath@1.0.0: {} | |
| 2963 | − | |
| 2964 | − fsevents@2.3.3: | |
| 2965 | − optional: true | |
| 2966 | − | |
| 2967 | − function-bind@1.1.2: {} | |
| 2968 | − | |
| 2969 | − function.prototype.name@1.2.0: | |
| 2970 | − dependencies: | |
| 2971 | − call-bind: 1.0.9 | |
| 2972 | − call-bound: 1.0.4 | |
| 2973 | − es-define-property: 1.0.1 | |
| 2974 | − es-errors: 1.3.0 | |
| 2975 | − functions-have-names: 1.2.3 | |
| 2976 | − has-property-descriptors: 1.0.2 | |
| 2977 | − hasown: 2.0.4 | |
| 2978 | − is-callable: 1.2.7 | |
| 2979 | − is-document.all: 1.0.0 | |
| 2980 | − | |
| 2981 | − functions-have-names@1.2.3: {} | |
| 2982 | − | |
| 2983 | − generator-function@2.0.1: {} | |
| 2984 | − | |
| 2985 | − get-intrinsic@1.3.0: | |
| 2986 | − dependencies: | |
| 2987 | − call-bind-apply-helpers: 1.0.2 | |
| 2988 | − es-define-property: 1.0.1 | |
| 2989 | − es-errors: 1.3.0 | |
| 2990 | − es-object-atoms: 1.1.2 | |
| 2991 | − function-bind: 1.1.2 | |
| 2992 | − get-proto: 1.0.1 | |
| 2993 | − gopd: 1.2.0 | |
| 2994 | − has-symbols: 1.1.0 | |
| 2995 | − hasown: 2.0.4 | |
| 2996 | − math-intrinsics: 1.1.0 | |
| 2997 | − | |
| 2998 | − get-proto@1.0.1: | |
| 2999 | − dependencies: | |
| 3000 | − dunder-proto: 1.0.1 | |
| 3001 | − es-object-atoms: 1.1.2 | |
| 3002 | − | |
| 3003 | − get-symbol-description@1.1.0: | |
| 3004 | − dependencies: | |
| 3005 | − call-bound: 1.0.4 | |
| 3006 | − es-errors: 1.3.0 | |
| 3007 | − get-intrinsic: 1.3.0 | |
| 3008 | − | |
| 3009 | − get-tsconfig@4.14.2: | |
| 3010 | − dependencies: | |
| 3011 | − resolve-pkg-maps: 1.0.0 | |
| 3012 | − | |
| 3013 | − glob-parent@5.1.2: | |
| 3014 | − dependencies: | |
| 3015 | − is-glob: 4.0.3 | |
| 3016 | − | |
| 3017 | − glob-parent@6.0.2: | |
| 3018 | − dependencies: | |
| 3019 | − is-glob: 4.0.3 | |
| 3020 | − | |
| 3021 | − glob@7.2.3: | |
| 3022 | − dependencies: | |
| 3023 | − fs.realpath: 1.0.0 | |
| 3024 | − inflight: 1.0.6 | |
| 3025 | − inherits: 2.0.4 | |
| 3026 | − minimatch: 3.1.5 | |
| 3027 | − once: 1.4.0 | |
| 3028 | − path-is-absolute: 1.0.1 | |
| 3029 | − | |
| 3030 | − globals@13.24.0: | |
| 3031 | − dependencies: | |
| 3032 | − type-fest: 0.20.2 | |
| 3033 | − | |
| 3034 | − globalthis@1.0.4: | |
| 3035 | − dependencies: | |
| 3036 | − define-properties: 1.2.1 | |
| 3037 | − gopd: 1.2.0 | |
| 3038 | − | |
| 3039 | − gopd@1.2.0: {} | |
| 3040 | − | |
| 3041 | − graphemer@1.4.0: {} | |
| 3042 | − | |
| 3043 | − has-bigints@1.1.0: {} | |
| 3044 | − | |
| 3045 | − has-flag@4.0.0: {} | |
| 3046 | − | |
| 3047 | − has-property-descriptors@1.0.2: | |
| 3048 | − dependencies: | |
| 3049 | − es-define-property: 1.0.1 | |
| 3050 | − | |
| 3051 | − has-proto@1.2.0: | |
| 3052 | − dependencies: | |
| 3053 | − dunder-proto: 1.0.1 | |
| 3054 | − | |
| 3055 | − has-symbols@1.1.0: {} | |
| 3056 | − | |
| 3057 | − has-tostringtag@1.0.2: | |
| 3058 | − dependencies: | |
| 3059 | − has-symbols: 1.1.0 | |
| 3060 | − | |
| 3061 | − hasown@2.0.4: | |
| 3062 | − dependencies: | |
| 3063 | − function-bind: 1.1.2 | |
| 3064 | − | |
| 3065 | − ignore@5.3.2: {} | |
| 3066 | − | |
| 3067 | − ignore@7.0.6: {} | |
| 3068 | − | |
| 3069 | − import-fresh@3.3.1: | |
| 3070 | − dependencies: | |
| 3071 | − parent-module: 1.0.1 | |
| 3072 | − resolve-from: 4.0.0 | |
| 3073 | − | |
| 3074 | − imurmurhash@0.1.4: {} | |
| 3075 | − | |
| 3076 | − inflight@1.0.6: | |
| 3077 | − dependencies: | |
| 3078 | − once: 1.4.0 | |
| 3079 | − wrappy: 1.0.2 | |
| 3080 | − | |
| 3081 | − inherits@2.0.4: {} | |
| 3082 | − | |
| 3083 | − internal-slot@1.1.0: | |
| 3084 | − dependencies: | |
| 3085 | − es-errors: 1.3.0 | |
| 3086 | − hasown: 2.0.4 | |
| 3087 | − side-channel: 1.1.1 | |
| 3088 | − | |
| 3089 | − is-array-buffer@3.0.5: | |
| 3090 | − dependencies: | |
| 3091 | − call-bind: 1.0.9 | |
| 3092 | − call-bound: 1.0.4 | |
| 3093 | − get-intrinsic: 1.3.0 | |
| 3094 | − | |
| 3095 | − is-async-function@2.1.1: | |
| 3096 | − dependencies: | |
| 3097 | − async-function: 1.0.0 | |
| 3098 | − call-bound: 1.0.4 | |
| 3099 | − get-proto: 1.0.1 | |
| 3100 | − has-tostringtag: 1.0.2 | |
| 3101 | − safe-regex-test: 1.1.0 | |
| 3102 | − | |
| 3103 | − is-bigint@1.1.0: | |
| 3104 | − dependencies: | |
| 3105 | − has-bigints: 1.1.0 | |
| 3106 | − | |
| 3107 | − is-binary-path@2.1.0: | |
| 3108 | − dependencies: | |
| 3109 | − binary-extensions: 2.3.0 | |
| 3110 | − | |
| 3111 | − is-boolean-object@1.2.2: | |
| 3112 | − dependencies: | |
| 3113 | − call-bound: 1.0.4 | |
| 3114 | − has-tostringtag: 1.0.2 | |
| 3115 | − | |
| 3116 | − is-bun-module@2.0.0: | |
| 3117 | − dependencies: | |
| 3118 | − semver: 7.8.5 | |
| 3119 | − | |
| 3120 | − is-callable@1.2.7: {} | |
| 3121 | − | |
| 3122 | − is-core-module@2.16.2: | |
| 3123 | − dependencies: | |
| 3124 | − hasown: 2.0.4 | |
| 3125 | − | |
| 3126 | − is-data-view@1.0.2: | |
| 3127 | − dependencies: | |
| 3128 | − call-bound: 1.0.4 | |
| 3129 | − get-intrinsic: 1.3.0 | |
| 3130 | − is-typed-array: 1.1.15 | |
| 3131 | − | |
| 3132 | − is-date-object@1.1.0: | |
| 3133 | − dependencies: | |
| 3134 | − call-bound: 1.0.4 | |
| 3135 | − has-tostringtag: 1.0.2 | |
| 3136 | − | |
| 3137 | − is-document.all@1.0.0: | |
| 3138 | − dependencies: | |
| 3139 | − call-bound: 1.0.4 | |
| 3140 | − | |
| 3141 | − is-extglob@2.1.1: {} | |
| 3142 | − | |
| 3143 | − is-finalizationregistry@1.1.1: | |
| 3144 | − dependencies: | |
| 3145 | − call-bound: 1.0.4 | |
| 3146 | − | |
| 3147 | − is-generator-function@1.1.2: | |
| 3148 | − dependencies: | |
| 3149 | − call-bound: 1.0.4 | |
| 3150 | − generator-function: 2.0.1 | |
| 3151 | − get-proto: 1.0.1 | |
| 3152 | − has-tostringtag: 1.0.2 | |
| 3153 | − safe-regex-test: 1.1.0 | |
| 3154 | − | |
| 3155 | − is-glob@4.0.3: | |
| 3156 | − dependencies: | |
| 3157 | − is-extglob: 2.1.1 | |
| 3158 | − | |
| 3159 | − is-map@2.0.3: {} | |
| 3160 | − | |
| 3161 | − is-negative-zero@2.0.3: {} | |
| 3162 | − | |
| 3163 | − is-number-object@1.1.1: | |
| 3164 | − dependencies: | |
| 3165 | − call-bound: 1.0.4 | |
| 3166 | − has-tostringtag: 1.0.2 | |
| 3167 | − | |
| 3168 | − is-number@7.0.0: {} | |
| 3169 | − | |
| 3170 | − is-path-inside@3.0.3: {} | |
| 3171 | − | |
| 3172 | − is-regex@1.2.1: | |
| 3173 | − dependencies: | |
| 3174 | − call-bound: 1.0.4 | |
| 3175 | − gopd: 1.2.0 | |
| 3176 | − has-tostringtag: 1.0.2 | |
| 3177 | − hasown: 2.0.4 | |
| 3178 | − | |
| 3179 | − is-set@2.0.3: {} | |
| 3180 | − | |
| 3181 | − is-shared-array-buffer@1.0.4: | |
| 3182 | − dependencies: | |
| 3183 | − call-bound: 1.0.4 | |
| 3184 | − | |
| 3185 | − is-string@1.1.1: | |
| 3186 | − dependencies: | |
| 3187 | − call-bound: 1.0.4 | |
| 3188 | − has-tostringtag: 1.0.2 | |
| 3189 | − | |
| 3190 | − is-symbol@1.1.1: | |
| 3191 | − dependencies: | |
| 3192 | − call-bound: 1.0.4 | |
| 3193 | − has-symbols: 1.1.0 | |
| 3194 | − safe-regex-test: 1.1.0 | |
| 3195 | − | |
| 3196 | − is-typed-array@1.1.15: | |
| 3197 | − dependencies: | |
| 3198 | − which-typed-array: 1.1.22 | |
| 3199 | − | |
| 3200 | − is-weakmap@2.0.2: {} | |
| 3201 | − | |
| 3202 | − is-weakref@1.1.1: | |
| 3203 | − dependencies: | |
| 3204 | − call-bound: 1.0.4 | |
| 3205 | − | |
| 3206 | − is-weakset@2.0.4: | |
| 3207 | − dependencies: | |
| 3208 | − call-bound: 1.0.4 | |
| 3209 | − get-intrinsic: 1.3.0 | |
| 3210 | − | |
| 3211 | − isarray@2.0.5: {} | |
| 3212 | − | |
| 3213 | − isexe@2.0.0: {} | |
| 3214 | − | |
| 3215 | − iterator.prototype@1.1.5: | |
| 3216 | − dependencies: | |
| 3217 | − define-data-property: 1.1.4 | |
| 3218 | − es-object-atoms: 1.1.2 | |
| 3219 | − get-intrinsic: 1.3.0 | |
| 3220 | − get-proto: 1.0.1 | |
| 3221 | − has-symbols: 1.1.0 | |
| 3222 | − set-function-name: 2.0.2 | |
| 3223 | − | |
| 3224 | − jiti@1.21.7: {} | |
| 3225 | − | |
| 3226 | − js-tokens@4.0.0: {} | |
| 3227 | − | |
| 3228 | − js-yaml@4.3.1: | |
| 3229 | − dependencies: | |
| 3230 | − argparse: 2.0.1 | |
| 3231 | − | |
| 3232 | − json-buffer@3.0.1: {} | |
| 3233 | − | |
| 3234 | − json-schema-traverse@0.4.1: {} | |
| 3235 | − | |
| 3236 | − json-stable-stringify-without-jsonify@1.0.1: {} | |
| 3237 | − | |
| 3238 | − json5@1.0.2: | |
| 3239 | − dependencies: | |
| 3240 | − minimist: 1.2.8 | |
| 3241 | − | |
| 3242 | − jsx-ast-utils@3.3.5: | |
| 3243 | − dependencies: | |
| 3244 | − array-includes: 3.1.9 | |
| 3245 | − array.prototype.flat: 1.3.3 | |
| 3246 | − object.assign: 4.1.7 | |
| 3247 | − object.values: 1.2.1 | |
| 3248 | − | |
| 3249 | − keyv@4.5.4: | |
| 3250 | − dependencies: | |
| 3251 | − json-buffer: 3.0.1 | |
| 3252 | − | |
| 3253 | − language-subtag-registry@0.3.23: {} | |
| 3254 | − | |
| 3255 | − language-tags@1.0.9: | |
| 3256 | − dependencies: | |
| 3257 | − language-subtag-registry: 0.3.23 | |
| 3258 | − | |
| 3259 | − levn@0.4.1: | |
| 3260 | − dependencies: | |
| 3261 | − prelude-ls: 1.2.1 | |
| 3262 | − type-check: 0.4.0 | |
| 3263 | − | |
| 3264 | − lilconfig@3.1.3: {} | |
| 3265 | − | |
| 3266 | − lines-and-columns@1.2.4: {} | |
| 3267 | − | |
| 3268 | − locate-path@6.0.0: | |
| 3269 | − dependencies: | |
| 3270 | − p-locate: 5.0.0 | |
| 3271 | − | |
| 3272 | − lodash.merge@4.6.2: {} | |
| 3273 | − | |
| 3274 | − loose-envify@1.4.0: | |
| 3275 | − dependencies: | |
| 3276 | − js-tokens: 4.0.0 | |
| 3277 | − | |
| 3278 | − math-intrinsics@1.1.0: {} | |
| 3279 | − | |
| 3280 | − merge2@1.4.1: {} | |
| 3281 | − | |
| 3282 | − micromatch@4.0.8: | |
| 3283 | − dependencies: | |
| 3284 | − braces: 3.0.3 | |
| 3285 | − picomatch: 2.3.2 | |
| 3286 | − | |
| 3287 | − minimatch@10.2.6: | |
| 3288 | − dependencies: | |
| 3289 | − brace-expansion: 5.0.9 | |
| 3290 | − | |
| 3291 | − minimatch@3.1.5: | |
| 3292 | − dependencies: | |
| 3293 | − brace-expansion: 1.1.18 | |
| 3294 | − | |
| 3295 | − minimist@1.2.8: {} | |
| 3296 | − | |
| 3297 | − ms@2.1.3: {} | |
| 3298 | − | |
| 3299 | − mz@2.7.0: | |
| 3300 | − dependencies: | |
| 3301 | − any-promise: 1.3.0 | |
| 3302 | − object-assign: 4.1.1 | |
| 3303 | − thenify-all: 1.6.0 | |
| 3304 | − | |
| 3305 | − nanoid@3.3.18: {} | |
| 3306 | − | |
| 3307 | − napi-postinstall@0.3.4: {} | |
| 3308 | − | |
| 3309 | − natural-compare@1.4.0: {} | |
| 3310 | − | |
| 3311 | − next@15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8): | |
| 3312 | − dependencies: | |
| 3313 | − '@next/env': 15.5.23 | |
| 3314 | − '@swc/helpers': 0.5.15 | |
| 3315 | − caniuse-lite: 1.0.30001809 | |
| 3316 | − postcss: 8.4.31 | |
| 3317 | − react: 19.2.8 | |
| 3318 | − react-dom: 19.2.8(react@19.2.8) | |
| 3319 | − styled-jsx: 5.1.6(react@19.2.8) | |
| 3320 | − optionalDependencies: | |
| 3321 | − '@next/swc-darwin-arm64': 15.5.23 | |
| 3322 | − '@next/swc-darwin-x64': 15.5.23 | |
| 3323 | − '@next/swc-linux-arm64-gnu': 15.5.23 | |
| 3324 | − '@next/swc-linux-arm64-musl': 15.5.23 | |
| 3325 | − '@next/swc-linux-x64-gnu': 15.5.23 | |
| 3326 | − '@next/swc-linux-x64-musl': 15.5.23 | |
| 3327 | − '@next/swc-win32-arm64-msvc': 15.5.23 | |
| 3328 | − '@next/swc-win32-x64-msvc': 15.5.23 | |
| 3329 | − sharp: 0.34.5 | |
| 3330 | − transitivePeerDependencies: | |
| 3331 | − - '@babel/core' | |
| 3332 | − - babel-plugin-macros | |
| 3333 | − | |
| 3334 | − node-exports-info@1.6.2: | |
| 3335 | − dependencies: | |
| 3336 | − array.prototype.flatmap: 1.3.3 | |
| 3337 | − es-errors: 1.3.0 | |
| 3338 | − object.entries: 1.1.9 | |
| 3339 | − semver: 6.3.1 | |
| 3340 | − | |
| 3341 | − node-releases@2.0.53: {} | |
| 3342 | − | |
| 3343 | − normalize-path@3.0.0: {} | |
| 3344 | − | |
| 3345 | − object-assign@4.1.1: {} | |
| 3346 | − | |
| 3347 | − object-hash@3.0.0: {} | |
| 3348 | − | |
| 3349 | − object-inspect@1.13.4: {} | |
| 3350 | − | |
| 3351 | − object-keys@1.1.1: {} | |
| 3352 | − | |
| 3353 | − object.assign@4.1.7: | |
| 3354 | − dependencies: | |
| 3355 | − call-bind: 1.0.9 | |
| 3356 | − call-bound: 1.0.4 | |
| 3357 | − define-properties: 1.2.1 | |
| 3358 | − es-object-atoms: 1.1.2 | |
| 3359 | − has-symbols: 1.1.0 | |
| 3360 | − object-keys: 1.1.1 | |
| 3361 | − | |
| 3362 | − object.entries@1.1.9: | |
| 3363 | − dependencies: | |
| 3364 | − call-bind: 1.0.9 | |
| 3365 | − call-bound: 1.0.4 | |
| 3366 | − define-properties: 1.2.1 | |
| 3367 | − es-object-atoms: 1.1.2 | |
| 3368 | − | |
| 3369 | − object.fromentries@2.0.8: | |
| 3370 | − dependencies: | |
| 3371 | − call-bind: 1.0.9 | |
| 3372 | − define-properties: 1.2.1 | |
| 3373 | − es-abstract: 1.24.2 | |
| 3374 | − es-object-atoms: 1.1.2 | |
| 3375 | − | |
| 3376 | − object.groupby@1.0.3: | |
| 3377 | − dependencies: | |
| 3378 | − call-bind: 1.0.9 | |
| 3379 | − define-properties: 1.2.1 | |
| 3380 | − es-abstract: 1.24.2 | |
| 3381 | − | |
| 3382 | − object.values@1.2.1: | |
| 3383 | − dependencies: | |
| 3384 | − call-bind: 1.0.9 | |
| 3385 | − call-bound: 1.0.4 | |
| 3386 | − define-properties: 1.2.1 | |
| 3387 | − es-object-atoms: 1.1.2 | |
| 3388 | − | |
| 3389 | − once@1.4.0: | |
| 3390 | − dependencies: | |
| 3391 | − wrappy: 1.0.2 | |
| 3392 | − | |
| 3393 | − optionator@0.9.4: | |
| 3394 | − dependencies: | |
| 3395 | − deep-is: 0.1.4 | |
| 3396 | − fast-levenshtein: 2.0.6 | |
| 3397 | − levn: 0.4.1 | |
| 3398 | − prelude-ls: 1.2.1 | |
| 3399 | − type-check: 0.4.0 | |
| 3400 | − word-wrap: 1.2.5 | |
| 3401 | − | |
| 3402 | − own-keys@1.0.2: | |
| 3403 | − dependencies: | |
| 3404 | − call-bound: 1.0.4 | |
| 3405 | − get-intrinsic: 1.3.0 | |
| 3406 | − object-keys: 1.1.1 | |
| 3407 | − safe-push-apply: 1.0.0 | |
| 3408 | − | |
| 3409 | − p-limit@3.1.0: | |
| 3410 | − dependencies: | |
| 3411 | − yocto-queue: 0.1.0 | |
| 3412 | − | |
| 3413 | − p-locate@5.0.0: | |
| 3414 | − dependencies: | |
| 3415 | − p-limit: 3.1.0 | |
| 3416 | − | |
| 3417 | − parent-module@1.0.1: | |
| 3418 | − dependencies: | |
| 3419 | − callsites: 3.1.0 | |
| 3420 | − | |
| 3421 | − path-exists@4.0.0: {} | |
| 3422 | − | |
| 3423 | − path-is-absolute@1.0.1: {} | |
| 3424 | − | |
| 3425 | − path-key@3.1.1: {} | |
| 3426 | − | |
| 3427 | − path-parse@1.0.7: {} | |
| 3428 | − | |
| 3429 | − picocolors@1.1.1: {} | |
| 3430 | − | |
| 3431 | − picomatch@2.3.2: {} | |
| 3432 | − | |
| 3433 | − picomatch@4.0.5: {} | |
| 3434 | − | |
| 3435 | − pify@2.3.0: {} | |
| 3436 | − | |
| 3437 | − pirates@4.0.7: {} | |
| 3438 | − | |
| 3439 | − possible-typed-array-names@1.1.0: {} | |
| 3440 | − | |
| 3441 | − postcss-import@15.1.0(postcss@8.5.26): | |
| 3442 | − dependencies: | |
| 3443 | − postcss: 8.5.26 | |
| 3444 | − postcss-value-parser: 4.2.0 | |
| 3445 | − read-cache: 1.0.0 | |
| 3446 | − resolve: 1.22.12 | |
| 3447 | − | |
| 3448 | − postcss-js@4.1.0(postcss@8.5.26): | |
| 3449 | − dependencies: | |
| 3450 | − camelcase-css: 2.0.1 | |
| 3451 | − postcss: 8.5.26 | |
| 3452 | − | |
| 3453 | − postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26): | |
| 3454 | − dependencies: | |
| 3455 | − lilconfig: 3.1.3 | |
| 3456 | − optionalDependencies: | |
| 3457 | − jiti: 1.21.7 | |
| 3458 | − postcss: 8.5.26 | |
| 3459 | − | |
| 3460 | − postcss-nested@6.2.0(postcss@8.5.26): | |
| 3461 | − dependencies: | |
| 3462 | − postcss: 8.5.26 | |
| 3463 | − postcss-selector-parser: 6.1.4 | |
| 3464 | − | |
| 3465 | − postcss-selector-parser@6.1.4: | |
| 3466 | − dependencies: | |
| 3467 | − cssesc: 3.0.0 | |
| 3468 | − util-deprecate: 1.0.2 | |
| 3469 | − | |
| 3470 | − postcss-value-parser@4.2.0: {} | |
| 3471 | − | |
| 3472 | − postcss@8.4.31: | |
| 3473 | − dependencies: | |
| 3474 | − nanoid: 3.3.18 | |
| 3475 | − picocolors: 1.1.1 | |
| 3476 | − source-map-js: 1.2.1 | |
| 3477 | − | |
| 3478 | − postcss@8.5.26: | |
| 3479 | − dependencies: | |
| 3480 | − nanoid: 3.3.18 | |
| 3481 | − picocolors: 1.1.1 | |
| 3482 | − source-map-js: 1.2.1 | |
| 3483 | − | |
| 3484 | − prelude-ls@1.2.1: {} | |
| 3485 | − | |
| 3486 | − prop-types@15.8.1: | |
| 3487 | − dependencies: | |
| 3488 | − loose-envify: 1.4.0 | |
| 3489 | − object-assign: 4.1.1 | |
| 3490 | − react-is: 16.13.1 | |
| 3491 | − | |
| 3492 | − punycode@2.3.1: {} | |
| 3493 | − | |
| 3494 | − queue-microtask@1.2.3: {} | |
| 3495 | − | |
| 3496 | − react-dom@19.2.8(react@19.2.8): | |
| 3497 | − dependencies: | |
| 3498 | − react: 19.2.8 | |
| 3499 | − scheduler: 0.27.0 | |
| 3500 | − | |
| 3501 | − react-is@16.13.1: {} | |
| 3502 | − | |
| 3503 | − react@19.2.8: {} | |
| 3504 | − | |
| 3505 | − read-cache@1.0.0: | |
| 3506 | − dependencies: | |
| 3507 | − pify: 2.3.0 | |
| 3508 | − | |
| 3509 | − readdirp@3.6.0: | |
| 3510 | − dependencies: | |
| 3511 | − picomatch: 2.3.2 | |
| 3512 | − | |
| 3513 | − reflect.getprototypeof@1.0.10: | |
| 3514 | − dependencies: | |
| 3515 | − call-bind: 1.0.9 | |
| 3516 | − define-properties: 1.2.1 | |
| 3517 | − es-abstract: 1.24.2 | |
| 3518 | − es-errors: 1.3.0 | |
| 3519 | − es-object-atoms: 1.1.2 | |
| 3520 | − get-intrinsic: 1.3.0 | |
| 3521 | − get-proto: 1.0.1 | |
| 3522 | − which-builtin-type: 1.2.1 | |
| 3523 | − | |
| 3524 | − regexp.prototype.flags@1.5.4: | |
| 3525 | − dependencies: | |
| 3526 | − call-bind: 1.0.9 | |
| 3527 | − define-properties: 1.2.1 | |
| 3528 | − es-errors: 1.3.0 | |
| 3529 | − get-proto: 1.0.1 | |
| 3530 | − gopd: 1.2.0 | |
| 3531 | − set-function-name: 2.0.2 | |
| 3532 | − | |
| 3533 | − resolve-from@4.0.0: {} | |
| 3534 | − | |
| 3535 | − resolve-pkg-maps@1.0.0: {} | |
| 3536 | − | |
| 3537 | − resolve@1.22.12: | |
| 3538 | − dependencies: | |
| 3539 | − es-errors: 1.3.0 | |
| 3540 | − is-core-module: 2.16.2 | |
| 3541 | − path-parse: 1.0.7 | |
| 3542 | − supports-preserve-symlinks-flag: 1.0.0 | |
| 3543 | − | |
| 3544 | − resolve@2.0.0-next.7: | |
| 3545 | − dependencies: | |
| 3546 | − es-errors: 1.3.0 | |
| 3547 | − is-core-module: 2.16.2 | |
| 3548 | − node-exports-info: 1.6.2 | |
| 3549 | − object-keys: 1.1.1 | |
| 3550 | − path-parse: 1.0.7 | |
| 3551 | − supports-preserve-symlinks-flag: 1.0.0 | |
| 3552 | − | |
| 3553 | − reusify@1.1.0: {} | |
| 3554 | − | |
| 3555 | − rimraf@3.0.2: | |
| 3556 | − dependencies: | |
| 3557 | − glob: 7.2.3 | |
| 3558 | − | |
| 3559 | − run-parallel@1.2.0: | |
| 3560 | − dependencies: | |
| 3561 | − queue-microtask: 1.2.3 | |
| 3562 | − | |
| 3563 | − safe-array-concat@1.1.4: | |
| 3564 | − dependencies: | |
| 3565 | − call-bind: 1.0.9 | |
| 3566 | − call-bound: 1.0.4 | |
| 3567 | − get-intrinsic: 1.3.0 | |
| 3568 | − has-symbols: 1.1.0 | |
| 3569 | − isarray: 2.0.5 | |
| 3570 | − | |
| 3571 | − safe-push-apply@1.0.0: | |
| 3572 | − dependencies: | |
| 3573 | − es-errors: 1.3.0 | |
| 3574 | − isarray: 2.0.5 | |
| 3575 | − | |
| 3576 | − safe-regex-test@1.1.0: | |
| 3577 | − dependencies: | |
| 3578 | − call-bound: 1.0.4 | |
| 3579 | − es-errors: 1.3.0 | |
| 3580 | − is-regex: 1.2.1 | |
| 3581 | − | |
| 3582 | − scheduler@0.27.0: {} | |
| 3583 | − | |
| 3584 | − semver@6.3.1: {} | |
| 3585 | − | |
| 3586 | − semver@7.8.5: {} | |
| 3587 | − | |
| 3588 | − set-function-length@1.2.2: | |
| 3589 | − dependencies: | |
| 3590 | − define-data-property: 1.1.4 | |
| 3591 | − es-errors: 1.3.0 | |
| 3592 | − function-bind: 1.1.2 | |
| 3593 | − get-intrinsic: 1.3.0 | |
| 3594 | − gopd: 1.2.0 | |
| 3595 | − has-property-descriptors: 1.0.2 | |
| 3596 | − | |
| 3597 | − set-function-name@2.0.2: | |
| 3598 | − dependencies: | |
| 3599 | − define-data-property: 1.1.4 | |
| 3600 | − es-errors: 1.3.0 | |
| 3601 | − functions-have-names: 1.2.3 | |
| 3602 | − has-property-descriptors: 1.0.2 | |
| 3603 | − | |
| 3604 | − set-proto@1.0.0: | |
| 3605 | − dependencies: | |
| 3606 | − dunder-proto: 1.0.1 | |
| 3607 | − es-errors: 1.3.0 | |
| 3608 | − es-object-atoms: 1.1.2 | |
| 3609 | − | |
| 3610 | − sharp@0.34.5: | |
| 3611 | − dependencies: | |
| 3612 | − '@img/colour': 1.1.0 | |
| 3613 | − detect-libc: 2.1.2 | |
| 3614 | − semver: 7.8.5 | |
| 3615 | − optionalDependencies: | |
| 3616 | − '@img/sharp-darwin-arm64': 0.34.5 | |
| 3617 | − '@img/sharp-darwin-x64': 0.34.5 | |
| 3618 | − '@img/sharp-libvips-darwin-arm64': 1.2.4 | |
| 3619 | − '@img/sharp-libvips-darwin-x64': 1.2.4 | |
| 3620 | − '@img/sharp-libvips-linux-arm': 1.2.4 | |
| 3621 | − '@img/sharp-libvips-linux-arm64': 1.2.4 | |
| 3622 | − '@img/sharp-libvips-linux-ppc64': 1.2.4 | |
| 3623 | − '@img/sharp-libvips-linux-riscv64': 1.2.4 | |
| 3624 | − '@img/sharp-libvips-linux-s390x': 1.2.4 | |
| 3625 | − '@img/sharp-libvips-linux-x64': 1.2.4 | |
| 3626 | − '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 | |
| 3627 | − '@img/sharp-libvips-linuxmusl-x64': 1.2.4 | |
| 3628 | − '@img/sharp-linux-arm': 0.34.5 | |
| 3629 | − '@img/sharp-linux-arm64': 0.34.5 | |
| 3630 | − '@img/sharp-linux-ppc64': 0.34.5 | |
| 3631 | − '@img/sharp-linux-riscv64': 0.34.5 | |
| 3632 | − '@img/sharp-linux-s390x': 0.34.5 | |
| 3633 | − '@img/sharp-linux-x64': 0.34.5 | |
| 3634 | − '@img/sharp-linuxmusl-arm64': 0.34.5 | |
| 3635 | − '@img/sharp-linuxmusl-x64': 0.34.5 | |
| 3636 | − '@img/sharp-wasm32': 0.34.5 | |
| 3637 | − '@img/sharp-win32-arm64': 0.34.5 | |
| 3638 | − '@img/sharp-win32-ia32': 0.34.5 | |
| 3639 | − '@img/sharp-win32-x64': 0.34.5 | |
| 3640 | − optional: true | |
| 3641 | − | |
| 3642 | − shebang-command@2.0.0: | |
| 3643 | − dependencies: | |
| 3644 | − shebang-regex: 3.0.0 | |
| 3645 | − | |
| 3646 | − shebang-regex@3.0.0: {} | |
| 3647 | − | |
| 3648 | − side-channel-list@1.0.1: | |
| 3649 | − dependencies: | |
| 3650 | − es-errors: 1.3.0 | |
| 3651 | − object-inspect: 1.13.4 | |
| 3652 | − | |
| 3653 | − side-channel-map@1.0.1: | |
| 3654 | − dependencies: | |
| 3655 | − call-bound: 1.0.4 | |
| 3656 | − es-errors: 1.3.0 | |
| 3657 | − get-intrinsic: 1.3.0 | |
| 3658 | − object-inspect: 1.13.4 | |
| 3659 | − | |
| 3660 | − side-channel-weakmap@1.0.2: | |
| 3661 | − dependencies: | |
| 3662 | − call-bound: 1.0.4 | |
| 3663 | − es-errors: 1.3.0 | |
| 3664 | − get-intrinsic: 1.3.0 | |
| 3665 | − object-inspect: 1.13.4 | |
| 3666 | − side-channel-map: 1.0.1 | |
| 3667 | − | |
| 3668 | − side-channel@1.1.1: | |
| 3669 | − dependencies: | |
| 3670 | − es-errors: 1.3.0 | |
| 3671 | − object-inspect: 1.13.4 | |
| 3672 | − side-channel-list: 1.0.1 | |
| 3673 | − side-channel-map: 1.0.1 | |
| 3674 | − side-channel-weakmap: 1.0.2 | |
| 3675 | − | |
| 3676 | − source-map-js@1.2.1: {} | |
| 3677 | − | |
| 3678 | − stable-hash@0.0.5: {} | |
| 3679 | − | |
| 3680 | − stop-iteration-iterator@1.1.0: | |
| 3681 | − dependencies: | |
| 3682 | − es-errors: 1.3.0 | |
| 3683 | − internal-slot: 1.1.0 | |
| 3684 | − | |
| 3685 | − string.prototype.includes@2.0.1: | |
| 3686 | − dependencies: | |
| 3687 | − call-bind: 1.0.9 | |
| 3688 | − define-properties: 1.2.1 | |
| 3689 | − es-abstract: 1.24.2 | |
| 3690 | − | |
| 3691 | − string.prototype.matchall@4.0.12: | |
| 3692 | − dependencies: | |
| 3693 | − call-bind: 1.0.9 | |
| 3694 | − call-bound: 1.0.4 | |
| 3695 | − define-properties: 1.2.1 | |
| 3696 | − es-abstract: 1.24.2 | |
| 3697 | − es-errors: 1.3.0 | |
| 3698 | − es-object-atoms: 1.1.2 | |
| 3699 | − get-intrinsic: 1.3.0 | |
| 3700 | − gopd: 1.2.0 | |
| 3701 | − has-symbols: 1.1.0 | |
| 3702 | − internal-slot: 1.1.0 | |
| 3703 | − regexp.prototype.flags: 1.5.4 | |
| 3704 | − set-function-name: 2.0.2 | |
| 3705 | − side-channel: 1.1.1 | |
| 3706 | − | |
| 3707 | − string.prototype.repeat@1.0.0: | |
| 3708 | − dependencies: | |
| 3709 | − define-properties: 1.2.1 | |
| 3710 | − es-abstract: 1.24.2 | |
| 3711 | − | |
| 3712 | − string.prototype.trim@1.2.11: | |
| 3713 | − dependencies: | |
| 3714 | − call-bind: 1.0.9 | |
| 3715 | − call-bound: 1.0.4 | |
| 3716 | − define-data-property: 1.1.4 | |
| 3717 | − define-properties: 1.2.1 | |
| 3718 | − es-abstract: 1.24.2 | |
| 3719 | − es-object-atoms: 1.1.2 | |
| 3720 | − has-property-descriptors: 1.0.2 | |
| 3721 | − safe-regex-test: 1.1.0 | |
| 3722 | − | |
| 3723 | − string.prototype.trimend@1.0.10: | |
| 3724 | − dependencies: | |
| 3725 | − call-bind: 1.0.9 | |
| 3726 | − call-bound: 1.0.4 | |
| 3727 | − define-properties: 1.2.1 | |
| 3728 | − es-object-atoms: 1.1.2 | |
| 3729 | − | |
| 3730 | − string.prototype.trimstart@1.0.8: | |
| 3731 | − dependencies: | |
| 3732 | − call-bind: 1.0.9 | |
| 3733 | − define-properties: 1.2.1 | |
| 3734 | − es-object-atoms: 1.1.2 | |
| 3735 | − | |
| 3736 | − strip-ansi@6.0.1: | |
| 3737 | − dependencies: | |
| 3738 | − ansi-regex: 5.0.1 | |
| 3739 | − | |
| 3740 | − strip-bom@3.0.0: {} | |
| 3741 | − | |
| 3742 | − strip-json-comments@3.1.1: {} | |
| 3743 | − | |
| 3744 | − styled-jsx@5.1.6(react@19.2.8): | |
| 3745 | − dependencies: | |
| 3746 | − client-only: 0.0.1 | |
| 3747 | − react: 19.2.8 | |
| 3748 | − | |
| 3749 | − sucrase@3.35.1: | |
| 3750 | − dependencies: | |
| 3751 | − '@jridgewell/gen-mapping': 0.3.13 | |
| 3752 | − commander: 4.1.1 | |
| 3753 | − lines-and-columns: 1.2.4 | |
| 3754 | − mz: 2.7.0 | |
| 3755 | − pirates: 4.0.7 | |
| 3756 | − tinyglobby: 0.2.17 | |
| 3757 | − ts-interface-checker: 0.1.13 | |
| 3758 | − | |
| 3759 | − supports-color@7.2.0: | |
| 3760 | − dependencies: | |
| 3761 | − has-flag: 4.0.0 | |
| 3762 | − | |
| 3763 | − supports-preserve-symlinks-flag@1.0.0: {} | |
| 3764 | − | |
| 3765 | − tailwind-merge@2.6.1: {} | |
| 3766 | − | |
| 3767 | − tailwindcss@3.4.19: | |
| 3768 | − dependencies: | |
| 3769 | − '@alloc/quick-lru': 5.2.0 | |
| 3770 | − arg: 5.0.2 | |
| 3771 | − chokidar: 3.6.0 | |
| 3772 | − didyoumean: 1.2.2 | |
| 3773 | − dlv: 1.1.3 | |
| 3774 | − fast-glob: 3.3.3 | |
| 3775 | − glob-parent: 6.0.2 | |
| 3776 | − is-glob: 4.0.3 | |
| 3777 | − jiti: 1.21.7 | |
| 3778 | − lilconfig: 3.1.3 | |
| 3779 | − micromatch: 4.0.8 | |
| 3780 | − normalize-path: 3.0.0 | |
| 3781 | − object-hash: 3.0.0 | |
| 3782 | − picocolors: 1.1.1 | |
| 3783 | − postcss: 8.5.26 | |
| 3784 | − postcss-import: 15.1.0(postcss@8.5.26) | |
| 3785 | − postcss-js: 4.1.0(postcss@8.5.26) | |
| 3786 | − postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26) | |
| 3787 | − postcss-nested: 6.2.0(postcss@8.5.26) | |
| 3788 | − postcss-selector-parser: 6.1.4 | |
| 3789 | − resolve: 1.22.12 | |
| 3790 | − sucrase: 3.35.1 | |
| 3791 | − transitivePeerDependencies: | |
| 3792 | − - tsx | |
| 3793 | − - yaml | |
| 3794 | − | |
| 3795 | − text-table@0.2.0: {} | |
| 3796 | − | |
| 3797 | − thenify-all@1.6.0: | |
| 3798 | − dependencies: | |
| 3799 | − thenify: 3.3.1 | |
| 3800 | − | |
| 3801 | − thenify@3.3.1: | |
| 3802 | − dependencies: | |
| 3803 | − any-promise: 1.3.0 | |
| 3804 | − | |
| 3805 | − tinyglobby@0.2.17: | |
| 3806 | − dependencies: | |
| 3807 | − fdir: 6.5.0(picomatch@4.0.5) | |
| 3808 | − picomatch: 4.0.5 | |
| 3809 | − | |
| 3810 | − to-regex-range@5.0.1: | |
| 3811 | − dependencies: | |
| 3812 | − is-number: 7.0.0 | |
| 3813 | − | |
| 3814 | − ts-api-utils@2.5.0(typescript@5.9.3): | |
| 3815 | − dependencies: | |
| 3816 | − typescript: 5.9.3 | |
| 3817 | − | |
| 3818 | − ts-interface-checker@0.1.13: {} | |
| 3819 | − | |
| 3820 | − tsconfig-paths@3.15.0: | |
| 3821 | − dependencies: | |
| 3822 | − '@types/json5': 0.0.29 | |
| 3823 | − json5: 1.0.2 | |
| 3824 | − minimist: 1.2.8 | |
| 3825 | − strip-bom: 3.0.0 | |
| 3826 | − | |
| 3827 | − tslib@2.8.1: {} | |
| 3828 | − | |
| 3829 | − type-check@0.4.0: | |
| 3830 | − dependencies: | |
| 3831 | − prelude-ls: 1.2.1 | |
| 3832 | − | |
| 3833 | − type-fest@0.20.2: {} | |
| 3834 | − | |
| 3835 | − typed-array-buffer@1.0.3: | |
| 3836 | − dependencies: | |
| 3837 | − call-bound: 1.0.4 | |
| 3838 | − es-errors: 1.3.0 | |
| 3839 | − is-typed-array: 1.1.15 | |
| 3840 | − | |
| 3841 | − typed-array-byte-length@1.0.3: | |
| 3842 | − dependencies: | |
| 3843 | − call-bind: 1.0.9 | |
| 3844 | − for-each: 0.3.5 | |
| 3845 | − gopd: 1.2.0 | |
| 3846 | − has-proto: 1.2.0 | |
| 3847 | − is-typed-array: 1.1.15 | |
| 3848 | − | |
| 3849 | − typed-array-byte-offset@1.0.4: | |
| 3850 | − dependencies: | |
| 3851 | − available-typed-arrays: 1.0.7 | |
| 3852 | − call-bind: 1.0.9 | |
| 3853 | − for-each: 0.3.5 | |
| 3854 | − gopd: 1.2.0 | |
| 3855 | − has-proto: 1.2.0 | |
| 3856 | − is-typed-array: 1.1.15 | |
| 3857 | − reflect.getprototypeof: 1.0.10 | |
| 3858 | − | |
| 3859 | − typed-array-length@1.0.8: | |
| 3860 | − dependencies: | |
| 3861 | − call-bind: 1.0.9 | |
| 3862 | − for-each: 0.3.5 | |
| 3863 | − gopd: 1.2.0 | |
| 3864 | − is-typed-array: 1.1.15 | |
| 3865 | − possible-typed-array-names: 1.1.0 | |
| 3866 | − reflect.getprototypeof: 1.0.10 | |
| 3867 | − | |
| 3868 | − typescript@5.9.3: {} | |
| 3869 | − | |
| 3870 | − unbox-primitive@1.1.0: | |
| 3871 | − dependencies: | |
| 3872 | − call-bound: 1.0.4 | |
| 3873 | − has-bigints: 1.1.0 | |
| 3874 | − has-symbols: 1.1.0 | |
| 3875 | − which-boxed-primitive: 1.1.1 | |
| 3876 | − | |
| 3877 | − undici-types@6.21.0: {} | |
| 3878 | − | |
| 3879 | − unrs-resolver@1.12.2: | |
| 3880 | − dependencies: | |
| 3881 | − napi-postinstall: 0.3.4 | |
| 3882 | − optionalDependencies: | |
| 3883 | − '@unrs/resolver-binding-android-arm-eabi': 1.12.2 | |
| 3884 | − '@unrs/resolver-binding-android-arm64': 1.12.2 | |
| 3885 | − '@unrs/resolver-binding-darwin-arm64': 1.12.2 | |
| 3886 | − '@unrs/resolver-binding-darwin-x64': 1.12.2 | |
| 3887 | − '@unrs/resolver-binding-freebsd-x64': 1.12.2 | |
| 3888 | − '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 | |
| 3889 | − '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 | |
| 3890 | − '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 | |
| 3891 | − '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 | |
| 3892 | − '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 | |
| 3893 | − '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 | |
| 3894 | − '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 | |
| 3895 | − '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 | |
| 3896 | − '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 | |
| 3897 | − '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 | |
| 3898 | − '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 | |
| 3899 | − '@unrs/resolver-binding-linux-x64-musl': 1.12.2 | |
| 3900 | − '@unrs/resolver-binding-openharmony-arm64': 1.12.2 | |
| 3901 | − '@unrs/resolver-binding-wasm32-wasi': 1.12.2 | |
| 3902 | − '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 | |
| 3903 | − '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 | |
| 3904 | − '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 | |
| 3905 | − | |
| 3906 | − update-browserslist-db@1.3.1(browserslist@4.28.8): | |
| 3907 | − dependencies: | |
| 3908 | − browserslist: 4.28.8 | |
| 3909 | − escalade: 3.2.0 | |
| 3910 | − picocolors: 1.1.1 | |
| 3911 | − | |
| 3912 | − uri-js@4.4.1: | |
| 3913 | − dependencies: | |
| 3914 | − punycode: 2.3.1 | |
| 3915 | − | |
| 3916 | − util-deprecate@1.0.2: {} | |
| 3917 | − | |
| 3918 | − which-boxed-primitive@1.1.1: | |
| 3919 | − dependencies: | |
| 3920 | − is-bigint: 1.1.0 | |
| 3921 | − is-boolean-object: 1.2.2 | |
| 3922 | − is-number-object: 1.1.1 | |
| 3923 | − is-string: 1.1.1 | |
| 3924 | − is-symbol: 1.1.1 | |
| 3925 | − | |
| 3926 | − which-builtin-type@1.2.1: | |
| 3927 | − dependencies: | |
| 3928 | − call-bound: 1.0.4 | |
| 3929 | − function.prototype.name: 1.2.0 | |
| 3930 | − has-tostringtag: 1.0.2 | |
| 3931 | − is-async-function: 2.1.1 | |
| 3932 | − is-date-object: 1.1.0 | |
| 3933 | − is-finalizationregistry: 1.1.1 | |
| 3934 | − is-generator-function: 1.1.2 | |
| 3935 | − is-regex: 1.2.1 | |
| 3936 | − is-weakref: 1.1.1 | |
| 3937 | − isarray: 2.0.5 | |
| 3938 | − which-boxed-primitive: 1.1.1 | |
| 3939 | − which-collection: 1.0.2 | |
| 3940 | − which-typed-array: 1.1.22 | |
| 3941 | − | |
| 3942 | − which-collection@1.0.2: | |
| 3943 | − dependencies: | |
| 3944 | − is-map: 2.0.3 | |
| 3945 | − is-set: 2.0.3 | |
| 3946 | − is-weakmap: 2.0.2 | |
| 3947 | − is-weakset: 2.0.4 | |
| 3948 | − | |
| 3949 | − which-typed-array@1.1.22: | |
| 3950 | − dependencies: | |
| 3951 | − available-typed-arrays: 1.0.7 | |
| 3952 | − call-bind: 1.0.9 | |
| 3953 | − call-bound: 1.0.4 | |
| 3954 | − for-each: 0.3.5 | |
| 3955 | − get-proto: 1.0.1 | |
| 3956 | − gopd: 1.2.0 | |
| 3957 | − has-tostringtag: 1.0.2 | |
| 3958 | − | |
| 3959 | − which@2.0.2: | |
| 3960 | − dependencies: | |
| 3961 | − isexe: 2.0.0 | |
| 3962 | − | |
| 3963 | − word-wrap@1.2.5: {} | |
| 3964 | − | |
| 3965 | − wrappy@1.0.2: {} | |
| 3966 | − | |
| 3967 | − yocto-queue@0.1.0: {} | |
deleted
M2M32crouve-ka/pnpm-workspace.yaml
+0 −8
@@ -1,8 +0,0 @@ | ||
| 1 | −# Trouve-KA — pnpm workspace | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | −packages: | |
| 5 | − - "apps/web" | |
| 6 | −onlyBuiltDependencies: | |
| 7 | − - sharp | |
| 8 | − - unrs-resolver | |
deleted
M2M32crouve-ka/pyproject.toml
+0 −85
@@ -1,85 +0,0 @@ | ||
| 1 | −# Trouve-KA — Python monorepo build configuration | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −[project] | |
| 6 | −name = "trouveka" | |
| 7 | −version = "0.1.0" | |
| 8 | −description = "Trouve-KA — moteur de recherche web indépendant, Québec-first" | |
| 9 | −requires-python = ">=3.12" | |
| 10 | −authors = [{ name = "Simon-Pierre Boucher", email = "contact@spboucher.ai" }] | |
| 11 | −dependencies = [ | |
| 12 | − "fastapi>=0.115", | |
| 13 | − "uvicorn[standard]>=0.30", | |
| 14 | − "httpx>=0.27", | |
| 15 | − "brotli>=1.1", | |
| 16 | − "selectolax>=0.3.21", | |
| 17 | − "protego>=0.3.1", | |
| 18 | − "asyncpg>=0.29", | |
| 19 | − "redis>=5.0", | |
| 20 | − "opensearch-py[async]>=2.6", | |
| 21 | − "pydantic>=2.7", | |
| 22 | − "pydantic-settings>=2.3", | |
| 23 | − "langdetect>=1.0.9", | |
| 24 | − "python-dateutil>=2.9", | |
| 25 | −] | |
| 26 | − | |
| 27 | −[project.optional-dependencies] | |
| 28 | −dev = [ | |
| 29 | − "pytest>=8", | |
| 30 | − "pytest-asyncio>=0.23", | |
| 31 | − "ruff>=0.5", | |
| 32 | −] | |
| 33 | − | |
| 34 | −[build-system] | |
| 35 | −requires = ["setuptools>=69"] | |
| 36 | −build-backend = "setuptools.build_meta" | |
| 37 | − | |
| 38 | −# The "trouveka" namespace package is assembled from the monorepo layout: | |
| 39 | −# packages/* hold shared libraries, services/* hold pipeline services, | |
| 40 | −# apps/api holds the FastAPI application (see CLAUDE.md §2). | |
| 41 | −[tool.setuptools] | |
| 42 | −packages = [ | |
| 43 | − "trouveka.config", | |
| 44 | − "trouveka.logging", | |
| 45 | − "trouveka.types", | |
| 46 | − "trouveka.shared", | |
| 47 | − "trouveka.database", | |
| 48 | − "trouveka.queue", | |
| 49 | − "trouveka.search_core", | |
| 50 | − "trouveka.crawler", | |
| 51 | − "trouveka.frontier", | |
| 52 | − "trouveka.parser", | |
| 53 | − "trouveka.classifier", | |
| 54 | − "trouveka.indexer", | |
| 55 | − "trouveka.ranking", | |
| 56 | − "trouveka.scheduler", | |
| 57 | − "trouveka.enrichment", | |
| 58 | − "trouveka.api", | |
| 59 | −] | |
| 60 | − | |
| 61 | −[tool.setuptools.package-dir] | |
| 62 | −"trouveka.config" = "packages/config" | |
| 63 | −"trouveka.logging" = "packages/logging" | |
| 64 | −"trouveka.types" = "packages/types" | |
| 65 | −"trouveka.shared" = "packages/shared" | |
| 66 | −"trouveka.database" = "packages/database" | |
| 67 | −"trouveka.queue" = "packages/queue" | |
| 68 | −"trouveka.search_core" = "packages/search-core" | |
| 69 | −"trouveka.crawler" = "services/crawler" | |
| 70 | −"trouveka.frontier" = "services/frontier" | |
| 71 | −"trouveka.parser" = "services/parser" | |
| 72 | −"trouveka.classifier" = "services/classifier" | |
| 73 | −"trouveka.indexer" = "services/indexer" | |
| 74 | −"trouveka.ranking" = "services/ranking" | |
| 75 | −"trouveka.scheduler" = "services/scheduler" | |
| 76 | −"trouveka.enrichment" = "services/enrichment" | |
| 77 | −"trouveka.api" = "apps/api" | |
| 78 | − | |
| 79 | −[tool.pytest.ini_options] | |
| 80 | −asyncio_mode = "auto" | |
| 81 | −testpaths = ["tests"] | |
| 82 | − | |
| 83 | −[tool.ruff] | |
| 84 | −line-length = 110 | |
| 85 | −target-version = "py312" | |
deleted
M2M32crouve-ka/scripts/bootstrap-seeds/seed.sh
+0 −14
@@ -1,14 +0,0 @@ | ||
| 1 | −#!/usr/bin/env bash | |
| 2 | −# Trouve-KA — chargement des seeds dans le frontier | |
| 3 | −# Author: Simon-Pierre Boucher | |
| 4 | −# Contact: contact@spboucher.ai | |
| 5 | −set -euo pipefail | |
| 6 | −cd "$(dirname "$0")/../.." | |
| 7 | − | |
| 8 | −if [ -x .venv/bin/python ]; then | |
| 9 | − PY=.venv/bin/python | |
| 10 | −else | |
| 11 | − PY=python3 | |
| 12 | −fi | |
| 13 | − | |
| 14 | −exec "$PY" -m trouveka.crawler.seed "${1:-scripts/bootstrap-seeds/seeds.txt}" | |
deleted
M2M32crouve-ka/scripts/bootstrap-seeds/seeds.txt
+0 −80
@@ -1,80 +0,0 @@ | ||
| 1 | −# Trouve-KA — seeds de démarrage (qualité > quantité, CLAUDE.md §8) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | −# Nœuds fortement connectés du web québécois. Une URL par ligne, # = commentaire. | |
| 5 | − | |
| 6 | −# --- Gouvernement du Québec --- | |
| 7 | −https://www.quebec.ca/ | |
| 8 | −https://www.assnat.qc.ca/ | |
| 9 | −https://www.revenuquebec.ca/ | |
| 10 | −https://www.ramq.gouv.qc.ca/ | |
| 11 | −https://saaq.gouv.qc.ca/ | |
| 12 | −https://www.cnesst.gouv.qc.ca/ | |
| 13 | −https://www.hydroquebec.com/ | |
| 14 | −https://www.investquebec.com/ | |
| 15 | −https://www.transitionenergetique.gouv.qc.ca/ | |
| 16 | − | |
| 17 | −# --- Municipalités --- | |
| 18 | −https://montreal.ca/ | |
| 19 | −https://www.ville.quebec.qc.ca/ | |
| 20 | −https://www.laval.ca/ | |
| 21 | −https://www.gatineau.ca/ | |
| 22 | −https://www.sherbrooke.ca/ | |
| 23 | −https://www.longueuil.quebec/ | |
| 24 | −https://www.trois-rivieres.ca/ | |
| 25 | −https://ville.saguenay.ca/ | |
| 26 | −https://www.levis.ca/ | |
| 27 | −https://www.terrebonne.ca/ | |
| 28 | −https://www.drummondville.ca/ | |
| 29 | −https://www.rimouski.ca/ | |
| 30 | − | |
| 31 | −# --- Universités et cégeps --- | |
| 32 | −https://www.ulaval.ca/ | |
| 33 | −https://www.umontreal.ca/ | |
| 34 | −https://www.mcgill.ca/ | |
| 35 | −https://uqam.ca/ | |
| 36 | −https://www.usherbrooke.ca/ | |
| 37 | −https://www.concordia.ca/ | |
| 38 | −https://www.polymtl.ca/ | |
| 39 | −https://www.etsmtl.ca/ | |
| 40 | −https://www.hec.ca/ | |
| 41 | −https://www.uqac.ca/ | |
| 42 | −https://www.uqtr.ca/ | |
| 43 | −https://www.uqar.ca/ | |
| 44 | −https://uqo.ca/ | |
| 45 | −https://www.inrs.ca/ | |
| 46 | −https://www.cegepsquebec.ca/ | |
| 47 | − | |
| 48 | −# --- Médias --- | |
| 49 | −https://www.lapresse.ca/ | |
| 50 | −https://www.ledevoir.com/ | |
| 51 | −https://www.journaldemontreal.com/ | |
| 52 | −https://www.journaldequebec.com/ | |
| 53 | −https://ici.radio-canada.ca/ | |
| 54 | −https://www.tvanouvelles.ca/ | |
| 55 | −https://www.lesoleil.com/ | |
| 56 | −https://www.ledroit.com/ | |
| 57 | −https://www.latribune.ca/ | |
| 58 | −https://www.lenouvelliste.ca/ | |
| 59 | −https://www.noovo.info/ | |
| 60 | −https://www.lactualite.com/ | |
| 61 | − | |
| 62 | −# --- Affaires, annuaires, associations --- | |
| 63 | −https://www.registreentreprises.gouv.qc.ca/ | |
| 64 | −https://www.desjardins.com/ | |
| 65 | −https://www.fccq.ca/ | |
| 66 | −https://www.ccmm.ca/ | |
| 67 | −https://www.pagesjaunes.ca/ | |
| 68 | −https://quebec.craigslist.org/ | |
| 69 | −https://www.cqcd.org/ | |
| 70 | −https://www.manufacturiersquebec.ca/ | |
| 71 | − | |
| 72 | −# --- Tourisme et culture --- | |
| 73 | −https://www.bonjourquebec.com/ | |
| 74 | −https://www.mtl.org/ | |
| 75 | −https://www.quebec-cite.com/ | |
| 76 | −https://www.tourisme-charlevoix.com/ | |
| 77 | −https://www.tourismegaspesie.com/ | |
| 78 | −https://www.sepaq.com/ | |
| 79 | −https://www.lavitrine.com/ | |
| 80 | −https://www.banq.qc.ca/ | |
deleted
M2M32crouve-ka/scripts/check-headers.py
+0 −38
@@ -1,38 +0,0 @@ | ||
| 1 | −#!/usr/bin/env python3 | |
| 2 | −# Trouve-KA — vérification CI des headers d'auteur (CLAUDE.md §0.1) | |
| 3 | −# Author: Simon-Pierre Boucher | |
| 4 | −# Contact: contact@spboucher.ai | |
| 5 | − | |
| 6 | −"""Vérifie que chaque fichier source porte le header auteur obligatoire.""" | |
| 7 | − | |
| 8 | −import pathlib | |
| 9 | −import sys | |
| 10 | − | |
| 11 | −ROOT = pathlib.Path(__file__).resolve().parents[1] | |
| 12 | −EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".mjs", ".cjs", ".sql", ".sh", ".css", ".yml", ".yaml", ".toml"} | |
| 13 | −SKIP_DIRS = {"node_modules", ".next", ".venv", "__pycache__", ".git", "dist", ".pytest_cache", ".ruff_cache"} | |
| 14 | −SKIP_FILES = {"pnpm-lock.yaml", "next-env.d.ts"} | |
| 15 | −REQUIRED = "Author: Simon-Pierre Boucher" | |
| 16 | − | |
| 17 | − | |
| 18 | −def main() -> int: | |
| 19 | − missing: list[pathlib.Path] = [] | |
| 20 | − for path in ROOT.rglob("*"): | |
| 21 | − if not path.is_file() or path.suffix not in EXTENSIONS or path.name in SKIP_FILES: | |
| 22 | − continue | |
| 23 | − if any(part in SKIP_DIRS for part in path.parts): | |
| 24 | − continue | |
| 25 | − head = path.read_text(encoding="utf-8", errors="replace")[:600] | |
| 26 | − if REQUIRED not in head: | |
| 27 | − missing.append(path.relative_to(ROOT)) | |
| 28 | − if missing: | |
| 29 | − print(f"✗ {len(missing)} fichier(s) sans header auteur :") | |
| 30 | − for p in missing: | |
| 31 | − print(f" - {p}") | |
| 32 | − return 1 | |
| 33 | − print("✓ Tous les fichiers source portent le header auteur.") | |
| 34 | − return 0 | |
| 35 | − | |
| 36 | − | |
| 37 | −if __name__ == "__main__": | |
| 38 | − sys.exit(main()) | |
deleted
M2M32crouve-ka/scripts/cleanup-garbage.py
+0 −63
@@ -1,63 +0,0 @@ | ||
| 1 | −#!/usr/bin/env python3 | |
| 2 | −# Trouve-KA — purge des documents mal décodés de l'index | |
| 3 | −# Author: Simon-Pierre Boucher | |
| 4 | −# Contact: contact@spboucher.ai | |
| 5 | − | |
| 6 | −"""Scanne l'index, supprime les documents au contenu charabia (binaire mal décodé) | |
| 7 | −et remet leurs URLs en recrawl (le fetcher corrigé les réindexera proprement). | |
| 8 | − | |
| 9 | −Usage : python -m scripts... non — exécuter depuis la racine : | |
| 10 | − .venv/bin/python scripts/cleanup-garbage.py # dev local | |
| 11 | − (sur m2m32 : docker compose run --rm --entrypoint python api /app/scripts/cleanup-garbage.py) | |
| 12 | −""" | |
| 13 | − | |
| 14 | −import asyncio | |
| 15 | − | |
| 16 | −from trouveka.config import get_settings | |
| 17 | −from trouveka.database import Database | |
| 18 | −from trouveka.parser import looks_like_garbage | |
| 19 | −from trouveka.search_core import SearchCore | |
| 20 | − | |
| 21 | − | |
| 22 | −async def main() -> None: | |
| 23 | − s = get_settings() | |
| 24 | − db = Database(s.database_url, pool_min=1, pool_max=3) | |
| 25 | − await db.connect() | |
| 26 | − search = SearchCore(s.search_url, s.search_index) | |
| 27 | − | |
| 28 | − removed = 0 | |
| 29 | − scanned = 0 | |
| 30 | − # Scroll de tout l'index par pages de 200 | |
| 31 | − body = {"query": {"match_all": {}}, "size": 200, "_source": ["url", "title", "description", "body"]} | |
| 32 | − resp = await search.client.search(index=s.search_index, body=body, scroll="2m") | |
| 33 | − scroll_id = resp.get("_scroll_id") | |
| 34 | − try: | |
| 35 | − while True: | |
| 36 | − hits = resp["hits"]["hits"] | |
| 37 | − if not hits: | |
| 38 | − break | |
| 39 | − for hit in hits: | |
| 40 | − scanned += 1 | |
| 41 | − src = hit["_source"] | |
| 42 | − text = " ".join([src.get("title") or "", src.get("description") or "", | |
| 43 | − (src.get("body") or "")[:20_000]]) | |
| 44 | − if looks_like_garbage(text, threshold=0.02): | |
| 45 | − await search.client.delete(index=s.search_index, id=hit["_id"], ignore=[404]) | |
| 46 | − await db.requeue_url(src["url"]) | |
| 47 | − await db.pool.execute( | |
| 48 | − "DELETE FROM documents WHERE url_id = (SELECT id FROM urls WHERE url = $1)", | |
| 49 | − src["url"], | |
| 50 | − ) | |
| 51 | − removed += 1 | |
| 52 | − print(f"purgé : {src['url']}") | |
| 53 | − resp = await search.client.scroll(scroll_id=scroll_id, scroll="2m") | |
| 54 | − finally: | |
| 55 | − if scroll_id: | |
| 56 | − await search.client.clear_scroll(scroll_id=scroll_id) | |
| 57 | − await search.close() | |
| 58 | − await db.close() | |
| 59 | − print(f"\n{scanned} documents scannés, {removed} purgés et remis en recrawl.") | |
| 60 | − | |
| 61 | − | |
| 62 | −if __name__ == "__main__": | |
| 63 | − asyncio.run(main()) | |
deleted
M2M32crouve-ka/scripts/eval/ranking-eval.yaml
+0 −29
@@ -1,29 +0,0 @@ | ||
| 1 | −# Trouve-KA — dataset d'évaluation du ranking (§16) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | −# Chaque cas : requête + domaines attendus dans le top-k. Mesurable, reproductible. | |
| 5 | −cases: | |
| 6 | − - query: "université québec" | |
| 7 | − expect_any_of: [uqam.ca, ulaval.ca, umontreal.ca, uqtr.ca, uqac.ca, uqar.ca, uquebec.ca, usherbrooke.ca] | |
| 8 | − top_k: 5 | |
| 9 | − - query: "plombier gatineau" | |
| 10 | − expect_any_of: [gatineau.ca, pagesjaunes.ca] | |
| 11 | − top_k: 10 | |
| 12 | − - query: "subvention thermopompe" | |
| 13 | − expect_any_of: [quebec.ca, hydroquebec.com, transitionenergetique.gouv.qc.ca] | |
| 14 | − top_k: 10 | |
| 15 | − - query: "heat pump rebate" | |
| 16 | − expect_any_of: [quebec.ca, hydroquebec.com, transitionenergetique.gouv.qc.ca] | |
| 17 | − top_k: 10 | |
| 18 | − - query: "impôt revenu" | |
| 19 | − expect_any_of: [revenuquebec.ca, quebec.ca] | |
| 20 | − top_k: 5 | |
| 21 | − - query: "tourisme gaspésie" | |
| 22 | − expect_any_of: [tourismegaspesie.com, bonjourquebec.com, quebecmaritime.ca, sepaq.com] | |
| 23 | − top_k: 5 | |
| 24 | − - query: "nouvelles montréal" | |
| 25 | − expect_any_of: [lapresse.ca, journaldemontreal.com, radio-canada.ca, ledevoir.com, tvanouvelles.ca, montreal.ca, mtl.org] | |
| 26 | − top_k: 10 | |
| 27 | − - query: "permis de conduire" | |
| 28 | − expect_any_of: [saaq.gouv.qc.ca, quebec.ca] | |
| 29 | − top_k: 5 | |
deleted
M2M32crouve-ka/scripts/eval/run-eval.py
+0 −65
@@ -1,65 +0,0 @@ | ||
| 1 | −#!/usr/bin/env python3 | |
| 2 | −# Trouve-KA — évaluation du ranking contre le dataset (§16) | |
| 3 | −# Author: Simon-Pierre Boucher | |
| 4 | −# Contact: contact@spboucher.ai | |
| 5 | − | |
| 6 | −"""Exécute le dataset d'évaluation contre l'API de recherche. | |
| 7 | − | |
| 8 | −Usage : python3 scripts/eval/run-eval.py [--api http://localhost:8080] | |
| 9 | −Un cas passe si au moins un domaine attendu apparaît dans le top-k. | |
| 10 | −L'index grandit en continu : les scores s'améliorent avec la couverture. | |
| 11 | −""" | |
| 12 | − | |
| 13 | −import argparse | |
| 14 | −import json | |
| 15 | −import pathlib | |
| 16 | −import re | |
| 17 | −import urllib.parse | |
| 18 | −import urllib.request | |
| 19 | − | |
| 20 | −HERE = pathlib.Path(__file__).parent | |
| 21 | − | |
| 22 | − | |
| 23 | −def load_cases() -> list[dict]: | |
| 24 | − """Parse minimaliste du YAML du dataset (pas de dépendance PyYAML).""" | |
| 25 | − cases, current = [], None | |
| 26 | − for line in (HERE / "ranking-eval.yaml").read_text(encoding="utf-8").splitlines(): | |
| 27 | − if m := re.match(r'\s*- query: "(.*)"', line): | |
| 28 | − current = {"query": m.group(1)} | |
| 29 | − cases.append(current) | |
| 30 | − elif m := re.match(r"\s*expect_any_of: \[(.*)\]", line): | |
| 31 | − current["expect_any_of"] = [d.strip() for d in m.group(1).split(",")] | |
| 32 | − elif m := re.match(r"\s*top_k: (\d+)", line): | |
| 33 | − current["top_k"] = int(m.group(1)) | |
| 34 | − return cases | |
| 35 | − | |
| 36 | − | |
| 37 | −def main() -> int: | |
| 38 | − ap = argparse.ArgumentParser() | |
| 39 | − ap.add_argument("--api", default="http://localhost:8080") | |
| 40 | − args = ap.parse_args() | |
| 41 | − | |
| 42 | − passed = 0 | |
| 43 | − cases = load_cases() | |
| 44 | − for case in cases: | |
| 45 | − q = urllib.parse.quote(case["query"]) | |
| 46 | − url = f"{args.api}/api/search?q={q}&limit={case['top_k']}" | |
| 47 | − try: | |
| 48 | − with urllib.request.urlopen(url, timeout=10) as resp: | |
| 49 | − data = json.load(resp) | |
| 50 | − except Exception as exc: | |
| 51 | − print(f"✗ {case['query']!r} — API injoignable : {exc}") | |
| 52 | − continue | |
| 53 | − domains = [r["domain"] for r in data["results"]] | |
| 54 | − hit = next((d for d in domains if any(d == e or d.endswith("." + e) for e in case["expect_any_of"])), None) | |
| 55 | − if hit: | |
| 56 | − passed += 1 | |
| 57 | − print(f"✓ {case['query']!r} → {hit} (rang {domains.index(hit) + 1}, {data['took_ms']} ms)") | |
| 58 | − else: | |
| 59 | − print(f"✗ {case['query']!r} → top-{case['top_k']}: {domains[:5]}") | |
| 60 | − print(f"\n{passed}/{len(cases)} cas réussis") | |
| 61 | − return 0 if passed == len(cases) else 1 | |
| 62 | − | |
| 63 | − | |
| 64 | −if __name__ == "__main__": | |
| 65 | − raise SystemExit(main()) | |
deleted
M2M32crouve-ka/scripts/health-check/check.sh
+0 −27
@@ -1,27 +0,0 @@ | ||
| 1 | −#!/usr/bin/env bash | |
| 2 | −# Trouve-KA — vérification de santé du stack | |
| 3 | −# Author: Simon-Pierre Boucher | |
| 4 | −# Contact: contact@spboucher.ai | |
| 5 | −set -uo pipefail | |
| 6 | − | |
| 7 | −API_URL="${API_URL:-http://localhost:8080}" | |
| 8 | −SEARCH_URL="${SEARCH_URL:-http://localhost:9200}" | |
| 9 | −WEB_URL="${WEB_URL:-http://localhost:3000}" | |
| 10 | −FAIL=0 | |
| 11 | − | |
| 12 | −check() { | |
| 13 | − local name="$1" url="$2" | |
| 14 | − if curl -sf --max-time 5 "$url" > /dev/null; then | |
| 15 | − echo "✓ $name ($url)" | |
| 16 | − else | |
| 17 | − echo "✗ $name ($url)" | |
| 18 | − FAIL=1 | |
| 19 | − fi | |
| 20 | −} | |
| 21 | − | |
| 22 | −check "API santé" "$API_URL/api/health" | |
| 23 | −check "API statut" "$API_URL/api/status" | |
| 24 | −check "OpenSearch" "$SEARCH_URL/_cluster/health" | |
| 25 | −check "Web" "$WEB_URL/" | |
| 26 | − | |
| 27 | −exit $FAIL | |
deleted
M2M32crouve-ka/scripts/start-crawler/start.sh
+0 −14
@@ -1,14 +0,0 @@ | ||
| 1 | −#!/usr/bin/env bash | |
| 2 | −# Trouve-KA — démarrage local d'un worker de crawl | |
| 3 | −# Author: Simon-Pierre Boucher | |
| 4 | −# Contact: contact@spboucher.ai | |
| 5 | −set -euo pipefail | |
| 6 | −cd "$(dirname "$0")/../.." | |
| 7 | − | |
| 8 | −if [ -x .venv/bin/python ]; then | |
| 9 | − PY=.venv/bin/python | |
| 10 | −else | |
| 11 | − PY=python3 | |
| 12 | −fi | |
| 13 | − | |
| 14 | −exec "$PY" -m trouveka.crawler.worker | |
deleted
M2M32crouve-ka/services/classifier/__init__.py
+0 −15
@@ -1,15 +0,0 @@ | ||
| 1 | −# Trouve-KA — classification Québec | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Détection de pertinence québécoise (CLAUDE.md §7). | |
| 6 | − | |
| 7 | −Deux scores distincts : page_quebec_score (calculé ici) et domain_quebec_score | |
| 8 | −(agrégé en base au fil des pages). Un article du NYT sur Montréal peut être | |
| 9 | −pertinent sans que le domaine le soit. | |
| 10 | −""" | |
| 11 | − | |
| 12 | −from .scorer import score_page | |
| 13 | −from .gazetteer import QUEBEC_TOPONYMS, QUEBEC_REGIONS | |
| 14 | − | |
| 15 | −__all__ = ["score_page", "QUEBEC_TOPONYMS", "QUEBEC_REGIONS"] | |
deleted
M2M32crouve-ka/services/classifier/gazetteer.py
+0 −75
@@ -1,75 +0,0 @@ | ||
| 1 | −# Trouve-KA — gazetteer québécois | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Toponymes, régions et signaux institutionnels du Québec. | |
| 6 | − | |
| 7 | −Liste de départ volontairement conservatrice : des noms non ambigus d'abord. | |
| 8 | −Les toponymes ambigus (Laval existe en France, Hull au Royaume-Uni…) portent | |
| 9 | −un poids réduit dans le scorer. | |
| 10 | −""" | |
| 11 | − | |
| 12 | −# Municipalités et lieux à fort signal (poids plein) | |
| 13 | −QUEBEC_TOPONYMS: set[str] = { | |
| 14 | − "québec", "quebec", "montréal", "montreal", "gatineau", "sherbrooke", | |
| 15 | − "trois-rivières", "trois-rivieres", "saguenay", "lévis", "levis", | |
| 16 | − "longueuil", "terrebonne", "brossard", "repentigny", "drummondville", | |
| 17 | − "saint-jérôme", "saint-jerome", "granby", "blainville", "shawinigan", | |
| 18 | − "dollard-des-ormeaux", "rimouski", "victoriaville", "saint-hyacinthe", | |
| 19 | − "boucherville", "salaberry-de-valleyfield", "vaudreuil-dorion", "châteauguay", | |
| 20 | − "chateauguay", "rouyn-noranda", "joliette", "sorel-tracy", "val-d'or", | |
| 21 | − "alma", "sept-îles", "sept-iles", "baie-comeau", "chicoutimi", "jonquière", | |
| 22 | − "jonquiere", "rivière-du-loup", "riviere-du-loup", "matane", "gaspé", "gaspe", | |
| 23 | − "magog", "thetford mines", "sainte-thérèse", "sainte-therese", "mirabel", | |
| 24 | − "beloeil", "mascouche", "varennes", "chambly", "saint-eustache", | |
| 25 | − "l'assomption", "lachute", "mont-tremblant", "sainte-adèle", "sainte-adele", | |
| 26 | − "kuujjuaq", "chibougamau", "amos", "la tuque", "cowansville", "beauharnois", | |
| 27 | − "outaouais", "estrie", "montérégie", "monteregie", "mauricie", "lanaudière", | |
| 28 | − "lanaudiere", "laurentides", "abitibi-témiscamingue", "abitibi-temiscamingue", | |
| 29 | − "bas-saint-laurent", "côte-nord", "cote-nord", "nord-du-québec", "nord-du-quebec", | |
| 30 | − "gaspésie", "gaspesie", "chaudière-appalaches", "chaudiere-appalaches", | |
| 31 | − "centre-du-québec", "centre-du-quebec", "saguenay-lac-saint-jean", | |
| 32 | − "nunavik", "îles-de-la-madeleine", "iles-de-la-madeleine", | |
| 33 | − "hochelaga", "rosemont", "ahuntsic", "verdun", "outremont", "westmount", | |
| 34 | − "plateau-mont-royal", "villeray", "anjou", "lasalle", "lachine", "dorval", | |
| 35 | − "pointe-claire", "kirkland", "beaconsfield", "côte-des-neiges", "cote-des-neiges", | |
| 36 | −} | |
| 37 | − | |
| 38 | −# Toponymes ambigus (existent ailleurs) — poids réduit | |
| 39 | −AMBIGUOUS_TOPONYMS: set[str] = { | |
| 40 | − "laval", "hull", "aylmer", "candiac", "delson", "bromont", "sutton", | |
| 41 | − "stanstead", "asbestos", "windsor", "richmond", "warwick", "kingsey", | |
| 42 | −} | |
| 43 | − | |
| 44 | −QUEBEC_REGIONS: set[str] = { | |
| 45 | − "outaouais", "estrie", "montérégie", "mauricie", "lanaudière", "laurentides", | |
| 46 | − "abitibi-témiscamingue", "bas-saint-laurent", "côte-nord", "gaspésie", | |
| 47 | − "chaudière-appalaches", "centre-du-québec", "saguenay-lac-saint-jean", "nunavik", | |
| 48 | −} | |
| 49 | − | |
| 50 | −# Institutions et organisations québécoises fortement identifiantes | |
| 51 | −QUEBEC_ORGS: set[str] = { | |
| 52 | − "hydro-québec", "hydro-quebec", "hydroquébec", "hydroquebec", | |
| 53 | − "desjardins", "gouvernement du québec", "gouvernement du quebec", | |
| 54 | − "assemblée nationale", "assemblee nationale", "revenu québec", "revenu quebec", | |
| 55 | − "saq", "sqdc", "caq", "société de transport de montréal", "stm", | |
| 56 | − "réseau de transport de la capitale", "rtc", "exo", "via rail", | |
| 57 | − "université laval", "universite laval", "université de montréal", | |
| 58 | − "universite de montreal", "mcgill", "uqam", "uqac", "uqtr", "uqar", "uqo", "uqat", | |
| 59 | − "université de sherbrooke", "universite de sherbrooke", "polytechnique montréal", | |
| 60 | − "polytechnique montreal", "hec montréal", "hec montreal", "éts", "ets", | |
| 61 | − "concordia", "inrs", "cégep", "cegep", "clsc", "chum", "chu de québec", | |
| 62 | − "chu sainte-justine", "ramq", "saaq", "cnesst", "sûreté du québec", | |
| 63 | − "surete du quebec", "loto-québec", "loto-quebec", "investissement québec", | |
| 64 | − "investissement quebec", "caisse de dépôt", "caisse de depot", "cdpq", | |
| 65 | − "radio-canada", "télé-québec", "tele-quebec", "la presse", "le devoir", | |
| 66 | − "journal de montréal", "journal de montreal", "journal de québec", | |
| 67 | − "journal de quebec", "tva", "noovo", "bonjour québec", "bonjour quebec", | |
| 68 | − "tourisme québec", "tourisme quebec", | |
| 69 | −} | |
| 70 | − | |
| 71 | −# Indicatifs téléphoniques du Québec (signal faible) | |
| 72 | −QUEBEC_AREA_CODES: set[str] = {"418", "514", "438", "450", "579", "819", "873", "367", "263", "354", "468"} | |
| 73 | − | |
| 74 | −# TLD / suffixes de domaine à signal fort | |
| 75 | −STRONG_DOMAIN_SUFFIXES: tuple[str, ...] = (".qc.ca", ".quebec", ".gouv.qc.ca", ".montreal.ca") | |
deleted
M2M32crouve-ka/services/classifier/scorer.py
+0 −115
@@ -1,115 +0,0 @@ | ||
| 1 | −# Trouve-KA — scoreur Québec | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Calcul de page_quebec_score ∈ [0, 1]. | |
| 6 | − | |
| 7 | −Combinaison de signaux hétérogènes (CLAUDE.md §7) : TLD, toponymes, codes | |
| 8 | −postaux QC, indicatifs téléphoniques, organisations connues, langue française, | |
| 9 | −mentions structurées de la province. Aucun signal seul ne suffit (un .ca seul | |
| 10 | −ne prouve rien); le score sature progressivement via une somme amortie. | |
| 11 | −""" | |
| 12 | − | |
| 13 | −import re | |
| 14 | − | |
| 15 | −from trouveka.types import ParsedPage, QuebecSignals | |
| 16 | − | |
| 17 | −from .gazetteer import ( | |
| 18 | − AMBIGUOUS_TOPONYMS, | |
| 19 | − QUEBEC_AREA_CODES, | |
| 20 | − QUEBEC_ORGS, | |
| 21 | − QUEBEC_TOPONYMS, | |
| 22 | − STRONG_DOMAIN_SUFFIXES, | |
| 23 | −) | |
| 24 | − | |
| 25 | −# Codes postaux du Québec : G, H, J en première lettre (format A1A 1A1) | |
| 26 | −_POSTAL_RE = re.compile(r"\b[GHJ]\d[A-Z]\s?\d[A-Z]\d\b", re.IGNORECASE) | |
| 27 | −_PHONE_RE = re.compile(r"(?:\+?1[\s.-]?)?\(?(\d{3})\)?[\s.-]?\d{3}[\s.-]?\d{4}\b") | |
| 28 | −_PROVINCE_RE = re.compile( | |
| 29 | − r"\b(?:province\s+(?:de\s+|du\s+)?qu[ée]bec|qu[ée]bec\s*\(qc\)|,\s*(?:qc|qu[ée]bec)\b)", | |
| 30 | − re.IGNORECASE, | |
| 31 | −) | |
| 32 | −_WORD_BOUNDARY = r"(?<![\w-]){}(?![\w-])" | |
| 33 | − | |
| 34 | − | |
| 35 | −def _count_terms(text: str, terms: set[str], cap: int = 10) -> tuple[int, list[str]]: | |
| 36 | − found: list[str] = [] | |
| 37 | − total = 0 | |
| 38 | − for term in terms: | |
| 39 | − pattern = re.compile(_WORD_BOUNDARY.format(re.escape(term)), re.IGNORECASE) | |
| 40 | − n = len(pattern.findall(text)) | |
| 41 | − if n: | |
| 42 | − found.append(term) | |
| 43 | − total += min(n, 4) # une page qui répète 200× « Montréal » n'est pas 200× plus québécoise | |
| 44 | − if total >= cap: | |
| 45 | − break | |
| 46 | − return min(total, cap), found | |
| 47 | − | |
| 48 | − | |
| 49 | −def score_page(page: ParsedPage, domain: str) -> QuebecSignals: | |
| 50 | − """Score Québec d'une page. Déterministe, sans LLM, économique (§12).""" | |
| 51 | − signals = QuebecSignals() | |
| 52 | − reasons: list[str] = [] | |
| 53 | − points = 0.0 | |
| 54 | − | |
| 55 | − text = " ".join([page.title, page.description, " ".join(page.headings), page.body[:20_000]]) | |
| 56 | − text_with_hints = text + " " + " ".join(page.structured_hints) | |
| 57 | − lower = text_with_hints.lower() | |
| 58 | − | |
| 59 | − # 1. Domaine (signal fort mais pas suffisant seul) | |
| 60 | − host = domain.lower() | |
| 61 | − if any(host.endswith(suffix) or host == suffix.lstrip(".") for suffix in STRONG_DOMAIN_SUFFIXES): | |
| 62 | − points += 4.0 | |
| 63 | − reasons.append("tld_quebec") | |
| 64 | − | |
| 65 | − # 2. Toponymes non ambigus (titre/headings pèsent plus que le corps) | |
| 66 | − head_text = " ".join([page.title, page.description, " ".join(page.headings)]) | |
| 67 | − head_hits, head_names = _count_terms(head_text, QUEBEC_TOPONYMS, cap=6) | |
| 68 | − body_hits, body_names = _count_terms(page.body[:20_000], QUEBEC_TOPONYMS, cap=8) | |
| 69 | − if head_hits: | |
| 70 | − points += 1.2 * head_hits | |
| 71 | − reasons.append("toponymes_titre") | |
| 72 | − if body_hits: | |
| 73 | − points += 0.4 * body_hits | |
| 74 | − reasons.append("toponymes_corps") | |
| 75 | − signals.locations = sorted({*head_names, *body_names})[:12] | |
| 76 | − | |
| 77 | − # 3. Toponymes ambigus — poids réduit | |
| 78 | − amb_hits, amb_names = _count_terms(lower, AMBIGUOUS_TOPONYMS, cap=3) | |
| 79 | − if amb_hits: | |
| 80 | − points += 0.15 * amb_hits | |
| 81 | − reasons.append("toponymes_ambigus") | |
| 82 | − signals.locations = sorted({*signals.locations, *amb_names})[:12] | |
| 83 | − | |
| 84 | − # 4. Codes postaux QC (signal fort : preuve d'adresse physique) | |
| 85 | − postal_hits = len(set(_POSTAL_RE.findall(text_with_hints))) | |
| 86 | − if postal_hits: | |
| 87 | − points += min(postal_hits, 3) * 1.5 | |
| 88 | − reasons.append("code_postal_qc") | |
| 89 | − | |
| 90 | − # 5. Mention structurée de la province (adresses, footers) | |
| 91 | − if _PROVINCE_RE.search(text_with_hints): | |
| 92 | − points += 1.5 | |
| 93 | − reasons.append("province_quebec") | |
| 94 | − | |
| 95 | − # 6. Indicatifs téléphoniques (signal faible) | |
| 96 | − area_codes = {m for m in _PHONE_RE.findall(text_with_hints) if m in QUEBEC_AREA_CODES} | |
| 97 | − if area_codes: | |
| 98 | − points += min(len(area_codes), 2) * 0.6 | |
| 99 | − reasons.append("indicatif_qc") | |
| 100 | − | |
| 101 | − # 7. Organisations québécoises connues | |
| 102 | − org_hits, _ = _count_terms(lower, QUEBEC_ORGS, cap=6) | |
| 103 | − if org_hits: | |
| 104 | − points += 0.8 * org_hits | |
| 105 | − reasons.append("organisations_qc") | |
| 106 | − | |
| 107 | − # 8. Langue : le français augmente la probabilité sans la prouver | |
| 108 | − if page.language == "fr": | |
| 109 | − points += 0.8 | |
| 110 | − reasons.append("francais") | |
| 111 | − | |
| 112 | − # Saturation douce : 0 pt → 0, ~3 pts → 0.5, ≥9 pts → ~0.95 | |
| 113 | − signals.score = round(points / (points + 3.0), 4) if points > 0 else 0.0 | |
| 114 | − signals.reasons = reasons | |
| 115 | − return signals | |
deleted
M2M32crouve-ka/services/crawler/__init__.py
+0 −12
@@ -1,12 +0,0 @@ | ||
| 1 | −# Trouve-KA — crawler | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Le crawler de Trouve-KA : frontier Postgres, politesse par hôte, robots.txt, | |
| 6 | −fetch HTTP sécurisé (SSRF), pièges de crawl, pipeline inline fetch→parse→score→index.""" | |
| 7 | − | |
| 8 | −from .robots import RobotsCache | |
| 9 | −from .fetcher import Fetcher | |
| 10 | −from .traps import looks_like_trap | |
| 11 | − | |
| 12 | −__all__ = ["RobotsCache", "Fetcher", "looks_like_trap"] | |
deleted
M2M32crouve-ka/services/crawler/fetcher.py
+0 −148
@@ -1,148 +0,0 @@ | ||
| 1 | −# Trouve-KA — fetcher HTTP sécurisé | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Fetch HTTP avec garde SSRF revalidée à chaque redirection, limites de taille, | |
| 6 | −gestion des content-types et cache HTTP conditionnel (ETag / Last-Modified). | |
| 7 | − | |
| 8 | −Jamais de Chromium ici (CLAUDE.md §5.10) : fetch HTTP pur. | |
| 9 | −""" | |
| 10 | − | |
| 11 | −import time | |
| 12 | −from urllib.parse import urlsplit | |
| 13 | − | |
| 14 | −import httpx | |
| 15 | − | |
| 16 | −from trouveka.config import Settings | |
| 17 | −from trouveka.shared import canonicalize_url, is_safe_url | |
| 18 | −from trouveka.types import ErrorCode, FetchResult | |
| 19 | − | |
| 20 | −ACCEPTED_CONTENT_TYPES = ("text/html", "application/xhtml+xml", "text/plain") | |
| 21 | − | |
| 22 | − | |
| 23 | −class Fetcher: | |
| 24 | − def __init__(self, client: httpx.AsyncClient, settings: Settings): | |
| 25 | − self._client = client | |
| 26 | − self._s = settings | |
| 27 | − | |
| 28 | − async def fetch( | |
| 29 | − self, url: str, *, etag: str | None = None, last_modified: str | None = None | |
| 30 | − ) -> FetchResult: | |
| 31 | − """Fetch une URL en suivant manuellement les redirections (revalidation SSRF à chaque saut).""" | |
| 32 | − start = time.monotonic() | |
| 33 | − chain: list[str] = [] | |
| 34 | − current = url | |
| 35 | − | |
| 36 | − for _hop in range(self._s.max_redirects + 1): | |
| 37 | − if not is_safe_url(current): | |
| 38 | − return self._fail(url, current, ErrorCode.SSRF_BLOCKED, start, chain) | |
| 39 | − | |
| 40 | − # Accept-Encoding volontairement absent : httpx annonce lui-même les | |
| 41 | − # encodages qu'il sait décoder (br seulement si brotli est installé). | |
| 42 | − # L'annoncer à la main fait indexer du binaire compressé (bug itum.qc.ca). | |
| 43 | − headers = { | |
| 44 | − "User-Agent": self._s.crawler_user_agent, | |
| 45 | − "Accept": "text/html,application/xhtml+xml;q=0.9,text/plain;q=0.5,*/*;q=0.1", | |
| 46 | − "Accept-Language": "fr-CA,fr;q=0.9,en-CA;q=0.8,en;q=0.7", | |
| 47 | − } | |
| 48 | − if etag and current == url: | |
| 49 | − headers["If-None-Match"] = etag | |
| 50 | − if last_modified and current == url: | |
| 51 | − headers["If-Modified-Since"] = last_modified | |
| 52 | − | |
| 53 | − try: | |
| 54 | − async with self._client.stream( | |
| 55 | − "GET", current, headers=headers, timeout=self._s.fetch_timeout, follow_redirects=False | |
| 56 | − ) as resp: | |
| 57 | − if resp.status_code in (301, 302, 303, 307, 308): | |
| 58 | − location = resp.headers.get("location") | |
| 59 | − if not location: | |
| 60 | − return self._fail(url, current, ErrorCode.HTTP_4XX, start, chain, resp.status_code) | |
| 61 | − next_url = canonicalize_url(location, base=current) | |
| 62 | − if not next_url: | |
| 63 | − return self._fail(url, current, ErrorCode.SSRF_BLOCKED, start, chain, resp.status_code) | |
| 64 | − chain.append(next_url) | |
| 65 | − current = next_url | |
| 66 | − continue | |
| 67 | − | |
| 68 | − if resp.status_code == 304: | |
| 69 | − return FetchResult( | |
| 70 | − url=url, final_url=current, status_code=304, | |
| 71 | − duration_ms=self._ms(start), redirect_chain=chain, | |
| 72 | − ) | |
| 73 | − if 400 <= resp.status_code < 500: | |
| 74 | − return self._fail(url, current, ErrorCode.HTTP_4XX, start, chain, resp.status_code) | |
| 75 | − if resp.status_code >= 500: | |
| 76 | − return self._fail(url, current, ErrorCode.HTTP_5XX, start, chain, resp.status_code) | |
| 77 | − | |
| 78 | − # X-Robots-Tag : noindex au niveau HTTP | |
| 79 | − x_robots = (resp.headers.get("x-robots-tag") or "").lower() | |
| 80 | − | |
| 81 | − raw_ct = resp.headers.get("content-type") or "" | |
| 82 | − content_type = raw_ct.split(";")[0].strip().lower() | |
| 83 | − charset = None | |
| 84 | − if "charset=" in raw_ct.lower(): | |
| 85 | − charset = raw_ct.lower().split("charset=")[-1].split(";")[0].strip(' "\'') or None | |
| 86 | − if content_type and not any(content_type.startswith(t) for t in ACCEPTED_CONTENT_TYPES): | |
| 87 | − return self._fail( | |
| 88 | − url, current, ErrorCode.UNSUPPORTED_CONTENT, start, chain, resp.status_code | |
| 89 | − ) | |
| 90 | − | |
| 91 | − declared = resp.headers.get("content-length") | |
| 92 | − if declared and int(declared) > self._s.max_response_bytes: | |
| 93 | − return self._fail(url, current, ErrorCode.TOO_LARGE, start, chain, resp.status_code) | |
| 94 | − | |
| 95 | − body = b"" | |
| 96 | − async for part in resp.aiter_bytes(): | |
| 97 | − body += part | |
| 98 | − if len(body) > self._s.max_response_bytes: | |
| 99 | − return self._fail(url, current, ErrorCode.TOO_LARGE, start, chain, resp.status_code) | |
| 100 | − | |
| 101 | − result = FetchResult( | |
| 102 | − url=url, | |
| 103 | − final_url=current, | |
| 104 | − status_code=resp.status_code, | |
| 105 | − content_type=content_type or None, | |
| 106 | − charset=charset, | |
| 107 | − body=body, | |
| 108 | − etag=resp.headers.get("etag"), | |
| 109 | − last_modified=resp.headers.get("last-modified"), | |
| 110 | − duration_ms=self._ms(start), | |
| 111 | − redirect_chain=chain, | |
| 112 | − ) | |
| 113 | − if "noindex" in x_robots: | |
| 114 | − result.error_code = ErrorCode.ROBOTS_DENIED | |
| 115 | − return result | |
| 116 | − | |
| 117 | − except httpx.TimeoutException: | |
| 118 | − return self._fail(url, current, ErrorCode.TIMEOUT, start, chain) | |
| 119 | − except httpx.ConnectError as exc: | |
| 120 | − code = ErrorCode.TLS if "SSL" in str(exc) or "certificate" in str(exc).lower() else ErrorCode.DNS | |
| 121 | − return self._fail(url, current, code, start, chain) | |
| 122 | − except httpx.HTTPError: | |
| 123 | − return self._fail(url, current, ErrorCode.CONNECTION, start, chain) | |
| 124 | − | |
| 125 | − return self._fail(url, current, ErrorCode.TOO_MANY_REDIRECTS, start, chain) | |
| 126 | − | |
| 127 | − @staticmethod | |
| 128 | − def _ms(start: float) -> int: | |
| 129 | − return int((time.monotonic() - start) * 1000) | |
| 130 | − | |
| 131 | − def _fail( | |
| 132 | − self, | |
| 133 | − url: str, | |
| 134 | − final_url: str, | |
| 135 | − code: ErrorCode, | |
| 136 | − start: float, | |
| 137 | − chain: list[str], | |
| 138 | − status: int | None = None, | |
| 139 | − ) -> FetchResult: | |
| 140 | − return FetchResult( | |
| 141 | − url=url, final_url=final_url, status_code=status, error_code=code, | |
| 142 | − duration_ms=self._ms(start), redirect_chain=chain, | |
| 143 | − ) | |
| 144 | − | |
| 145 | − | |
| 146 | −def scheme_host(url: str) -> str: | |
| 147 | − parts = urlsplit(url) | |
| 148 | − return f"{parts.scheme}://{parts.netloc}" | |
deleted
M2M32crouve-ka/services/crawler/robots.py
+0 −84
@@ -1,84 +0,0 @@ | ||
| 1 | −# Trouve-KA — gestion robots.txt | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Cache robots.txt conforme aux standards (Protego, le parseur de Scrapy). | |
| 6 | − | |
| 7 | −Les règles sont persistées en base (domains.robots_body) et rafraîchies | |
| 8 | −après 24 h. Un robots.txt introuvable (404) = tout permis; une erreur | |
| 9 | −serveur = prudence, on refuse temporairement. | |
| 10 | −""" | |
| 11 | − | |
| 12 | −from datetime import UTC, datetime, timedelta | |
| 13 | − | |
| 14 | −import httpx | |
| 15 | −from protego import Protego | |
| 16 | − | |
| 17 | −from trouveka.logging import get_logger | |
| 18 | − | |
| 19 | −log = get_logger("crawler.robots") | |
| 20 | − | |
| 21 | −ROBOTS_TTL = timedelta(hours=24) | |
| 22 | −_MAX_ROBOTS_BYTES = 512_000 | |
| 23 | − | |
| 24 | − | |
| 25 | −class RobotsCache: | |
| 26 | − def __init__(self, db, client: httpx.AsyncClient, user_agent: str): | |
| 27 | − self._db = db | |
| 28 | − self._client = client | |
| 29 | − self._ua = user_agent | |
| 30 | − self._parsers: dict[int, tuple[Protego | None, datetime]] = {} | |
| 31 | − | |
| 32 | − async def _fetch_robots(self, scheme_host: str) -> tuple[str | None, str]: | |
| 33 | − """Retourne (body, status) : ok | not_found | error.""" | |
| 34 | − try: | |
| 35 | − resp = await self._client.get( | |
| 36 | − f"{scheme_host}/robots.txt", | |
| 37 | − headers={"User-Agent": self._ua}, | |
| 38 | − timeout=15, | |
| 39 | − follow_redirects=True, | |
| 40 | − ) | |
| 41 | − except httpx.HTTPError: | |
| 42 | − return None, "error" | |
| 43 | − if resp.status_code == 200: | |
| 44 | − return resp.text[:_MAX_ROBOTS_BYTES], "ok" | |
| 45 | − if 400 <= resp.status_code < 500: | |
| 46 | − return None, "not_found" # 4xx = pas de robots → tout permis (standard) | |
| 47 | − return None, "error" | |
| 48 | − | |
| 49 | − async def get_parser(self, domain_id: int, scheme_host: str) -> tuple[Protego | None, str, int | None]: | |
| 50 | − """Parseur robots pour un domaine. Retourne (parser|None, status, crawl_delay_ms).""" | |
| 51 | − cached = self._parsers.get(domain_id) | |
| 52 | − now = datetime.now(UTC) | |
| 53 | − if cached and now - cached[1] < ROBOTS_TTL: | |
| 54 | − parser = cached[0] | |
| 55 | − return parser, "ok" if parser else "not_found", None | |
| 56 | − | |
| 57 | − row = await self._db.get_domain_by_id(domain_id) | |
| 58 | − if row and row["robots_fetched_at"] and now - row["robots_fetched_at"] < ROBOTS_TTL: | |
| 59 | − body, status = row["robots_body"], row["robots_status"] | |
| 60 | − else: | |
| 61 | − body, status = await self._fetch_robots(scheme_host) | |
| 62 | − delay_ms = None | |
| 63 | − if body: | |
| 64 | − delay = Protego.parse(body).crawl_delay(self._ua) | |
| 65 | − delay_ms = int(delay * 1000) if delay else None | |
| 66 | − await self._db.save_robots(domain_id, body, status, delay_ms) | |
| 67 | − | |
| 68 | − parser = Protego.parse(body) if body else None | |
| 69 | − self._parsers[domain_id] = (parser, now) | |
| 70 | − if len(self._parsers) > 5000: # borne mémoire du cache local | |
| 71 | − self._parsers.pop(next(iter(self._parsers))) | |
| 72 | − return parser, status, row["crawl_delay_ms"] if row else None | |
| 73 | − | |
| 74 | − async def allowed(self, domain_id: int, url: str, scheme_host: str) -> tuple[bool, float | None]: | |
| 75 | − """(autorisé, crawl_delay_secondes). Erreur robots = refus temporaire (prudence).""" | |
| 76 | − parser, status, delay_ms = await self.get_parser(domain_id, scheme_host) | |
| 77 | − if status == "error": | |
| 78 | − return False, None | |
| 79 | − if parser is None: | |
| 80 | − return True, None | |
| 81 | − delay = parser.crawl_delay(self._ua) | |
| 82 | − if delay is None and delay_ms: | |
| 83 | − delay = delay_ms / 1000 | |
| 84 | − return parser.can_fetch(url, self._ua), delay | |
deleted
M2M32crouve-ka/services/crawler/seed.py
+0 −57
@@ -1,57 +0,0 @@ | ||
| 1 | −# Trouve-KA — chargeur de seeds | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Charge les seeds dans le frontier avec priorité maximale. | |
| 6 | − | |
| 7 | −Usage : python -m trouveka.crawler.seed [chemin/vers/seeds.txt] | |
| 8 | −""" | |
| 9 | − | |
| 10 | −import asyncio | |
| 11 | −import pathlib | |
| 12 | −import sys | |
| 13 | − | |
| 14 | −from trouveka.config import get_settings | |
| 15 | −from trouveka.database import Database, run_migrations | |
| 16 | −from trouveka.logging import get_logger | |
| 17 | −from trouveka.shared import canonicalize_url, extract_domain | |
| 18 | − | |
| 19 | −log = get_logger("crawler.seed") | |
| 20 | − | |
| 21 | −DEFAULT_SEEDS = pathlib.Path(__file__).resolve().parents[2] / "scripts" / "bootstrap-seeds" / "seeds.txt" | |
| 22 | − | |
| 23 | − | |
| 24 | −async def load_seeds(path: pathlib.Path) -> int: | |
| 25 | − settings = get_settings() | |
| 26 | − applied = await run_migrations(settings.database_url) | |
| 27 | − if applied: | |
| 28 | − log.info("migrations appliquées", extra={"ctx": {"files": applied}}) | |
| 29 | − | |
| 30 | − db = Database(settings.database_url, pool_min=1, pool_max=3) | |
| 31 | − await db.connect() | |
| 32 | − added = 0 | |
| 33 | − try: | |
| 34 | − for line in path.read_text(encoding="utf-8").splitlines(): | |
| 35 | − line = line.strip() | |
| 36 | − if not line or line.startswith("#"): | |
| 37 | − continue | |
| 38 | − url = canonicalize_url(line) | |
| 39 | − domain = extract_domain(url) if url else None | |
| 40 | − if not url or not domain: | |
| 41 | − log.info("seed ignorée (URL invalide)", extra={"ctx": {"line": line}}) | |
| 42 | − continue | |
| 43 | − if await db.enqueue_url(url, domain, priority=1.0, depth=0, is_seed=True): | |
| 44 | − added += 1 | |
| 45 | − finally: | |
| 46 | − await db.close() | |
| 47 | − log.info("seeds chargées", extra={"ctx": {"added": added, "file": str(path)}}) | |
| 48 | − return added | |
| 49 | − | |
| 50 | − | |
| 51 | −def main() -> None: | |
| 52 | − path = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_SEEDS | |
| 53 | − asyncio.run(load_seeds(path)) | |
| 54 | − | |
| 55 | − | |
| 56 | −if __name__ == "__main__": | |
| 57 | − main() | |
deleted
M2M32crouve-ka/services/crawler/traps.py
+0 −59
@@ -1,59 +0,0 @@ | ||
| 1 | −# Trouve-KA — détection de pièges de crawl | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Heuristiques anti-pièges (CLAUDE.md §5.8) : calendriers infinis, session IDs, | |
| 6 | −explosions de facettes, pagination infinie, chemins répétitifs.""" | |
| 7 | − | |
| 8 | −import re | |
| 9 | −from urllib.parse import parse_qsl, urlsplit | |
| 10 | − | |
| 11 | −_SESSION_PARAMS = {"sid", "sessionid", "session_id", "phpsessid", "jsessionid", "aspsessionid", "cfid", "cftoken"} | |
| 12 | −_CALENDAR_RE = re.compile(r"/(?:19|20)\d{2}[-/](?:0?\d|1[0-2])(?:[-/](?:0?\d|[12]\d|3[01]))?/?$") | |
| 13 | −_LONG_NUMBER_RE = re.compile(r"\d{10,}") | |
| 14 | − | |
| 15 | − | |
| 16 | −def looks_like_trap( | |
| 17 | − url: str, | |
| 18 | − *, | |
| 19 | − max_query_params: int = 8, | |
| 20 | − max_path_segments: int = 12, | |
| 21 | − max_url_length: int = 1024, | |
| 22 | −) -> bool: | |
| 23 | − """True si l'URL ressemble à un piège de crawl et ne doit pas entrer au frontier.""" | |
| 24 | − if len(url) > max_url_length: | |
| 25 | − return True | |
| 26 | − try: | |
| 27 | − parts = urlsplit(url) | |
| 28 | − except ValueError: | |
| 29 | − return True | |
| 30 | − | |
| 31 | − params = parse_qsl(parts.query, keep_blank_values=True) | |
| 32 | − if len(params) > max_query_params: | |
| 33 | − return True | |
| 34 | − keys = {k.lower() for k, _ in params} | |
| 35 | − if keys & _SESSION_PARAMS: | |
| 36 | − return True | |
| 37 | − # Même paramètre répété (facettes qui explosent : ?filter=a&filter=b&filter=c…) | |
| 38 | − raw_keys = [k.lower() for k, _ in params] | |
| 39 | − if any(raw_keys.count(k) > 3 for k in set(raw_keys)): | |
| 40 | − return True | |
| 41 | − | |
| 42 | − segments = [s for s in parts.path.split("/") if s] | |
| 43 | − if len(segments) > max_path_segments: | |
| 44 | − return True | |
| 45 | − # Segment répété (boucles : /a/b/a/b/a/b) | |
| 46 | − if any(segments.count(s) > 3 for s in set(segments)): | |
| 47 | − return True | |
| 48 | − # Calendriers profonds (au-delà de l'année-mois raisonnable) : /events/2031/05/17 | |
| 49 | − if _CALENDAR_RE.search(parts.path): | |
| 50 | − year_match = re.search(r"/((?:19|20)\d{2})[-/]", parts.path) | |
| 51 | − if year_match and not (1995 <= int(year_match.group(1)) <= 2027): | |
| 52 | − return True | |
| 53 | − # Pagination excessive | |
| 54 | − for key, value in params: | |
| 55 | − if key.lower() in ("page", "p", "offset", "start") and value.isdigit() and int(value) > 500: | |
| 56 | − return True | |
| 57 | − if _LONG_NUMBER_RE.search(parts.query): | |
| 58 | − return True | |
| 59 | − return False | |
deleted
M2M32crouve-ka/services/crawler/worker.py
+0 −447
@@ -1,447 +0,0 @@ | ||
| 1 | −# Trouve-KA — worker de crawl (pipeline complet) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Worker de crawl : réclame des URLs au frontier et exécute le pipeline inline | |
| 6 | −fetch → parse → score Québec → indexation IMMÉDIATE (principe cardinal §0.3). | |
| 7 | − | |
| 8 | −Chaque page traitée avec succès est cherchable en quelques secondes. | |
| 9 | −L'enrichissement (étapes 2-3) part dans Redis Streams et ne bloque jamais. | |
| 10 | −N workers peuvent tourner en parallèle (frontier SKIP LOCKED + locks Redis par hôte). | |
| 11 | −""" | |
| 12 | − | |
| 13 | −import asyncio | |
| 14 | −import os | |
| 15 | −import random | |
| 16 | −import signal | |
| 17 | −import uuid | |
| 18 | −from datetime import UTC, datetime, timedelta | |
| 19 | − | |
| 20 | −import httpx | |
| 21 | − | |
| 22 | −from trouveka.classifier import score_page | |
| 23 | −from trouveka.config import get_settings | |
| 24 | −from trouveka.database import Database | |
| 25 | −from trouveka.frontier import compute_priority, next_recrawl_delay, retry_delay | |
| 26 | −from trouveka.indexer import build_search_document | |
| 27 | −from trouveka.logging import get_logger | |
| 28 | −from trouveka.parser import looks_like_garbage, parse_html | |
| 29 | −from trouveka.queue import Coordination | |
| 30 | −from trouveka.search_core import SearchCore | |
| 31 | −from trouveka.shared import canonicalize_url, content_hash, extract_domain | |
| 32 | −from trouveka.types import ErrorCode, Outcome | |
| 33 | − | |
| 34 | −from .fetcher import Fetcher, scheme_host | |
| 35 | −from .robots import RobotsCache | |
| 36 | −from .traps import looks_like_trap | |
| 37 | − | |
| 38 | −log = get_logger("crawler.worker") | |
| 39 | − | |
| 40 | −TRANSIENT_ERRORS = {ErrorCode.TIMEOUT, ErrorCode.HTTP_5XX, ErrorCode.CONNECTION, ErrorCode.DNS} | |
| 41 | −MAX_RETRIES = 3 | |
| 42 | − | |
| 43 | − | |
| 44 | −class CrawlerWorker: | |
| 45 | − def __init__(self) -> None: | |
| 46 | − self.s = get_settings() | |
| 47 | − self.worker_id = f"crawler-{os.getpid()}-{uuid.uuid4().hex[:6]}" | |
| 48 | − self.db = Database(self.s.database_url, pool_min=self.s.pg_pool_min, pool_max=self.s.pg_pool_max) | |
| 49 | − self.coord = Coordination(self.s.redis_url) | |
| 50 | − self.search = SearchCore(self.s.search_url, self.s.search_index) | |
| 51 | − self.client = httpx.AsyncClient( | |
| 52 | − limits=httpx.Limits( | |
| 53 | − max_connections=self.s.max_global_concurrency, | |
| 54 | − max_keepalive_connections=self.s.max_global_concurrency, | |
| 55 | − ), | |
| 56 | − http2=False, | |
| 57 | − ) | |
| 58 | − self.fetcher = Fetcher(self.client, self.s) | |
| 59 | − self.robots: RobotsCache | None = None | |
| 60 | − self.stop_event = asyncio.Event() | |
| 61 | − self._domain_cache: dict[int, dict] = {} | |
| 62 | − # Cache domaine→row pour la découverte : évite un SELECT par lien sortant | |
| 63 | − # (une page peut avoir 300 liens, souvent vers les mêmes domaines). | |
| 64 | − self._domain_by_name: dict[str, tuple[object, datetime]] = {} | |
| 65 | − | |
| 66 | − # ------------------------------------------------------------------ cycle de vie | |
| 67 | − | |
| 68 | − async def start(self) -> None: | |
| 69 | − await self.db.connect() | |
| 70 | − self.robots = RobotsCache(self.db, self.client, self.s.crawler_user_agent) | |
| 71 | − await self.search.ensure_index() | |
| 72 | − log.info("worker démarré", extra={"ctx": {"worker_id": self.worker_id}}) | |
| 73 | − | |
| 74 | − loop = asyncio.get_running_loop() | |
| 75 | − for sig in (signal.SIGINT, signal.SIGTERM): | |
| 76 | − loop.add_signal_handler(sig, self.stop_event.set) | |
| 77 | − | |
| 78 | − # Pool continu : on réclame de nouvelles URLs dès qu'un slot se libère. | |
| 79 | − # (Un gather par lot créait du head-of-line blocking : une page lente | |
| 80 | − # bloquait tout le lot avant la réclamation suivante.) | |
| 81 | − in_flight: set[asyncio.Task] = set() | |
| 82 | − idle_sleep = 1.0 | |
| 83 | − | |
| 84 | − async def run_one(item) -> None: | |
| 85 | − try: | |
| 86 | − await self.process_item(item) | |
| 87 | − except Exception: | |
| 88 | − log.exception( | |
| 89 | − "échec inattendu du pipeline", | |
| 90 | − extra={"ctx": {"url": item["url"], "worker_id": self.worker_id}}, | |
| 91 | − ) | |
| 92 | − await self.db.release_item( | |
| 93 | − item["url_id"], status="failed", error_code="parse_failed" | |
| 94 | − ) | |
| 95 | − | |
| 96 | − while not self.stop_event.is_set(): | |
| 97 | − if await self.coord.is_paused(): | |
| 98 | − await asyncio.sleep(3) | |
| 99 | − continue | |
| 100 | − free = self.s.max_global_concurrency - len(in_flight) | |
| 101 | − if free <= 0: | |
| 102 | − await asyncio.wait(in_flight, return_when=asyncio.FIRST_COMPLETED) | |
| 103 | − continue | |
| 104 | − batch = await self.db.claim_batch(self.worker_id, limit=free) | |
| 105 | − if not batch: | |
| 106 | − if in_flight: | |
| 107 | − await asyncio.wait( | |
| 108 | − in_flight, return_when=asyncio.FIRST_COMPLETED, timeout=idle_sleep | |
| 109 | − ) | |
| 110 | − else: | |
| 111 | − await asyncio.sleep(idle_sleep) | |
| 112 | − idle_sleep = min(idle_sleep * 1.5, 15) | |
| 113 | − continue | |
| 114 | − idle_sleep = 1.0 | |
| 115 | − for item in batch: | |
| 116 | − task = asyncio.create_task(run_one(item)) | |
| 117 | − in_flight.add(task) | |
| 118 | − task.add_done_callback(in_flight.discard) | |
| 119 | − | |
| 120 | − if in_flight: | |
| 121 | − await asyncio.gather(*in_flight, return_exceptions=True) | |
| 122 | − await self.shutdown() | |
| 123 | − | |
| 124 | − async def shutdown(self) -> None: | |
| 125 | − await self.client.aclose() | |
| 126 | − await self.search.close() | |
| 127 | − await self.coord.close() | |
| 128 | − await self.db.close() | |
| 129 | − log.info("worker arrêté", extra={"ctx": {"worker_id": self.worker_id}}) | |
| 130 | − | |
| 131 | − # ------------------------------------------------------------------ helpers | |
| 132 | − | |
| 133 | − async def _domain(self, domain_id: int) -> dict: | |
| 134 | − cached = self._domain_cache.get(domain_id) | |
| 135 | − if cached and (datetime.now(UTC) - cached["_at"]).total_seconds() < 60: | |
| 136 | − return cached | |
| 137 | − row = await self.db.get_domain_by_id(domain_id) | |
| 138 | − entry = dict(row) | {"_at": datetime.now(UTC)} | |
| 139 | − self._domain_cache[domain_id] = entry | |
| 140 | − if len(self._domain_cache) > 5000: | |
| 141 | − self._domain_cache.pop(next(iter(self._domain_cache))) | |
| 142 | − return entry | |
| 143 | − | |
| 144 | − async def _defer(self, url_id: int, seconds: float) -> None: | |
| 145 | − """Repousse un item sans compter d'erreur (politesse : trop tôt pour cet hôte). | |
| 146 | − | |
| 147 | − Jitter aléatoire pour désynchroniser les grappes d'URLs d'un même hôte | |
| 148 | − (sinon elles reviennent toutes en tête de file au même instant et churnent). | |
| 149 | − """ | |
| 150 | − await self.db.release_item( | |
| 151 | − url_id, | |
| 152 | − status="pending", | |
| 153 | − next_crawl_at=datetime.now(UTC) + timedelta(seconds=seconds + random.uniform(0.2, 3.0)), | |
| 154 | − ) | |
| 155 | − | |
| 156 | − async def _domain_row_by_name(self, domain: str): | |
| 157 | − """Row domaine (ou None si inconnu), avec cache TTL 60 s.""" | |
| 158 | − cached = self._domain_by_name.get(domain) | |
| 159 | − now = datetime.now(UTC) | |
| 160 | − if cached and (now - cached[1]).total_seconds() < 60: | |
| 161 | − return cached[0] | |
| 162 | − row = await self.db.get_domain(domain) | |
| 163 | − self._domain_by_name[domain] = (row, now) | |
| 164 | − if len(self._domain_by_name) > 20_000: | |
| 165 | − self._domain_by_name.pop(next(iter(self._domain_by_name))) | |
| 166 | − return row | |
| 167 | − | |
| 168 | − def _previous_delay_hours(self, item) -> float | None: | |
| 169 | − if item["last_crawled_at"] and item["next_crawl_at"]: | |
| 170 | − delta = (item["next_crawl_at"] - item["last_crawled_at"]).total_seconds() / 3600 | |
| 171 | − return max(delta, 0.1) | |
| 172 | − return None | |
| 173 | − | |
| 174 | − # ------------------------------------------------------------------ pipeline | |
| 175 | − | |
| 176 | − async def process_item(self, item) -> None: | |
| 177 | − url: str = item["url"] | |
| 178 | − url_id: int = item["url_id"] | |
| 179 | − domain_row = await self._domain(item["domain_id"]) | |
| 180 | − domain: str = domain_row["domain"] | |
| 181 | − | |
| 182 | − if domain_row["blocked"]: | |
| 183 | − await self.db.release_item(url_id, status="blocked") | |
| 184 | − return | |
| 185 | − | |
| 186 | − host = scheme_host(url) | |
| 187 | − | |
| 188 | − # robots.txt d'abord (le fetch de robots ne compte pas dans la politesse) | |
| 189 | − allowed, robots_delay = await self.robots.allowed(item["domain_id"], url, host) | |
| 190 | − if not allowed: | |
| 191 | − await self.db.record_attempt( | |
| 192 | − url_id, status_code=None, error_code=ErrorCode.ROBOTS_DENIED, | |
| 193 | − outcome=Outcome.ROBOTS_BLOCKED, | |
| 194 | − ) | |
| 195 | − await self.db.release_item(url_id, status="done", error_code=ErrorCode.ROBOTS_DENIED) | |
| 196 | − return | |
| 197 | − | |
| 198 | − # Politesse par hôte, tous workers confondus | |
| 199 | − delay = max(robots_delay or 0, self.s.default_host_delay) | |
| 200 | − if not await self.coord.acquire_host_slot(domain, delay): | |
| 201 | − await self._defer(url_id, delay + 0.5) | |
| 202 | − return | |
| 203 | − | |
| 204 | − # Cache HTTP conditionnel | |
| 205 | − existing = await self.db.get_document(url_id) | |
| 206 | − result = await self.fetcher.fetch( | |
| 207 | − url, | |
| 208 | − etag=existing["etag"] if existing else None, | |
| 209 | − last_modified=existing["last_modified"] if existing else None, | |
| 210 | − ) | |
| 211 | − | |
| 212 | − # --- 304 : inchangé | |
| 213 | − if result.status_code == 304: | |
| 214 | − await self._finish_unchanged(item, existing) | |
| 215 | − return | |
| 216 | − | |
| 217 | − # --- erreurs | |
| 218 | − if result.error_code not in (ErrorCode.OK, ErrorCode.ROBOTS_DENIED) or result.body is None: | |
| 219 | − await self._finish_error(item, result) | |
| 220 | − return | |
| 221 | − | |
| 222 | − # --- redirection vers une autre URL canonique : suivre la cible | |
| 223 | − final = canonicalize_url(result.final_url) or result.final_url | |
| 224 | − if final != url: | |
| 225 | − final_domain = extract_domain(final) | |
| 226 | − if final_domain and not looks_like_trap(final): | |
| 227 | − await self.db.enqueue_url( | |
| 228 | − final, final_domain, priority=item["priority"], depth=item["depth"], | |
| 229 | − source_url_id=url_id, max_urls_per_domain=self.s.max_urls_per_domain, | |
| 230 | − ) | |
| 231 | − await self.db.set_canonical(url_id, final) | |
| 232 | − await self.db.record_attempt( | |
| 233 | − url_id, status_code=result.status_code, error_code=ErrorCode.OK, | |
| 234 | − outcome=Outcome.REDIRECT, redirect_url=final, duration_ms=result.duration_ms, | |
| 235 | − ) | |
| 236 | − await self.db.release_item(url_id, status="done") | |
| 237 | − return | |
| 238 | − | |
| 239 | − # --- parse | |
| 240 | − try: | |
| 241 | − page = parse_html( | |
| 242 | − url, result.body, max_links=self.s.max_links_per_page, charset=result.charset | |
| 243 | − ) | |
| 244 | − except Exception: | |
| 245 | − await self.db.record_attempt( | |
| 246 | − url_id, status_code=result.status_code, error_code=ErrorCode.PARSE_FAILED, | |
| 247 | − outcome=Outcome.ERROR, duration_ms=result.duration_ms, | |
| 248 | − ) | |
| 249 | − await self.db.release_item(url_id, status="failed", error_code=ErrorCode.PARSE_FAILED) | |
| 250 | − return | |
| 251 | − | |
| 252 | − # --- garde anti-charabia : du binaire/mal décodé ne doit JAMAIS être indexé | |
| 253 | − if looks_like_garbage(page.title) or looks_like_garbage(page.body): | |
| 254 | − await self.db.record_attempt( | |
| 255 | − url_id, status_code=result.status_code, error_code=ErrorCode.PARSE_FAILED, | |
| 256 | − outcome=Outcome.ERROR, duration_ms=result.duration_ms, title=page.title[:80], | |
| 257 | − ) | |
| 258 | − await self.db.release_item(url_id, status="failed", error_code=ErrorCode.PARSE_FAILED) | |
| 259 | − return | |
| 260 | − | |
| 261 | − # --- score Québec (immédiat, déterministe) | |
| 262 | − signals = score_page(page, domain) | |
| 263 | − | |
| 264 | − # --- détection de changement | |
| 265 | − chash = content_hash(page.title, page.body) | |
| 266 | − if existing and existing["content_hash"] == chash: | |
| 267 | − await self._finish_unchanged(item, existing, status_code=result.status_code) | |
| 268 | − await self._discover_links(item, page, signals) # les liens peuvent avoir changé de priorité | |
| 269 | − return | |
| 270 | − | |
| 271 | − # --- doublon exact sur une autre URL | |
| 272 | − duplicate = await self.db.find_duplicate(chash, url_id) | |
| 273 | − if duplicate: | |
| 274 | − await self.db.record_attempt( | |
| 275 | − url_id, status_code=result.status_code, error_code=ErrorCode.DUPLICATE, | |
| 276 | − outcome=Outcome.DUPLICATE, content_hash=chash, duration_ms=result.duration_ms, | |
| 277 | − title=page.title, quebec_score=signals.score, | |
| 278 | − ) | |
| 279 | − await self.db.release_item( | |
| 280 | − url_id, status="done", | |
| 281 | − next_crawl_at=datetime.now(UTC) + timedelta(hours=self.s.max_recrawl_hours), | |
| 282 | − ) | |
| 283 | − return | |
| 284 | − | |
| 285 | − indexable = ( | |
| 286 | − not page.noindex | |
| 287 | − and result.error_code != ErrorCode.ROBOTS_DENIED # X-Robots-Tag: noindex | |
| 288 | − and signals.score >= self.s.min_quebec_score_to_index | |
| 289 | − and len(page.body) >= self.s.min_body_length | |
| 290 | − ) | |
| 291 | − | |
| 292 | − if indexable: | |
| 293 | − # ------- INDEXATION IMMÉDIATE : la page est cherchable en ~1 s ------- | |
| 294 | − canonical_target = page.canonical_url or url | |
| 295 | − doc = build_search_document( | |
| 296 | − page, signals, | |
| 297 | − domain=domain, | |
| 298 | − domain_quebec_score=float(domain_row["quebec_score"]), | |
| 299 | − authority_score=float(domain_row["authority_score"]), | |
| 300 | − ) | |
| 301 | − await self.search.index_document(doc) | |
| 302 | − await self.db.upsert_document( | |
| 303 | − url_id, | |
| 304 | − content_hash=chash, | |
| 305 | − etag=result.etag, | |
| 306 | − last_modified=result.last_modified, | |
| 307 | − title=page.title, | |
| 308 | − description=page.description, | |
| 309 | − language=page.language, | |
| 310 | − page_quebec_score=signals.score, | |
| 311 | − published_at=page.published_at, | |
| 312 | − changed=existing is not None, | |
| 313 | − ) | |
| 314 | − if page.canonical_url and page.canonical_url != url: | |
| 315 | − await self.db.set_canonical(url_id, canonical_target) | |
| 316 | − # Enrichissement asynchrone (jamais bloquant) | |
| 317 | − await self.coord.enqueue_enrichment( | |
| 318 | − {"url": canonical_target, "url_id": url_id, "domain_id": item["domain_id"], "stage": 2} | |
| 319 | − ) | |
| 320 | − outcome = Outcome.INDEXED | |
| 321 | − else: | |
| 322 | − outcome = Outcome.NOT_QUEBEC if signals.score < self.s.min_quebec_score_to_index else Outcome.UNCHANGED | |
| 323 | − | |
| 324 | − await self.db.update_domain_after_page(item["domain_id"], signals.score, page.language) | |
| 325 | − self._domain_cache.pop(item["domain_id"], None) | |
| 326 | − | |
| 327 | − await self.db.record_attempt( | |
| 328 | − url_id, status_code=result.status_code, error_code=ErrorCode.OK, | |
| 329 | − outcome=outcome, content_hash=chash, num_bytes=len(result.body), | |
| 330 | − duration_ms=result.duration_ms, title=page.title, quebec_score=signals.score, | |
| 331 | − ) | |
| 332 | − recrawl = next_recrawl_delay( | |
| 333 | − changed=True, previous_delay_hours=self._previous_delay_hours(item), | |
| 334 | − min_hours=self.s.min_recrawl_hours, max_hours=self.s.max_recrawl_hours, | |
| 335 | − default_hours=self.s.default_recrawl_hours, | |
| 336 | − ) | |
| 337 | − await self.db.release_item( | |
| 338 | − url_id, status="pending", next_crawl_at=datetime.now(UTC) + recrawl, | |
| 339 | − ) | |
| 340 | − | |
| 341 | − await self._discover_links(item, page, signals) | |
| 342 | − log.info( | |
| 343 | − "page traitée", | |
| 344 | − extra={"ctx": { | |
| 345 | − "worker_id": self.worker_id, "url_id": url_id, "domain_id": item["domain_id"], | |
| 346 | − "url": url, "outcome": str(outcome), "quebec_score": signals.score, | |
| 347 | − "links": len(page.links), "ms": result.duration_ms, | |
| 348 | − }}, | |
| 349 | − ) | |
| 350 | − | |
| 351 | − # ------------------------------------------------------------------ issues terminales | |
| 352 | − | |
| 353 | − async def _finish_unchanged(self, item, existing, status_code: int | None = 304) -> None: | |
| 354 | − recrawl = next_recrawl_delay( | |
| 355 | − changed=False, previous_delay_hours=self._previous_delay_hours(item), | |
| 356 | − min_hours=self.s.min_recrawl_hours, max_hours=self.s.max_recrawl_hours, | |
| 357 | − default_hours=self.s.default_recrawl_hours, | |
| 358 | − ) | |
| 359 | − await self.db.record_attempt( | |
| 360 | − item["url_id"], status_code=status_code, error_code=ErrorCode.OK, | |
| 361 | − outcome=Outcome.UNCHANGED, | |
| 362 | − content_hash=existing["content_hash"] if existing else None, | |
| 363 | − ) | |
| 364 | − await self.db.release_item( | |
| 365 | − item["url_id"], status="pending", | |
| 366 | − next_crawl_at=datetime.now(UTC) + recrawl, | |
| 367 | − ) | |
| 368 | − | |
| 369 | − async def _finish_error(self, item, result) -> None: | |
| 370 | − url_id = item["url_id"] | |
| 371 | − await self.db.record_attempt( | |
| 372 | − url_id, status_code=result.status_code, error_code=result.error_code, | |
| 373 | − outcome=Outcome.ERROR, duration_ms=result.duration_ms, | |
| 374 | − ) | |
| 375 | − transient = result.error_code in TRANSIENT_ERRORS | |
| 376 | − if transient and item["retries"] < MAX_RETRIES: | |
| 377 | − await self.db.release_item( | |
| 378 | − url_id, status="pending", error_code=result.error_code, | |
| 379 | − next_crawl_at=datetime.now(UTC) + retry_delay(item["retries"]), | |
| 380 | − increment_retries=True, | |
| 381 | − ) | |
| 382 | − else: | |
| 383 | − await self.db.release_item(url_id, status="failed", error_code=result.error_code) | |
| 384 | − | |
| 385 | − # ------------------------------------------------------------------ découverte | |
| 386 | − | |
| 387 | − async def _discover_links(self, item, page, signals) -> None: | |
| 388 | − """Boucle de découverte (§8) : liens sortants → scoring → frontier.""" | |
| 389 | − if item["depth"] >= self.s.max_crawl_depth: | |
| 390 | − return | |
| 391 | − domain_row = await self._domain(item["domain_id"]) | |
| 392 | − source_domain = domain_row["domain"] | |
| 393 | − source_quebec = max(float(domain_row["quebec_score"]), signals.score) | |
| 394 | − | |
| 395 | − outlink_domains: dict[int, int] = {} | |
| 396 | − enqueued = 0 | |
| 397 | − for link in page.links: | |
| 398 | − if link.nofollow or looks_like_trap( | |
| 399 | − link.url, | |
| 400 | − max_query_params=self.s.max_query_params, | |
| 401 | − max_path_segments=self.s.max_path_segments, | |
| 402 | − ): | |
| 403 | − continue | |
| 404 | − target_domain = extract_domain(link.url) | |
| 405 | − if not target_domain: | |
| 406 | − continue | |
| 407 | − same_domain = target_domain == source_domain | |
| 408 | − target_row = await self._domain_row_by_name(target_domain) | |
| 409 | − is_new = target_row is None | |
| 410 | − target_quebec = float(target_row["quebec_score"]) if target_row else 0.0 | |
| 411 | − # Un domaine découvert depuis une page québécoise hérite d'un a priori Québec | |
| 412 | − effective_quebec = target_quebec if not is_new else source_quebec * 0.7 | |
| 413 | − if same_domain: | |
| 414 | − effective_quebec = max(effective_quebec, source_quebec) | |
| 415 | − | |
| 416 | − priority = compute_priority( | |
| 417 | − domain_quebec_score=effective_quebec, | |
| 418 | − authority_score=float(target_row["authority_score"]) if target_row else 0.0, | |
| 419 | − link_signal=0.5 if not same_domain else 0.2, | |
| 420 | − is_new_domain=is_new, | |
| 421 | − depth=item["depth"] + 1, | |
| 422 | − ) | |
| 423 | − # Économie de crawl : ignorer les cibles au signal Québec quasi nul | |
| 424 | − if priority < 0.1: | |
| 425 | − continue | |
| 426 | − url_id = await self.db.enqueue_url( | |
| 427 | − link.url, target_domain, | |
| 428 | − priority=priority, depth=item["depth"] + 1, source_url_id=item["url_id"], | |
| 429 | − max_urls_per_domain=self.s.max_urls_per_domain, | |
| 430 | − ) | |
| 431 | − if url_id: | |
| 432 | − enqueued += 1 | |
| 433 | − if not same_domain: | |
| 434 | − target_id = (target_row["id"] if target_row | |
| 435 | − else (await self.db.upsert_domain(target_domain))) | |
| 436 | − outlink_domains[target_id] = outlink_domains.get(target_id, 0) + 1 | |
| 437 | − | |
| 438 | − if outlink_domains: | |
| 439 | − await self.db.record_domain_links(item["domain_id"], outlink_domains) | |
| 440 | − | |
| 441 | − | |
| 442 | −def main() -> None: | |
| 443 | − asyncio.run(CrawlerWorker().start()) | |
| 444 | − | |
| 445 | − | |
| 446 | −if __name__ == "__main__": | |
| 447 | − main() | |
deleted
M2M32crouve-ka/services/enrichment/__init__.py
+0 −14
@@ -1,14 +0,0 @@ | ||
| 1 | −# Trouve-KA — enrichissement asynchrone | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Enrichissement asynchrone des documents (étapes 2-3, CLAUDE.md §4). | |
| 6 | − | |
| 7 | −Ne bloque JAMAIS l'indexation : consomme Redis Streams et met à jour | |
| 8 | −les documents déjà cherchables (scores de domaine, fraîcheur, plus tard | |
| 9 | −embeddings et entités). | |
| 10 | −""" | |
| 11 | − | |
| 12 | −from .worker import EnrichmentWorker | |
| 13 | − | |
| 14 | −__all__ = ["EnrichmentWorker"] | |
deleted
M2M32crouve-ka/services/enrichment/worker.py
+0 −82
@@ -1,82 +0,0 @@ | ||
| 1 | −# Trouve-KA — worker d'enrichissement | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Worker d'enrichissement : consomme trouveka:enrich (Redis Streams). | |
| 6 | − | |
| 7 | −Étape 2 (implémentée) : propage domain_quebec_score et authority_score à jour | |
| 8 | −dans le document indexé (le domaine apprend au fil du crawl, les documents déjà | |
| 9 | −indexés en profitent rétroactivement). | |
| 10 | −Étapes futures : embeddings, entités, classification thématique — même canal, | |
| 11 | −même contrat : mise à jour partielle du document, jamais bloquante. | |
| 12 | −""" | |
| 13 | − | |
| 14 | −import asyncio | |
| 15 | −import os | |
| 16 | −import signal | |
| 17 | −import uuid | |
| 18 | − | |
| 19 | −from trouveka.config import get_settings | |
| 20 | −from trouveka.database import Database | |
| 21 | −from trouveka.logging import get_logger | |
| 22 | −from trouveka.queue import Coordination | |
| 23 | −from trouveka.search_core import SearchCore | |
| 24 | − | |
| 25 | −log = get_logger("enrichment") | |
| 26 | − | |
| 27 | −GROUP = "enrichers" | |
| 28 | − | |
| 29 | − | |
| 30 | −class EnrichmentWorker: | |
| 31 | − def __init__(self) -> None: | |
| 32 | − self.s = get_settings() | |
| 33 | − self.consumer = f"enrich-{os.getpid()}-{uuid.uuid4().hex[:6]}" | |
| 34 | − self.db = Database(self.s.database_url, pool_min=1, pool_max=3) | |
| 35 | − self.coord = Coordination(self.s.redis_url) | |
| 36 | − self.search = SearchCore(self.s.search_url, self.s.search_index) | |
| 37 | − self.stop_event = asyncio.Event() | |
| 38 | − | |
| 39 | − async def enrich(self, payload: dict) -> None: | |
| 40 | − domain_row = await self.db.get_domain_by_id(int(payload["domain_id"])) | |
| 41 | − if not domain_row: | |
| 42 | − return | |
| 43 | − await self.search.update_document( | |
| 44 | − payload["url"], | |
| 45 | − { | |
| 46 | − "domain_quebec_score": round(float(domain_row["quebec_score"]), 4), | |
| 47 | − "authority_score": round(float(domain_row["authority_score"]), 4), | |
| 48 | − }, | |
| 49 | − ) | |
| 50 | − | |
| 51 | − async def start(self) -> None: | |
| 52 | − await self.db.connect() | |
| 53 | − loop = asyncio.get_running_loop() | |
| 54 | − for sig in (signal.SIGINT, signal.SIGTERM): | |
| 55 | − loop.add_signal_handler(sig, self.stop_event.set) | |
| 56 | − log.info("worker d'enrichissement démarré", extra={"ctx": {"consumer": self.consumer}}) | |
| 57 | − | |
| 58 | − while not self.stop_event.is_set(): | |
| 59 | − try: | |
| 60 | − messages = await self.coord.read_enrichment(GROUP, self.consumer, count=20, block_ms=5000) | |
| 61 | − for msg_id, payload in messages: | |
| 62 | − try: | |
| 63 | − await self.enrich(payload) | |
| 64 | − except Exception: | |
| 65 | − log.exception("échec enrichissement", extra={"ctx": payload}) | |
| 66 | − finally: | |
| 67 | − await self.coord.ack_enrichment(GROUP, msg_id) | |
| 68 | − except Exception: | |
| 69 | − log.exception("erreur boucle enrichissement (on continue)") | |
| 70 | − await asyncio.sleep(2) | |
| 71 | − | |
| 72 | − await self.search.close() | |
| 73 | − await self.coord.close() | |
| 74 | − await self.db.close() | |
| 75 | − | |
| 76 | − | |
| 77 | −def main() -> None: | |
| 78 | − asyncio.run(EnrichmentWorker().start()) | |
| 79 | − | |
| 80 | − | |
| 81 | −if __name__ == "__main__": | |
| 82 | − main() | |
deleted
M2M32crouve-ka/services/frontier/__init__.py
+0 −9
@@ -1,9 +0,0 @@ | ||
| 1 | −# Trouve-KA — politique du frontier | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Politique du frontier : fonction de priorité et recrawl adaptatif (CLAUDE.md §5.3, §5.5).""" | |
| 6 | − | |
| 7 | −from .policy import compute_priority, next_recrawl_delay, retry_delay | |
| 8 | − | |
| 9 | −__all__ = ["compute_priority", "next_recrawl_delay", "retry_delay"] | |
deleted
M2M32crouve-ka/services/frontier/policy.py
+0 −65
@@ -1,65 +0,0 @@ | ||
| 1 | −# Trouve-KA — priorités et recrawl adaptatif | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Fonction de priorité du frontier et scheduling de recrawl. | |
| 6 | − | |
| 7 | −P = w_q·Q + w_a·A + w_f·F + w_l·L + w_n·N − w_d·D − w_s·S (CLAUDE.md §5.3) | |
| 8 | −Les poids sont des constantes nommées, destinées à être calibrées par mesures. | |
| 9 | −""" | |
| 10 | − | |
| 11 | −from datetime import timedelta | |
| 12 | − | |
| 13 | −# Poids de la fonction de priorité — à calibrer empiriquement, jamais figés | |
| 14 | −W_QUEBEC = 0.40 | |
| 15 | −W_AUTHORITY = 0.15 | |
| 16 | −W_FRESHNESS = 0.10 | |
| 17 | −W_LINKS = 0.10 | |
| 18 | −W_NOVELTY = 0.15 | |
| 19 | −W_DEPTH = 0.05 | |
| 20 | −W_SPAM = 0.30 | |
| 21 | − | |
| 22 | − | |
| 23 | −def compute_priority( | |
| 24 | − *, | |
| 25 | − domain_quebec_score: float, | |
| 26 | − authority_score: float = 0.0, | |
| 27 | − freshness_hint: float = 0.0, | |
| 28 | − link_signal: float = 0.0, | |
| 29 | − is_new_domain: bool = False, | |
| 30 | − depth: int = 0, | |
| 31 | − spam_signal: float = 0.0, | |
| 32 | − is_seed: bool = False, | |
| 33 | −) -> float: | |
| 34 | − """Priorité ∈ [0, 1]. Les seeds démarrent au maximum.""" | |
| 35 | − if is_seed: | |
| 36 | − return 1.0 | |
| 37 | − p = ( | |
| 38 | − W_QUEBEC * domain_quebec_score | |
| 39 | − + W_AUTHORITY * min(authority_score, 1.0) | |
| 40 | − + W_FRESHNESS * min(freshness_hint, 1.0) | |
| 41 | − + W_LINKS * min(link_signal, 1.0) | |
| 42 | − + W_NOVELTY * (1.0 if is_new_domain else 0.3) | |
| 43 | − - W_DEPTH * min(depth, 10) / 10.0 | |
| 44 | − - W_SPAM * min(spam_signal, 1.0) | |
| 45 | − ) | |
| 46 | − return max(0.0, min(1.0, round(p, 4))) | |
| 47 | − | |
| 48 | − | |
| 49 | −def next_recrawl_delay( | |
| 50 | − *, | |
| 51 | − changed: bool, | |
| 52 | − previous_delay_hours: float | None, | |
| 53 | − min_hours: float = 1.0, | |
| 54 | − max_hours: float = 24 * 30.0, | |
| 55 | − default_hours: float = 24.0, | |
| 56 | −) -> timedelta: | |
| 57 | − """Recrawl adaptatif (§5.5) : page inchangée → intervalle ×2; page volatile → intervalle ÷2.""" | |
| 58 | − prev = previous_delay_hours or default_hours | |
| 59 | − hours = max(min_hours, prev / 2.0) if changed else min(max_hours, prev * 2.0) | |
| 60 | − return timedelta(hours=hours) | |
| 61 | − | |
| 62 | − | |
| 63 | −def retry_delay(retries: int) -> timedelta: | |
| 64 | − """Backoff exponentiel plafonné pour les erreurs transitoires.""" | |
| 65 | − return timedelta(minutes=min(15 * (2**retries), 60 * 24)) | |
deleted
M2M32crouve-ka/services/indexer/__init__.py
+0 −13
@@ -1,13 +0,0 @@ | ||
| 1 | −# Trouve-KA — indexeur | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Construction et indexation immédiate des documents (étape 1, CLAUDE.md §4). | |
| 6 | − | |
| 7 | −L'indexation ne dépend JAMAIS de l'enrichissement : les champs des étapes 2-3 | |
| 8 | −(embedding, entités, autorité…) sont remplis plus tard par mise à jour partielle. | |
| 9 | −""" | |
| 10 | − | |
| 11 | −from .build import build_search_document | |
| 12 | − | |
| 13 | −__all__ = ["build_search_document"] | |
deleted
M2M32crouve-ka/services/indexer/build.py
+0 −66
@@ -1,66 +0,0 @@ | ||
| 1 | −# Trouve-KA — construction du document d'index | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Transforme une page parsée + signaux Québec en document OpenSearch (étape 1).""" | |
| 6 | − | |
| 7 | −from datetime import UTC, datetime | |
| 8 | −from typing import Any | |
| 9 | − | |
| 10 | −from trouveka.types import ParsedPage, QuebecSignals | |
| 11 | − | |
| 12 | −# Catégorisation grossière par domaine — raffinée en asynchrone (étape 2) | |
| 13 | −_GOV_SUFFIXES = (".gouv.qc.ca", ".gc.ca", ".quebec.ca") | |
| 14 | −_GOV_DOMAINS = {"quebec.ca", "canada.ca", "montreal.ca", "laval.ca", "gatineau.ca", "sherbrooke.ca"} | |
| 15 | −_NEWS_DOMAINS = { | |
| 16 | − "lapresse.ca", "ledevoir.com", "journaldemontreal.com", "journaldequebec.com", | |
| 17 | − "radio-canada.ca", "tvanouvelles.ca", "lesoleil.com", "ledroit.com", | |
| 18 | − "latribune.ca", "lenouvelliste.ca", "lequotidien.com", "lavoixdelest.ca", | |
| 19 | − "noovo.info", "24heures.ca", "montrealgazette.com", | |
| 20 | −} | |
| 21 | −_EDU_SUFFIXES = (".ulaval.ca", ".umontreal.ca", ".mcgill.ca", ".uqam.ca", ".usherbrooke.ca", | |
| 22 | − ".concordia.ca", ".polymtl.ca", ".etsmtl.ca", ".hec.ca") | |
| 23 | − | |
| 24 | − | |
| 25 | −def categorize_domain(domain: str) -> list[str]: | |
| 26 | − d = domain.lower() | |
| 27 | − cats: list[str] = [] | |
| 28 | − if d in _GOV_DOMAINS or any(d.endswith(s) for s in _GOV_SUFFIXES) or ".gouv." in d: | |
| 29 | − cats.append("government") | |
| 30 | − if d in _NEWS_DOMAINS: | |
| 31 | − cats.append("news") | |
| 32 | − if any(d.endswith(s) or d == s.lstrip(".") for s in _EDU_SUFFIXES) or d.endswith(".edu"): | |
| 33 | − cats.append("education") | |
| 34 | − return cats | |
| 35 | − | |
| 36 | − | |
| 37 | −def build_search_document( | |
| 38 | − page: ParsedPage, | |
| 39 | − signals: QuebecSignals, | |
| 40 | − *, | |
| 41 | − domain: str, | |
| 42 | − domain_quebec_score: float, | |
| 43 | − authority_score: float = 0.0, | |
| 44 | −) -> dict[str, Any]: | |
| 45 | − return { | |
| 46 | − "url": page.url, | |
| 47 | − "canonical_url": page.canonical_url or page.url, | |
| 48 | − "domain": domain, | |
| 49 | − "title": page.title, | |
| 50 | − "description": page.description, | |
| 51 | − "body": page.body[:100_000], | |
| 52 | − "headings": page.headings, | |
| 53 | − "language": page.language, | |
| 54 | − "page_quebec_score": signals.score, | |
| 55 | − "domain_quebec_score": round(domain_quebec_score, 4), | |
| 56 | − "locations": signals.locations, | |
| 57 | − "organizations": [], # enrichissement étape 2 | |
| 58 | − "people": [], # enrichissement étape 2 | |
| 59 | − "categories": categorize_domain(domain), | |
| 60 | − "published_at": page.published_at.isoformat() if page.published_at else None, | |
| 61 | − "crawled_at": datetime.now(UTC).isoformat(), | |
| 62 | − "authority_score": round(authority_score, 4), | |
| 63 | − "freshness_score": 0.0, # enrichissement étape 3 | |
| 64 | − "quality_score": 0.0, # enrichissement étape 3 | |
| 65 | − "spam_score": 0.0, # enrichissement étape 3 | |
| 66 | − } | |
deleted
M2M32crouve-ka/services/parser/__init__.py
+0 −13
@@ -1,13 +0,0 @@ | ||
| 1 | −# Trouve-KA — extraction de contenu | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Extraction de contenu des pages crawlées (CLAUDE.md §6). | |
| 6 | − | |
| 7 | −Chaque page est traitée comme non fiable : texte extrait puis assaini, | |
| 8 | −URLs re-canonicalisées, MIME types jamais crus sur parole. | |
| 9 | −""" | |
| 10 | − | |
| 11 | −from .html_parser import parse_html, decode_html, looks_like_garbage | |
| 12 | − | |
| 13 | −__all__ = ["parse_html", "decode_html", "looks_like_garbage"] | |
deleted
M2M32crouve-ka/services/parser/html_parser.py
+0 −246
@@ -1,246 +0,0 @@ | ||
| 1 | −# Trouve-KA — parseur HTML | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Extraction HTML avec selectolax (rapide, tolérant). | |
| 6 | − | |
| 7 | −Extrait : titre, meta description, corps principal (boilerplate retiré), | |
| 8 | −headings, liens + anchors + nofollow, canonical, langue, dates de publication, | |
| 9 | −auteur, indices structurés (JSON-LD/OpenGraph) pour le scoring Québec. | |
| 10 | −""" | |
| 11 | − | |
| 12 | −import json | |
| 13 | −import re | |
| 14 | −from datetime import datetime | |
| 15 | − | |
| 16 | −from dateutil import parser as dateparser | |
| 17 | −from langdetect import DetectorFactory, LangDetectException, detect | |
| 18 | −from selectolax.parser import HTMLParser | |
| 19 | − | |
| 20 | −from trouveka.shared import canonicalize_url | |
| 21 | −from trouveka.types import ExtractedLink, ParsedPage | |
| 22 | − | |
| 23 | −DetectorFactory.seed = 42 # détection de langue déterministe | |
| 24 | − | |
| 25 | −# Éléments retirés avant extraction du corps (navigation, pub, scripts…) | |
| 26 | −_STRIP_SELECTORS = ( | |
| 27 | − "script", "style", "noscript", "template", "svg", "iframe", "form", | |
| 28 | − "nav", "header", "footer", "aside", | |
| 29 | − "[role=navigation]", "[role=banner]", "[role=contentinfo]", "[role=complementary]", | |
| 30 | − "[aria-hidden=true]", ".cookie-banner", "#cookie-banner", ".cookies", "[class*=cookie-consent]", | |
| 31 | −) | |
| 32 | − | |
| 33 | −_MAIN_SELECTORS = ("main", "article", "[role=main]", "#main", "#content", ".main-content", ".content") | |
| 34 | − | |
| 35 | −_WS = re.compile(r"\s+") | |
| 36 | −_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") | |
| 37 | − | |
| 38 | − | |
| 39 | −def _clean(text: str | None) -> str: | |
| 40 | − if not text: | |
| 41 | − return "" | |
| 42 | − return _WS.sub(" ", _CTRL.sub(" ", text)).strip() | |
| 43 | − | |
| 44 | − | |
| 45 | −def _parse_date(value: str | None) -> datetime | None: | |
| 46 | − if not value: | |
| 47 | − return None | |
| 48 | − try: | |
| 49 | − dt = dateparser.parse(value) | |
| 50 | − except (ValueError, OverflowError, TypeError): | |
| 51 | − return None | |
| 52 | − if dt and dt.year >= 1990: | |
| 53 | − return dt | |
| 54 | − return None | |
| 55 | − | |
| 56 | − | |
| 57 | −def _meta(tree: HTMLParser, *names: str) -> str | None: | |
| 58 | − for name in names: | |
| 59 | − for attr in ("name", "property", "itemprop"): | |
| 60 | − node = tree.css_first(f'meta[{attr}="{name}"]') | |
| 61 | − if node: | |
| 62 | − content = node.attributes.get("content") | |
| 63 | − if content and content.strip(): | |
| 64 | − return content.strip() | |
| 65 | − return None | |
| 66 | − | |
| 67 | − | |
| 68 | −def _extract_jsonld_hints(tree: HTMLParser, max_blocks: int = 10) -> tuple[list[str], datetime | None]: | |
| 69 | − """Extrait des textes utiles des blocs JSON-LD (adresses, noms d'org) + datePublished.""" | |
| 70 | − hints: list[str] = [] | |
| 71 | − published: datetime | None = None | |
| 72 | − for node in tree.css('script[type="application/ld+json"]')[:max_blocks]: | |
| 73 | − raw = node.text() | |
| 74 | − if not raw or len(raw) > 100_000: | |
| 75 | − continue | |
| 76 | − try: | |
| 77 | − data = json.loads(raw) | |
| 78 | − except (json.JSONDecodeError, ValueError): | |
| 79 | − continue | |
| 80 | − stack = [data] | |
| 81 | − while stack: | |
| 82 | − item = stack.pop() | |
| 83 | − if isinstance(item, list): | |
| 84 | − stack.extend(item[:20]) | |
| 85 | − elif isinstance(item, dict): | |
| 86 | − for key in ("name", "legalName", "addressLocality", "addressRegion", | |
| 87 | − "postalCode", "streetAddress", "telephone"): | |
| 88 | − val = item.get(key) | |
| 89 | − if isinstance(val, str) and len(val) < 200: | |
| 90 | − hints.append(val) | |
| 91 | − if published is None: | |
| 92 | − published = _parse_date(item.get("datePublished")) or _parse_date(item.get("dateCreated")) | |
| 93 | − stack.extend(v for v in item.values() if isinstance(v, (dict, list))) | |
| 94 | − if len(hints) > 60: | |
| 95 | − break | |
| 96 | − return hints[:60], published | |
| 97 | − | |
| 98 | − | |
| 99 | −_META_CHARSET_RE = re.compile( | |
| 100 | − rb'<meta[^>]+charset=["\']?\s*([a-zA-Z0-9_-]{2,20})', re.IGNORECASE | |
| 101 | −) | |
| 102 | −_CHARSET_ALIASES = {"iso-8859-1": "cp1252", "latin-1": "cp1252", "latin1": "cp1252", "ansi": "cp1252"} | |
| 103 | − | |
| 104 | − | |
| 105 | −def decode_html(body: bytes, declared_charset: str | None = None) -> str: | |
| 106 | − """Décode le HTML : charset HTTP déclaré → meta charset → UTF-8 strict → cp1252. | |
| 107 | − | |
| 108 | − Les vieux sites québécois sont souvent en ISO-8859-1/cp1252; décoder | |
| 109 | − aveuglément en UTF-8 produit du charabia qui finirait dans l'index. | |
| 110 | − """ | |
| 111 | − candidates: list[str] = [] | |
| 112 | − if declared_charset: | |
| 113 | − candidates.append(declared_charset.lower()) | |
| 114 | − meta = _META_CHARSET_RE.search(body[:4096]) | |
| 115 | − if meta: | |
| 116 | − candidates.append(meta.group(1).decode("ascii", errors="ignore").lower()) | |
| 117 | − candidates.extend(["utf-8", "cp1252"]) | |
| 118 | − for charset in candidates: | |
| 119 | − charset = _CHARSET_ALIASES.get(charset, charset) | |
| 120 | − try: | |
| 121 | − return body.decode(charset) | |
| 122 | − except (UnicodeDecodeError, LookupError): | |
| 123 | − continue | |
| 124 | − return body.decode("utf-8", errors="replace") | |
| 125 | − | |
| 126 | − | |
| 127 | −def looks_like_garbage(text: str, *, threshold: float = 0.03) -> bool: | |
| 128 | − """True si le texte contient trop de caractères de remplacement/contrôle | |
| 129 | − (contenu binaire ou mal décodé — ne doit jamais être indexé).""" | |
| 130 | − if not text: | |
| 131 | − return False | |
| 132 | − sample = text[:20_000] | |
| 133 | − bad = sum(1 for c in sample if c == "�" or (ord(c) < 32 and c not in "\t\n\r")) | |
| 134 | − return bad / len(sample) > threshold | |
| 135 | − | |
| 136 | − | |
| 137 | −def parse_html( | |
| 138 | − url: str, html: bytes | str, *, max_links: int = 300, charset: str | None = None | |
| 139 | −) -> ParsedPage: | |
| 140 | − """Parse une page HTML en ParsedPage. Ne lève pas sur du HTML dégueulasse.""" | |
| 141 | − if isinstance(html, bytes): | |
| 142 | − html = decode_html(html, charset) | |
| 143 | − tree = HTMLParser(html) | |
| 144 | − | |
| 145 | − # Directives robots de la page | |
| 146 | − robots_meta = (_meta(tree, "robots", "googlebot") or "").lower() | |
| 147 | − noindex = "noindex" in robots_meta | |
| 148 | − nofollow_page = "nofollow" in robots_meta | |
| 149 | − | |
| 150 | − # Canonical | |
| 151 | − canonical = None | |
| 152 | − link_canonical = tree.css_first('link[rel="canonical"]') | |
| 153 | − if link_canonical: | |
| 154 | − canonical = canonicalize_url(link_canonical.attributes.get("href") or "", base=url) | |
| 155 | − | |
| 156 | − # Titre / description | |
| 157 | − title = _clean(tree.css_first("title").text() if tree.css_first("title") else None) | |
| 158 | − og_title = _meta(tree, "og:title") | |
| 159 | − if not title and og_title: | |
| 160 | − title = _clean(og_title) | |
| 161 | − description = _clean(_meta(tree, "description", "og:description") or "") | |
| 162 | − | |
| 163 | − # Langue déclarée | |
| 164 | − declared_lang = None | |
| 165 | − html_node = tree.css_first("html") | |
| 166 | − if html_node: | |
| 167 | − lang_attr = (html_node.attributes.get("lang") or "").strip().lower() | |
| 168 | − if lang_attr: | |
| 169 | − declared_lang = lang_attr[:2] | |
| 170 | − | |
| 171 | − # Dates / auteur | |
| 172 | − published = _parse_date(_meta(tree, "article:published_time", "datePublished", "date", "dc.date")) | |
| 173 | − modified = _parse_date(_meta(tree, "article:modified_time", "dateModified")) | |
| 174 | − author = _meta(tree, "author", "article:author") | |
| 175 | − | |
| 176 | − # Indices structurés (avant strip : JSON-LD est dans <script>) | |
| 177 | − structured_hints, jsonld_published = _extract_jsonld_hints(tree) | |
| 178 | − published = published or jsonld_published | |
| 179 | − | |
| 180 | − # Liens (avant strip : les liens de nav comptent pour la découverte) | |
| 181 | − links: list[ExtractedLink] = [] | |
| 182 | − seen: set[str] = set() | |
| 183 | − for node in tree.css("a[href]"): | |
| 184 | − if len(links) >= max_links: | |
| 185 | − break | |
| 186 | − href = node.attributes.get("href") or "" | |
| 187 | − if href.startswith(("#", "javascript:", "mailto:", "tel:", "data:")): | |
| 188 | − continue | |
| 189 | − normalized = canonicalize_url(href, base=url) | |
| 190 | − if not normalized or normalized in seen: | |
| 191 | − continue | |
| 192 | − seen.add(normalized) | |
| 193 | − rel = (node.attributes.get("rel") or "").lower() | |
| 194 | − links.append( | |
| 195 | − ExtractedLink( | |
| 196 | − url=normalized, | |
| 197 | − anchor=_clean(node.text())[:200], | |
| 198 | − nofollow="nofollow" in rel or nofollow_page, | |
| 199 | − ) | |
| 200 | − ) | |
| 201 | − | |
| 202 | − # Headings | |
| 203 | − headings = [_clean(n.text()) for n in tree.css("h1, h2, h3")[:40]] | |
| 204 | − headings = [h for h in headings if h] | |
| 205 | − | |
| 206 | − # Corps principal : strip du boilerplate, puis zone principale si identifiable | |
| 207 | − for selector in _STRIP_SELECTORS: | |
| 208 | − for node in tree.css(selector): | |
| 209 | − node.decompose() | |
| 210 | − body_node = None | |
| 211 | − for selector in _MAIN_SELECTORS: | |
| 212 | − body_node = tree.css_first(selector) | |
| 213 | − if body_node: | |
| 214 | − break | |
| 215 | − if body_node is None: | |
| 216 | − body_node = tree.css_first("body") or tree.root | |
| 217 | − body = _clean(body_node.text(separator=" ", deep=True))[:200_000] | |
| 218 | − | |
| 219 | − # Langue : déclaration HTML validée/complétée par détection statistique | |
| 220 | − language = declared_lang if declared_lang in ("fr", "en") else None | |
| 221 | − sample = body[:4000] or title | |
| 222 | − if sample and len(sample) > 40: | |
| 223 | − try: | |
| 224 | − detected = detect(sample) | |
| 225 | − if detected in ("fr", "en"): | |
| 226 | − # La détection l'emporte si elle contredit une déclaration douteuse | |
| 227 | − language = detected if language is None or detected != language else language | |
| 228 | − except LangDetectException: | |
| 229 | − pass | |
| 230 | − | |
| 231 | − return ParsedPage( | |
| 232 | − url=url, | |
| 233 | − canonical_url=canonical, | |
| 234 | − title=title[:500], | |
| 235 | − description=description[:1000], | |
| 236 | − body=body, | |
| 237 | − headings=headings, | |
| 238 | − language=language, | |
| 239 | − links=links, | |
| 240 | − published_at=published, | |
| 241 | − modified_at=modified, | |
| 242 | − author=_clean(author)[:200] if author else None, | |
| 243 | − noindex=noindex, | |
| 244 | − nofollow_page=nofollow_page, | |
| 245 | − structured_hints=structured_hints, | |
| 246 | − ) | |
deleted
M2M32crouve-ka/services/ranking/__init__.py
+0 −13
@@ -1,13 +0,0 @@ | ||
| 1 | −# Trouve-KA — ranking | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Ranking Québec-first (CLAUDE.md §9). | |
| 6 | − | |
| 7 | −Score(d,q) = w_b·BM25 + w_q·Québec + w_a·Autorité + w_f·Fraîcheur + w_l·Localité | |
| 8 | −Chaque composant avancé est optionnel : BM25 fonctionne même si tout le reste tombe. | |
| 9 | −""" | |
| 10 | − | |
| 11 | −from .query import analyze_query, build_search_body | |
| 12 | − | |
| 13 | −__all__ = ["analyze_query", "build_search_body"] | |
deleted
M2M32crouve-ka/services/ranking/query.py
+0 −146
@@ -1,146 +0,0 @@ | ||
| 1 | −# Trouve-KA — pipeline de requête et ranking | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Pipeline de requête : normalisation → langue → lieux → requête OpenSearch. | |
| 6 | − | |
| 7 | −BM25 (multi_match bilingue) au cœur, function_score par-dessus : | |
| 8 | −Québec, autorité, fraîcheur, localité. Poids nommés, non figés (§9). | |
| 9 | −""" | |
| 10 | − | |
| 11 | −import re | |
| 12 | −import unicodedata | |
| 13 | −from typing import Any | |
| 14 | − | |
| 15 | −from trouveka.classifier import QUEBEC_TOPONYMS | |
| 16 | − | |
| 17 | −# Poids de ranking — à calibrer avec le dataset d'évaluation (§16) | |
| 18 | −W_PAGE_QUEBEC = 1.6 | |
| 19 | −W_DOMAIN_QUEBEC = 1.0 | |
| 20 | −W_AUTHORITY = 0.8 | |
| 21 | −W_FRESHNESS = 0.5 | |
| 22 | −W_LOCALITY = 2.0 | |
| 23 | − | |
| 24 | −_FR_HINTS = {"le", "la", "les", "des", "une", "un", "du", "de", "et", "ou", "pour", | |
| 25 | − "avec", "dans", "sur", "meilleur", "meilleure", "comment", "où", "quel", "quelle"} | |
| 26 | −_EN_HINTS = {"the", "a", "an", "of", "and", "or", "for", "with", "in", "on", "best", "how", "what", "where"} | |
| 27 | − | |
| 28 | − | |
| 29 | −def _fold(text: str) -> str: | |
| 30 | − return "".join(c for c in unicodedata.normalize("NFD", text) if unicodedata.category(c) != "Mn") | |
| 31 | − | |
| 32 | − | |
| 33 | −def analyze_query(q: str) -> dict[str, Any]: | |
| 34 | − """Normalisation, détection de langue heuristique, extraction de lieux québécois.""" | |
| 35 | − normalized = re.sub(r"\s+", " ", q).strip()[:200] | |
| 36 | − tokens = [t.lower() for t in re.findall(r"[\w'-]+", normalized, re.UNICODE)] | |
| 37 | − | |
| 38 | − fr_hits = sum(1 for t in tokens if t in _FR_HINTS) | |
| 39 | − en_hits = sum(1 for t in tokens if t in _EN_HINTS) | |
| 40 | − has_accents = normalized != _fold(normalized) | |
| 41 | − language = "fr" if (fr_hits > en_hits or has_accents) else ("en" if en_hits > fr_hits else None) | |
| 42 | − | |
| 43 | − # Lieux : tokens simples + bigrammes contre le gazetteer | |
| 44 | − folded_gazetteer = {_fold(t): t for t in QUEBEC_TOPONYMS} | |
| 45 | − locations: list[str] = [] | |
| 46 | − candidates = tokens + [f"{a}-{b}" for a, b in zip(tokens, tokens[1:])] + [ | |
| 47 | − f"{a} {b}" for a, b in zip(tokens, tokens[1:]) | |
| 48 | − ] | |
| 49 | − for cand in candidates: | |
| 50 | − folded = _fold(cand) | |
| 51 | − if folded in folded_gazetteer: | |
| 52 | − locations.append(folded_gazetteer[folded]) | |
| 53 | − return {"query": normalized, "language": language, "locations": sorted(set(locations))} | |
| 54 | − | |
| 55 | − | |
| 56 | −def build_search_body( | |
| 57 | − q: str, | |
| 58 | − *, | |
| 59 | − page: int = 1, | |
| 60 | − limit: int = 10, | |
| 61 | − language: str | None = None, | |
| 62 | − category: str | None = None, | |
| 63 | − quebec_only: bool = False, | |
| 64 | − freshness: str | None = None, | |
| 65 | −) -> tuple[dict[str, Any], dict[str, Any]]: | |
| 66 | − """Construit le corps de requête OpenSearch. Retourne (body, analyse).""" | |
| 67 | − analysis = analyze_query(q) | |
| 68 | − normalized = analysis["query"] | |
| 69 | − | |
| 70 | − # BM25 bilingue : les deux analyzers interrogés, le meilleur champ gagne | |
| 71 | − text_query: dict[str, Any] = { | |
| 72 | − "multi_match": { | |
| 73 | − "query": normalized, | |
| 74 | − "type": "most_fields", | |
| 75 | − "fields": [ | |
| 76 | − "title^4", "title.en^4", | |
| 77 | − "headings^2", "headings.en^2", | |
| 78 | − "description^2", "description.en^2", | |
| 79 | − "body", "body.en", | |
| 80 | − ], | |
| 81 | − "fuzziness": "AUTO", | |
| 82 | − "prefix_length": 2, | |
| 83 | − } | |
| 84 | − } | |
| 85 | − | |
| 86 | − filters: list[dict[str, Any]] = [] | |
| 87 | − if language in ("fr", "en"): | |
| 88 | − filters.append({"term": {"language": language}}) | |
| 89 | − if category: | |
| 90 | − filters.append({"term": {"categories": category}}) | |
| 91 | − if quebec_only: | |
| 92 | − filters.append({ | |
| 93 | − "bool": { | |
| 94 | − "should": [ | |
| 95 | − {"range": {"page_quebec_score": {"gte": 0.45}}}, | |
| 96 | − {"range": {"domain_quebec_score": {"gte": 0.6}}}, | |
| 97 | − ], | |
| 98 | − "minimum_should_match": 1, | |
| 99 | − } | |
| 100 | − }) | |
| 101 | − if freshness in ("day", "week", "month", "year"): | |
| 102 | − filters.append({"range": {"crawled_at": {"gte": f"now-1{freshness[0]}/d"}}}) | |
| 103 | − | |
| 104 | − functions: list[dict[str, Any]] = [ | |
| 105 | − {"field_value_factor": {"field": "page_quebec_score", "factor": W_PAGE_QUEBEC, "missing": 0}}, | |
| 106 | − {"field_value_factor": {"field": "domain_quebec_score", "factor": W_DOMAIN_QUEBEC, "missing": 0}}, | |
| 107 | − {"field_value_factor": {"field": "authority_score", "factor": W_AUTHORITY, "missing": 0}}, | |
| 108 | − { | |
| 109 | − "gauss": {"published_at": {"origin": "now", "scale": "180d", "decay": 0.6}}, | |
| 110 | − "weight": W_FRESHNESS, | |
| 111 | − }, | |
| 112 | − ] | |
| 113 | − # Localité : « plombier Gatineau » booste les documents avec preuve géographique explicite | |
| 114 | − if analysis["locations"]: | |
| 115 | − functions.append({ | |
| 116 | − "filter": {"terms": {"locations": analysis["locations"]}}, | |
| 117 | − "weight": W_LOCALITY, | |
| 118 | − }) | |
| 119 | − | |
| 120 | − body = { | |
| 121 | − "from": max(page - 1, 0) * limit, | |
| 122 | − "size": limit, | |
| 123 | − "query": { | |
| 124 | − "function_score": { | |
| 125 | − "query": {"bool": {"must": [text_query], "filter": filters}}, | |
| 126 | − "functions": functions, | |
| 127 | − "score_mode": "sum", | |
| 128 | − "boost_mode": "sum", | |
| 129 | − } | |
| 130 | − }, | |
| 131 | − "highlight": { | |
| 132 | − "pre_tags": ["<em>"], | |
| 133 | − "post_tags": ["</em>"], | |
| 134 | − "fields": { | |
| 135 | − "body": {"fragment_size": 180, "number_of_fragments": 2}, | |
| 136 | − "description": {"fragment_size": 180, "number_of_fragments": 1}, | |
| 137 | − }, | |
| 138 | − "encoder": "html", | |
| 139 | − }, | |
| 140 | − "_source": [ | |
| 141 | − "url", "canonical_url", "domain", "title", "description", "language", | |
| 142 | − "page_quebec_score", "domain_quebec_score", "categories", "published_at", | |
| 143 | − ], | |
| 144 | − "track_total_hits": True, | |
| 145 | − } | |
| 146 | − return body, analysis | |
deleted
M2M32crouve-ka/services/scheduler/__init__.py
+0 −9
@@ -1,9 +0,0 @@ | ||
| 1 | −# Trouve-KA — scheduler | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Boucle de maintenance : items abandonnés, autorité de domaine, hygiène du frontier.""" | |
| 6 | − | |
| 7 | −from .loop import SchedulerLoop | |
| 8 | − | |
| 9 | −__all__ = ["SchedulerLoop"] | |
deleted
M2M32crouve-ka/services/scheduler/loop.py
+0 −81
@@ -1,81 +0,0 @@ | ||
| 1 | −# Trouve-KA — boucle de maintenance | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −"""Scheduler : tâches périodiques légères. | |
| 6 | − | |
| 7 | −- Relance les items in_progress abandonnés (worker mort) — dégradation gracieuse §13. | |
| 8 | −- Recalcule l'autorité de domaine à partir du graphe de liens (inlinks pondérés | |
| 9 | − par le score Québec des domaines source) — version simple, PageRank-like plus tard. | |
| 10 | −""" | |
| 11 | − | |
| 12 | −import asyncio | |
| 13 | −import signal | |
| 14 | − | |
| 15 | −from trouveka.config import get_settings | |
| 16 | −from trouveka.database import Database | |
| 17 | −from trouveka.logging import get_logger | |
| 18 | − | |
| 19 | −log = get_logger("scheduler") | |
| 20 | − | |
| 21 | −STALE_RESET_INTERVAL = 60 # secondes | |
| 22 | −AUTHORITY_INTERVAL = 15 * 60 # secondes | |
| 23 | − | |
| 24 | − | |
| 25 | −class SchedulerLoop: | |
| 26 | − def __init__(self) -> None: | |
| 27 | − self.s = get_settings() | |
| 28 | − self.db = Database(self.s.database_url, pool_min=1, pool_max=3) | |
| 29 | − self.stop_event = asyncio.Event() | |
| 30 | − | |
| 31 | − async def recompute_authority(self) -> None: | |
| 32 | − """Autorité ∈ [0,1] : log-saturation des inlinks pondérés par le Québec-score des sources.""" | |
| 33 | − await self.db.pool.execute( | |
| 34 | − """ | |
| 35 | − WITH weighted AS ( | |
| 36 | − SELECT dl.to_domain_id AS id, | |
| 37 | − sum(least(dl.link_count, 50) * greatest(d.quebec_score, 0.1)) AS w, | |
| 38 | − count(DISTINCT dl.from_domain_id) AS in_domains | |
| 39 | − FROM domain_links dl JOIN domains d ON d.id = dl.from_domain_id | |
| 40 | − GROUP BY dl.to_domain_id | |
| 41 | − ) | |
| 42 | − UPDATE domains SET | |
| 43 | − authority_score = least(1.0, ln(1 + w.w) / ln(1 + 5000)), | |
| 44 | − inlink_domains = w.in_domains | |
| 45 | − FROM weighted w WHERE domains.id = w.id | |
| 46 | − """ | |
| 47 | − ) | |
| 48 | − log.info("autorité de domaine recalculée") | |
| 49 | − | |
| 50 | − async def start(self) -> None: | |
| 51 | − await self.db.connect() | |
| 52 | − loop = asyncio.get_running_loop() | |
| 53 | − for sig in (signal.SIGINT, signal.SIGTERM): | |
| 54 | − loop.add_signal_handler(sig, self.stop_event.set) | |
| 55 | − log.info("scheduler démarré") | |
| 56 | − | |
| 57 | − elapsed_authority = AUTHORITY_INTERVAL # premier calcul immédiat | |
| 58 | − while not self.stop_event.is_set(): | |
| 59 | − try: | |
| 60 | − reset = await self.db.reset_stale_items(older_than_minutes=30) | |
| 61 | − if reset: | |
| 62 | − log.info("items abandonnés relancés", extra={"ctx": {"count": reset}}) | |
| 63 | − if elapsed_authority >= AUTHORITY_INTERVAL: | |
| 64 | − await self.recompute_authority() | |
| 65 | − elapsed_authority = 0 | |
| 66 | − except Exception: | |
| 67 | − log.exception("erreur scheduler (on continue)") | |
| 68 | − try: | |
| 69 | − await asyncio.wait_for(self.stop_event.wait(), timeout=STALE_RESET_INTERVAL) | |
| 70 | − except TimeoutError: | |
| 71 | − pass | |
| 72 | − elapsed_authority += STALE_RESET_INTERVAL | |
| 73 | − await self.db.close() | |
| 74 | − | |
| 75 | − | |
| 76 | −def main() -> None: | |
| 77 | − asyncio.run(SchedulerLoop().start()) | |
| 78 | − | |
| 79 | − | |
| 80 | −if __name__ == "__main__": | |
| 81 | − main() | |
deleted
M2M32crouve-ka/tests/test_fetcher.py
+0 −110
@@ -1,110 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests du fetcher (web de fixtures via MockTransport) | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −import functools | |
| 6 | − | |
| 7 | −import httpx | |
| 8 | −import pytest | |
| 9 | − | |
| 10 | −import trouveka.crawler.fetcher as fetcher_mod | |
| 11 | −from trouveka.config import Settings | |
| 12 | −from trouveka.crawler.fetcher import Fetcher | |
| 13 | −from trouveka.shared import is_safe_url | |
| 14 | −from trouveka.types import ErrorCode | |
| 15 | − | |
| 16 | − | |
| 17 | −@pytest.fixture(autouse=True) | |
| 18 | −def no_dns(monkeypatch): | |
| 19 | − # Les hôtes de fixtures ne résolvent pas en vrai DNS : on garde toute la | |
| 20 | − # logique SSRF (schémas, hôtes bloqués, IP littérales) sans résolution. | |
| 21 | − monkeypatch.setattr(fetcher_mod, "is_safe_url", functools.partial(is_safe_url, resolve=False)) | |
| 22 | − | |
| 23 | − | |
| 24 | −def make_fetcher(handler, **overrides) -> Fetcher: | |
| 25 | − settings = Settings(max_response_bytes=1000, max_redirects=3, fetch_timeout=5, **overrides) | |
| 26 | − client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) | |
| 27 | − return Fetcher(client, settings) | |
| 28 | − | |
| 29 | − | |
| 30 | −async def test_simple_fetch(): | |
| 31 | − def handler(request): | |
| 32 | − return httpx.Response(200, html="<html><title>ok</title></html>", | |
| 33 | − headers={"content-type": "text/html; charset=utf-8"}) | |
| 34 | − | |
| 35 | − result = await make_fetcher(handler).fetch("https://site.qc.ca/page") | |
| 36 | − assert result.status_code == 200 | |
| 37 | − assert result.error_code == ErrorCode.OK | |
| 38 | − assert b"ok" in result.body | |
| 39 | − | |
| 40 | − | |
| 41 | −async def test_redirect_followed_with_ssrf_recheck(): | |
| 42 | − def handler(request): | |
| 43 | − if request.url.path == "/a": | |
| 44 | − return httpx.Response(301, headers={"location": "/b"}) | |
| 45 | − return httpx.Response(200, html="fini", headers={"content-type": "text/html"}) | |
| 46 | − | |
| 47 | − result = await make_fetcher(handler).fetch("https://site.qc.ca/a") | |
| 48 | − assert result.status_code == 200 | |
| 49 | − assert result.final_url == "https://site.qc.ca/b" | |
| 50 | − assert result.redirect_chain == ["https://site.qc.ca/b"] | |
| 51 | − | |
| 52 | − | |
| 53 | −async def test_redirect_to_private_ip_blocked(): | |
| 54 | − def handler(request): | |
| 55 | − return httpx.Response(302, headers={"location": "http://169.254.169.254/latest/meta-data/"}) | |
| 56 | − | |
| 57 | − result = await make_fetcher(handler).fetch("https://site.qc.ca/evil") | |
| 58 | − assert result.error_code == ErrorCode.SSRF_BLOCKED | |
| 59 | − | |
| 60 | − | |
| 61 | −async def test_too_many_redirects(): | |
| 62 | − def handler(request): | |
| 63 | − n = int(request.url.path.strip("/") or 0) | |
| 64 | − return httpx.Response(301, headers={"location": f"/{n + 1}"}) | |
| 65 | − | |
| 66 | − result = await make_fetcher(handler).fetch("https://site.qc.ca/0") | |
| 67 | − assert result.error_code == ErrorCode.TOO_MANY_REDIRECTS | |
| 68 | − | |
| 69 | − | |
| 70 | −async def test_body_size_limit(): | |
| 71 | − def handler(request): | |
| 72 | − return httpx.Response(200, content=b"x" * 5000, headers={"content-type": "text/html"}) | |
| 73 | − | |
| 74 | − result = await make_fetcher(handler).fetch("https://site.qc.ca/gros") | |
| 75 | − assert result.error_code == ErrorCode.TOO_LARGE | |
| 76 | − | |
| 77 | − | |
| 78 | −async def test_unsupported_content_type(): | |
| 79 | − def handler(request): | |
| 80 | − return httpx.Response(200, content=b"%PDF-1.4", headers={"content-type": "application/pdf"}) | |
| 81 | − | |
| 82 | − result = await make_fetcher(handler).fetch("https://site.qc.ca/doc.pdf") | |
| 83 | − assert result.error_code == ErrorCode.UNSUPPORTED_CONTENT | |
| 84 | − | |
| 85 | − | |
| 86 | −async def test_conditional_304(): | |
| 87 | − def handler(request): | |
| 88 | − assert request.headers.get("if-none-match") == 'W/"abc"' | |
| 89 | − return httpx.Response(304) | |
| 90 | − | |
| 91 | − result = await make_fetcher(handler).fetch("https://site.qc.ca/page", etag='W/"abc"') | |
| 92 | − assert result.status_code == 304 | |
| 93 | − | |
| 94 | − | |
| 95 | −async def test_4xx_and_5xx_classified(): | |
| 96 | − async def run(status): | |
| 97 | − def handler(request): | |
| 98 | − return httpx.Response(status) | |
| 99 | − return await make_fetcher(handler).fetch("https://site.qc.ca/x") | |
| 100 | − | |
| 101 | − assert (await run(404)).error_code == ErrorCode.HTTP_4XX | |
| 102 | − assert (await run(503)).error_code == ErrorCode.HTTP_5XX | |
| 103 | − | |
| 104 | − | |
| 105 | −async def test_ssrf_blocked_upfront(): | |
| 106 | − def handler(request): # pragma: no cover — ne doit jamais être appelé | |
| 107 | − raise AssertionError("fetch ne doit pas partir") | |
| 108 | − | |
| 109 | − result = await make_fetcher(handler).fetch("http://127.0.0.1:8080/admin") | |
| 110 | − assert result.error_code == ErrorCode.SSRF_BLOCKED | |
deleted
M2M32crouve-ka/tests/test_frontier_policy.py
+0 −44
@@ -1,44 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests de la politique du frontier | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −from trouveka.frontier import compute_priority, next_recrawl_delay, retry_delay | |
| 6 | − | |
| 7 | − | |
| 8 | −def test_seed_priority_is_max(): | |
| 9 | − assert compute_priority(domain_quebec_score=0, is_seed=True) == 1.0 | |
| 10 | − | |
| 11 | − | |
| 12 | −def test_priority_bounded(): | |
| 13 | − assert 0.0 <= compute_priority(domain_quebec_score=1.0, authority_score=1.0, | |
| 14 | − link_signal=1.0, is_new_domain=True) <= 1.0 | |
| 15 | − assert compute_priority(domain_quebec_score=0.0, spam_signal=1.0, depth=10) == 0.0 | |
| 16 | − | |
| 17 | − | |
| 18 | −def test_quebec_dominates_priority(): | |
| 19 | − high_q = compute_priority(domain_quebec_score=0.9, depth=2) | |
| 20 | − low_q = compute_priority(domain_quebec_score=0.1, depth=2) | |
| 21 | − assert high_q > low_q + 0.2 | |
| 22 | − | |
| 23 | − | |
| 24 | −def test_recrawl_backoff_doubles_when_unchanged(): | |
| 25 | − d = next_recrawl_delay(changed=False, previous_delay_hours=24.0) | |
| 26 | − assert d.total_seconds() == 48 * 3600 | |
| 27 | − | |
| 28 | − | |
| 29 | −def test_recrawl_halves_when_changed(): | |
| 30 | − d = next_recrawl_delay(changed=True, previous_delay_hours=24.0) | |
| 31 | − assert d.total_seconds() == 12 * 3600 | |
| 32 | − | |
| 33 | − | |
| 34 | −def test_recrawl_bounded(): | |
| 35 | − fast = next_recrawl_delay(changed=True, previous_delay_hours=0.5, min_hours=1.0) | |
| 36 | − slow = next_recrawl_delay(changed=False, previous_delay_hours=10_000, max_hours=720) | |
| 37 | − assert fast.total_seconds() == 3600 | |
| 38 | − assert slow.total_seconds() == 720 * 3600 | |
| 39 | − | |
| 40 | − | |
| 41 | −def test_retry_delay_grows_and_caps(): | |
| 42 | − assert retry_delay(0).total_seconds() == 15 * 60 | |
| 43 | − assert retry_delay(1).total_seconds() == 30 * 60 | |
| 44 | − assert retry_delay(10).total_seconds() == 24 * 3600 | |
deleted
M2M32crouve-ka/tests/test_parser.py
+0 −92
@@ -1,92 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests d'extraction HTML | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −from trouveka.parser import decode_html, looks_like_garbage, parse_html | |
| 6 | − | |
| 7 | −FIXTURE = """<!DOCTYPE html> | |
| 8 | −<html lang="fr"> | |
| 9 | −<head> | |
| 10 | − <title>Plomberie XYZ — Plombier à Gatineau</title> | |
| 11 | − <meta name="description" content="Service de plomberie en Outaouais depuis 1998."> | |
| 12 | − <link rel="canonical" href="https://plomberiexyz.qc.ca/services"> | |
| 13 | − <meta property="article:published_time" content="2024-06-12T10:00:00-04:00"> | |
| 14 | − <script type="application/ld+json"> | |
| 15 | − {"@type": "Plumber", "name": "Plomberie XYZ", | |
| 16 | − "address": {"addressLocality": "Gatineau", "addressRegion": "QC", "postalCode": "J8X 3X3"}} | |
| 17 | − </script> | |
| 18 | −</head> | |
| 19 | −<body> | |
| 20 | − <nav><a href="/menu1">Menu qui ne doit pas polluer le corps</a></nav> | |
| 21 | − <main> | |
| 22 | − <h1>Plombier Gatineau</h1> | |
| 23 | − <h2>Nos services</h2> | |
| 24 | − <p>Nous desservons Gatineau et tout l'Outaouais depuis 1998.</p> | |
| 25 | − <a href="/tarifs?utm_source=nav">Tarifs</a> | |
| 26 | − <a href="https://autresite.qc.ca/page" rel="nofollow">Partenaire</a> | |
| 27 | − <a href="mailto:info@x.ca">Courriel</a> | |
| 28 | − <a href="javascript:void(0)">JS</a> | |
| 29 | − </main> | |
| 30 | − <footer>123 rue Principale, Gatineau (Québec) J8X 3X3 — 819-555-1234</footer> | |
| 31 | − <script>trackingStuff();</script> | |
| 32 | −</body> | |
| 33 | −</html>""" | |
| 34 | − | |
| 35 | − | |
| 36 | −def test_full_extraction(): | |
| 37 | − page = parse_html("https://plomberiexyz.qc.ca/services/", FIXTURE) | |
| 38 | − assert page.title == "Plomberie XYZ — Plombier à Gatineau" | |
| 39 | − assert "Outaouais" in page.description | |
| 40 | − assert page.canonical_url == "https://plomberiexyz.qc.ca/services" | |
| 41 | − assert page.language == "fr" | |
| 42 | − assert "Plombier Gatineau" in page.headings | |
| 43 | − assert page.published_at and page.published_at.year == 2024 | |
| 44 | − # Corps : contenu principal présent, nav/script exclus | |
| 45 | − assert "Nous desservons Gatineau" in page.body | |
| 46 | − assert "Menu qui ne doit pas" not in page.body | |
| 47 | − assert "trackingStuff" not in page.body | |
| 48 | − # JSON-LD → indices structurés | |
| 49 | − assert "Gatineau" in page.structured_hints | |
| 50 | − | |
| 51 | − | |
| 52 | −def test_links_normalized_and_flagged(): | |
| 53 | − page = parse_html("https://plomberiexyz.qc.ca/services/", FIXTURE) | |
| 54 | − urls = {link.url for link in page.links} | |
| 55 | − assert "https://plomberiexyz.qc.ca/tarifs" in urls # utm retiré, relatif résolu | |
| 56 | − nofollow = {link.url: link.nofollow for link in page.links} | |
| 57 | − assert nofollow["https://autresite.qc.ca/page"] is True | |
| 58 | − assert not any(u.startswith(("mailto:", "javascript:")) for u in urls) | |
| 59 | − | |
| 60 | − | |
| 61 | −def test_noindex_detected(): | |
| 62 | − html = '<html><head><meta name="robots" content="noindex, nofollow"><title>x</title></head><body>corps</body></html>' | |
| 63 | − page = parse_html("https://x.qc.ca/", html) | |
| 64 | − assert page.noindex is True | |
| 65 | − assert page.nofollow_page is True | |
| 66 | − | |
| 67 | − | |
| 68 | −def test_garbage_html_does_not_crash(): | |
| 69 | − page = parse_html("https://x.qc.ca/", b"\x00\xffPas du HTML <div <<< &&& </span>") | |
| 70 | − assert page.url == "https://x.qc.ca/" | |
| 71 | − | |
| 72 | − | |
| 73 | −def test_decode_latin1_quebec_site(): | |
| 74 | − # Vieux site québécois en ISO-8859-1 : les accents doivent survivre | |
| 75 | − body = "<html><head><meta charset=\"iso-8859-1\"><title>Éducation à Québec</title></head><body>Ministère de l'Éducation</body></html>".encode("cp1252") | |
| 76 | − assert "Éducation à Québec" in decode_html(body) | |
| 77 | − page = parse_html("https://vieux-site.qc.ca/", body) | |
| 78 | − assert page.title == "Éducation à Québec" | |
| 79 | − | |
| 80 | − | |
| 81 | −def test_decode_http_charset_priority(): | |
| 82 | − body = "<html><title>Montréal</title></html>".encode("cp1252") | |
| 83 | − assert "Montréal" in decode_html(body, "iso-8859-1") | |
| 84 | − | |
| 85 | − | |
| 86 | −def test_binary_garbage_detected(): | |
| 87 | − # Contenu binaire (ex. brotli non décodé) décodé de force → détecté comme charabia | |
| 88 | − binary = bytes(range(256)) * 40 | |
| 89 | − text = binary.decode("utf-8", errors="replace") | |
| 90 | − assert looks_like_garbage(text) | |
| 91 | − assert not looks_like_garbage("Une page normale sur le Québec, avec des accents é à ç.") | |
| 92 | − assert not looks_like_garbage("") | |
deleted
M2M32crouve-ka/tests/test_quebec_scoring.py
+0 −88
@@ -1,88 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests du scoring Québec | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −from trouveka.classifier import score_page | |
| 6 | −from trouveka.types import ParsedPage | |
| 7 | − | |
| 8 | − | |
| 9 | −def _page(**kwargs) -> ParsedPage: | |
| 10 | − defaults = {"url": "https://example.com/page", "title": "", "body": ""} | |
| 11 | − return ParsedPage(**(defaults | kwargs)) | |
| 12 | − | |
| 13 | − | |
| 14 | −def test_quebec_business_scores_high(): | |
| 15 | − page = _page( | |
| 16 | − url="https://plomberiexyz.qc.ca/services", | |
| 17 | − title="Plomberie XYZ — Plombier à Gatineau", | |
| 18 | − description="Service de plomberie résidentielle en Outaouais", | |
| 19 | − body="Plomberie XYZ dessert Gatineau et l'Outaouais. 123 rue Principale, Gatineau (Québec) J8X 3X3. " | |
| 20 | − "Appelez-nous au 819-555-1234. Urgences 24h partout au Québec.", | |
| 21 | − headings=["Plombier Gatineau", "Nos services"], | |
| 22 | − language="fr", | |
| 23 | − ) | |
| 24 | − signals = score_page(page, "plomberiexyz.qc.ca") | |
| 25 | − assert signals.score > 0.7 | |
| 26 | − assert "gatineau" in signals.locations | |
| 27 | − assert "tld_quebec" in signals.reasons | |
| 28 | − assert "code_postal_qc" in signals.reasons | |
| 29 | − | |
| 30 | − | |
| 31 | −def test_ontario_business_scores_low(): | |
| 32 | − page = _page( | |
| 33 | − url="https://ottawaplumbing.ca/", | |
| 34 | − title="Ottawa Plumbing Services", | |
| 35 | − description="Plumbing services in Ottawa, Ontario", | |
| 36 | − body="We serve Ottawa, Kanata and Nepean. 456 Bank Street, Ottawa, ON K1S 3T4. Call 613-555-9999.", | |
| 37 | − headings=["Plumbing Ottawa"], | |
| 38 | − language="en", | |
| 39 | − ) | |
| 40 | − signals = score_page(page, "ottawaplumbing.ca") | |
| 41 | − assert signals.score < 0.2 | |
| 42 | − | |
| 43 | − | |
| 44 | −def test_nyt_article_about_montreal_gets_page_signal(): | |
| 45 | − # Un article international SUR Montréal : le domaine n'est pas québécois, | |
| 46 | − # mais la page a un signal réel (page_quebec_score > 0, sans être maximal). | |
| 47 | − page = _page( | |
| 48 | − url="https://www.nytimes.com/2026/01/01/travel/montreal.html", | |
| 49 | − title="36 Hours in Montreal", | |
| 50 | − description="What to do in Montreal, Quebec's largest city", | |
| 51 | − body="Montreal is the largest city in Quebec. From the Plateau-Mont-Royal to Old Montreal, " | |
| 52 | − "the city offers poutine, festivals and more. Nearby Laval and Longueuil...", | |
| 53 | − headings=["36 Hours in Montreal"], | |
| 54 | − language="en", | |
| 55 | − ) | |
| 56 | − signals = score_page(page, "nytimes.com") | |
| 57 | − assert 0.2 < signals.score < 0.8 | |
| 58 | − assert "montreal" in signals.locations | |
| 59 | − | |
| 60 | − | |
| 61 | −def test_generic_english_page_scores_near_zero(): | |
| 62 | − page = _page( | |
| 63 | − url="https://techblog.com/post", | |
| 64 | − title="Understanding Rust lifetimes", | |
| 65 | − body="Rust lifetimes are a way to express the scope of references in your program. " * 20, | |
| 66 | − language="en", | |
| 67 | − ) | |
| 68 | − signals = score_page(page, "techblog.com") | |
| 69 | − assert signals.score < 0.1 | |
| 70 | − | |
| 71 | − | |
| 72 | −def test_french_alone_is_not_proof(): | |
| 73 | − # Une page française de France ne doit pas passer le seuil sur la langue seule | |
| 74 | − page = _page( | |
| 75 | − url="https://lemonde.fr/article", | |
| 76 | − title="Actualités françaises", | |
| 77 | − body="La France annonce de nouvelles mesures. Paris, Lyon et Marseille concernées. " * 10, | |
| 78 | − language="fr", | |
| 79 | − ) | |
| 80 | − signals = score_page(page, "lemonde.fr") | |
| 81 | − assert signals.score < 0.3 | |
| 82 | − | |
| 83 | − | |
| 84 | −def test_score_saturates_below_one(): | |
| 85 | − body = "Montréal Québec Gatineau Sherbrooke Laval " * 100 + " H2X 1Y6 G1R 4S9 514-555-0000 Hydro-Québec" | |
| 86 | − page = _page(url="https://x.qc.ca/", title="Montréal Québec", body=body, language="fr") | |
| 87 | − signals = score_page(page, "x.qc.ca") | |
| 88 | − assert 0.8 < signals.score <= 1.0 | |
deleted
M2M32crouve-ka/tests/test_ranking.py
+0 −41
@@ -1,41 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests du pipeline de requête et du ranking | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −from trouveka.ranking import analyze_query, build_search_body | |
| 6 | − | |
| 7 | − | |
| 8 | −def test_language_detection_heuristics(): | |
| 9 | − assert analyze_query("meilleur programme de subvention pour thermopompe")["language"] == "fr" | |
| 10 | − assert analyze_query("best heat pump rebate program")["language"] == "en" | |
| 11 | − | |
| 12 | − | |
| 13 | −def test_location_extraction_with_accents(): | |
| 14 | − a = analyze_query("plombier gatineau") | |
| 15 | − assert "gatineau" in a["locations"] | |
| 16 | − b = analyze_query("subvention Trois-Rivières") | |
| 17 | − assert any("trois-rivieres" in loc or "trois-rivières" in loc for loc in b["locations"]) | |
| 18 | − | |
| 19 | − | |
| 20 | −def test_body_structure_bm25_core(): | |
| 21 | − body, analysis = build_search_body("plombier Gatineau", page=2, limit=10) | |
| 22 | − assert body["from"] == 10 and body["size"] == 10 | |
| 23 | − fs = body["query"]["function_score"] | |
| 24 | − fields = fs["query"]["bool"]["must"][0]["multi_match"]["fields"] | |
| 25 | − assert "title^4" in fields and "body" in fields and "body.en" in fields | |
| 26 | − # Localité : fonction de boost présente quand un lieu est détecté | |
| 27 | − locality = [f for f in fs["functions"] if "filter" in f] | |
| 28 | − assert locality and locality[0]["filter"]["terms"]["locations"] == ["gatineau"] | |
| 29 | − | |
| 30 | − | |
| 31 | −def test_filters(): | |
| 32 | − body, _ = build_search_body("test", language="fr", category="government", quebec_only=True) | |
| 33 | − filters = body["query"]["function_score"]["query"]["bool"]["filter"] | |
| 34 | − assert {"term": {"language": "fr"}} in filters | |
| 35 | − assert {"term": {"categories": "government"}} in filters | |
| 36 | − assert any("bool" in f for f in filters) # quebec_only | |
| 37 | − | |
| 38 | − | |
| 39 | −def test_no_locality_function_without_location(): | |
| 40 | − body, _ = build_search_body("recette de tourtière") | |
| 41 | − assert not [f for f in body["query"]["function_score"]["functions"] if "filter" in f] | |
deleted
M2M32crouve-ka/tests/test_ssrf.py
+0 −39
@@ -1,39 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests de prévention SSRF | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −from trouveka.shared import is_safe_ip, is_safe_url | |
| 6 | − | |
| 7 | − | |
| 8 | −def test_blocks_loopback_and_private(): | |
| 9 | − for ip in ("127.0.0.1", "10.0.0.5", "192.168.2.10", "172.16.0.1", "0.0.0.0", "::1", "fc00::1"): | |
| 10 | − assert not is_safe_ip(ip), ip | |
| 11 | − | |
| 12 | − | |
| 13 | −def test_blocks_link_local_and_metadata(): | |
| 14 | − assert not is_safe_ip("169.254.169.254") | |
| 15 | − assert not is_safe_ip("169.254.1.1") | |
| 16 | − assert not is_safe_ip("fe80::1") | |
| 17 | − | |
| 18 | − | |
| 19 | −def test_allows_public_ips(): | |
| 20 | − assert is_safe_ip("142.226.10.10") | |
| 21 | − assert is_safe_ip("2607:f8b0::1") | |
| 22 | − | |
| 23 | − | |
| 24 | −def test_blocks_localhost_and_schemes(): | |
| 25 | − assert not is_safe_url("http://localhost/admin", resolve=False) | |
| 26 | − assert not is_safe_url("http://foo.localhost/x", resolve=False) | |
| 27 | − assert not is_safe_url("http://metadata.google.internal/", resolve=False) | |
| 28 | − assert not is_safe_url("file:///etc/passwd", resolve=False) | |
| 29 | − assert not is_safe_url("gopher://x.ca", resolve=False) | |
| 30 | − | |
| 31 | − | |
| 32 | −def test_blocks_ip_literal_urls(): | |
| 33 | − assert not is_safe_url("http://127.0.0.1:8080/", resolve=False) | |
| 34 | − assert not is_safe_url("http://169.254.169.254/latest/meta-data/", resolve=False) | |
| 35 | − assert not is_safe_url("http://[::1]/", resolve=False) | |
| 36 | − | |
| 37 | − | |
| 38 | −def test_allows_normal_url_without_resolution(): | |
| 39 | − assert is_safe_url("https://www.quebec.ca/", resolve=False) | |
deleted
M2M32crouve-ka/tests/test_traps.py
+0 −39
@@ -1,39 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests de détection de pièges | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −from trouveka.crawler.traps import looks_like_trap | |
| 6 | − | |
| 7 | − | |
| 8 | −def test_session_ids(): | |
| 9 | − assert looks_like_trap("https://x.ca/p?PHPSESSID=abc123") | |
| 10 | − assert looks_like_trap("https://x.ca/p?sid=9f8e7d") | |
| 11 | − | |
| 12 | − | |
| 13 | −def test_too_many_params(): | |
| 14 | − url = "https://x.ca/p?" + "&".join(f"f{i}={i}" for i in range(12)) | |
| 15 | − assert looks_like_trap(url) | |
| 16 | − | |
| 17 | − | |
| 18 | −def test_repeated_facet_param(): | |
| 19 | − assert looks_like_trap("https://x.ca/s?f=a&f=b&f=c&f=d&f=e") | |
| 20 | − | |
| 21 | − | |
| 22 | −def test_deep_paths_and_loops(): | |
| 23 | − assert looks_like_trap("https://x.ca/" + "/".join(["seg"] * 15)) | |
| 24 | − assert looks_like_trap("https://x.ca/a/b/a/b/a/b/a/b") | |
| 25 | − | |
| 26 | − | |
| 27 | −def test_far_future_calendar(): | |
| 28 | − assert looks_like_trap("https://x.ca/events/2085/05/17") | |
| 29 | − assert not looks_like_trap("https://x.ca/nouvelles/2024/06/12") | |
| 30 | − | |
| 31 | − | |
| 32 | −def test_excessive_pagination(): | |
| 33 | − assert looks_like_trap("https://x.ca/liste?page=9999") | |
| 34 | − assert not looks_like_trap("https://x.ca/liste?page=3") | |
| 35 | − | |
| 36 | − | |
| 37 | −def test_normal_urls_pass(): | |
| 38 | − assert not looks_like_trap("https://www.quebec.ca/services/permis?type=conduire") | |
| 39 | − assert not looks_like_trap("https://ici.radio-canada.ca/nouvelle/2024/ceci-est-un-titre") | |
deleted
M2M32crouve-ka/tests/test_urls.py
+0 −53
@@ -1,53 +0,0 @@ | ||
| 1 | −# Trouve-KA — tests de canonicalisation d'URL | |
| 2 | −# Author: Simon-Pierre Boucher | |
| 3 | −# Contact: contact@spboucher.ai | |
| 4 | − | |
| 5 | −from trouveka.shared import canonicalize_url, display_url, extract_domain | |
| 6 | − | |
| 7 | − | |
| 8 | −def test_removes_fragment_and_default_port(): | |
| 9 | − assert canonicalize_url("https://quebec.ca:443/services#section") == "https://quebec.ca/services" | |
| 10 | − assert canonicalize_url("http://quebec.ca:80/") == "http://quebec.ca/" | |
| 11 | − | |
| 12 | − | |
| 13 | −def test_preserves_non_default_port(): | |
| 14 | − assert canonicalize_url("http://example.qc.ca:8080/x") == "http://example.qc.ca:8080/x" | |
| 15 | − | |
| 16 | − | |
| 17 | −def test_lowercases_host_but_preserves_path_case(): | |
| 18 | − assert canonicalize_url("HTTPS://WWW.Quebec.CA/Services/PDF") == "https://www.quebec.ca/Services/PDF" | |
| 19 | − | |
| 20 | − | |
| 21 | −def test_strips_tracking_params_only(): | |
| 22 | − url = "https://ledevoir.com/a?utm_source=fb&fbclid=xyz&id=42&gclid=1" | |
| 23 | − assert canonicalize_url(url) == "https://ledevoir.com/a?id=42" | |
| 24 | − | |
| 25 | − | |
| 26 | −def test_preserves_unknown_params_and_order(): | |
| 27 | − # Deux ressources distinctes ne doivent jamais fusionner : ordre préservé | |
| 28 | − assert canonicalize_url("https://x.ca/p?b=2&a=1") == "https://x.ca/p?b=2&a=1" | |
| 29 | − assert canonicalize_url("https://x.ca/p?a=1&b=2") == "https://x.ca/p?a=1&b=2" | |
| 30 | − | |
| 31 | − | |
| 32 | −def test_rejects_non_http_schemes(): | |
| 33 | − assert canonicalize_url("file:///etc/passwd") is None | |
| 34 | − assert canonicalize_url("javascript:alert(1)") is None | |
| 35 | − assert canonicalize_url("ftp://x.ca/f") is None | |
| 36 | − | |
| 37 | − | |
| 38 | −def test_relative_resolution(): | |
| 39 | − assert canonicalize_url("../b", base="https://x.ca/a/c/") == "https://x.ca/a/b" | |
| 40 | − | |
| 41 | − | |
| 42 | −def test_collapses_duplicate_slashes(): | |
| 43 | − assert canonicalize_url("https://x.ca//a///b") == "https://x.ca/a/b" | |
| 44 | − | |
| 45 | − | |
| 46 | −def test_extract_domain_strips_www(): | |
| 47 | − assert extract_domain("https://www.quebec.ca/services") == "quebec.ca" | |
| 48 | − assert extract_domain("https://ici.radio-canada.ca/n") == "ici.radio-canada.ca" | |
| 49 | − | |
| 50 | − | |
| 51 | −def test_display_url_breadcrumb(): | |
| 52 | − crumb = display_url("https://www.quebec.ca/services/permis/conduire") | |
| 53 | − assert crumb.startswith("quebec.ca › services › permis") | |
modified
apps/api/main.py
+95 −4
@@ -11,6 +11,7 @@ Dégradation gracieuse : si OpenSearch tombe, /api/status répond quand même; | ||
| 11 | 11 | si Postgres tombe, la recherche répond quand même (analytics sautées). |
| 12 | 12 | """ |
| 13 | 13 | |
| 14 | +import asyncio | |
| 14 | 15 | import html |
| 15 | 16 | import re |
| 16 | 17 | import time |
@@ -25,8 +26,8 @@ from trouveka.config import get_settings | ||
| 25 | 26 | from trouveka.database import Database |
| 26 | 27 | from trouveka.logging import get_logger |
| 27 | 28 | from trouveka.queue import Coordination |
| 28 | −from trouveka.ranking import build_search_body | |
| 29 | −from trouveka.search_core import SearchCore | |
| 29 | +from trouveka.ranking import build_knn_body, build_search_body, rrf_fuse | |
| 30 | +from trouveka.search_core import EmbedClient, SearchCore | |
| 30 | 31 | from trouveka.shared import canonicalize_url, display_url, extract_domain, is_http_url |
| 31 | 32 | |
| 32 | 33 | log = get_logger("api") |
@@ -35,6 +36,7 @@ settings = get_settings() | ||
| 35 | 36 | db = Database(settings.database_url, pool_min=settings.pg_pool_min, pool_max=settings.pg_pool_max) |
| 36 | 37 | coord = Coordination(settings.redis_url) |
| 37 | 38 | search = SearchCore(settings.search_url, settings.search_index) |
| 39 | +embedder = EmbedClient(settings.embedding_url) | |
| 38 | 40 | |
| 39 | 41 | |
| 40 | 42 | @asynccontextmanager |
@@ -43,6 +45,7 @@ async def lifespan(_app: FastAPI): | ||
| 43 | 45 | await search.ensure_index() |
| 44 | 46 | yield |
| 45 | 47 | await search.close() |
| 48 | + await embedder.close() | |
| 46 | 49 | await coord.close() |
| 47 | 50 | await db.close() |
| 48 | 51 | |
@@ -79,6 +82,22 @@ def _safe_snippet(fragments: list[str]) -> str: | ||
| 79 | 82 | |
| 80 | 83 | BADGE_LABELS = {"government": "Gouvernement", "news": "Actualités", "education": "Éducation"} |
| 81 | 84 | |
| 85 | +# Le kNN n'entre dans la fusion que si le backfill couvre assez l'index : | |
| 86 | +# sous ce seuil, les voisins vectoriels sortent d'un échantillon arbitraire | |
| 87 | +# et dégradent le top-10 au lieu de l'améliorer. | |
| 88 | +KNN_MIN_COVERAGE = 0.6 | |
| 89 | +_knn_ready_cache: dict = {"at": 0.0, "ready": False} | |
| 90 | + | |
| 91 | + | |
| 92 | +async def _knn_ready() -> bool: | |
| 93 | + now = time.monotonic() | |
| 94 | + if now - _knn_ready_cache["at"] > 300: | |
| 95 | + coverage = await search.embedding_coverage() | |
| 96 | + _knn_ready_cache.update(at=now, ready=coverage >= KNN_MIN_COVERAGE) | |
| 97 | + if not _knn_ready_cache["ready"]: | |
| 98 | + log.info("kNN désactivé", extra={"ctx": {"embedding_coverage": round(coverage, 3)}}) | |
| 99 | + return _knn_ready_cache["ready"] | |
| 100 | + | |
| 82 | 101 | |
| 83 | 102 | # ---------------------------------------------------------------------- publique |
| 84 | 103 | |
@@ -106,8 +125,69 @@ async def api_search( | ||
| 106 | 125 | category=category, quebec_only=quebec_only, freshness=freshness, |
| 107 | 126 | images_only=images, |
| 108 | 127 | ) |
| 128 | + | |
| 129 | + # --- Recherche hybride : BM25 + kNN fusionnés par RRF. | |
| 130 | + # Chaque étage est optionnel (§9) : pas de service d'embeddings, timeout, | |
| 131 | + # page trop profonde, index sans vecteurs → lexical seul, transparent. | |
| 132 | + semantic = False | |
| 133 | + reranked = False | |
| 134 | + fuse_depth = page * limit + limit | |
| 135 | + vector = None | |
| 136 | + if embedder.enabled and fuse_depth <= 200 and not images and await _knn_ready(): | |
| 137 | + vector = await embedder.embed_query(query_text) | |
| 138 | + | |
| 139 | + RERANK_WINDOW = 20 | |
| 140 | + | |
| 141 | + async def _rerank_top(hits: list) -> tuple[list, bool]: | |
| 142 | + """Réordonne le top-20 par cross-encoder (titre+description+extraits). | |
| 143 | + En échec/timeout : ordre d'origine, aucun impact.""" | |
| 144 | + if not embedder.enabled or len(hits) < 2: | |
| 145 | + return hits, False | |
| 146 | + top = hits[:RERANK_WINDOW] | |
| 147 | + texts = [] | |
| 148 | + for h in top: | |
| 149 | + frags = (h.get("highlight", {}).get("body") or [])[:2] | |
| 150 | + frag_text = _TAG_RE.sub("", " ".join(frags)).replace("<em>", "").replace("</em>", "") | |
| 151 | + t = (f"{h['_source'].get('title') or ''}. " | |
| 152 | + f"{h['_source'].get('description') or ''} {frag_text}").strip(". ") | |
| 153 | + texts.append(t or h["_source"]["url"]) | |
| 154 | + scores = await embedder.rerank(query_text, texts) | |
| 155 | + if not scores: | |
| 156 | + return hits, False | |
| 157 | + order = sorted(range(len(top)), key=lambda i: scores[i], reverse=True) | |
| 158 | + return [top[i] for i in order] + hits[RERANK_WINDOW:], True | |
| 159 | + | |
| 109 | 160 | try: |
| 110 | − res = await search.search(body) | |
| 161 | + start_idx = (page - 1) * limit | |
| 162 | + if vector is not None: | |
| 163 | + depth = max(50, fuse_depth) | |
| 164 | + lexical_body = dict(body, **{"from": 0, "size": depth}) | |
| 165 | + knn_body = build_knn_body(vector, filters=analysis.get("filters", []), size=depth) | |
| 166 | + lex_res, knn_res = await asyncio.gather( | |
| 167 | + search.search(lexical_body), search.search(knn_body), return_exceptions=True | |
| 168 | + ) | |
| 169 | + if isinstance(lex_res, Exception): | |
| 170 | + raise lex_res | |
| 171 | + if isinstance(knn_res, Exception): | |
| 172 | + fused = lex_res["hits"]["hits"] # kNN indisponible : lexical seul | |
| 173 | + else: | |
| 174 | + fused = rrf_fuse(lex_res["hits"]["hits"], knn_res["hits"]["hits"]) | |
| 175 | + semantic = True | |
| 176 | + if start_idx < RERANK_WINDOW: | |
| 177 | + fused, reranked = await _rerank_top(fused) | |
| 178 | + res = {"hits": {"hits": fused[start_idx : start_idx + limit], | |
| 179 | + "total": lex_res["hits"]["total"]}} | |
| 180 | + elif embedder.enabled and start_idx < RERANK_WINDOW and not images: | |
| 181 | + # Lexical pur (kNN gaté ou embed indisponible) : le reranker | |
| 182 | + # s'applique quand même — il ne dépend pas des embeddings de l'index. | |
| 183 | + depth = max(50, fuse_depth) | |
| 184 | + lexical_body = dict(body, **{"from": 0, "size": depth}) | |
| 185 | + lex_res = await search.search(lexical_body) | |
| 186 | + hits, reranked = await _rerank_top(lex_res["hits"]["hits"]) | |
| 187 | + res = {"hits": {"hits": hits[start_idx : start_idx + limit], | |
| 188 | + "total": lex_res["hits"]["total"]}} | |
| 189 | + else: | |
| 190 | + res = await search.search(body) | |
| 111 | 191 | except Exception: |
| 112 | 192 | log.exception("recherche échouée", extra={"ctx": {"q": q}}) |
| 113 | 193 | raise HTTPException(status_code=503, detail="Le moteur de recherche est temporairement indisponible") |
@@ -142,7 +222,8 @@ async def api_search( | ||
| 142 | 222 | except Exception: |
| 143 | 223 | log.exception("analytics de recherche sautées") |
| 144 | 224 | |
| 145 | − return {"query": q, "total": total, "took_ms": took_ms, "page": page, "limit": limit, "results": results} | |
| 225 | + return {"query": q, "total": total, "took_ms": took_ms, "page": page, "limit": limit, | |
| 226 | + "semantic": semantic, "reranked": reranked, "results": results} | |
| 146 | 227 | |
| 147 | 228 | |
| 148 | 229 | @app.get("/api/status") |
@@ -245,6 +326,16 @@ async def admin_recent(limit: int = Query(50, ge=1, le=200)): | ||
| 245 | 326 | return {"events": await db.recent_events(limit)} |
| 246 | 327 | |
| 247 | 328 | |
| 329 | +@app.get("/api/admin/zero-results", dependencies=[Depends(require_admin)]) | |
| 330 | +async def admin_zero_results(limit: int = Query(50, ge=1, le=200)): | |
| 331 | + """Requêtes sans résultat : matière première des synonymes et des seeds.""" | |
| 332 | + rows = await db.top_zero_result_queries(limit) | |
| 333 | + return {"queries": [ | |
| 334 | + {"query": r["query"], "count": r["n"], "last_seen": r["last_seen"].isoformat()} | |
| 335 | + for r in rows | |
| 336 | + ]} | |
| 337 | + | |
| 338 | + | |
| 248 | 339 | @app.post("/api/admin/pause", dependencies=[Depends(require_admin)]) |
| 249 | 340 | async def admin_pause(): |
| 250 | 341 | await coord.pause_crawler() |
modified
docker-compose.yml
+20 −5
@@ -8,6 +8,21 @@ | ||
| 8 | 8 | services: |
| 9 | 9 | postgres: |
| 10 | 10 | image: postgres:16-alpine |
| 11 | + # max_connections : 5 workers locaux (pool 10) + API/enrichment/scheduler | |
| 12 | + # + 6 satellites LAN (pool 6) dépassent les 100 par défaut. | |
| 13 | + # Keepalives + timeouts : un worker satellite tué (pkill, reboot, sommeil | |
| 14 | + # d'un MacBook) laisse sinon des transactions zombies qui tiennent des | |
| 15 | + # verrous de ligne pendant des heures et bloquent crawl ET autovacuum. | |
| 16 | + command: [ | |
| 17 | + "postgres", | |
| 18 | + "-c", "max_connections=200", | |
| 19 | + "-c", "shared_buffers=2GB", | |
| 20 | + "-c", "tcp_keepalives_idle=60", | |
| 21 | + "-c", "tcp_keepalives_interval=10", | |
| 22 | + "-c", "tcp_keepalives_count=6", | |
| 23 | + "-c", "idle_in_transaction_session_timeout=120000", | |
| 24 | + "-c", "statement_timeout=300000", | |
| 25 | + ] | |
| 11 | 26 | environment: |
| 12 | 27 | POSTGRES_USER: trouveka |
| 13 | 28 | POSTGRES_PASSWORD: trouveka |
@@ -65,12 +80,16 @@ services: | ||
| 65 | 80 | environment: &backend_env |
| 66 | 81 | DATABASE_URL: postgresql://trouveka:trouveka@postgres:5432/trouveka |
| 67 | 82 | REDIS_URL: redis://redis:6379/0 |
| 68 | − SEARCH_URL: http://opensearch:9200 | |
| 83 | + # Surcharger via .env pour un OpenSearch externe (ex. M2M32b via relais | |
| 84 | + # socat : http://host.docker.internal:9202). Défaut : le service local. | |
| 85 | + SEARCH_URL: ${SEARCH_URL_BACKEND:-http://opensearch:9200} | |
| 69 | 86 | ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me-admin-token} |
| 70 | 87 | PUBLIC_URL: ${PUBLIC_URL:-https://www.trouve-ka.com} |
| 71 | 88 | MAX_GLOBAL_CONCURRENCY: ${MAX_GLOBAL_CONCURRENCY:-24} |
| 72 | 89 | DEFAULT_HOST_DELAY: ${DEFAULT_HOST_DELAY:-2.0} |
| 73 | 90 | MAX_RESPONSE_BYTES: ${MAX_RESPONSE_BYTES:-3000000} |
| 91 | + # Service d'embeddings (recherche sémantique) — vide = lexical seul | |
| 92 | + EMBEDDING_URL: ${EMBEDDING_URL:-} | |
| 74 | 93 | ports: |
| 75 | 94 | - "8080:8080" |
| 76 | 95 | depends_on: |
@@ -78,8 +97,6 @@ services: | ||
| 78 | 97 | condition: service_healthy |
| 79 | 98 | redis: |
| 80 | 99 | condition: service_healthy |
| 81 | − opensearch: | |
| 82 | − condition: service_healthy | |
| 83 | 100 | restart: unless-stopped |
| 84 | 101 | |
| 85 | 102 | migrate: |
@@ -102,8 +119,6 @@ services: | ||
| 102 | 119 | depends_on: |
| 103 | 120 | migrate: |
| 104 | 121 | condition: service_completed_successfully |
| 105 | − opensearch: | |
| 106 | − condition: service_healthy | |
| 107 | 122 | redis: |
| 108 | 123 | condition: service_healthy |
| 109 | 124 | restart: unless-stopped |
modified
infrastructure/deployment/deploy-m2m32.sh
+9 −1
@@ -5,6 +5,12 @@ | ||
| 5 | 5 | # |
| 6 | 6 | # Prérequis sur m2m32 : Docker (ou colima) + docker compose, ngrok authentifié. |
| 7 | 7 | # Le domaine www.trouve-ka.com doit être réservé dans le compte ngrok. |
| 8 | +# | |
| 9 | +# ⚠️ OBSOLÈTE pour la prod depuis 2026-08-15 : M2M32 tourne en pm2 NATIF | |
| 10 | +# (docker = Postgres+Redis seulement; OpenSearch sur M2M32b). Déployer par | |
| 11 | +# rsync + pm2 restart tk-api tk-crawler-1 tk-crawler-2 tk-enrichment tk-scheduler | |
| 12 | +# (+ pnpm build dans apps/web puis pm2 restart tk-web si le web change). | |
| 13 | +# Voir la mémoire projet « trouve-ka-architecture-native ». | |
| 8 | 14 | set -euo pipefail |
| 9 | 15 | |
| 10 | 16 | NODE="${NODE:-M2M32}" |
@@ -14,9 +20,11 @@ NGROK_DOMAIN="${NGROK_DOMAIN:-www.trouve-ka.com}" | ||
| 14 | 20 | cd "$(dirname "$0")/../.." |
| 15 | 21 | |
| 16 | 22 | echo "→ Synchronisation du code vers $NODE:$REMOTE_DIR" |
| 23 | +# .env est exclu : celui du laptop contient des ports de dev (5435/6380) qui | |
| 24 | +# cassent l'exposition LAN 5432/6379 dont dépendent les workers satellites. | |
| 17 | 25 | rsync -az --delete \ |
| 18 | 26 | --exclude node_modules --exclude .next --exclude .venv --exclude __pycache__ \ |
| 19 | − --exclude .git --exclude data --exclude '*.egg-info' \ | |
| 27 | + --exclude .git --exclude data --exclude '*.egg-info' --exclude .env \ | |
| 20 | 28 | ./ "$NODE:$REMOTE_DIR/" |
| 21 | 29 | |
| 22 | 30 | echo "→ Build et démarrage du stack sur $NODE" |
added
infrastructure/migrations/003_url_count_et_autovacuum.sql
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +-- Trouve-KA — migration 003 : cap réel d'URLs par domaine + autovacuum frontier | |
| 2 | +-- Author: Simon-Pierre Boucher | |
| 3 | +-- Contact: contact@spboucher.ai | |
| 4 | +-- | |
| 5 | +-- 1) domains.url_count : le cap max_urls_per_domain comparait page_count | |
| 6 | +-- (pages crawlées) au lieu du nombre d'URLs connues — un domaine pouvait | |
| 7 | +-- accumuler 20 000+ URLs pending avant que le cap ne morde. | |
| 8 | +-- 2) frontier_items churne énormément (claim/defer/release = UPDATE) : les | |
| 9 | +-- réglages autovacuum par défaut (20 % de la table) laissent s'accumuler | |
| 10 | +-- des centaines de milliers de tuples morts qui ralentissent le claim. | |
| 11 | + | |
| 12 | +BEGIN; | |
| 13 | + | |
| 14 | +ALTER TABLE domains ADD COLUMN IF NOT EXISTS url_count integer NOT NULL DEFAULT 0; | |
| 15 | + | |
| 16 | +UPDATE domains d SET url_count = c.n | |
| 17 | +FROM (SELECT domain_id, count(*) AS n FROM urls GROUP BY domain_id) c | |
| 18 | +WHERE c.domain_id = d.id; | |
| 19 | + | |
| 20 | +ALTER TABLE frontier_items SET ( | |
| 21 | + autovacuum_vacuum_scale_factor = 0.02, | |
| 22 | + autovacuum_vacuum_cost_delay = 0, | |
| 23 | + autovacuum_analyze_scale_factor = 0.05 | |
| 24 | +); | |
| 25 | + | |
| 26 | +INSERT INTO schema_migrations (version) VALUES (3) ON CONFLICT DO NOTHING; | |
| 27 | + | |
| 28 | +COMMIT; | |
added
infrastructure/migrations/004_domains_autovacuum.sql
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +-- Trouve-KA — migration 004 : autovacuum agressif sur domains | |
| 2 | +-- Author: Simon-Pierre Boucher | |
| 3 | +-- Contact: contact@spboucher.ai | |
| 4 | +-- | |
| 5 | +-- domains est une table chaude : url_count, page_count, quebec_score et | |
| 6 | +-- language_stats sont mis à jour en continu sur un petit nombre de lignes | |
| 7 | +-- très sollicitées. Les défauts d'autovacuum (20 %) laissent le bloat | |
| 8 | +-- dégrader les upserts de découverte. | |
| 9 | + | |
| 10 | +BEGIN; | |
| 11 | + | |
| 12 | +ALTER TABLE domains SET ( | |
| 13 | + autovacuum_vacuum_scale_factor = 0.05, | |
| 14 | + autovacuum_vacuum_cost_delay = 0, | |
| 15 | + autovacuum_analyze_scale_factor = 0.1 | |
| 16 | +); | |
| 17 | + | |
| 18 | +INSERT INTO schema_migrations (version) VALUES (4) ON CONFLICT DO NOTHING; | |
| 19 | + | |
| 20 | +COMMIT; | |
modified
packages/config/__init__.py
+7 −1
@@ -34,7 +34,9 @@ class Settings(BaseSettings): | ||
| 34 | 34 | # Crawler — limites de sécurité (chaque réponse a des limites) |
| 35 | 35 | max_response_bytes: int = 3_000_000 |
| 36 | 36 | max_redirects: int = 5 |
| 37 | − fetch_timeout: float = 20.0 | |
| 37 | + # 10 s suffisent pour un site sain; 20 s laissait les hôtes morts occuper | |
| 38 | + # les slots de concurrence et effondrer le débit global. | |
| 39 | + fetch_timeout: float = 10.0 | |
| 38 | 40 | max_crawl_depth: int = 8 |
| 39 | 41 | max_links_per_page: int = 300 |
| 40 | 42 | max_urls_per_domain: int = 5_000 |
@@ -50,6 +52,10 @@ class Settings(BaseSettings): | ||
| 50 | 52 | min_quebec_score_to_index: float = 0.15 |
| 51 | 53 | min_body_length: int = 80 |
| 52 | 54 | |
| 55 | + # Recherche sémantique (optionnelle — vide = lexical seul, dégradation §13) | |
| 56 | + embedding_url: str | None = None | |
| 57 | + embedding_dim: int = 384 | |
| 58 | + | |
| 53 | 59 | # API |
| 54 | 60 | api_host: str = "0.0.0.0" |
| 55 | 61 | api_port: int = 8080 |
modified
packages/database/db.py
+149 −10
@@ -147,11 +147,13 @@ class Database: | ||
| 147 | 147 | is_seed, |
| 148 | 148 | ) |
| 149 | 149 | dom = await conn.fetchrow( |
| 150 | − "SELECT blocked, page_count FROM domains WHERE id = $1", domain_id | |
| 150 | + "SELECT blocked, url_count FROM domains WHERE id = $1", domain_id | |
| 151 | 151 | ) |
| 152 | 152 | if dom["blocked"]: |
| 153 | 153 | return None |
| 154 | − if not is_seed and dom["page_count"] >= max_urls_per_domain: | |
| 154 | + # Cap sur les URLs CONNUES du domaine (pas les pages crawlées : | |
| 155 | + # page_count laissait un domaine enfiler 20 000+ URLs pending). | |
| 156 | + if not is_seed and dom["url_count"] >= max_urls_per_domain: | |
| 155 | 157 | return None |
| 156 | 158 | url_id = await conn.fetchval( |
| 157 | 159 | """ |
@@ -163,6 +165,9 @@ class Database: | ||
| 163 | 165 | ) |
| 164 | 166 | if url_id is None: |
| 165 | 167 | return None # URL déjà connue : pas de doublon dans le frontier |
| 168 | + await conn.execute( | |
| 169 | + "UPDATE domains SET url_count = url_count + 1 WHERE id = $1", domain_id | |
| 170 | + ) | |
| 166 | 171 | await conn.execute( |
| 167 | 172 | """ |
| 168 | 173 | INSERT INTO frontier_items (url_id, priority, depth, source_url_id) |
@@ -175,20 +180,131 @@ class Database: | ||
| 175 | 180 | ) |
| 176 | 181 | return url_id |
| 177 | 182 | |
| 183 | + async def enqueue_urls_bulk( | |
| 184 | + self, | |
| 185 | + items: list[tuple[str, str, float]], | |
| 186 | + *, | |
| 187 | + depth: int, | |
| 188 | + source_url_id: int | None, | |
| 189 | + max_urls_per_domain: int = 5000, | |
| 190 | + ) -> tuple[dict[str, int], int]: | |
| 191 | + """Enfile toutes les URLs découvertes sur UNE page en une seule transaction. | |
| 192 | + | |
| 193 | + items : liste de (url, domain, priority). Retourne ({domain: id}, nb_insérées). | |
| 194 | + | |
| 195 | + Une page peut avoir 300 liens : la version unitaire ouvrait 300 | |
| 196 | + transactions qui verrouillaient chacune la ligne du domaine cible | |
| 197 | + (upsert + url_count). Avec 11 workers découvrant les mêmes domaines | |
| 198 | + populaires, Postgres passait son temps en attentes Lock:transactionid. | |
| 199 | + Ici : 1 transaction, 1 verrou par domaine, ordre de verrouillage stable | |
| 200 | + (tri par domaine puis par id) pour éviter les deadlocks croisés. | |
| 201 | + """ | |
| 202 | + if not items: | |
| 203 | + return {}, 0 | |
| 204 | + domains = sorted({d for _, d, _ in items}) | |
| 205 | + async with self.pool.acquire() as conn: | |
| 206 | + async with conn.transaction(): | |
| 207 | + await conn.execute( | |
| 208 | + """ | |
| 209 | + INSERT INTO domains (domain) | |
| 210 | + SELECT unnest($1::text[]) ON CONFLICT (domain) DO NOTHING | |
| 211 | + """, | |
| 212 | + domains, | |
| 213 | + ) | |
| 214 | + rows = await conn.fetch( | |
| 215 | + "SELECT id, domain, blocked, url_count FROM domains WHERE domain = ANY($1::text[])", | |
| 216 | + domains, | |
| 217 | + ) | |
| 218 | + dom_info = {r["domain"]: r for r in rows} | |
| 219 | + budget = { | |
| 220 | + d: max(0, max_urls_per_domain - r["url_count"]) for d, r in dom_info.items() | |
| 221 | + } | |
| 222 | + urls: list[str] = [] | |
| 223 | + dids: list[int] = [] | |
| 224 | + prios: list[float] = [] | |
| 225 | + seen: set[str] = set() | |
| 226 | + for url, d, p in items: | |
| 227 | + info = dom_info.get(d) | |
| 228 | + if info is None or info["blocked"] or url in seen or budget[d] <= 0: | |
| 229 | + continue | |
| 230 | + budget[d] -= 1 | |
| 231 | + seen.add(url) | |
| 232 | + urls.append(url) | |
| 233 | + dids.append(info["id"]) | |
| 234 | + prios.append(p) | |
| 235 | + ids_map = {d: r["id"] for d, r in dom_info.items()} | |
| 236 | + if not urls: | |
| 237 | + return ids_map, 0 | |
| 238 | + new_rows = await conn.fetch( | |
| 239 | + """ | |
| 240 | + INSERT INTO urls (url, domain_id) | |
| 241 | + SELECT u, d FROM unnest($1::text[], $2::bigint[]) AS t(u, d) | |
| 242 | + ON CONFLICT (url) DO NOTHING | |
| 243 | + RETURNING id, url, domain_id | |
| 244 | + """, | |
| 245 | + urls, | |
| 246 | + dids, | |
| 247 | + ) | |
| 248 | + if not new_rows: | |
| 249 | + return ids_map, 0 | |
| 250 | + prio_by_url = dict(zip(urls, prios)) | |
| 251 | + await conn.execute( | |
| 252 | + """ | |
| 253 | + INSERT INTO frontier_items (url_id, priority, depth, source_url_id) | |
| 254 | + SELECT i, p, $3, $4 FROM unnest($1::bigint[], $2::real[]) AS t(i, p) | |
| 255 | + ON CONFLICT (url_id) DO NOTHING | |
| 256 | + """, | |
| 257 | + [r["id"] for r in new_rows], | |
| 258 | + [prio_by_url[r["url"]] for r in new_rows], | |
| 259 | + depth, | |
| 260 | + source_url_id, | |
| 261 | + ) | |
| 262 | + counts: dict[int, int] = {} | |
| 263 | + for r in new_rows: | |
| 264 | + counts[r["domain_id"]] = counts.get(r["domain_id"], 0) + 1 | |
| 265 | + ordered = sorted(counts.items()) | |
| 266 | + await conn.execute( | |
| 267 | + """ | |
| 268 | + UPDATE domains d SET url_count = url_count + c.n | |
| 269 | + FROM unnest($1::bigint[], $2::int[]) AS c(id, n) | |
| 270 | + WHERE d.id = c.id | |
| 271 | + """, | |
| 272 | + [i for i, _ in ordered], | |
| 273 | + [n for _, n in ordered], | |
| 274 | + ) | |
| 275 | + return ids_map, len(new_rows) | |
| 276 | + | |
| 178 | 277 | async def claim_batch(self, worker_id: str, limit: int = 20) -> list[asyncpg.Record]: |
| 179 | − """Réclame un lot d'URLs prêtes à crawler (SKIP LOCKED, multi-workers sûr).""" | |
| 278 | + """Réclame un lot d'URLs prêtes à crawler (SKIP LOCKED, multi-workers sûr). | |
| 279 | + | |
| 280 | + Équité par domaine : au plus UNE URL par domaine par lot. Sans cette | |
| 281 | + contrainte, les gros domaines à haute priorité monopolisent le claim, | |
| 282 | + puis leurs URLs sont différées en boucle par la politesse par hôte | |
| 283 | + (churn massif d'UPDATE sur frontier_items) pendant que les 25 000+ | |
| 284 | + autres domaines pending ne sont jamais explorés. | |
| 285 | + """ | |
| 180 | 286 | return await self.pool.fetch( |
| 181 | 287 | """ |
| 182 | − WITH claimed AS ( | |
| 183 | − SELECT f.id FROM frontier_items f | |
| 288 | + WITH candidates AS ( | |
| 289 | + SELECT f.id, f.priority, f.next_crawl_at, u.domain_id | |
| 290 | + FROM frontier_items f | |
| 291 | + JOIN urls u ON u.id = f.url_id | |
| 184 | 292 | WHERE f.status = 'pending' AND f.next_crawl_at <= now() |
| 185 | 293 | ORDER BY f.priority DESC, f.next_crawl_at |
| 186 | − LIMIT $2 | |
| 187 | − FOR UPDATE SKIP LOCKED | |
| 294 | + LIMIT $3 | |
| 295 | + FOR UPDATE OF f SKIP LOCKED | |
| 296 | + ), | |
| 297 | + picked AS ( | |
| 298 | + SELECT DISTINCT ON (domain_id) id, priority, next_crawl_at | |
| 299 | + FROM candidates | |
| 300 | + ORDER BY domain_id, priority DESC, next_crawl_at | |
| 301 | + ), | |
| 302 | + chosen AS ( | |
| 303 | + SELECT id FROM picked ORDER BY priority DESC, next_crawl_at LIMIT $2 | |
| 188 | 304 | ) |
| 189 | 305 | UPDATE frontier_items f SET status = 'in_progress', locked_by = $1, locked_at = now() |
| 190 | − FROM claimed | |
| 191 | − WHERE f.id = claimed.id | |
| 306 | + FROM chosen | |
| 307 | + WHERE f.id = chosen.id | |
| 192 | 308 | RETURNING f.id, f.url_id, f.priority, f.depth, f.retries, |
| 193 | 309 | f.last_crawled_at, f.next_crawl_at, |
| 194 | 310 | (SELECT url FROM urls WHERE id = f.url_id) AS url, |
@@ -196,6 +312,9 @@ class Database: | ||
| 196 | 312 | """, |
| 197 | 313 | worker_id, |
| 198 | 314 | limit, |
| 315 | + # Pool de candidats borné : assez profond pour la diversité de | |
| 316 | + # domaines, sans verrouiller 1000 lignes par claim à concurrence élevée. | |
| 317 | + min(max(limit * 10, 200), 600), | |
| 199 | 318 | ) |
| 200 | 319 | |
| 201 | 320 | async def release_item( |
@@ -207,13 +326,17 @@ class Database: | ||
| 207 | 326 | next_crawl_at: datetime | None = None, |
| 208 | 327 | priority: float | None = None, |
| 209 | 328 | increment_retries: bool = False, |
| 329 | + touch_last_crawled: bool = True, | |
| 210 | 330 | ) -> None: |
| 331 | + """touch_last_crawled=False pour les defers de politesse : sinon le | |
| 332 | + recrawl adaptatif mesure l'écart du defer (~secondes) au lieu du vrai | |
| 333 | + intervalle entre deux crawls, et le backoff s'effondre au minimum.""" | |
| 211 | 334 | await self.pool.execute( |
| 212 | 335 | """ |
| 213 | 336 | UPDATE frontier_items SET |
| 214 | 337 | status = $2, |
| 215 | 338 | error_code = $3, |
| 216 | − last_crawled_at = now(), | |
| 339 | + last_crawled_at = CASE WHEN $7 THEN now() ELSE last_crawled_at END, | |
| 217 | 340 | next_crawl_at = COALESCE($4, next_crawl_at), |
| 218 | 341 | priority = COALESCE($5, priority), |
| 219 | 342 | retries = retries + CASE WHEN $6 THEN 1 ELSE 0 END, |
@@ -226,6 +349,7 @@ class Database: | ||
| 226 | 349 | next_crawl_at, |
| 227 | 350 | priority, |
| 228 | 351 | increment_retries, |
| 352 | + touch_last_crawled, | |
| 229 | 353 | ) |
| 230 | 354 | |
| 231 | 355 | async def reset_stale_items(self, older_than_minutes: int = 30) -> int: |
@@ -374,6 +498,21 @@ class Database: | ||
| 374 | 498 | took_ms, |
| 375 | 499 | ) |
| 376 | 500 | |
| 501 | + async def top_zero_result_queries(self, limit: int = 50) -> list[asyncpg.Record]: | |
| 502 | + """Requêtes zéro-résultat les plus fréquentes (§12 : « de l'or ») — | |
| 503 | + elles pilotent les seeds, les synonymes et les priorités de crawl.""" | |
| 504 | + return await self.pool.fetch( | |
| 505 | + """ | |
| 506 | + SELECT lower(query) AS query, count(*) AS n, max(created_at) AS last_seen | |
| 507 | + FROM search_queries | |
| 508 | + WHERE zero_result | |
| 509 | + GROUP BY lower(query) | |
| 510 | + ORDER BY n DESC, last_seen DESC | |
| 511 | + LIMIT $1 | |
| 512 | + """, | |
| 513 | + limit, | |
| 514 | + ) | |
| 515 | + | |
| 377 | 516 | async def add_submission(self, url: str) -> None: |
| 378 | 517 | await self.pool.execute("INSERT INTO submissions (url) VALUES ($1)", url) |
| 379 | 518 | |
modified
packages/search-core/__init__.py
+3 −2
@@ -5,6 +5,7 @@ | ||
| 5 | 5 | """Couche OpenSearch : client async, mapping bilingue FR/EN, indexation immédiate.""" |
| 6 | 6 | |
| 7 | 7 | from .client import SearchCore |
| 8 | −from .mapping import INDEX_SETTINGS | |
| 8 | +from .embed_client import EmbedClient | |
| 9 | +from .mapping import EMBEDDING_DIM, INDEX_SETTINGS | |
| 9 | 10 | |
| 10 | −__all__ = ["SearchCore", "INDEX_SETTINGS"] | |
| 11 | +__all__ = ["SearchCore", "EmbedClient", "EMBEDDING_DIM", "INDEX_SETTINGS"] | |
modified
packages/search-core/client.py
+49 −0
@@ -52,6 +52,39 @@ class SearchCore: | ||
| 52 | 52 | async def search(self, body: dict[str, Any]) -> dict[str, Any]: |
| 53 | 53 | return await self.client.search(index=self.index, body=body) |
| 54 | 54 | |
| 55 | + async def get_source(self, url: str, fields: list[str]) -> dict[str, Any] | None: | |
| 56 | + """_source partiel d'un document (pour l'enrichissement), ou None.""" | |
| 57 | + try: | |
| 58 | + res = await self.client.get( | |
| 59 | + index=self.index, id=doc_id_for_url(url), _source_includes=fields | |
| 60 | + ) | |
| 61 | + return res.get("_source") | |
| 62 | + except NotFoundError: | |
| 63 | + return None | |
| 64 | + | |
| 65 | + async def missing_embeddings(self, size: int = 128) -> list[tuple[str, dict[str, Any]]]: | |
| 66 | + """Documents sans embedding (backfill sémantique en arrière-plan).""" | |
| 67 | + res = await self.client.search( | |
| 68 | + index=self.index, | |
| 69 | + body={ | |
| 70 | + "size": size, | |
| 71 | + "query": {"bool": {"must_not": [{"exists": {"field": "embedding"}}]}}, | |
| 72 | + "_source": ["title", "description", "body"], | |
| 73 | + "sort": [{"crawled_at": {"order": "desc", "missing": "_last", "unmapped_type": "date"}}], | |
| 74 | + }, | |
| 75 | + ) | |
| 76 | + return [(hit["_id"], hit["_source"]) for hit in res["hits"]["hits"]] | |
| 77 | + | |
| 78 | + async def set_embeddings(self, pairs: list[tuple[str, list[float]]]) -> None: | |
| 79 | + """Écrit les embeddings en bulk (mise à jour partielle, jamais bloquante).""" | |
| 80 | + if not pairs: | |
| 81 | + return | |
| 82 | + lines: list[dict[str, Any]] = [] | |
| 83 | + for _id, vector in pairs: | |
| 84 | + lines.append({"update": {"_index": self.index, "_id": _id}}) | |
| 85 | + lines.append({"doc": {"embedding": vector}}) | |
| 86 | + await self.client.bulk(body=lines) | |
| 87 | + | |
| 55 | 88 | async def count(self) -> int: |
| 56 | 89 | try: |
| 57 | 90 | res = await self.client.count(index=self.index) |
@@ -59,6 +92,22 @@ class SearchCore: | ||
| 59 | 92 | except NotFoundError: |
| 60 | 93 | return 0 |
| 61 | 94 | |
| 95 | + async def embedding_coverage(self) -> float: | |
| 96 | + """Fraction des documents ayant un embedding ∈ [0,1]. | |
| 97 | + | |
| 98 | + Sert de garde-fou : tant que le backfill n'a pas couvert l'index, | |
| 99 | + le kNN chercherait ses voisins dans un sous-ensemble arbitraire et | |
| 100 | + polluerait la fusion — mieux vaut du lexical pur (§13).""" | |
| 101 | + try: | |
| 102 | + total = await self.client.count(index=self.index) | |
| 103 | + with_vec = await self.client.count( | |
| 104 | + index=self.index, | |
| 105 | + body={"query": {"exists": {"field": "embedding"}}}, | |
| 106 | + ) | |
| 107 | + return (with_vec["count"] / total["count"]) if total["count"] else 0.0 | |
| 108 | + except Exception: | |
| 109 | + return 0.0 | |
| 110 | + | |
| 62 | 111 | async def ping(self) -> bool: |
| 63 | 112 | try: |
| 64 | 113 | return await self.client.ping() |
added
packages/search-core/embed_client.py
+76 −0
@@ -0,0 +1,76 @@ | ||
| 1 | +# Trouve-KA — client du service d'embeddings | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Client HTTP du service d'embeddings — conçu pour la dégradation gracieuse (§13). | |
| 6 | + | |
| 7 | +Ne lève JAMAIS : toute erreur (service absent, timeout, réponse invalide) | |
| 8 | +retourne None et le chemin lexical continue seul. Timeout requête court pour | |
| 9 | +ne pas pénaliser la latence de recherche; timeout passage plus généreux pour | |
| 10 | +l'enrichissement en arrière-plan. | |
| 11 | +""" | |
| 12 | + | |
| 13 | +import httpx | |
| 14 | + | |
| 15 | + | |
| 16 | +class EmbedClient: | |
| 17 | + def __init__( | |
| 18 | + self, | |
| 19 | + base_url: str | None, | |
| 20 | + *, | |
| 21 | + query_timeout: float = 0.25, | |
| 22 | + passage_timeout: float = 30.0, | |
| 23 | + rerank_timeout: float = 0.6, | |
| 24 | + ): | |
| 25 | + self.base_url = (base_url or "").rstrip("/") or None | |
| 26 | + self._query_client = httpx.AsyncClient(timeout=query_timeout) if self.base_url else None | |
| 27 | + self._passage_client = httpx.AsyncClient(timeout=passage_timeout) if self.base_url else None | |
| 28 | + self._rerank_client = httpx.AsyncClient(timeout=rerank_timeout) if self.base_url else None | |
| 29 | + | |
| 30 | + @property | |
| 31 | + def enabled(self) -> bool: | |
| 32 | + return self.base_url is not None | |
| 33 | + | |
| 34 | + async def close(self) -> None: | |
| 35 | + for c in (self._query_client, self._passage_client, self._rerank_client): | |
| 36 | + if c is not None: | |
| 37 | + await c.aclose() | |
| 38 | + | |
| 39 | + async def rerank(self, query: str, texts: list[str]) -> list[float] | None: | |
| 40 | + """Scores cross-encoder pour le top-N, ou None (l'ordre RRF est conservé).""" | |
| 41 | + if not self.base_url or self._rerank_client is None or not texts: | |
| 42 | + return None | |
| 43 | + try: | |
| 44 | + resp = await self._rerank_client.post( | |
| 45 | + f"{self.base_url}/rerank", json={"query": query, "texts": texts} | |
| 46 | + ) | |
| 47 | + resp.raise_for_status() | |
| 48 | + scores = resp.json()["scores"] | |
| 49 | + if not isinstance(scores, list) or len(scores) != len(texts): | |
| 50 | + return None | |
| 51 | + return [float(s) for s in scores] | |
| 52 | + except Exception: | |
| 53 | + return None | |
| 54 | + | |
| 55 | + async def embed_query(self, text: str) -> list[float] | None: | |
| 56 | + """Vecteur de la requête utilisateur, ou None (fallback lexical).""" | |
| 57 | + return await self._embed([text], kind="query", client=self._query_client, single=True) | |
| 58 | + | |
| 59 | + async def embed_passages(self, texts: list[str]) -> list[list[float]] | None: | |
| 60 | + """Vecteurs de passages (enrichissement asynchrone), ou None.""" | |
| 61 | + if not texts: | |
| 62 | + return [] | |
| 63 | + return await self._embed(texts, kind="passage", client=self._passage_client, single=False) | |
| 64 | + | |
| 65 | + async def _embed(self, texts: list[str], *, kind: str, client: httpx.AsyncClient | None, single: bool): | |
| 66 | + if not self.base_url or client is None: | |
| 67 | + return None | |
| 68 | + try: | |
| 69 | + resp = await client.post(f"{self.base_url}/embed", json={"texts": texts, "kind": kind}) | |
| 70 | + resp.raise_for_status() | |
| 71 | + vectors = resp.json()["vectors"] | |
| 72 | + if not isinstance(vectors, list) or len(vectors) != len(texts): | |
| 73 | + return None | |
| 74 | + return vectors[0] if single else vectors | |
| 75 | + except Exception: | |
| 76 | + return None | |
modified
packages/search-core/mapping.py
+67 −10
@@ -10,32 +10,79 @@ | ||
| 10 | 10 | - Refresh 1 s : une page indexée est cherchable en ~1 seconde (principe cardinal). |
| 11 | 11 | """ |
| 12 | 12 | |
| 13 | −# Synonymes bilingues de départ — extensibles sans réindexation (search-time) | |
| 13 | +# Synonymes bilingues et québécismes — search-time : modifiables sans réindexer | |
| 14 | +# (fermer/rouvrir l'index suffit, voir scripts/update-synonyms). Nourrir cette | |
| 15 | +# liste avec les requêtes zéro-résultat (§12 : « de l'or »). | |
| 14 | 16 | BILINGUAL_SYNONYMS = [ |
| 15 | 17 | "thermopompe, heat pump", |
| 16 | 18 | "subvention, grant, subsidy", |
| 17 | 19 | "emploi, job, employment", |
| 18 | − "entreprise, company, business", | |
| 20 | + "entreprise, company, business, compagnie", | |
| 19 | 21 | "université, university", |
| 20 | − "cégep, cegep, college", | |
| 21 | − "garderie, daycare, cpe", | |
| 22 | − "impôt, tax, taxes", | |
| 22 | + "cégep, cegep, college, collège", | |
| 23 | + "garderie, daycare, cpe, service de garde", | |
| 24 | + "impôt, tax, taxes, impôts", | |
| 23 | 25 | "santé, health", |
| 24 | − "plombier, plumber", | |
| 26 | + "plombier, plumber, plomberie, plumbing", | |
| 25 | 27 | "électricien, electrician", |
| 26 | − "déneigement, snow removal", | |
| 28 | + "déneigement, snow removal, déneigeur", | |
| 27 | 29 | "assurance, insurance", |
| 28 | − "logement, housing, apartment", | |
| 30 | + "logement, housing, apartment, appartement", | |
| 29 | 31 | "ville, city, municipalité, municipality", |
| 30 | 32 | "gouvernement, government", |
| 31 | 33 | "permis, permit, license, licence", |
| 34 | + # Institutions et programmes québécois | |
| 35 | + "saaq, société de l'assurance automobile", | |
| 36 | + "ramq, régie de l'assurance maladie", | |
| 37 | + "cnesst, normes du travail", | |
| 38 | + "reer, rrsp", | |
| 39 | + "celi, tfsa", | |
| 40 | + "rqap, congé parental, parental leave", | |
| 41 | + "assurance-emploi, chômage, employment insurance", | |
| 42 | + "aide sociale, welfare, assistance sociale", | |
| 43 | + "hydro, hydro-québec", | |
| 44 | + # Vie courante QC | |
| 45 | + "dépanneur, convenience store", | |
| 46 | + "souffleuse, snowblower, snow blower", | |
| 47 | + "stationnement, parking", | |
| 48 | + "condo, condominium", | |
| 49 | + "chalet, cottage", | |
| 50 | + "vidanges, ordures, garbage, déchets", | |
| 51 | + "urgence, emergency", | |
| 52 | + "médecin de famille, family doctor, omnipraticien", | |
| 53 | + "clsc, clinique", | |
| 54 | + "école, school", | |
| 55 | + "métro, subway, stm", | |
| 56 | + "autobus, bus", | |
| 57 | + "traversier, ferry", | |
| 58 | + "pourvoirie, outfitter", | |
| 59 | + "érablière, cabane à sucre, sugar shack", | |
| 60 | + "friperie, thrift store", | |
| 61 | + "rénovation, renovation, réno", | |
| 62 | + "toiture, roofing, couvreur", | |
| 63 | + "arpenteur, land surveyor", | |
| 64 | + "notaire, notary", | |
| 65 | + "comptable, accountant, cpa", | |
| 66 | + "immobilier, real estate, courtier immobilier", | |
| 67 | + "loyer, rent, bail, lease", | |
| 68 | + "vaccin, vaccination, vaccine", | |
| 69 | + "festival, festivals", | |
| 70 | + "randonnée, hiking, sentier, trail", | |
| 71 | + "ski de fond, cross-country ski", | |
| 72 | + "motoneige, snowmobile, skidoo", | |
| 73 | + "vélo, bike, bicycle, piste cyclable", | |
| 32 | 74 | ] |
| 33 | 75 | |
| 76 | +# Dimension des embeddings (multilingual-e5-small). Changer de modèle de | |
| 77 | +# dimension différente = nouvel index (migration scripts/migrate-search-index). | |
| 78 | +EMBEDDING_DIM = 384 | |
| 79 | + | |
| 34 | 80 | INDEX_SETTINGS = { |
| 35 | 81 | "settings": { |
| 36 | 82 | "number_of_shards": 1, |
| 37 | 83 | "number_of_replicas": 0, |
| 38 | 84 | "refresh_interval": "1s", |
| 85 | + "index.knn": True, | |
| 39 | 86 | "analysis": { |
| 40 | 87 | "filter": { |
| 41 | 88 | "french_elision": { |
@@ -118,8 +165,18 @@ INDEX_SETTINGS = { | ||
| 118 | 165 | "freshness_score": {"type": "float"}, |
| 119 | 166 | "quality_score": {"type": "float"}, |
| 120 | 167 | "spam_score": {"type": "float"}, |
| 121 | − # embedding (dense_vector/knn) ajouté à l'étape sémantique (§17.7), | |
| 122 | − # le mapping est extensible sans réindexation pour un nouveau champ. | |
| 168 | + # Recherche sémantique : vecteur e5-small normalisé (cosine). | |
| 169 | + # Rempli en ASYNCHRONE par l'enrichissement — jamais au crawl (§0.3). | |
| 170 | + "embedding": { | |
| 171 | + "type": "knn_vector", | |
| 172 | + "dimension": EMBEDDING_DIM, | |
| 173 | + "method": { | |
| 174 | + "name": "hnsw", | |
| 175 | + "engine": "lucene", | |
| 176 | + "space_type": "cosinesimil", | |
| 177 | + "parameters": {"m": 16, "ef_construction": 128}, | |
| 178 | + }, | |
| 179 | + }, | |
| 123 | 180 | } |
| 124 | 181 | }, |
| 125 | 182 | } |
modified
packages/shared/__init__.py
+3 −0
@@ -7,12 +7,15 @@ | ||
| 7 | 7 | from .urls import canonicalize_url, display_url, extract_domain, is_http_url |
| 8 | 8 | from .ssrf import is_safe_url, is_safe_ip |
| 9 | 9 | from .hashing import content_hash, text_fingerprint |
| 10 | +from .exclusions import EXCLUDED_DOMAINS, is_excluded_domain | |
| 10 | 11 | |
| 11 | 12 | __all__ = [ |
| 12 | 13 | "canonicalize_url", |
| 13 | 14 | "display_url", |
| 14 | 15 | "extract_domain", |
| 15 | 16 | "is_http_url", |
| 17 | + "EXCLUDED_DOMAINS", | |
| 18 | + "is_excluded_domain", | |
| 16 | 19 | "is_safe_url", |
| 17 | 20 | "is_safe_ip", |
| 18 | 21 | "content_hash", |
added
packages/shared/exclusions.py
+50 −0
@@ -0,0 +1,50 @@ | ||
| 1 | +# Trouve-KA — exclusions de crawl | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Domaines jamais crawlés : plateformes globales (réseaux sociaux, app stores, | |
| 6 | +CDN, moteurs) qui polluent le frontier sans jamais produire de contenu québécois | |
| 7 | +indexable — la plupart exigent JS/login et leurs pages sont vides en fetch HTTP. | |
| 8 | + | |
| 9 | +Le budget de crawl est précieux (§12) : chaque slot dépensé sur apps.apple.com | |
| 10 | +est un slot volé à un vrai site québécois. | |
| 11 | +""" | |
| 12 | + | |
| 13 | +# Comparaison sur le domaine enregistrable ET ses sous-domaines. | |
| 14 | +EXCLUDED_DOMAINS: frozenset[str] = frozenset({ | |
| 15 | + # Réseaux sociaux | |
| 16 | + "facebook.com", "fb.com", "instagram.com", "twitter.com", "x.com", | |
| 17 | + "linkedin.com", "pinterest.com", "pinterest.ca", "tiktok.com", | |
| 18 | + "snapchat.com", "reddit.com", "threads.net", "bsky.app", "mastodon.social", | |
| 19 | + # Vidéo / streaming | |
| 20 | + "youtube.com", "youtu.be", "vimeo.com", "dailymotion.com", | |
| 21 | + "twitch.tv", "spotify.com", "soundcloud.com", | |
| 22 | + # App stores / OS | |
| 23 | + "apps.apple.com", "itunes.apple.com", "apple.com", | |
| 24 | + "play.google.com", "microsoft.com", "chrome.google.com", | |
| 25 | + # Moteurs / géants génériques | |
| 26 | + "google.com", "google.ca", "bing.com", "yahoo.com", "duckduckgo.com", | |
| 27 | + "amazon.com", "amazon.ca", "ebay.com", "ebay.ca", "aliexpress.com", | |
| 28 | + "wikimedia.org", "wikidata.org", "archive.org", # wikipedia.org reste crawlable (contenu QC réel) | |
| 29 | + # Infra / outils | |
| 30 | + "d2cmedia.ca", # CDN d'images de concessionnaires — des milliers d'URLs, zéro texte | |
| 31 | + "cloudflare.com", "wordpress.com", "wordpress.org", "blogspot.com", | |
| 32 | + "wix.com", "squarespace.com", "shopify.com", "godaddy.com", | |
| 33 | + "goo.gl", "bit.ly", "t.co", "ow.ly", "tinyurl.com", | |
| 34 | + "gravatar.com", "addthis.com", "sharethis.com", | |
| 35 | + "doubleclick.net", "googletagmanager.com", "google-analytics.com", | |
| 36 | + # Cartes / avis génériques | |
| 37 | + "maps.google.com", "waze.com", "tripadvisor.com", "tripadvisor.ca", | |
| 38 | + "yelp.com", "yelp.ca", "booking.com", "airbnb.com", "airbnb.ca", | |
| 39 | + "eventbrite.com", "eventbrite.ca", "meetup.com", | |
| 40 | +}) | |
| 41 | + | |
| 42 | + | |
| 43 | +def is_excluded_domain(domain: str) -> bool: | |
| 44 | + """True si le domaine (ou un de ses parents) est dans la liste d'exclusion.""" | |
| 45 | + d = domain.lower().rstrip(".") | |
| 46 | + while d: | |
| 47 | + if d in EXCLUDED_DOMAINS: | |
| 48 | + return True | |
| 49 | + _, _, d = d.partition(".") | |
| 50 | + return False | |
modified
pyproject.toml
+5 −0
@@ -30,6 +30,11 @@ dev = [ | ||
| 30 | 30 | "pytest-asyncio>=0.23", |
| 31 | 31 | "ruff>=0.5", |
| 32 | 32 | ] |
| 33 | +# Service d'embeddings uniquement (venv dédié sur un node satellite; | |
| 34 | +# torch n'entre PAS dans l'image Docker principale) | |
| 35 | +embedding = [ | |
| 36 | + "sentence-transformers>=3.0", | |
| 37 | +] | |
| 33 | 38 | |
| 34 | 39 | [build-system] |
| 35 | 40 | requires = ["setuptools>=69"] |
added
scripts/migrate-search-index/migrate.py
+77 −0
@@ -0,0 +1,77 @@ | ||
| 1 | +# Trouve-KA — migration de l'index de recherche vers le mapping kNN | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Migre l'index vers le mapping v2 (champ embedding kNN). | |
| 6 | + | |
| 7 | +`index.knn` est un réglage statique OpenSearch : impossible de l'activer sur un | |
| 8 | +index existant. Procédure (idempotente) : | |
| 9 | + | |
| 10 | +1. crawl en pause (les workers respectent trouveka:crawler:paused); | |
| 11 | +2. création de <index>-v2 avec le mapping kNN; | |
| 12 | +3. _reindex serveur <index> → <index>-v2; | |
| 13 | +4. suppression de l'ancien index, alias <index> → <index>-v2 | |
| 14 | + (le code continue de parler à <index> sans changement); | |
| 15 | +5. reprise du crawl. | |
| 16 | + | |
| 17 | +Usage : python -m scripts.migrate-search-index.migrate (ou via docker compose run) | |
| 18 | +""" | |
| 19 | + | |
| 20 | +import asyncio | |
| 21 | + | |
| 22 | +from opensearchpy import AsyncOpenSearch | |
| 23 | + | |
| 24 | +from trouveka.config import get_settings | |
| 25 | +from trouveka.queue import Coordination | |
| 26 | +from trouveka.search_core import INDEX_SETTINGS | |
| 27 | + | |
| 28 | + | |
| 29 | +async def main() -> None: | |
| 30 | + s = get_settings() | |
| 31 | + client = AsyncOpenSearch(hosts=[s.search_url], timeout=600) | |
| 32 | + coord = Coordination(s.redis_url) | |
| 33 | + index, v2 = s.search_index, f"{s.search_index}-v2" | |
| 34 | + | |
| 35 | + try: | |
| 36 | + if await client.indices.exists_alias(name=index): | |
| 37 | + print(f"✓ {index} est déjà un alias — migration déjà faite") | |
| 38 | + return | |
| 39 | + if not await client.indices.exists(index=index): | |
| 40 | + await client.indices.create(index=v2, body=INDEX_SETTINGS) | |
| 41 | + await client.indices.put_alias(index=v2, name=index) | |
| 42 | + print(f"✓ installation neuve : {v2} créé avec alias {index}") | |
| 43 | + return | |
| 44 | + | |
| 45 | + print("→ pause du crawl") | |
| 46 | + await coord.pause_crawler() | |
| 47 | + await asyncio.sleep(5) # laisser les indexations en vol se terminer | |
| 48 | + | |
| 49 | + if not await client.indices.exists(index=v2): | |
| 50 | + await client.indices.create(index=v2, body=INDEX_SETTINGS) | |
| 51 | + print(f"→ {v2} créé") | |
| 52 | + | |
| 53 | + print(f"→ _reindex {index} → {v2} (serveur, peut prendre quelques minutes)") | |
| 54 | + result = await client.reindex( | |
| 55 | + body={"source": {"index": index}, "dest": {"index": v2}}, | |
| 56 | + params={"wait_for_completion": "true", "refresh": "true"}, | |
| 57 | + ) | |
| 58 | + print(f"→ réindexé : {result.get('total')} docs, {result.get('took')} ms") | |
| 59 | + | |
| 60 | + old_count = (await client.count(index=index))["count"] | |
| 61 | + new_count = (await client.count(index=v2))["count"] | |
| 62 | + print(f"→ comptes : ancien={old_count} nouveau={new_count}") | |
| 63 | + if new_count < old_count * 0.99: | |
| 64 | + raise RuntimeError("perte de documents détectée, on ne bascule pas") | |
| 65 | + | |
| 66 | + await client.indices.delete(index=index) | |
| 67 | + await client.indices.put_alias(index=v2, name=index) | |
| 68 | + print(f"✓ alias {index} → {v2} en place") | |
| 69 | + finally: | |
| 70 | + await coord.resume_crawler() | |
| 71 | + print("→ crawl repris") | |
| 72 | + await coord.close() | |
| 73 | + await client.close() | |
| 74 | + | |
| 75 | + | |
| 76 | +if __name__ == "__main__": | |
| 77 | + asyncio.run(main()) | |
added
scripts/update-synonyms/update.py
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +# Trouve-KA — mise à jour à chaud des synonymes de recherche | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Applique le dictionnaire BILINGUAL_SYNONYMS courant à l'index live. | |
| 6 | + | |
| 7 | +Les synonymes sont search-time : pas de réindexation nécessaire, mais les | |
| 8 | +analyzers font partie des settings → close/open de l'index (indisponibilité | |
| 9 | +de ~1-2 s, les workers retentent d'eux-mêmes). | |
| 10 | + | |
| 11 | +Usage : python scripts/update-synonyms/update.py | |
| 12 | +""" | |
| 13 | + | |
| 14 | +import asyncio | |
| 15 | + | |
| 16 | +from opensearchpy import AsyncOpenSearch | |
| 17 | + | |
| 18 | +from trouveka.config import get_settings | |
| 19 | +from trouveka.search_core import INDEX_SETTINGS | |
| 20 | + | |
| 21 | + | |
| 22 | +async def main() -> None: | |
| 23 | + s = get_settings() | |
| 24 | + client = AsyncOpenSearch(hosts=[s.search_url], timeout=120) | |
| 25 | + try: | |
| 26 | + # Résoudre l'alias vers l'index concret | |
| 27 | + aliases = await client.indices.get_alias(name=s.search_index) | |
| 28 | + concrete = next(iter(aliases)) | |
| 29 | + analysis = {"analysis": INDEX_SETTINGS["settings"]["analysis"]} | |
| 30 | + | |
| 31 | + print(f"→ fermeture de {concrete}") | |
| 32 | + await client.indices.close(index=concrete) | |
| 33 | + try: | |
| 34 | + await client.indices.put_settings(index=concrete, body=analysis) | |
| 35 | + print("→ analyzers/synonymes mis à jour") | |
| 36 | + finally: | |
| 37 | + await client.indices.open(index=concrete) | |
| 38 | + print(f"→ {concrete} rouvert") | |
| 39 | + await client.cluster.health(index=concrete, wait_for_status="yellow", timeout=60) | |
| 40 | + n = len(INDEX_SETTINGS["settings"]["analysis"]["filter"]["bilingual_synonyms"]["synonyms"]) | |
| 41 | + print(f"✓ {n} groupes de synonymes actifs") | |
| 42 | + finally: | |
| 43 | + await client.close() | |
| 44 | + | |
| 45 | + | |
| 46 | +if __name__ == "__main__": | |
| 47 | + asyncio.run(main()) | |
modified
services/crawler/worker.py
+47 −24
@@ -28,7 +28,7 @@ from trouveka.logging import get_logger | ||
| 28 | 28 | from trouveka.parser import looks_like_garbage, parse_html |
| 29 | 29 | from trouveka.queue import Coordination |
| 30 | 30 | from trouveka.search_core import SearchCore |
| 31 | −from trouveka.shared import canonicalize_url, content_hash, extract_domain | |
| 31 | +from trouveka.shared import canonicalize_url, content_hash, extract_domain, is_excluded_domain | |
| 32 | 32 | from trouveka.types import ErrorCode, Outcome |
| 33 | 33 | |
| 34 | 34 | from .fetcher import Fetcher, scheme_host |
@@ -94,14 +94,23 @@ class CrawlerWorker: | ||
| 94 | 94 | ) |
| 95 | 95 | |
| 96 | 96 | while not self.stop_event.is_set(): |
| 97 | − if await self.coord.is_paused(): | |
| 98 | − await asyncio.sleep(3) | |
| 99 | − continue | |
| 100 | − free = self.s.max_global_concurrency - len(in_flight) | |
| 101 | − if free <= 0: | |
| 102 | − await asyncio.wait(in_flight, return_when=asyncio.FIRST_COMPLETED) | |
| 97 | + try: | |
| 98 | + if await self.coord.is_paused(): | |
| 99 | + await asyncio.sleep(3) | |
| 100 | + continue | |
| 101 | + free = self.s.max_global_concurrency - len(in_flight) | |
| 102 | + if free <= 0: | |
| 103 | + await asyncio.wait(in_flight, return_when=asyncio.FIRST_COMPLETED) | |
| 104 | + continue | |
| 105 | + batch = await self.db.claim_batch(self.worker_id, limit=free) | |
| 106 | + except Exception: | |
| 107 | + # Dégradation gracieuse (§13) : un Postgres/Redis qui redémarre ne | |
| 108 | + # doit pas tuer le worker — le pool asyncpg se répare au prochain | |
| 109 | + # acquire, on attend et on réessaie. | |
| 110 | + log.exception("claim/coordination indisponible, retry dans 5 s", | |
| 111 | + extra={"ctx": {"worker_id": self.worker_id}}) | |
| 112 | + await asyncio.sleep(5) | |
| 103 | 113 | continue |
| 104 | − batch = await self.db.claim_batch(self.worker_id, limit=free) | |
| 105 | 114 | if not batch: |
| 106 | 115 | if in_flight: |
| 107 | 116 | await asyncio.wait( |
@@ -151,6 +160,7 @@ class CrawlerWorker: | ||
| 151 | 160 | url_id, |
| 152 | 161 | status="pending", |
| 153 | 162 | next_crawl_at=datetime.now(UTC) + timedelta(seconds=seconds + random.uniform(0.2, 3.0)), |
| 163 | + touch_last_crawled=False, # un defer n'est pas un crawl : ne pas casser le backoff adaptatif | |
| 154 | 164 | ) |
| 155 | 165 | |
| 156 | 166 | async def _domain_row_by_name(self, domain: str): |
@@ -223,7 +233,7 @@ class CrawlerWorker: | ||
| 223 | 233 | final = canonicalize_url(result.final_url) or result.final_url |
| 224 | 234 | if final != url: |
| 225 | 235 | final_domain = extract_domain(final) |
| 226 | − if final_domain and not looks_like_trap(final): | |
| 236 | + if final_domain and not is_excluded_domain(final_domain) and not looks_like_trap(final): | |
| 227 | 237 | await self.db.enqueue_url( |
| 228 | 238 | final, final_domain, priority=item["priority"], depth=item["depth"], |
| 229 | 239 | source_url_id=url_id, max_urls_per_domain=self.s.max_urls_per_domain, |
@@ -329,11 +339,18 @@ class CrawlerWorker: | ||
| 329 | 339 | outcome=outcome, content_hash=chash, num_bytes=len(result.body), |
| 330 | 340 | duration_ms=result.duration_ms, title=page.title, quebec_score=signals.score, |
| 331 | 341 | ) |
| 342 | + # changed=True seulement si la page a été (ré)indexée : une page non | |
| 343 | + # indexable qui divisait son intervalle par 2 finissait recrawlée | |
| 344 | + # toutes les heures pour rien. | |
| 332 | 345 | recrawl = next_recrawl_delay( |
| 333 | − changed=True, previous_delay_hours=self._previous_delay_hours(item), | |
| 346 | + changed=(outcome == Outcome.INDEXED), | |
| 347 | + previous_delay_hours=self._previous_delay_hours(item), | |
| 334 | 348 | min_hours=self.s.min_recrawl_hours, max_hours=self.s.max_recrawl_hours, |
| 335 | 349 | default_hours=self.s.default_recrawl_hours, |
| 336 | 350 | ) |
| 351 | + if outcome == Outcome.NOT_QUEBEC: | |
| 352 | + # Non pertinent Québec : on revérifie rarement (le score du domaine évolue) | |
| 353 | + recrawl = max(recrawl, timedelta(days=7)) | |
| 337 | 354 | await self.db.release_item( |
| 338 | 355 | url_id, status="pending", next_crawl_at=datetime.now(UTC) + recrawl, |
| 339 | 356 | ) |
@@ -361,9 +378,13 @@ class CrawlerWorker: | ||
| 361 | 378 | outcome=Outcome.UNCHANGED, |
| 362 | 379 | content_hash=existing["content_hash"] if existing else None, |
| 363 | 380 | ) |
| 381 | + # Décroissance de priorité : une page stable cède sa place aux URLs | |
| 382 | + # jamais crawlées — sinon les recrawls des gros domaines monopolisent | |
| 383 | + # éternellement la tête du frontier (exploration > re-vérification). | |
| 364 | 384 | await self.db.release_item( |
| 365 | 385 | item["url_id"], status="pending", |
| 366 | 386 | next_crawl_at=datetime.now(UTC) + recrawl, |
| 387 | + priority=max(0.05, float(item["priority"]) * 0.9), | |
| 367 | 388 | ) |
| 368 | 389 | |
| 369 | 390 | async def _finish_error(self, item, result) -> None: |
@@ -392,8 +413,8 @@ class CrawlerWorker: | ||
| 392 | 413 | source_domain = domain_row["domain"] |
| 393 | 414 | source_quebec = max(float(domain_row["quebec_score"]), signals.score) |
| 394 | 415 | |
| 395 | − outlink_domains: dict[int, int] = {} | |
| 396 | − enqueued = 0 | |
| 416 | + candidates: list[tuple[str, str, float]] = [] | |
| 417 | + cross_domain_counts: dict[str, int] = {} | |
| 397 | 418 | for link in page.links: |
| 398 | 419 | if link.nofollow or looks_like_trap( |
| 399 | 420 | link.url, |
@@ -402,7 +423,7 @@ class CrawlerWorker: | ||
| 402 | 423 | ): |
| 403 | 424 | continue |
| 404 | 425 | target_domain = extract_domain(link.url) |
| 405 | − if not target_domain: | |
| 426 | + if not target_domain or is_excluded_domain(target_domain): | |
| 406 | 427 | continue |
| 407 | 428 | same_domain = target_domain == source_domain |
| 408 | 429 | target_row = await self._domain_row_by_name(target_domain) |
@@ -423,18 +444,20 @@ class CrawlerWorker: | ||
| 423 | 444 | # Économie de crawl : ignorer les cibles au signal Québec quasi nul |
| 424 | 445 | if priority < 0.1: |
| 425 | 446 | continue |
| 426 | − url_id = await self.db.enqueue_url( | |
| 427 | − link.url, target_domain, | |
| 428 | − priority=priority, depth=item["depth"] + 1, source_url_id=item["url_id"], | |
| 429 | − max_urls_per_domain=self.s.max_urls_per_domain, | |
| 430 | − ) | |
| 431 | − if url_id: | |
| 432 | − enqueued += 1 | |
| 447 | + candidates.append((link.url, target_domain, priority)) | |
| 433 | 448 | if not same_domain: |
| 434 | − target_id = (target_row["id"] if target_row | |
| 435 | − else (await self.db.upsert_domain(target_domain))) | |
| 436 | − outlink_domains[target_id] = outlink_domains.get(target_id, 0) + 1 | |
| 437 | − | |
| 449 | + cross_domain_counts[target_domain] = cross_domain_counts.get(target_domain, 0) + 1 | |
| 450 | + | |
| 451 | + # Toute la page en UNE transaction (verrous domains minimisés) | |
| 452 | + ids_map, _enqueued = await self.db.enqueue_urls_bulk( | |
| 453 | + candidates, | |
| 454 | + depth=item["depth"] + 1, | |
| 455 | + source_url_id=item["url_id"], | |
| 456 | + max_urls_per_domain=self.s.max_urls_per_domain, | |
| 457 | + ) | |
| 458 | + outlink_domains = { | |
| 459 | + ids_map[d]: n for d, n in cross_domain_counts.items() if d in ids_map | |
| 460 | + } | |
| 438 | 461 | if outlink_domains: |
| 439 | 462 | await self.db.record_domain_links(item["domain_id"], outlink_domains) |
| 440 | 463 | |
added
services/embedding/__init__.py
+8 −0
@@ -0,0 +1,8 @@ | ||
| 1 | +# Trouve-KA — service d'embeddings | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Service d'embeddings multilingues (FR/EN alignés) pour la recherche sémantique. | |
| 6 | + | |
| 7 | +Tourne hors du chemin critique : l'indexation lexicale n'en dépend jamais (§0.3). | |
| 8 | +""" | |
added
services/embedding/server.py
+106 −0
@@ -0,0 +1,106 @@ | ||
| 1 | +# Trouve-KA — serveur d'embeddings | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | + | |
| 5 | +"""Serveur HTTP d'embeddings multilingues. | |
| 6 | + | |
| 7 | +Modèle : intfloat/multilingual-e5-small (384 dims) — espace vectoriel aligné | |
| 8 | +FR/EN : « thermopompe » et « heat pump » sont voisins sans traduction (§9). | |
| 9 | +e5 exige les préfixes "query: " / "passage: " — le paramètre `kind` les gère. | |
| 10 | + | |
| 11 | +Volontairement autonome (fastapi + sentence-transformers seulement) : il tourne | |
| 12 | +dans son propre venv sur un node satellite, PAS dans l'image Docker principale | |
| 13 | +(torch pèse ~1 Go et n'a rien à faire dans le chemin de crawl). | |
| 14 | + | |
| 15 | +Lancement : uvicorn services.embedding.server:app --host 0.0.0.0 --port 8091 | |
| 16 | +""" | |
| 17 | + | |
| 18 | +import os | |
| 19 | +import threading | |
| 20 | +from typing import Literal | |
| 21 | + | |
| 22 | +from fastapi import FastAPI | |
| 23 | +from pydantic import BaseModel, Field | |
| 24 | + | |
| 25 | +MODEL_NAME = os.environ.get("EMBEDDING_MODEL", "intfloat/multilingual-e5-small") | |
| 26 | +RERANK_MODEL_NAME = os.environ.get( | |
| 27 | + "RERANK_MODEL", "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1" | |
| 28 | +) | |
| 29 | +MAX_TEXTS = int(os.environ.get("EMBEDDING_MAX_TEXTS", "256")) | |
| 30 | +MAX_CHARS = int(os.environ.get("EMBEDDING_MAX_CHARS", "1200")) | |
| 31 | + | |
| 32 | +app = FastAPI(title="Trouve-KA Embeddings", docs_url=None, redoc_url=None) | |
| 33 | + | |
| 34 | +_model = None | |
| 35 | +_reranker = None | |
| 36 | +_model_lock = threading.Lock() | |
| 37 | + | |
| 38 | + | |
| 39 | +def _get_model(): | |
| 40 | + global _model | |
| 41 | + if _model is None: | |
| 42 | + with _model_lock: | |
| 43 | + if _model is None: | |
| 44 | + from sentence_transformers import SentenceTransformer | |
| 45 | + | |
| 46 | + _model = SentenceTransformer(MODEL_NAME, device="cpu") | |
| 47 | + return _model | |
| 48 | + | |
| 49 | + | |
| 50 | +def _get_reranker(): | |
| 51 | + global _reranker | |
| 52 | + if _reranker is None: | |
| 53 | + with _model_lock: | |
| 54 | + if _reranker is None: | |
| 55 | + from sentence_transformers import CrossEncoder | |
| 56 | + | |
| 57 | + _reranker = CrossEncoder(RERANK_MODEL_NAME, device="cpu") | |
| 58 | + return _reranker | |
| 59 | + | |
| 60 | + | |
| 61 | +class EmbedRequest(BaseModel): | |
| 62 | + texts: list[str] = Field(..., min_length=1, max_length=MAX_TEXTS) | |
| 63 | + kind: Literal["query", "passage"] = "passage" | |
| 64 | + | |
| 65 | + | |
| 66 | +class EmbedResponse(BaseModel): | |
| 67 | + vectors: list[list[float]] | |
| 68 | + dim: int | |
| 69 | + model: str | |
| 70 | + | |
| 71 | + | |
| 72 | +@app.get("/health") | |
| 73 | +def health() -> dict: | |
| 74 | + return {"ok": True, "model": MODEL_NAME, "loaded": _model is not None} | |
| 75 | + | |
| 76 | + | |
| 77 | +@app.post("/embed", response_model=EmbedResponse) | |
| 78 | +def embed(req: EmbedRequest) -> EmbedResponse: | |
| 79 | + model = _get_model() | |
| 80 | + prefix = "query: " if req.kind == "query" else "passage: " | |
| 81 | + texts = [prefix + t[:MAX_CHARS] for t in req.texts] | |
| 82 | + vectors = model.encode(texts, normalize_embeddings=True, batch_size=64) | |
| 83 | + return EmbedResponse(vectors=vectors.tolist(), dim=len(vectors[0]), model=MODEL_NAME) | |
| 84 | + | |
| 85 | + | |
| 86 | +class RerankRequest(BaseModel): | |
| 87 | + query: str = Field(..., min_length=1, max_length=300) | |
| 88 | + texts: list[str] = Field(..., min_length=1, max_length=64) | |
| 89 | + | |
| 90 | + | |
| 91 | +class RerankResponse(BaseModel): | |
| 92 | + scores: list[float] | |
| 93 | + model: str | |
| 94 | + | |
| 95 | + | |
| 96 | +@app.post("/rerank", response_model=RerankResponse) | |
| 97 | +def rerank(req: RerankRequest) -> RerankResponse: | |
| 98 | + """Cross-encoder (requête, passage) → score de pertinence fin. | |
| 99 | + | |
| 100 | + Réservé au top-N (coût quadratique en attention) : l'API n'envoie que ~20 | |
| 101 | + candidats déjà filtrés par BM25+kNN. | |
| 102 | + """ | |
| 103 | + model = _get_reranker() | |
| 104 | + pairs = [(req.query, t[:MAX_CHARS]) for t in req.texts] | |
| 105 | + scores = model.predict(pairs, batch_size=32) | |
| 106 | + return RerankResponse(scores=[float(s) for s in scores], model=RERANK_MODEL_NAME) | |
modified
services/enrichment/worker.py
+10 −1
@@ -20,11 +20,21 @@ from trouveka.config import get_settings | ||
| 20 | 20 | from trouveka.database import Database |
| 21 | 21 | from trouveka.logging import get_logger |
| 22 | 22 | from trouveka.queue import Coordination |
| 23 | −from trouveka.search_core import SearchCore | |
| 23 | +from trouveka.search_core import EmbedClient, SearchCore | |
| 24 | 24 | |
| 25 | 25 | log = get_logger("enrichment") |
| 26 | 26 | |
| 27 | 27 | GROUP = "enrichers" |
| 28 | +BACKFILL_INTERVAL = 15 # secondes entre deux lots de backfill d'embeddings | |
| 29 | +BACKFILL_BATCH = 64 | |
| 30 | +BACKFILL_CYCLE_TIMEOUT = 120 # un cycle qui dépasse ça est tué (anti-blocage) | |
| 31 | + | |
| 32 | + | |
| 33 | +def _passage_text(src: dict) -> str: | |
| 34 | + """Texte embeddé : titre + description + début du corps (le signal sémantique | |
| 35 | + d'une page est concentré en tête; 1200 chars suffisent pour e5-small).""" | |
| 36 | + parts = [src.get("title") or "", src.get("description") or "", (src.get("body") or "")[:900]] | |
Diff truncated — file too large.