recherche: /api/v1/search + /api/v1/suggest — proxy du moteur Trouve-Ka (pivot moteur de recherche Groupe KA)
10 changed files +957 −1
modified
.env.example
+2 −0
@@ -18,6 +18,8 @@ API_PORT=8000 | ||
| 18 | 18 | DAILY_RUN_HOUR=02 |
| 19 | 19 | BACKUP_RETENTION_DAYS=90 |
| 20 | 20 | RATE_LIMIT_PER_MINUTE=120 |
| 21 | +TROUVEKA_SEARCH_URL=https://www.trouve-ka.com/api/search | |
| 22 | +TROUVEKA_SUGGEST_URL=https://www.trouve-ka.com/api/suggest | |
| 21 | 23 | LOUKA_SOURCE_URL= |
| 22 | 24 | IMMOKA_SOURCE_URL= |
| 23 | 25 | FOODKA_SOURCE_URL= |
added
frontend/src/ka/GroupeKaBadge.tsx
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// ka-ui/react/GroupeKaBadge.tsx — badge « Un service Groupe KA », cliquable | |
| 3 | +// vers le hub. À placer dans le header de chaque site (à côté du logo de la | |
| 4 | +// marque). Variante dark pour les surfaces encre. Zone de protection : le | |
| 5 | +// padding interne du badge suffit ; ne rien coller à moins de 8 px du badge. | |
| 6 | +export default function GroupeKaBadge({ dark = false }: { dark?: boolean }) { | |
| 7 | + return ( | |
| 8 | + <a | |
| 9 | + className={`gk-badge${dark ? " gk-badge--dark" : ""}`} | |
| 10 | + href="https://www.groupe-ka.com" | |
| 11 | + target="_blank" | |
| 12 | + rel="noopener noreferrer" | |
| 13 | + aria-label="Un service Groupe KA — visiter le portail de l'écosystème" | |
| 14 | + > | |
| 15 | + Un service | |
| 16 | + <b> | |
| 17 | + Groupe<span className="ka">KA</span> | |
| 18 | + </b> | |
| 19 | + </a> | |
| 20 | + ); | |
| 21 | +} | |
added
frontend/src/ka/KaFooter.tsx
+91 −0
@@ -0,0 +1,91 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// ka-ui/react/KaFooter.tsx — footer commun Groupe KA (fond encre), à vendorer | |
| 3 | +// dans chaque app Vite/React sous frontend/src/ka/. Consomme ecosystem.json : | |
| 4 | +// toute modification des infos de contact se fait dans ka-ui/ecosystem.json | |
| 5 | +// puis se resynchronise sur les 12 sites (voir ka-ui/README.md). | |
| 6 | +import eco from "./ecosystem.json"; | |
| 7 | + | |
| 8 | +type LocalLink = { label: string; href: string }; | |
| 9 | + | |
| 10 | +export default function KaFooter({ | |
| 11 | + siteId, | |
| 12 | + localLegal = [], | |
| 13 | +}: { | |
| 14 | + /** id du site courant dans ecosystem.json (ex. "resto-ka") */ | |
| 15 | + siteId: string; | |
| 16 | + /** pages légales PROPRES au site (ex. /conditions), affichées avant celles du hub */ | |
| 17 | + localLegal?: LocalLink[]; | |
| 18 | +}) { | |
| 19 | + const year = new Date().getFullYear(); | |
| 20 | + const site = eco.sites.find((s) => s.id === siteId); | |
| 21 | + const others = eco.sites.filter((s) => s.id !== siteId); | |
| 22 | + return ( | |
| 23 | + <footer className="ka-footer" id="contact"> | |
| 24 | + <div className="container"> | |
| 25 | + <a | |
| 26 | + className="wordmark" | |
| 27 | + href={eco.hub.url} | |
| 28 | + target={siteId === "groupe-ka" ? undefined : "_blank"} | |
| 29 | + rel="noopener noreferrer" | |
| 30 | + aria-label="Groupe KA — le portail de l'écosystème" | |
| 31 | + > | |
| 32 | + Groupe <span className="ka">KA</span> | |
| 33 | + </a> | |
| 34 | + <p className="desc"> | |
| 35 | + {eco.org.tagline} Des centaines de connecteurs lisent les sites à la | |
| 36 | + source, normalisent la donnée et se resynchronisent seuls.{" "} | |
| 37 | + {site ? ( | |
| 38 | + <> | |
| 39 | + <b style={{ color: "var(--paper)" }}>{site.wordmark}</b> —{" "} | |
| 40 | + {site.tagline} — est un service Groupe KA. | |
| 41 | + </> | |
| 42 | + ) : null} | |
| 43 | + </p> | |
| 44 | + <p className="notice"> | |
| 45 | + <b>{eco.org.disclaimer}</b> | |
| 46 | + </p> | |
| 47 | + <ul className="sites"> | |
| 48 | + {others.map((s) => ( | |
| 49 | + <li key={s.id}> | |
| 50 | + <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer"> | |
| 51 | + {s.wordmark} | |
| 52 | + </a> | |
| 53 | + </li> | |
| 54 | + ))} | |
| 55 | + {eco.extraFooterLinks.map((l) => ( | |
| 56 | + <li key={l.href}> | |
| 57 | + <a href={l.href} target="_blank" rel="noopener noreferrer"> | |
| 58 | + {l.label} | |
| 59 | + </a> | |
| 60 | + </li> | |
| 61 | + ))} | |
| 62 | + </ul> | |
| 63 | + <div className="contacts"> | |
| 64 | + {eco.contacts.map((c) => ( | |
| 65 | + <p key={c.email} style={{ margin: 0 }}> | |
| 66 | + <a href={`mailto:${c.email}`}>{c.email}</a> | |
| 67 | + <span>{c.role}</span> | |
| 68 | + </p> | |
| 69 | + ))} | |
| 70 | + </div> | |
| 71 | + <p className="legal"> | |
| 72 | + © {year} {eco.org.copyrightHolder} | |
| 73 | + {localLegal.map((l) => ( | |
| 74 | + <span key={l.href}> | |
| 75 | + {" · "} | |
| 76 | + <a href={l.href}>{l.label}</a> | |
| 77 | + </span> | |
| 78 | + ))} | |
| 79 | + {eco.legal.map((l) => ( | |
| 80 | + <span key={l.href}> | |
| 81 | + {" · "} | |
| 82 | + <a href={l.href} target="_blank" rel="noopener noreferrer"> | |
| 83 | + {l.label} | |
| 84 | + </a> | |
| 85 | + </span> | |
| 86 | + ))} | |
| 87 | + </p> | |
| 88 | + </div> | |
| 89 | + </footer> | |
| 90 | + ); | |
| 91 | +} | |
added
frontend/src/ka/ecosystem.json
+196 −0
@@ -0,0 +1,196 @@ | ||
| 1 | +{ | |
| 2 | + "org": { | |
| 3 | + "name": "Groupe KA", | |
| 4 | + "legalName": "Groupe KA — Simon-Pierre Boucher", | |
| 5 | + "tagline": "Holding québécois d'agrégateurs de produits et services entièrement automatisés.", | |
| 6 | + "disclaimer": "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction.", | |
| 7 | + "copyrightHolder": "Groupe KA — Simon-Pierre Boucher" | |
| 8 | + }, | |
| 9 | + "hub": { | |
| 10 | + "url": "https://www.groupe-ka.com", | |
| 11 | + "loginPath": "/connexion", | |
| 12 | + "signupNote": "La création de compte KA ID se fait sur le hub groupe-ka.com ; chaque site délègue sa connexion via /api/auth/ka/login." | |
| 13 | + }, | |
| 14 | + "contacts": [ | |
| 15 | + { | |
| 16 | + "email": "contact@groupe-ka.com", | |
| 17 | + "role": "Projets, partenariats & données" | |
| 18 | + }, | |
| 19 | + { | |
| 20 | + "email": "info@groupe-ka.com", | |
| 21 | + "role": "Médias & questions générales" | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "email": "admin@groupe-ka.com", | |
| 25 | + "role": "Légal, vie privée & Loi 25" | |
| 26 | + } | |
| 27 | + ], | |
| 28 | + "legal": [ | |
| 29 | + { | |
| 30 | + "label": "Conditions d'utilisation", | |
| 31 | + "href": "https://www.groupe-ka.com/conditions" | |
| 32 | + }, | |
| 33 | + { | |
| 34 | + "label": "Politique de confidentialité", | |
| 35 | + "href": "https://www.groupe-ka.com/confidentialite" | |
| 36 | + }, | |
| 37 | + { | |
| 38 | + "label": "Protection des renseignements personnels (Loi 25)", | |
| 39 | + "href": "https://www.groupe-ka.com/loi-25" | |
| 40 | + }, | |
| 41 | + { | |
| 42 | + "label": "Transparence des robots d'indexation", | |
| 43 | + "href": "https://www.groupe-ka.com/bots" | |
| 44 | + } | |
| 45 | + ], | |
| 46 | + "sites": [ | |
| 47 | + { | |
| 48 | + "id": "groupe-ka", | |
| 49 | + "wordmark": "Groupe KA", | |
| 50 | + "domain": "www.groupe-ka.com", | |
| 51 | + "accent": "#d9f26b", | |
| 52 | + "accentSoft": "#f0f9d2", | |
| 53 | + "accentDeep": "#123f2e", | |
| 54 | + "onAccent": "#141814", | |
| 55 | + "tagline": "Le portail de l'écosystème ·Ka" | |
| 56 | + }, | |
| 57 | + { | |
| 58 | + "id": "trouve-ka", | |
| 59 | + "wordmark": "Trouve·Ka", | |
| 60 | + "domain": "www.trouve-ka.com", | |
| 61 | + "accent": "#1c7ed6", | |
| 62 | + "accentSoft": "#e7f2fd", | |
| 63 | + "accentDeep": "#14508f", | |
| 64 | + "onAccent": "#ffffff", | |
| 65 | + "tagline": "Le moteur de recherche du web québécois" | |
| 66 | + }, | |
| 67 | + { | |
| 68 | + "id": "lou-ka", | |
| 69 | + "wordmark": "Lou·Ka", | |
| 70 | + "domain": "www.lou-ka.com", | |
| 71 | + "accent": "#ff6a00", | |
| 72 | + "accentSoft": "#fff1e6", | |
| 73 | + "accentDeep": "#cc5500", | |
| 74 | + "onAccent": "#ffffff", | |
| 75 | + "tagline": "Tous les logements à louer" | |
| 76 | + }, | |
| 77 | + { | |
| 78 | + "id": "immo-ka", | |
| 79 | + "wordmark": "Immo·Ka", | |
| 80 | + "domain": "www.immo-ka.com", | |
| 81 | + "accent": "#e23744", | |
| 82 | + "accentSoft": "#fbe0e2", | |
| 83 | + "accentDeep": "#a8232e", | |
| 84 | + "onAccent": "#ffffff", | |
| 85 | + "tagline": "Toutes les propriétés à vendre" | |
| 86 | + }, | |
| 87 | + { | |
| 88 | + "id": "vrai-prix", | |
| 89 | + "wordmark": "Vrai-Prix", | |
| 90 | + "domain": "www.vrai-prix.com", | |
| 91 | + "accent": "#ff5148", | |
| 92 | + "accentSoft": "#ffe3e0", | |
| 93 | + "accentDeep": "#9e2a25", | |
| 94 | + "onAccent": "#ffffff", | |
| 95 | + "tagline": "La valeur réelle de chaque propriété" | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + "id": "auto-ka", | |
| 99 | + "wordmark": "Auto·Ka", | |
| 100 | + "domain": "www.auto-ka.com", | |
| 101 | + "accent": "#ff5a2a", | |
| 102 | + "accentSoft": "#ffe8de", | |
| 103 | + "accentDeep": "#cc3f16", | |
| 104 | + "onAccent": "#ffffff", | |
| 105 | + "tagline": "Les voitures usagées du Québec" | |
| 106 | + }, | |
| 107 | + { | |
| 108 | + "id": "fabri-ka", | |
| 109 | + "wordmark": "Fabri·Ka", | |
| 110 | + "domain": "www.fabri-ka.com", | |
| 111 | + "accent": "#c4532e", | |
| 112 | + "accentSoft": "#f7e3da", | |
| 113 | + "accentDeep": "#a94525", | |
| 114 | + "onAccent": "#ffffff", | |
| 115 | + "tagline": "Les produits fabriqués au Québec" | |
| 116 | + }, | |
| 117 | + { | |
| 118 | + "id": "food-ka", | |
| 119 | + "wordmark": "Food·Ka", | |
| 120 | + "domain": "www.food-ka.com", | |
| 121 | + "accent": "#1f9d55", | |
| 122 | + "accentSoft": "#e2f5ea", | |
| 123 | + "accentDeep": "#157a40", | |
| 124 | + "onAccent": "#ffffff", | |
| 125 | + "tagline": "Les prix d'épicerie, suivis à la source" | |
| 126 | + }, | |
| 127 | + { | |
| 128 | + "id": "resto-ka", | |
| 129 | + "wordmark": "Resto·Ka", | |
| 130 | + "domain": "www.resto-ka.com", | |
| 131 | + "accent": "#f08c00", | |
| 132 | + "accentSoft": "#fdeed7", | |
| 133 | + "accentDeep": "#b96a00", | |
| 134 | + "onAccent": "#141814", | |
| 135 | + "tagline": "Chaque resto, chaque plat, chaque prix" | |
| 136 | + }, | |
| 137 | + { | |
| 138 | + "id": "sorti-ka", | |
| 139 | + "wordmark": "Sorti·Ka", | |
| 140 | + "domain": "www.sorti-ka.com", | |
| 141 | + "accent": "#d6336c", | |
| 142 | + "accentSoft": "#fbe0eb", | |
| 143 | + "accentDeep": "#a12551", | |
| 144 | + "onAccent": "#ffffff", | |
| 145 | + "tagline": "Toutes les sorties, dans les 17 régions" | |
| 146 | + }, | |
| 147 | + { | |
| 148 | + "id": "crea-ka", | |
| 149 | + "wordmark": "Créa·Ka", | |
| 150 | + "domain": "www.crea-ka.com", | |
| 151 | + "accent": "#7048e8", | |
| 152 | + "accentSoft": "#ece5fc", | |
| 153 | + "accentDeep": "#5433b8", | |
| 154 | + "onAccent": "#ffffff", | |
| 155 | + "tagline": "Les créateurs d'ici, tous leurs liens" | |
| 156 | + }, | |
| 157 | + { | |
| 158 | + "id": "api-ka", | |
| 159 | + "wordmark": "API·Ka", | |
| 160 | + "domain": "www.api-ka.com", | |
| 161 | + "accent": "#3b5bdb", | |
| 162 | + "accentSoft": "#e4eafb", | |
| 163 | + "accentDeep": "#2b44a8", | |
| 164 | + "onAccent": "#ffffff", | |
| 165 | + "tagline": "La donnée de l'écosystème, par API" | |
| 166 | + }, | |
| 167 | + { | |
| 168 | + "id": "job-ka", | |
| 169 | + "wordmark": "Job·Ka", | |
| 170 | + "domain": "www.job-ka.com", | |
| 171 | + "accent": "#0c8599", | |
| 172 | + "accentSoft": "#def0f4", | |
| 173 | + "accentDeep": "#095c6b", | |
| 174 | + "onAccent": "#ffffff", | |
| 175 | + "tagline": "Tous les emplois des employeurs québécois" | |
| 176 | + } | |
| 177 | + ], | |
| 178 | + "extraFooterLinks": [ | |
| 179 | + { | |
| 180 | + "label": "ValoPlex", | |
| 181 | + "href": "https://www.valoplex.com" | |
| 182 | + }, | |
| 183 | + { | |
| 184 | + "label": "Ka2", | |
| 185 | + "href": "https://www.ka2.bot" | |
| 186 | + }, | |
| 187 | + { | |
| 188 | + "label": "Ka4", | |
| 189 | + "href": "https://www.ka4.bot" | |
| 190 | + }, | |
| 191 | + { | |
| 192 | + "label": "Ka6", | |
| 193 | + "href": "https://www.ka6.bot" | |
| 194 | + } | |
| 195 | + ] | |
| 196 | +} | |
| \ No newline at end of file | ||
added
frontend/src/ka/tokens.css
+319 −0
@@ -0,0 +1,319 @@ | ||
| 1 | +/* ----------------------------------------------------------------------------- | |
| 2 | + Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | + Fichier : ka-ui/tokens.css — SOURCE CANONIQUE (repo ka-ui sur spbgit) | |
| 4 | + Desc. : Design system commun GROUPE KA « éditorial sharp » — tokens + socle | |
| 5 | + de composants partagés par les 12 plateformes. Chaque site importe ce | |
| 6 | + fichier PUIS surcharge uniquement son accent (voir accents.css). | |
| 7 | + | |
| 8 | + · Typo : display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono | |
| 9 | + · Palette: papier #f5f3ee · encre #141814 · vert profond #1c5c41 + ACCENT du site | |
| 10 | + · Signature : bordures encre 1,5–2 px + ombres décalées dures, grain de film, | |
| 11 | + surlignés d'accent inclinés (néo-brutalisme raffiné) | |
| 12 | + · Breakpoints communs : 360 / 768 / 1024 / 1440 px (mobile-first) | |
| 13 | + · Zones tactiles ≥ 44×44 px, safe-areas iOS, typographie fluide (clamp) | |
| 14 | +----------------------------------------------------------------------------- */ | |
| 15 | + | |
| 16 | +:root { | |
| 17 | + /* ---- Palette fixe Groupe KA (identique sur les 12 sites) ---- */ | |
| 18 | + --paper: #f5f3ee; | |
| 19 | + --surface: #ffffff; | |
| 20 | + --surface-2: #faf9f5; | |
| 21 | + --ink: #141814; | |
| 22 | + --ink-2: #4d5551; | |
| 23 | + --ink-3: #8b928c; | |
| 24 | + --line: rgba(20, 24, 20, 0.14); | |
| 25 | + --line-strong: rgba(20, 24, 20, 0.85); | |
| 26 | + --green: #1c5c41; | |
| 27 | + --green-deep: #123f2e; | |
| 28 | + --amber: #e8a33d; | |
| 29 | + --amber-soft: #fdf3e2; | |
| 30 | + --danger: #b3423a; | |
| 31 | + --danger-soft: #fbe9e7; | |
| 32 | + | |
| 33 | + /* ---- ACCENT du site — SEULES variables surchargées par marque ---- */ | |
| 34 | + --accent: #d9f26b; /* défaut : lime Groupe KA */ | |
| 35 | + --accent-soft: #f0f9d2; | |
| 36 | + --accent-deep: #123f2e; | |
| 37 | + --on-accent: var(--ink); /* couleur du texte posé SUR l'accent */ | |
| 38 | + | |
| 39 | + /* alias rétro-compatibles (les satellites historiques utilisent --lime) */ | |
| 40 | + --lime: var(--accent); | |
| 41 | + --lime-soft: var(--accent-soft); | |
| 42 | + | |
| 43 | + /* ---- Géométrie sharp ---- */ | |
| 44 | + --r-card: 10px; | |
| 45 | + --r-ctl: 6px; | |
| 46 | + --r-pill: 999px; | |
| 47 | + | |
| 48 | + /* ---- Ombres décalées — la signature du groupe ---- */ | |
| 49 | + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05); | |
| 50 | + --shadow-off: 6px 6px 0 var(--ink); | |
| 51 | + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08); | |
| 52 | + --shadow-off-mid: 4px 4px 0 rgba(20, 24, 20, 0.18); | |
| 53 | + | |
| 54 | + /* ---- Typographie ---- */ | |
| 55 | + --font-display: "Space Grotesk", system-ui, sans-serif; | |
| 56 | + --font-body: "Inter", system-ui, sans-serif; | |
| 57 | + --font-mono: "JetBrains Mono", ui-monospace, monospace; | |
| 58 | + | |
| 59 | + /* Échelle fluide (mobile-first, clamp) */ | |
| 60 | + --fs-h1: clamp(30px, 3.2vw + 18px, 54px); | |
| 61 | + --fs-h2: clamp(23px, 1.8vw + 14px, 34px); | |
| 62 | + --fs-h3: clamp(17px, 0.9vw + 12px, 22px); | |
| 63 | + --fs-body: 15px; | |
| 64 | + --fs-small: 13px; | |
| 65 | + | |
| 66 | + /* Espacements */ | |
| 67 | + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; | |
| 68 | + --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px; | |
| 69 | + | |
| 70 | + /* Cible tactile minimale */ | |
| 71 | + --touch: 44px; | |
| 72 | + | |
| 73 | + /* ---- Échelle z-index COMMUNE (obligatoire — aucune valeur arbitraire) ---- | |
| 74 | + grain de film body::before = 9999 (pointer-events:none, toujours au-dessus, | |
| 75 | + purement visuel). Tout composant interactif se place sous 9999 : */ | |
| 76 | + --z-content: 1; /* contenu positionné ordinaire */ | |
| 77 | + --z-sticky: 300; /* éléments sticky de contenu (sous-nav…) */ | |
| 78 | + --z-header: 500; /* header du site */ | |
| 79 | + --z-bottombar: 600; /* tab bar / barre d'action basse */ | |
| 80 | + --z-dropdown: 700; /* menus déroulants (au-dessus des barres) */ | |
| 81 | + --z-overlay: 800; /* voile sous modale/panneau */ | |
| 82 | + --z-modal: 900; /* modales, panneaux de filtres, galeries */ | |
| 83 | + --z-toast: 950; /* notifications */ | |
| 84 | + | |
| 85 | + /* ---- Hauteur de viewport DYNAMIQUE (barre du navigateur mobile qui se | |
| 86 | + replie au scroll) : toujours var(--vh100), JAMAIS 100vh en dur. ---- */ | |
| 87 | + --vh100: 100vh; | |
| 88 | +} | |
| 89 | +@supports (height: 100dvh) { :root { --vh100: 100dvh; } } | |
| 90 | + | |
| 91 | +/* ---- Socle ---- */ | |
| 92 | +* { box-sizing: border-box; } | |
| 93 | +/* overflow-x: clip sur html ET body — sur WebKit iOS, clip posé sur body seul | |
| 94 | + ne bloque PAS le défilement latéral du viewport (quirk de propagation) ; | |
| 95 | + clip (≠ hidden) ne casse pas position:sticky. */ | |
| 96 | +html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; overflow-x: clip; } | |
| 97 | +body { | |
| 98 | + margin: 0; | |
| 99 | + background: var(--paper); | |
| 100 | + color: var(--ink); | |
| 101 | + font-family: var(--font-body); | |
| 102 | + font-size: var(--fs-body); | |
| 103 | + line-height: 1.55; | |
| 104 | + -webkit-font-smoothing: antialiased; | |
| 105 | + padding-bottom: env(safe-area-inset-bottom); | |
| 106 | + overflow-x: clip; /* jamais de débordement horizontal */ | |
| 107 | +} | |
| 108 | +img, svg, video { max-width: 100%; height: auto; display: block; } | |
| 109 | +h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0 0 0.5em; } | |
| 110 | +h1 { font-size: var(--fs-h1); line-height: 1.06; } | |
| 111 | +h2 { font-size: var(--fs-h2); line-height: 1.15; } | |
| 112 | +h3 { font-size: var(--fs-h3); line-height: 1.25; } | |
| 113 | +a { color: inherit; } | |
| 114 | +::selection { background: var(--accent); color: var(--on-accent); } | |
| 115 | +:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; } | |
| 116 | + | |
| 117 | +/* Grain de film pleine page — ⚠️ jamais de z-index sur body > * (position: | |
| 118 | + relative seulement), sinon les utilitaires z-* sont écrasés. */ | |
| 119 | +body::before { | |
| 120 | + content: ""; | |
| 121 | + position: fixed; inset: 0; pointer-events: none; opacity: 0.35; z-index: 9999; | |
| 122 | + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E"); | |
| 123 | +} | |
| 124 | +body > * { position: relative; } | |
| 125 | + | |
| 126 | +/* ---- Conteneur commun ---- */ | |
| 127 | +.container { max-width: 1152px; margin: 0 auto; padding: 0 var(--sp-4); } | |
| 128 | +@media (min-width: 768px) { .container { padding: 0 var(--sp-5); } } | |
| 129 | + | |
| 130 | +/* ---- Micro-typographie signature ---- */ | |
| 131 | +.kicker { | |
| 132 | + display: inline-flex; align-items: center; gap: 10px; | |
| 133 | + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500; | |
| 134 | + text-transform: uppercase; letter-spacing: 0.12em; color: var(--green); | |
| 135 | +} | |
| 136 | +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); } | |
| 137 | +.klabel { | |
| 138 | + font-family: var(--font-mono); font-size: 10px; font-weight: 700; | |
| 139 | + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); | |
| 140 | +} | |
| 141 | +.hl { | |
| 142 | + display: inline-block; background: var(--accent); color: var(--on-accent); | |
| 143 | + border-radius: 8px; padding: 0 10px 2px; transform: rotate(-1deg); | |
| 144 | +} | |
| 145 | +.outline-txt { color: transparent; -webkit-text-stroke: 2px var(--ink); } | |
| 146 | + | |
| 147 | +/* ---- Boutons (cible ≥ 44 px) ---- */ | |
| 148 | +.btn { | |
| 149 | + display: inline-flex; align-items: center; justify-content: center; gap: 8px; | |
| 150 | + min-height: var(--touch); padding: 10px 18px; | |
| 151 | + font-family: var(--font-display); font-weight: 700; font-size: 14px; | |
| 152 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | |
| 153 | + background: var(--surface); color: var(--ink); | |
| 154 | + cursor: pointer; text-decoration: none; | |
| 155 | + transition: transform 0.15s, box-shadow 0.15s, background 0.15s, color 0.15s; | |
| 156 | +} | |
| 157 | +.btn:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); } | |
| 158 | +.btn:active { transform: translate(0, 0); box-shadow: none; } | |
| 159 | +.btn:disabled { opacity: 0.45; pointer-events: none; } | |
| 160 | +.btn-primary { background: var(--ink); color: var(--accent); } | |
| 161 | +.btn-primary:hover { background: var(--accent-deep); } | |
| 162 | +.btn-accent { background: var(--accent); color: var(--on-accent); } | |
| 163 | +.btn-ghost { background: transparent; border-color: var(--line); } | |
| 164 | +.btn-ghost:hover { border-color: var(--ink); } | |
| 165 | + | |
| 166 | +/* ---- Cartes ---- */ | |
| 167 | +.card { | |
| 168 | + background: var(--surface); border: 1.5px solid var(--ink); | |
| 169 | + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); | |
| 170 | + overflow: hidden; | |
| 171 | +} | |
| 172 | +.card-hover { transition: transform 0.15s, box-shadow 0.15s; } | |
| 173 | +.card-hover:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); } | |
| 174 | + | |
| 175 | +/* ---- Chips / badges ---- */ | |
| 176 | +.chip { | |
| 177 | + display: inline-flex; align-items: center; gap: 6px; | |
| 178 | + padding: 4px 11px; font-family: var(--font-mono); font-size: 11px; font-weight: 700; | |
| 179 | + text-transform: uppercase; letter-spacing: 0.06em; | |
| 180 | + border: 1.5px solid var(--ink); border-radius: var(--r-pill); | |
| 181 | + background: var(--surface); color: var(--ink); | |
| 182 | +} | |
| 183 | +.chip-accent { background: var(--accent); color: var(--on-accent); } | |
| 184 | +.chip-soft { background: var(--accent-soft); border-color: var(--line); } | |
| 185 | + | |
| 186 | +/* ---- Champs ---- */ | |
| 187 | +.input, .select, .textarea { | |
| 188 | + width: 100%; min-height: var(--touch); padding: 10px 14px; | |
| 189 | + font: inherit; color: var(--ink); background: var(--surface); | |
| 190 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | |
| 191 | +} | |
| 192 | +.input:focus, .select:focus, .textarea:focus { | |
| 193 | + outline: none; box-shadow: 3px 3px 0 var(--accent); border-color: var(--ink); | |
| 194 | +} | |
| 195 | +.input::placeholder { color: var(--ink-3); } | |
| 196 | + | |
| 197 | +/* ---- Badge « Un service Groupe KA » (header, cliquable → hub) ---- */ | |
| 198 | +.gk-badge { | |
| 199 | + display: inline-flex; align-items: center; gap: 7px; | |
| 200 | + min-height: 30px; padding: 3px 10px 4px; | |
| 201 | + font-family: var(--font-mono); font-size: 10px; font-weight: 700; | |
| 202 | + letter-spacing: 0.08em; text-transform: uppercase; text-decoration: none; | |
| 203 | + border: 1.5px solid var(--ink); border-radius: var(--r-pill); | |
| 204 | + background: var(--surface); color: var(--ink-2); white-space: nowrap; | |
| 205 | +} | |
| 206 | +.gk-badge b { | |
| 207 | + font-family: var(--font-display); font-size: 12px; letter-spacing: -0.02em; | |
| 208 | + text-transform: none; color: var(--ink); | |
| 209 | +} | |
| 210 | +.gk-badge b .ka { | |
| 211 | + display: inline-block; background: var(--ink); color: var(--accent); | |
| 212 | + border-radius: 5px; padding: 0 5px 1px; margin-left: 3px; transform: rotate(-2deg); | |
| 213 | +} | |
| 214 | +.gk-badge:hover .ka { transform: rotate(0); } | |
| 215 | +.gk-badge--dark { background: transparent; border-color: rgba(245,243,238,0.4); color: rgba(245,243,238,0.75); } | |
| 216 | +.gk-badge--dark b { color: var(--paper); } | |
| 217 | + | |
| 218 | +/* ---- Footer commun (fond encre — voir react/KaFooter.tsx) ---- */ | |
| 219 | +.ka-footer { margin-top: var(--sp-8); background: var(--ink); padding: 44px 0; font-size: 13px; color: rgba(245,243,238,0.75); } | |
| 220 | +.ka-footer a { text-decoration: none; } | |
| 221 | +.ka-footer .wordmark { font-family: var(--font-display); font-weight: 700; font-size: 30px; letter-spacing: -0.04em; color: var(--paper); text-decoration: none; } | |
| 222 | +.ka-footer .wordmark .ka { color: var(--accent); } | |
| 223 | +.ka-footer .desc { max-width: 640px; margin-top: var(--sp-4); } | |
| 224 | +.ka-footer .notice { max-width: 640px; margin-top: var(--sp-4); border-left: 2px solid var(--accent); padding-left: var(--sp-4); } | |
| 225 | +.ka-footer .notice b { color: var(--paper); } | |
| 226 | +.ka-footer .sites { list-style: none; display: flex; flex-wrap: wrap; gap: 10px 26px; margin: 26px 0 0; padding: 0; font-family: var(--font-mono); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } | |
| 227 | +.ka-footer .sites a { color: rgba(245,243,238,0.65); } | |
| 228 | +.ka-footer .sites a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; } | |
| 229 | +.ka-footer .contacts { display: grid; gap: 14px 32px; border-top: 1px solid rgba(245,243,238,0.15); margin-top: 30px; padding-top: 26px; } | |
| 230 | +@media (min-width: 768px) { .ka-footer .contacts { grid-template-columns: repeat(3, 1fr); } } | |
| 231 | +.ka-footer .contacts a { font-family: var(--font-mono); font-weight: 700; font-size: 12px; color: rgba(245,243,238,0.85); } | |
| 232 | +.ka-footer .contacts a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; } | |
| 233 | +.ka-footer .contacts span { display: block; margin-top: 3px; font-size: 11px; color: rgba(245,243,238,0.5); } | |
| 234 | +.ka-footer .legal { margin-top: 30px; font-family: var(--font-mono); font-size: 11px; color: rgba(245,243,238,0.45); } | |
| 235 | +.ka-footer .legal a { color: inherit; } | |
| 236 | +.ka-footer .legal a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; } | |
| 237 | + | |
| 238 | +/* ---- Tableaux → cartes empilées sous 768 px ---- */ | |
| 239 | +.tbl-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } | |
| 240 | +@media (max-width: 767px) { | |
| 241 | + .tbl-stack thead { display: none; } | |
| 242 | + .tbl-stack tr { display: block; border: 1.5px solid var(--ink); border-radius: var(--r-card); margin-bottom: var(--sp-3); background: var(--surface); } | |
| 243 | + .tbl-stack td { display: flex; justify-content: space-between; gap: var(--sp-3); padding: 8px 12px; border: 0; } | |
| 244 | + .tbl-stack td::before { content: attr(data-label); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); } | |
| 245 | +} | |
| 246 | + | |
| 247 | +/* ---- Utilitaires responsive ---- */ | |
| 248 | +.only-mobile { display: initial; } .only-desktop { display: none; } | |
| 249 | +@media (min-width: 768px) { .only-mobile { display: none; } .only-desktop { display: initial; } } | |
| 250 | + | |
| 251 | +/* ============================================================================= | |
| 252 | + SOCLE MOBILE OBLIGATOIRE (2026-08-19) — règles communes aux 13 sites. | |
| 253 | + RÈGLES pour tout composant futur : | |
| 254 | + · TAP, jamais :hover, pour ouvrir un menu (le survol n'existe pas au doigt) ; | |
| 255 | + le :hover ne sert qu'aux effets décoratifs, sous @media (hover: hover). | |
| 256 | + · var(--vh100) (dvh) et JAMAIS 100vh pour tout élément calé sur le viewport. | |
| 257 | + · position:fixed exige qu'AUCUN ancêtre n'ait transform/filter/perspective/ | |
| 258 | + will-change/backdrop-filter — sinon monter l'élément à la racine (body). | |
| 259 | + · Barre basse fixe = .ka-bottombar + classe has-bottombar sur <body>. | |
| 260 | + · Menu/panneau ouvert = .ka-scroll-lock sur <html> (scroll arrière-plan gelé). | |
| 261 | + · z-index : uniquement l'échelle --z-* ci-dessus. | |
| 262 | + · Zones tactiles ≥ var(--touch) (44 px), champs ≥ 16 px (zoom iOS). | |
| 263 | + ========================================================================== */ | |
| 264 | + | |
| 265 | +/* Anti-zoom iOS : au doigt, aucun champ sous 16 px (le focus d'un champ <16 px | |
| 266 | + déclenche un zoom automatique de page sur Safari iOS). !important assumé : | |
| 267 | + c'est un filet d'accessibilité, un champ plus petit casse le zoom quoi | |
| 268 | + qu'il arrive — ne s'applique qu'aux écrans tactiles. */ | |
| 269 | +@media (hover: none) and (pointer: coarse) { | |
| 270 | + input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), | |
| 271 | + select, textarea { font-size: max(16px, 1em) !important; } | |
| 272 | +} | |
| 273 | + | |
| 274 | +/* Zone tactile du badge Groupe KA étendue à ≥44 px au doigt, sans changer | |
| 275 | + son rendu (débord de la zone cliquable via pseudo-élément). */ | |
| 276 | +@media (pointer: coarse) { | |
| 277 | + .gk-badge { position: relative; } | |
| 278 | + .gk-badge::after { content: ""; position: absolute; inset: -8px; } | |
| 279 | +} | |
| 280 | + | |
| 281 | +/* Hauteurs viewport dynamiques */ | |
| 282 | +.h-viewport { height: 100vh; height: 100dvh; } | |
| 283 | +.min-h-viewport { min-height: 100vh; min-height: 100dvh; } | |
| 284 | + | |
| 285 | +/* Barre fixe basse fiable : safe-area iPhone incluse, immunisée contre les | |
| 286 | + transforms accidentels, et compensation d'espace via body.has-bottombar. */ | |
| 287 | +.ka-bottombar { | |
| 288 | + position: fixed; left: 0; right: 0; bottom: 0; | |
| 289 | + z-index: var(--z-bottombar); | |
| 290 | + padding-bottom: env(safe-area-inset-bottom, 0px); | |
| 291 | + background: var(--surface); | |
| 292 | + border-top: 1.5px solid var(--ink); | |
| 293 | + transform: none !important; filter: none !important; | |
| 294 | +} | |
| 295 | +body.has-bottombar { padding-bottom: calc(var(--bottombar-h, 64px) + env(safe-area-inset-bottom, 0px)); } | |
| 296 | + | |
| 297 | +/* Verrou de scroll d'arrière-plan (menu mobile, modale, panneau de filtres | |
| 298 | + ouverts) : ajouter/retirer .ka-scroll-lock sur <html>. */ | |
| 299 | +html.ka-scroll-lock, html.ka-scroll-lock body { overflow: hidden !important; overscroll-behavior: none; } | |
| 300 | + | |
| 301 | +/* Menu déroulant de référence : au-dessus de tout contenu (cartes Mapbox | |
| 302 | + incluses), défilement interne avec élan, jamais plus haut que l'écran. */ | |
| 303 | +.ka-menu { | |
| 304 | + position: absolute; z-index: var(--z-dropdown); | |
| 305 | + min-width: 180px; | |
| 306 | + max-height: min(60vh, 420px); max-height: min(60dvh, 420px); | |
| 307 | + overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; | |
| 308 | + background: var(--surface); border: 1.5px solid var(--ink); | |
| 309 | + border-radius: var(--r-card); box-shadow: var(--shadow-off-mid); | |
| 310 | +} | |
| 311 | +.ka-menu a, .ka-menu button, .ka-menu label, .ka-menu [role="menuitem"], .ka-menu [role="option"] { | |
| 312 | + display: flex; align-items: center; min-height: var(--touch); | |
| 313 | + padding: 10px 14px; width: 100%; text-decoration: none; | |
| 314 | +} | |
| 315 | +/* Voile plein écran sous une modale / un panneau */ | |
| 316 | +.ka-overlay { | |
| 317 | + position: fixed; inset: 0; z-index: var(--z-overlay); | |
| 318 | + background: rgba(20, 24, 20, 0.45); | |
| 319 | +} | |
modified
src/api/main.py
+4 −1
@@ -34,6 +34,7 @@ from src.api.routes import ( | ||
| 34 | 34 | iosauth, |
| 35 | 35 | monitoring, |
| 36 | 36 | runs, |
| 37 | + search, | |
| 37 | 38 | services, |
| 38 | 39 | stats, |
| 39 | 40 | ) |
@@ -87,12 +88,14 @@ app.add_middleware( | ||
| 87 | 88 | app.add_middleware(RateLimitMiddleware) |
| 88 | 89 | app.add_middleware(RequestLoggingMiddleware) |
| 89 | 90 | |
| 90 | −# /api/v1/runs doit être enregistré avant les routes génériques /api/v1/{service}. | |
| 91 | +# /api/v1/runs et /api/v1/search doivent être enregistrés avant les routes | |
| 92 | +# génériques /api/v1/{service} (sinon leur catch-all capturerait ces chemins). | |
| 91 | 93 | app.include_router(health.router) |
| 92 | 94 | app.include_router(auth.router) |
| 93 | 95 | app.include_router(runs.router) |
| 94 | 96 | app.include_router(monitoring.router) |
| 95 | 97 | app.include_router(stats.router) |
| 98 | +app.include_router(search.router) | |
| 96 | 99 | app.include_router(services.router) |
| 97 | 100 | app.include_router(agent.router) |
| 98 | 101 | app.include_router(iosauth.router) |
added
src/api/routes/search.py
+121 −0
@@ -0,0 +1,121 @@ | ||
| 1 | +# ============================================ | |
| 2 | +# Projet : API-KA | |
| 3 | +# Fichier : src/api/routes/search.py | |
| 4 | +# Node : m3u96b | |
| 5 | +# Author : Simon-Pierre Boucher | |
| 6 | +# Contact : contact@spboucher.ai | |
| 7 | +# Date : 2026-08-23 | |
| 8 | +# ============================================ | |
| 9 | +"""Routes /api/v1/search et /api/v1/suggest — recherche Trouve·Ka. | |
| 10 | + | |
| 11 | +Proxy public du moteur de recherche interne du Groupe KA (www.trouve-ka.com) : | |
| 12 | +API-KA relaie les requêtes vers le moteur amont et renvoie ses résultats dans | |
| 13 | +l'enveloppe uniforme ``{success, data, meta}`` de la plateforme. | |
| 14 | +""" | |
| 15 | + | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +from typing import Any | |
| 19 | + | |
| 20 | +import httpx | |
| 21 | +from fastapi import APIRouter, HTTPException, Query | |
| 22 | + | |
| 23 | +from src.api.routes import envelope | |
| 24 | +from src.config import get_settings | |
| 25 | + | |
| 26 | +router = APIRouter(prefix="/api/v1", tags=["search"]) | |
| 27 | + | |
| 28 | +# Le moteur Trouve·Ka plafonne `limit` à 50 — même plafond ici. | |
| 29 | +MAX_LIMIT = 50 | |
| 30 | + | |
| 31 | + | |
| 32 | +async def _get(url: str, params: dict[str, Any]) -> Any: | |
| 33 | + """GET JSON vers le moteur Trouve·Ka (mêmes réglages que le proxy agent).""" | |
| 34 | + async with httpx.AsyncClient(timeout=12, follow_redirects=True) as cx: | |
| 35 | + r = await cx.get(url, params=params) | |
| 36 | + r.raise_for_status() | |
| 37 | + return r.json() | |
| 38 | + | |
| 39 | + | |
| 40 | +@router.get("/search") | |
| 41 | +async def search( | |
| 42 | + q: str = Query( | |
| 43 | + ..., min_length=1, max_length=200, description="Termes de recherche" | |
| 44 | + ), | |
| 45 | + page: int = Query(1, ge=1, description="Numéro de page (défaut 1)"), | |
| 46 | + limit: int = Query( | |
| 47 | + 10, ge=1, le=MAX_LIMIT, description="Résultats par page (défaut 10, max 50)" | |
| 48 | + ), | |
| 49 | + language: str | None = Query( | |
| 50 | + None, pattern="^(fr|en)$", description="Langue des résultats : fr ou en" | |
| 51 | + ), | |
| 52 | + site: str | None = Query( | |
| 53 | + None, | |
| 54 | + max_length=253, | |
| 55 | + description="Limiter à un site — domaine, ex. www.lou-ka.com", | |
| 56 | + ), | |
| 57 | + category: str | None = Query(None, description="Catégorie Trouve·Ka"), | |
| 58 | + freshness: str | None = Query( | |
| 59 | + None, | |
| 60 | + pattern="^(day|week|month|year)$", | |
| 61 | + description="Fraîcheur : day, week, month ou year", | |
| 62 | + ), | |
| 63 | +) -> dict[str, Any]: | |
| 64 | + """Recherche web québécoise propulsée par le moteur Trouve·Ka. | |
| 65 | + | |
| 66 | + Proxifie ``GET https://www.trouve-ka.com/api/search`` (moteur de recherche | |
| 67 | + interne du Groupe KA) : résultats classés (sémantique + rerank), filtrables | |
| 68 | + par langue, site, catégorie et fraîcheur. ``data`` contient les résultats | |
| 69 | + (``title``, ``url``, ``snippet``, ``domain``, ``quebec_score``…) ; ``meta`` | |
| 70 | + expose ``total``, ``took_ms`` et les requêtes reliées (``related``). | |
| 71 | + """ | |
| 72 | + params: dict[str, Any] = {"q": q, "page": page, "limit": limit} | |
| 73 | + for key, value in ( | |
| 74 | + ("language", language), | |
| 75 | + ("site", site), | |
| 76 | + ("category", category), | |
| 77 | + ("freshness", freshness), | |
| 78 | + ): | |
| 79 | + if value: | |
| 80 | + params[key] = value | |
| 81 | + try: | |
| 82 | + data = await _get(get_settings().trouveka_search_url, params) | |
| 83 | + except httpx.HTTPError as exc: | |
| 84 | + raise HTTPException( | |
| 85 | + status_code=502, detail=f"Moteur Trouve·Ka injoignable : {exc}" | |
| 86 | + ) from exc | |
| 87 | + return envelope( | |
| 88 | + data.get("results", []), | |
| 89 | + total=data.get("total"), | |
| 90 | + page=data.get("page", page), | |
| 91 | + limit=data.get("limit", limit), | |
| 92 | + extra_meta={ | |
| 93 | + "query": data.get("query", q), | |
| 94 | + "took_ms": data.get("took_ms"), | |
| 95 | + "related": data.get("related", []), | |
| 96 | + "source": "trouve-ka", | |
| 97 | + }, | |
| 98 | + ) | |
| 99 | + | |
| 100 | + | |
| 101 | +@router.get("/suggest") | |
| 102 | +async def suggest( | |
| 103 | + q: str = Query( | |
| 104 | + ..., | |
| 105 | + min_length=2, | |
| 106 | + max_length=200, | |
| 107 | + description="Préfixe de recherche (2 caractères minimum)", | |
| 108 | + ), | |
| 109 | +) -> dict[str, Any]: | |
| 110 | + """Suggestions de recherche (autocomplétion) du moteur Trouve·Ka. | |
| 111 | + | |
| 112 | + Proxifie ``GET https://www.trouve-ka.com/api/suggest`` : renvoie dans | |
| 113 | + ``data`` la liste des suggestions de requêtes pour le préfixe donné. | |
| 114 | + """ | |
| 115 | + try: | |
| 116 | + data = await _get(get_settings().trouveka_suggest_url, {"q": q}) | |
| 117 | + except httpx.HTTPError as exc: | |
| 118 | + raise HTTPException( | |
| 119 | + status_code=502, detail=f"Moteur Trouve·Ka injoignable : {exc}" | |
| 120 | + ) from exc | |
| 121 | + return envelope(data.get("suggestions", []), extra_meta={"source": "trouve-ka"}) | |
modified
src/api/web/index.html
+21 −0
@@ -346,6 +346,22 @@ const ENDPOINTS = [ | ||
| 346 | 346 | const q=[["service",v.service_opt],["status",v.status],["page",v.page||1],["limit",v.limit||100]] |
| 347 | 347 | .filter(([,x])=>x).map(([k,x])=>`${k}=${x}`).join("&"); |
| 348 | 348 | return `/api/v1/runs?${q}`;}}, |
| 349 | + {id:"search", path:"/api/v1/search", titre:"Recherche Trouve·Ka", | |
| 350 | + desc:"Recherche web québécoise propulsée par le moteur Trouve·Ka (www.trouve-ka.com) : résultats classés (sémantique + rerank), filtrables par site KA, langue et fraîcheur.", | |
| 351 | + params:[ | |
| 352 | + ["q","query","termes de recherche",true], | |
| 353 | + ["site","query","limiter à un site KA (ex. www.lou-ka.com)",false], | |
| 354 | + ["page","query","page (défaut 1)",false], | |
| 355 | + ["limit","query","résultats par page (max 50)",false]], | |
| 356 | + fields:["q","site","page","limit50"], | |
| 357 | + build:v=>{ | |
| 358 | + const p=[["q",encodeURIComponent(v.q||"montreal")],["site",v.site?encodeURIComponent(v.site):""],["page",v.page||1],["limit",v.limit50||10]] | |
| 359 | + .filter(([,x])=>x).map(([k,x])=>`${k}=${x}`).join("&"); | |
| 360 | + return `/api/v1/search?${p}`;}}, | |
| 361 | + {id:"suggest", path:"/api/v1/suggest", titre:"Suggestions de recherche", | |
| 362 | + desc:"Autocomplétion du moteur Trouve·Ka : suggestions de requêtes à partir d'un préfixe (2 caractères minimum).", | |
| 363 | + params:[["q","query","préfixe de recherche (min. 2 caractères)",true]], | |
| 364 | + fields:["q"], build:v=>`/api/v1/suggest?q=${encodeURIComponent(v.q||"logem")}`}, | |
| 349 | 365 | ]; |
| 350 | 366 | |
| 351 | 367 | function snippets(url){ |
@@ -420,6 +436,9 @@ const FIELD_DEFS = { | ||
| 420 | 436 | date: {label:"Date (YYYY-MM-DD)", type:"date"}, |
| 421 | 437 | page: {label:"Page", type:"number", value:1, min:1}, |
| 422 | 438 | limit: {label:"Limit (max 500)", type:"number", value:25, min:1, max:500}, |
| 439 | + limit50: {label:"Limit (max 50)", type:"number", value:10, min:1, max:50}, | |
| 440 | + q: {label:"Recherche (q)", type:"text", value:"montreal"}, | |
| 441 | + site: {label:"Site (optionnel, ex. www.lou-ka.com)", type:"text", value:""}, | |
| 423 | 442 | }; |
| 424 | 443 | |
| 425 | 444 | function renderFields(){ |
@@ -437,6 +456,8 @@ function renderFields(){ | ||
| 437 | 456 | } else if (def.type === "date") { |
| 438 | 457 | const today = new Date().toISOString().slice(0,10); |
| 439 | 458 | div.innerHTML = `<label for="${id}">${def.label}</label><input id="${id}" type="date" value="${today}">`; |
| 459 | + } else if (def.type === "text") { | |
| 460 | + div.innerHTML = `<label for="${id}">${def.label}</label><input id="${id}" type="text" value="${def.value||""}">`; | |
| 440 | 461 | } else { |
| 441 | 462 | div.innerHTML = `<label for="${id}">${def.label}</label> |
| 442 | 463 | <input id="${id}" type="number" value="${def.value}" min="${def.min||0}" ${def.max?`max="${def.max}"`:""}>`; |
modified
src/config.py
+8 −0
@@ -80,6 +80,8 @@ class Settings: | ||
| 80 | 80 | daily_run_hour: int |
| 81 | 81 | backup_retention_days: int |
| 82 | 82 | rate_limit_per_minute: int |
| 83 | + trouveka_search_url: str | |
| 84 | + trouveka_suggest_url: str | |
| 83 | 85 | source_urls: dict[str, str] |
| 84 | 86 | base_dir: Path |
| 85 | 87 | logs_dir: Path |
@@ -105,6 +107,12 @@ def get_settings() -> Settings: | ||
| 105 | 107 | daily_run_hour=int(os.getenv("DAILY_RUN_HOUR", "02")), |
| 106 | 108 | backup_retention_days=max(90, int(os.getenv("BACKUP_RETENTION_DAYS", "90"))), |
| 107 | 109 | rate_limit_per_minute=int(os.getenv("RATE_LIMIT_PER_MINUTE", "120")), |
| 110 | + trouveka_search_url=os.getenv( | |
| 111 | + "TROUVEKA_SEARCH_URL", "https://www.trouve-ka.com/api/search" | |
| 112 | + ), | |
| 113 | + trouveka_suggest_url=os.getenv( | |
| 114 | + "TROUVEKA_SUGGEST_URL", "https://www.trouve-ka.com/api/suggest" | |
| 115 | + ), | |
| 108 | 116 | source_urls={ |
| 109 | 117 | service: os.getenv(env_var, "") |
| 110 | 118 | for service, env_var in SERVICE_SOURCE_ENV.items() |
added
tests/test_search.py
+174 −0
@@ -0,0 +1,174 @@ | ||
| 1 | +# ============================================ | |
| 2 | +# Projet : API-KA | |
| 3 | +# Fichier : tests/test_search.py | |
| 4 | +# Node : m3u96b | |
| 5 | +# Author : Simon-Pierre Boucher | |
| 6 | +# Contact : contact@spboucher.ai | |
| 7 | +# Date : 2026-08-23 | |
| 8 | +# ============================================ | |
| 9 | +"""Tests des routes /api/v1/search et /api/v1/suggest (proxy Trouve·Ka). | |
| 10 | + | |
| 11 | +Le moteur amont est mocké (monkeypatch de ``search._get``) : aucun appel | |
| 12 | +réseau réel pendant la suite de tests. | |
| 13 | +""" | |
| 14 | + | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +from typing import Any | |
| 18 | + | |
| 19 | +import httpx | |
| 20 | +import pytest | |
| 21 | +from fastapi.testclient import TestClient | |
| 22 | + | |
| 23 | +from src.api.main import app | |
| 24 | +from src.api.routes import search as search_module | |
| 25 | + | |
| 26 | +FAKE_SEARCH = { | |
| 27 | + "query": "montreal", | |
| 28 | + "total": 42, | |
| 29 | + "took_ms": 12, | |
| 30 | + "page": 1, | |
| 31 | + "limit": 3, | |
| 32 | + "semantic": True, | |
| 33 | + "reranked": True, | |
| 34 | + "related": ["hôtel montreal"], | |
| 35 | + "results": [ | |
| 36 | + { | |
| 37 | + "title": "Montréal", | |
| 38 | + "url": "https://example.com/montreal", | |
| 39 | + "display_url": "example.com › montreal", | |
| 40 | + "snippet": "La métropole du Québec.", | |
| 41 | + "domain": "example.com", | |
| 42 | + "language": "fr", | |
| 43 | + "quebec_score": 0.9, | |
| 44 | + "badges": [], | |
| 45 | + "published_at": None, | |
| 46 | + "image": None, | |
| 47 | + } | |
| 48 | + ], | |
| 49 | +} | |
| 50 | + | |
| 51 | +FAKE_SUGGEST = {"suggestions": ["logement montreal", "logement quebec"]} | |
| 52 | + | |
| 53 | + | |
| 54 | +@pytest.fixture(scope="module") | |
| 55 | +def client(): | |
| 56 | + """Client de test FastAPI avec cycle de vie (lifespan) actif.""" | |
| 57 | + with TestClient(app) as test_client: | |
| 58 | + yield test_client | |
| 59 | + | |
| 60 | + | |
| 61 | +def _mock_upstream(monkeypatch: pytest.MonkeyPatch, captured: dict[str, Any]) -> None: | |
| 62 | + """Remplace le GET amont par un faux moteur qui capture url + params.""" | |
| 63 | + | |
| 64 | + async def fake_get(url: str, params: dict[str, Any]) -> Any: | |
| 65 | + captured["url"] = url | |
| 66 | + captured["params"] = params | |
| 67 | + if "suggest" in url: | |
| 68 | + return FAKE_SUGGEST | |
| 69 | + return FAKE_SEARCH | |
| 70 | + | |
| 71 | + monkeypatch.setattr(search_module, "_get", fake_get) | |
| 72 | + | |
| 73 | + | |
| 74 | +def test_search_envelope(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: | |
| 75 | + """GET /api/v1/search proxifie le moteur et renvoie l'enveloppe uniforme.""" | |
| 76 | + captured: dict[str, Any] = {} | |
| 77 | + _mock_upstream(monkeypatch, captured) | |
| 78 | + response = client.get("/api/v1/search", params={"q": "montreal", "limit": 3}) | |
| 79 | + assert response.status_code == 200 | |
| 80 | + body = response.json() | |
| 81 | + assert body["success"] is True | |
| 82 | + assert body["data"] == FAKE_SEARCH["results"] | |
| 83 | + assert body["meta"]["total"] == 42 | |
| 84 | + assert body["meta"]["page"] == 1 | |
| 85 | + assert body["meta"]["limit"] == 3 | |
| 86 | + assert body["meta"]["query"] == "montreal" | |
| 87 | + assert body["meta"]["took_ms"] == 12 | |
| 88 | + assert body["meta"]["related"] == ["hôtel montreal"] | |
| 89 | + assert body["meta"]["source"] == "trouve-ka" | |
| 90 | + assert captured["params"] == {"q": "montreal", "page": 1, "limit": 3} | |
| 91 | + | |
| 92 | + | |
| 93 | +def test_search_forwards_optional_filters( | |
| 94 | + client: TestClient, monkeypatch: pytest.MonkeyPatch | |
| 95 | +) -> None: | |
| 96 | + """Les filtres optionnels (language, site, category, freshness) sont transmis.""" | |
| 97 | + captured: dict[str, Any] = {} | |
| 98 | + _mock_upstream(monkeypatch, captured) | |
| 99 | + response = client.get( | |
| 100 | + "/api/v1/search", | |
| 101 | + params={ | |
| 102 | + "q": "logement", | |
| 103 | + "language": "fr", | |
| 104 | + "site": "www.lou-ka.com", | |
| 105 | + "category": "immobilier", | |
| 106 | + "freshness": "week", | |
| 107 | + }, | |
| 108 | + ) | |
| 109 | + assert response.status_code == 200 | |
| 110 | + assert captured["params"]["language"] == "fr" | |
| 111 | + assert captured["params"]["site"] == "www.lou-ka.com" | |
| 112 | + assert captured["params"]["category"] == "immobilier" | |
| 113 | + assert captured["params"]["freshness"] == "week" | |
| 114 | + | |
| 115 | + | |
| 116 | +def test_search_validation(client: TestClient) -> None: | |
| 117 | + """q est requis, limit plafonné à 50, freshness/language contraints.""" | |
| 118 | + assert client.get("/api/v1/search").status_code == 422 | |
| 119 | + assert client.get("/api/v1/search", params={"q": ""}).status_code == 422 | |
| 120 | + assert ( | |
| 121 | + client.get("/api/v1/search", params={"q": "x", "limit": 51}).status_code == 422 | |
| 122 | + ) | |
| 123 | + assert ( | |
| 124 | + client.get("/api/v1/search", params={"q": "x", "language": "es"}).status_code | |
| 125 | + == 422 | |
| 126 | + ) | |
| 127 | + assert ( | |
| 128 | + client.get("/api/v1/search", params={"q": "x", "freshness": "hour"}).status_code | |
| 129 | + == 422 | |
| 130 | + ) | |
| 131 | + | |
| 132 | + | |
| 133 | +def test_search_upstream_error_is_502( | |
| 134 | + client: TestClient, monkeypatch: pytest.MonkeyPatch | |
| 135 | +) -> None: | |
| 136 | + """Une erreur httpx du moteur amont devient un 502 explicite.""" | |
| 137 | + | |
| 138 | + async def broken(url: str, params: dict[str, Any]) -> Any: | |
| 139 | + raise httpx.ConnectError("moteur injoignable") | |
| 140 | + | |
| 141 | + monkeypatch.setattr(search_module, "_get", broken) | |
| 142 | + response = client.get("/api/v1/search", params={"q": "montreal"}) | |
| 143 | + assert response.status_code == 502 | |
| 144 | + assert "Trouve·Ka" in response.json()["detail"] | |
| 145 | + | |
| 146 | + | |
| 147 | +def test_suggest_envelope(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: | |
| 148 | + """GET /api/v1/suggest renvoie les suggestions dans l'enveloppe uniforme.""" | |
| 149 | + captured: dict[str, Any] = {} | |
| 150 | + _mock_upstream(monkeypatch, captured) | |
| 151 | + response = client.get("/api/v1/suggest", params={"q": "logem"}) | |
| 152 | + assert response.status_code == 200 | |
| 153 | + body = response.json() | |
| 154 | + assert body["success"] is True | |
| 155 | + assert body["data"] == FAKE_SUGGEST["suggestions"] | |
| 156 | + assert body["meta"]["source"] == "trouve-ka" | |
| 157 | + assert captured["params"] == {"q": "logem"} | |
| 158 | + | |
| 159 | + | |
| 160 | +def test_suggest_min_length(client: TestClient) -> None: | |
| 161 | + """q exige au moins 2 caractères.""" | |
| 162 | + assert client.get("/api/v1/suggest", params={"q": "l"}).status_code == 422 | |
| 163 | + | |
| 164 | + | |
| 165 | +def test_search_not_captured_by_service_catchall(client: TestClient) -> None: | |
| 166 | + """/api/v1/search ne doit PAS tomber dans le catch-all /api/v1/{service}. | |
| 167 | + | |
| 168 | + Sans paramètres, la route search répond 422 (q requis) ; si le catch-all | |
| 169 | + l'attrapait, on obtiendrait le 404 « Service inconnu : search ». | |
| 170 | + """ | |
| 171 | + response = client.get("/api/v1/search") | |
| 172 | + assert response.status_code == 422 | |
| 173 | + response = client.get("/api/v1/notaservice") | |
| 174 | + assert response.status_code == 404 | |
| 175 | ||