0d3b2420c3
CI / Contraintes NON-NÉGOCIABLES (CLAUDE.md) (push) Has been cancelled
CI / Validation JSON (schémas Faisabilité) (push) Has been cancelled
CI / Qualité documentaire (liens + 4Big) (push) Has been cancelled
CI / Publiciste · parser + schéma + generator (unittest) (push) Has been cancelled
CI / RBAC · 50 rôles + schéma (unittest) (push) Has been cancelled
CI / Faisabilité · générateur 4 volets + round-trip (unittest) (push) Has been cancelled
CI / RBAC · fixtures ERPNext (Role + Custom DocPerm) (push) Has been cancelled
CI / RBAC · plan User Permission (row-level) (push) Has been cancelled
CI / RBAC · Role Profile (bundles par portail) (push) Has been cancelled
CI / RBAC · run-book d'application unifié (agrégat 3 volets) (push) Has been cancelled
CI / Faisabilité · dossier bancable trilingue FR/EN/ES (push) Has been cancelled
CI / CRM · workflow vente ERPNext (lead → CONFOTUR) (push) Has been cancelled
CI / CRM · DocType porteur OTO Dossier Vente (push) Has been cancelled
CI / CRM · barème commissions vendeurs (push) Has been cancelled
CI / Fiscal · e-CF DGII (Compupar) (push) Has been cancelled
CI / Frontend · Workspaces 5 portails rôle (push) Has been cancelled
CI / Legal · DocType CONFOTUR Application (push) Has been cancelled
CI / QA · Audit 5D conformité (push) Has been cancelled
CI / SEO · mots-clés trilingues + schema.org + hreflang (push) Has been cancelled
CI / E2E baseline Playwright (manuel) (push) Has been cancelled
CI / Gate qualité (agrégat) (push) Has been cancelled
308 lines
13 KiB
Python
308 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Generateur SEO trilingue · Sprint 6 · SEO.
|
|
|
|
Roadmap Sprint 6 SEO (L60) : « Refactor mission seo_autonome/ -> 200+ mots-cles
|
|
FR/EN/ES · schema.org · hreflang ». Consomme `projets_master.json` (sortie
|
|
Publiciste, derivee de data_room/PXX) et produit un bundle de hand-off :
|
|
- seo_keywords.json : 200+ mots-cles FR/EN/ES, chacun sourcE ;
|
|
- seo_schema_org.json : graphe JSON-LD (Organization + une Residence/projet) ;
|
|
- seo_hreflang.json : carte hreflang (alternates + x-default) ;
|
|
- MANIFEST.json : compte-rendu.
|
|
|
|
Ce worker n'ecrit JAMAIS sur le VPS (#8) : l'injection des balises dans les
|
|
pages `www/`, le sitemap et la soumission Google Search Console restent cote
|
|
agent SEO / Frontend.
|
|
|
|
ANTI-INVENTION (#6) : aucun fait de projet (nom/localisation/prix) n'est fabrique
|
|
ici — tout vient de l'entree. Un mot-cle ne peut contenir que les chiffres deja
|
|
presents dans son champ projet source ; schema.org n'emet un prix que pour un
|
|
projet 'disponible' a typologie sourcee (USD · #10). 15 invariants le garantissent.
|
|
|
|
Sous-commandes :
|
|
build [--master M] [-o OUT] -> ecrit les 4 fichiers de hand-off ;
|
|
validate [--master M] -> (re)genere en memoire, valide entree +
|
|
schema de sortie + 15 invariants ; sort en
|
|
erreur sinon.
|
|
|
|
Sortie deterministe (tri stable, aucun horodatage) -> diffable + re-generable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, _HERE)
|
|
|
|
from seolib import builder, deps # noqa: E402
|
|
|
|
_DEFAULT_OUT = os.path.join(_HERE, "out")
|
|
|
|
|
|
def _eprint(*args) -> None:
|
|
print(*args, file=sys.stderr)
|
|
|
|
|
|
def _project_field_ok(source: str, by_code: dict) -> bool:
|
|
"""Une source `projet:<code>.<champ>` doit viser un projet + un champ reels."""
|
|
body = source[len("projet:"):]
|
|
code, _, field = body.partition(".")
|
|
proj = by_code.get(code)
|
|
return proj is not None and field in proj
|
|
|
|
|
|
def _validate(spec: dict, master: dict, bundle: dict) -> list[str]:
|
|
"""Schema de sortie + 15 invariants de cross-coherence / anti-invention."""
|
|
errors = deps.validate(bundle, deps.load_json(deps.SCHEMA_PATH))
|
|
|
|
site = spec["site"]
|
|
langs = site["langs"]
|
|
base = site["base_url"].rstrip("/")
|
|
default_lang = site["default_lang"]
|
|
targets = spec["targets"]
|
|
projets = master["projets"]
|
|
by_code = {p["code"]: p for p in projets}
|
|
|
|
kws = bundle["keywords"]
|
|
sorg = bundle["schema_org"]
|
|
href = bundle["hreflang"]
|
|
m = bundle["manifest"]
|
|
|
|
# 1 · l'entree respecte le contrat Publiciste (garde-fou amont).
|
|
for e in deps.validate_master(master):
|
|
errors.append(f"entree projets_master non conforme : {e}")
|
|
|
|
# 2 · volume total >= cible roadmap (200+).
|
|
if len(kws) < targets["min_keywords_total"]:
|
|
errors.append(
|
|
f"mots-cles total {len(kws)} < cible {targets['min_keywords_total']} (roadmap L60)"
|
|
)
|
|
|
|
# 3 · les 3 langues du site sont couvertes, chacune >= min par langue.
|
|
per_lang = {lang: [k for k in kws if k["lang"] == lang] for lang in langs}
|
|
present = {k["lang"] for k in kws}
|
|
if present != set(langs):
|
|
errors.append(f"langues couvertes {sorted(present)} != site.langs {langs}")
|
|
for lang in langs:
|
|
n = len(per_lang.get(lang, []))
|
|
if n < targets["min_keywords_per_lang"]:
|
|
errors.append(
|
|
f"langue {lang!r} : {n} mots-cles < min {targets['min_keywords_per_lang']}"
|
|
)
|
|
|
|
# 4 · unicite (term, lang).
|
|
keys = [(k["term"], k["lang"]) for k in kws]
|
|
if len(keys) != len(set(keys)):
|
|
errors.append("mot-cle (term, lang) duplique")
|
|
|
|
# 5 · chaque mot-cle est sourcE et resoluble (lexicon:* ou projet:<code>.<champ>).
|
|
for k in kws:
|
|
if not k["sources"]:
|
|
errors.append(f"mot-cle {k['term']!r} sans source")
|
|
for s in k["sources"]:
|
|
if s.startswith("lexicon:"):
|
|
continue
|
|
if s.startswith("projet:"):
|
|
if not _project_field_ok(s, by_code):
|
|
errors.append(f"mot-cle {k['term']!r} : source {s!r} non resoluble")
|
|
else:
|
|
errors.append(f"mot-cle {k['term']!r} : source {s!r} de forme inconnue")
|
|
|
|
# 6 · scope coherent : projet<->code present ; global<->projet null.
|
|
for k in kws:
|
|
if k["scope"] == "projet":
|
|
if k["projet"] not in by_code:
|
|
errors.append(f"mot-cle {k['term']!r} scope projet mais code {k['projet']!r} absent")
|
|
elif k["projet"] is not None:
|
|
errors.append(f"mot-cle {k['term']!r} scope global mais projet={k['projet']!r}")
|
|
|
|
# 7 · ANTI-INVENTION (#6) : un mot-cle ne peut porter que les chiffres deja
|
|
# presents dans son champ projet source (aucun chiffre fabrique).
|
|
for k in kws:
|
|
term_digits = deps.digits(k["term"])
|
|
if not term_digits:
|
|
continue
|
|
allowed: set[str] = set()
|
|
if k["projet"] in by_code:
|
|
p = by_code[k["projet"]]
|
|
allowed = deps.digits(p["nom"] + " " + p["localisation"])
|
|
if not term_digits <= allowed:
|
|
errors.append(
|
|
f"mot-cle {k['term']!r} : chiffre(s) {sorted(term_digits - allowed)} "
|
|
f"absent(s) de la donnee source (invention interdite #6)"
|
|
)
|
|
|
|
# 8 · schema.org : contexte + noeud Organization aligne au spec.
|
|
if sorg["@context"] != spec["schema_org"]["context"]:
|
|
errors.append("schema_org.@context != spec")
|
|
orgs = [n for n in sorg["@graph"] if n.get("@type") == spec["schema_org"]["organization"]["type"]]
|
|
if len(orgs) != 1:
|
|
errors.append(f"schema_org : {len(orgs)} noeud Organization (attendu 1)")
|
|
elif orgs[0].get("name") != spec["schema_org"]["organization"]["name"]:
|
|
errors.append("schema_org Organization.name != spec")
|
|
|
|
# 9 · un listing par projet ; nom + localite = donnee source (fidelite).
|
|
listing_type = spec["schema_org"]["listing_type"]
|
|
listings = [n for n in sorg["@graph"] if n.get("@type") == listing_type]
|
|
if len(listings) != len(projets):
|
|
errors.append(f"schema_org : {len(listings)} listings != {len(projets)} projets")
|
|
listing_by_name = {n.get("name"): n for n in listings}
|
|
for p in projets:
|
|
node = listing_by_name.get(p["nom"])
|
|
if node is None:
|
|
errors.append(f"schema_org : aucun listing pour {p['nom']!r}")
|
|
continue
|
|
if node.get("address", {}).get("addressLocality") != p["localisation"]:
|
|
errors.append(f"schema_org {p['nom']!r} : addressLocality != localisation source")
|
|
if not str(node.get("url", "")).startswith(base):
|
|
errors.append(f"schema_org {p['nom']!r} : url hors base_url")
|
|
|
|
# 10 · offres : presentes IFF projet 'disponible' + typologie a prix numerique
|
|
# sourcE ; prix == valeur source ; devise USD (#10).
|
|
for p in projets:
|
|
node = listing_by_name.get(p["nom"], {})
|
|
priced = [t for t in p.get("typologies", [])
|
|
if isinstance(t.get("prix_depuis_usd"), (int, float))
|
|
and not isinstance(t.get("prix_depuis_usd"), bool)]
|
|
expect_offer = p["statut"] not in deps.STATUTS_SANS_PRIX and bool(priced)
|
|
offers = node.get("offers", [])
|
|
if bool(offers) != expect_offer:
|
|
errors.append(f"schema_org {p['nom']!r} : presence d'offre {bool(offers)} != attendu {expect_offer}")
|
|
for off in offers:
|
|
if off.get("priceCurrency") != deps.DEVISE_PRIMAIRE:
|
|
errors.append(f"schema_org {p['nom']!r} : devise offre != {deps.DEVISE_PRIMAIRE}")
|
|
src_prices = {t["prix_depuis_usd"] for t in priced}
|
|
if off.get("price") not in src_prices:
|
|
errors.append(f"schema_org {p['nom']!r} : prix d'offre non sourcE dans le master")
|
|
|
|
# 11 · ANTI-INVENTION (#6) : aucun prix pour un statut sans prix.
|
|
for p in projets:
|
|
if p["statut"] in deps.STATUTS_SANS_PRIX and listing_by_name.get(p["nom"], {}).get("offers"):
|
|
errors.append(f"schema_org {p['nom']!r} : offre interdite (statut '{p['statut']}' sans prix · #6)")
|
|
|
|
# 12 · hreflang : une page d'accueil + une page par projet.
|
|
pages = href["pages"]
|
|
page_names = [pg["page"] for pg in pages]
|
|
expect_pages = ["home"] + [p["code"] for p in projets]
|
|
if sorted(page_names) != sorted(expect_pages):
|
|
errors.append(f"hreflang : pages {sorted(page_names)} != {sorted(expect_pages)}")
|
|
|
|
# 13 · chaque page : exactement une alternate par langue + un x-default ;
|
|
# x-default et canonical == URL de la langue par defaut.
|
|
for pg in pages:
|
|
alts = {a["hreflang"]: a["href"] for a in pg["alternates"]}
|
|
if len(pg["alternates"]) != len(alts):
|
|
errors.append(f"hreflang page {pg['page']!r} : hreflang duplique")
|
|
if set(alts) != set(langs) | {"x-default"}:
|
|
errors.append(f"hreflang page {pg['page']!r} : alternates {sorted(alts)} != langs + x-default")
|
|
if alts.get("x-default") != alts.get(default_lang):
|
|
errors.append(f"hreflang page {pg['page']!r} : x-default != langue par defaut")
|
|
if pg["canonical"] != alts.get(default_lang):
|
|
errors.append(f"hreflang page {pg['page']!r} : canonical != URL langue par defaut")
|
|
|
|
# 14 · toutes les URLs (schema.org + hreflang) sous base_url en https.
|
|
urls = [n.get("url") for n in listings if n.get("url")]
|
|
urls += [n.get("@id") for n in sorg["@graph"]]
|
|
for pg in pages:
|
|
urls.append(pg["canonical"])
|
|
urls += [a["href"] for a in pg["alternates"]]
|
|
for u in urls:
|
|
if not str(u).startswith(base + "/") and str(u) != base:
|
|
errors.append(f"URL hors base_url : {u!r}")
|
|
|
|
# 15 · comptes du manifeste coherents.
|
|
checks = {
|
|
"keywords_total": len(kws),
|
|
"schema_org_nodes": len(sorg["@graph"]),
|
|
"listings": len(listings),
|
|
"hreflang_pages": len(pages),
|
|
"projects": len(projets),
|
|
"offers": sum(len(n.get("offers", [])) for n in sorg["@graph"]),
|
|
}
|
|
for key, val in checks.items():
|
|
if m["counts"].get(key) != val:
|
|
errors.append(f"manifest.counts.{key} incoherent ({m['counts'].get(key)} != {val})")
|
|
for lang in langs:
|
|
if m["counts"]["keywords_per_lang"].get(lang) != len(per_lang[lang]):
|
|
errors.append(f"manifest.counts.keywords_per_lang.{lang} incoherent")
|
|
|
|
return errors
|
|
|
|
|
|
def _build(master_path: str | None):
|
|
spec = deps.load_spec()
|
|
master = deps.load_master(master_path)
|
|
bundle = builder.build_bundle(spec, master)
|
|
return spec, master, bundle
|
|
|
|
|
|
def _write_json(path: str, data) -> None:
|
|
import json
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
json.dump(data, fh, ensure_ascii=False, indent=2)
|
|
fh.write("\n")
|
|
|
|
|
|
def cmd_build(args: argparse.Namespace) -> int:
|
|
spec, master, bundle = _build(args.master)
|
|
errors = _validate(spec, master, bundle)
|
|
if errors:
|
|
_eprint("❌ Bundle SEO invalide — generation refusee (anti-regression) :")
|
|
for e in errors:
|
|
_eprint(f" - {e}")
|
|
return 1
|
|
|
|
out = os.path.abspath(args.out)
|
|
os.makedirs(out, exist_ok=True)
|
|
_write_json(os.path.join(out, "seo_keywords.json"), bundle["keywords"])
|
|
_write_json(os.path.join(out, "seo_schema_org.json"), bundle["schema_org"])
|
|
_write_json(os.path.join(out, "seo_hreflang.json"), bundle["hreflang"])
|
|
_write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])
|
|
|
|
c = bundle["manifest"]["counts"]
|
|
print(f"✅ Bundle SEO genere dans {out}")
|
|
print(f" mots-cles : {c['keywords_total']} "
|
|
f"({' · '.join(f'{l}={n}' for l, n in c['keywords_per_lang'].items())})")
|
|
print(f" schema.org : {c['schema_org_nodes']} noeuds ({c['listings']} listings · "
|
|
f"{c['offers']} offres) · hreflang : {c['hreflang_pages']} pages")
|
|
print(" ⚠ Injection balises + sitemap + Google Search Console cote agent SEO/Frontend (VPS · #8).")
|
|
return 0
|
|
|
|
|
|
def cmd_validate(args: argparse.Namespace) -> int:
|
|
spec, master, bundle = _build(args.master)
|
|
errors = _validate(spec, master, bundle)
|
|
if errors:
|
|
_eprint("❌ Validation KO :")
|
|
for e in errors:
|
|
_eprint(f" - {e}")
|
|
return 1
|
|
c = bundle["manifest"]["counts"]
|
|
print(f"✅ Validation OK — {c['keywords_total']} mots-cles FR/EN/ES, "
|
|
f"{c['listings']} listings schema.org, {c['hreflang_pages']} pages hreflang, "
|
|
f"schema + 15 invariants verts.")
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
p = argparse.ArgumentParser(description="Generateur SEO trilingue (Sprint 6 · SEO).")
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
pb = sub.add_parser("build", help="genere seo_keywords/schema_org/hreflang + MANIFEST")
|
|
pb.add_argument("--master", default=None, help="projets_master.json (defaut: fixtures/)")
|
|
pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (defaut: ./out)")
|
|
pb.set_defaults(func=cmd_build)
|
|
|
|
pv = sub.add_parser("validate", help="valide le bundle (schema + 15 invariants) sans ecrire")
|
|
pv.add_argument("--master", default=None, help="projets_master.json (defaut: fixtures/)")
|
|
pv.set_defaults(func=cmd_validate)
|
|
|
|
args = p.parse_args(argv)
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|