# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/seo.py # Rôle : Référencement — SSR léger, robots.txt, sitemaps, données structurées # Créé : 2026-08-22 Modifié : 2026-08-22 # # Principe (pattern Lou-Ka) : pour chaque route publique, le serveur renvoie le # MÊME index.html que le build Vite, mais avec un
unique (title, # description, canonical, hreflang, og:, JSON-LD) et le contenu essentiel en # HTML DANSJob-Ka — Un service ' 'Groupe KA
' + "Voir toutes les offres d\'emploi au ' "Québec
", status=404) # --- API JSON pour les pages villes du frontend -------------------------------- @router.get("/api/seo/villes") def api_villes(): con = db.connect() rows = _villes_rows(con) con.close() return {"villes": rows} @router.get("/api/seo/ville/{slug}") def api_ville(slug: str): e = _villes_index().get(slug) if not e: raise HTTPException(404, "Ville inconnue") neighbors = [v for v in _villes_rows(minimum=MIN_JOBS_PAGE) if v["slug"] != slug][:12] return {"city": e["city"], "slug": slug, "n": e["n"], "employers": e["employers"], "with_salary": e["with_salary"], "neighbors": neighbors} # --- robots.txt & sitemaps ---------------------------------------------------- @router.get("/robots.txt", include_in_schema=False) def robots() -> PlainTextResponse: return PlainTextResponse( "User-agent: *\n" "Allow: /\n" "Disallow: /api/\n" "Disallow: /health\n" f"\nSitemap: {BASE_URL}/sitemap.xml\n") def _xml(content: str) -> Response: return Response('\n' + content, media_type="application/xml", headers={"Cache-Control": "public, max-age=3600"}) def _urlset(urls: list[tuple[str, str | None]]) -> Response: rows = [] for loc, lastmod in urls: lm = f"Job-Ka recense les offres d'emploi de {_fmt_n(employers)} " "employeurs québécois, directement à la source : chaque offre est " "traçable à sa page carrière ou à son portail d'origine, avec le " f"salaire affiché quand il est publié ({_fmt_n(with_salary)} offres " "avec transparence salariale).
" + "Toutes les villes ({len(villes)})
' + "Voir les offres sur la carte · ' 'Employeurs et sources · ' 'Statistiques du marché de l\'emploi · ' 'Publier une offre
') jsonld = [ {"@context": "https://schema.org", "@type": "WebSite", "name": SITE_NAME, "url": BASE_URL + "/", "description": description, "inLanguage": "fr-CA"}, {"@context": "https://schema.org", "@type": "Organization", "name": "Job-Ka (Groupe KA)", "url": BASE_URL + "/", "logo": BASE_URL + "/og.png"}] return _render(title=title, description=description, path="/", jsonld=jsonld, body=body) @router.get("/emploi/{uid:path}", include_in_schema=False) def job_ssr(uid: str): con = db.connect() row = con.execute("SELECT * FROM jobs WHERE uid=?", (uid,)).fetchone() con.close() path = f"/emploi/{uid}" if row is None: return _render(title="Offre introuvable | Job-Ka", description="Cette offre d'emploi n'existe pas ou plus sur Job-Ka.", path=path, body="Voir toutes les offres d\'emploi ' "au Québec
", status=404) d = dict(row) if not d["active"]: # offre retirée chez la source : 410 Gone + lien vers l'accueil return _render( title="Offre retirée | Job-Ka", description="Cette offre d'emploi a été retirée par l'employeur.", path=path, body="Elle a été retirée par l'employeur ou le poste est comblé. " 'Voir les offres d\'emploi actuellement ' "ouvertes au Québec.
", status=410) label = d["title_clean"] or d["title"] or "Offre d'emploi" where_bits = [b for b in (d["employer"], d["city"]) if b] title = f"{label}{' — ' + ', '.join(where_bits) if where_bits else ''} | Job-Ka" salary_txt = _salary_txt(d) etype_fr = EMPLOYMENT_TYPE_FR.get(d["employment_type"] or "", "") mode_fr = WORK_MODE_FR.get(d["work_mode"] or "", "") bits = [b for b in (d["employer"], d["city"] or d["region"], etype_fr, mode_fr, salary_txt) if b] desc_txt = re.sub(r"\s+", " ", d["description"] or "").strip() description = (" · ".join(bits) + ". " if bits else "") + \ (desc_txt[:160].strip() + "…" if len(desc_txt) > 160 else desc_txt) description = description[:300] or \ f"Offre d'emploi{' à ' + d['city'] if d['city'] else ' au Québec'} sur Job-Ka." facts = [("Employeur", d["employer"]), ("Ville", d["city"]), ("Région", d["region"]), ("Type d'emploi", etype_fr), ("Mode de travail", mode_fr), ("Salaire", salary_txt), ("Publiée le", d["date_posted"]), ("Postuler avant le", d["date_deadline"])] body = [f"{_e(desc_txt[:800])}
") link = d["apply_url"] or d["url"] if link: body.append(f'' "Postuler chez l'employeur (offre originale)
") if d["city"]: body.append(f'' f"Autres offres d'emploi à {_e(d['city'])}
") body.append('Toutes les offres d\'emploi au Québec
') # --- JSON-LD JobPosting (schéma Google Offres d'emploi) --- posting: dict = { "@context": "https://schema.org", "@type": "JobPosting", "title": label, "description": desc_txt[:6000] or label, "datePosted": d["date_posted"] or _iso(d["first_seen"]), "hiringOrganization": {"@type": "Organization", "name": d["employer"] or SITE_NAME}, "identifier": {"@type": "PropertyValue", "name": d["source"], "value": d["external_id"]}, "url": BASE_URL + path, "inLanguage": {"fr": "fr-CA", "en": "en-CA"}.get(d["language"] or "", "fr-CA"), } if d["company_logo"] and str(d["company_logo"]).startswith("http"): posting["hiringOrganization"]["logo"] = d["company_logo"] address: dict = {"@type": "PostalAddress", "addressRegion": "QC", "addressCountry": "CA"} if d["city"]: address["addressLocality"] = d["city"] if d["postal_code"]: address["postalCode"] = d["postal_code"] if d["address"]: address["streetAddress"] = d["address"] posting["jobLocation"] = {"@type": "Place", "address": address} if d["work_mode"] == "teletravail": posting["jobLocationType"] = "TELECOMMUTE" posting["applicantLocationRequirements"] = { "@type": "Country", "name": "CA"} if d["employment_type"] in EMPLOYMENT_TYPE_SCHEMA: posting["employmentType"] = EMPLOYMENT_TYPE_SCHEMA[d["employment_type"]] if d["date_deadline"]: posting["validThrough"] = d["date_deadline"] if d["salary_min"] is not None and d["salary_unit"] in SALARY_UNIT_SCHEMA: value: dict = {"@type": "QuantitativeValue", "unitText": SALARY_UNIT_SCHEMA[d["salary_unit"]]} if d["salary_max"] and d["salary_max"] != d["salary_min"]: value["minValue"] = d["salary_min"] value["maxValue"] = d["salary_max"] else: value["value"] = d["salary_min"] posting["baseSalary"] = {"@type": "MonetaryAmount", "currency": "CAD", "value": value} crumbs = [("Accueil", "/"), (label, path)] # doublon inter-sources : la fiche canonique est l'offre maîtresse ; # quarantaine qualité : fiche accessible mais non indexée canonical = f"/emploi/{d['dup_of']}" if d["dup_of"] else None return _render(title=title, description=description, path=path, canonical_path=canonical, noindex=bool(d["quarantine"]), jsonld=[posting, _breadcrumb(crumbs)], body="".join(body), og_image=d["company_logo"] if d["company_logo"] and str(d["company_logo"]).startswith("http") else None) # --- Pages programmatiques villes : /villes + /ville/{slug} ------------------- @router.get("/villes", include_in_schema=False) def villes_ssr(): con = db.connect() villes = _villes_rows(con) con.close() total = sum(v["n"] for v in villes) title = f"Offres d'emploi par ville au Québec ({len(villes)} villes) | Job-Ka" description = ( f"Toutes les villes du Québec où Job-Ka recense des offres d'emploi : " f"{_fmt_n(total)} postes dans {len(villes)} villes, avec le nombre " "d'offres et d'employeurs par ville.") body = (f"{_fmt_n(e['employers'])} employeurs recrutent à {_e(city)}" f" ; {_fmt_n(e['with_salary'])} offres affichent le salaire.
", "Toutes les villes · ' 'Toutes les offres d\'emploi au Québec
') crumbs = [("Accueil", "/"), ("Villes", "/villes"), (city, path)] jsonld = [_breadcrumb(crumbs), {"@context": "https://schema.org", "@type": "ItemList", "name": f"Offres d'emploi à {city}", "numberOfItems": n, "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": r["title_clean"] or r["title"] or r["uid"], "url": f"{BASE_URL}/emploi/{quote(r['uid'], safe='')}"} for i, r in enumerate(rows[:50])]}] return _render(title=title, description=description, path=path, jsonld=jsonld, body="".join(body)) # --- Pages statiques de l'app : head unique, contenu rendu par React ---------- _STATIC_META = { "/carte": ("Carte des offres d'emploi au Québec | Job-Ka", "Toutes les offres d'emploi géolocalisées sur la carte du " "Québec : explorez les postes ouverts près de chez vous, " "ville par ville, avec salaire et employeur."), "/sources": ("Employeurs et sources des offres | Job-Ka", "La liste des employeurs québécois et des sources (pages " "carrières, portails) dont Job-Ka agrège les offres " "d'emploi, avec le nombre d'offres actives de chacun."), "/stats": ("Statistiques du marché de l'emploi au Québec | Job-Ka", "Salaires moyens, répartition des offres par ville, catégorie " "et mode de travail : les statistiques du marché de l'emploi " "québécois, calculées en continu par Job-Ka."), "/employeurs": ("Publier une offre d'emploi gratuitement | Job-Ka", "Employeurs québécois : publiez gratuitement votre offre " "d'emploi sur Job-Ka. Dépôt direct, modération rapide, " "visibilité auprès des chercheurs d'emploi du Québec."), "/contact": ("Nous joindre | Job-Ka", "Contactez l'équipe de Job-Ka : questions, signalement d'une " "offre, retrait d'une offre ou partenariat employeur."), } def _static_page(path: str): title, description = _STATIC_META[path] return _render(title=title, description=description, path=path, jsonld=[_breadcrumb([("Accueil", "/"), (title.split(" | ")[0], path)])]) @router.get("/carte", include_in_schema=False) def carte_page(): return _static_page("/carte") @router.get("/sources", include_in_schema=False) def sources_page(): return _static_page("/sources") @router.get("/stats", include_in_schema=False) def stats_page(): return _static_page("/stats") @router.get("/employeurs", include_in_schema=False) def employeurs_page(): return _static_page("/employeurs") @router.get("/contact", include_in_schema=False) def contact_page(): return _static_page("/contact")