[DTP-Worker 20260730_085719] Auto exec · session 20260730_085719
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

This commit is contained in:
Claude Code DTP Worker
2026-07-30 09:12:19 +00:00
parent c06e15c058
commit 0d3b2420c3
20 changed files with 5082 additions and 1 deletions
@@ -0,0 +1,10 @@
"""seolib · generateur SEO trilingue (Sprint 6 · SEO).
Modules purs (aucun effet de bord, aucune horloge) :
deps - dependances partagees (validateur + branding Publiciste, chemins,
slugify) reutilisees, JAMAIS reimplementees (#5 anti-duplication).
keywords - generation deterministe des mots-cles FR/EN/ES.
schemaorg - generation du graphe JSON-LD schema.org.
hreflang - generation de la carte hreflang (alternates + x-default).
builder - assemblage du bundle + manifeste.
"""
+63
View File
@@ -0,0 +1,63 @@
"""Assemblage du bundle SEO + manifeste (deterministe, sans horloge)."""
from __future__ import annotations
from . import hreflang, keywords, schemaorg
def _manifest(spec: dict, master: dict, kws: list, sorg: dict, href: dict) -> dict:
langs = spec["site"]["langs"]
per_lang = {lang: sum(1 for k in kws if k["lang"] == lang) for lang in langs}
listing_type = spec["schema_org"]["listing_type"]
listings = [n for n in sorg["@graph"] if n.get("@type") == listing_type]
offers = sum(len(n.get("offers", [])) for n in sorg["@graph"])
regime = spec["lexicon"].get("regime")
confotur = bool(regime) and any(
any(regime["requires_inclus"] in (s or "") for s in p.get("inclus", []))
for p in master["projets"]
)
return {
"deliverable": "seo",
"sprint": "6",
"roadmap_line": "L60",
"source_input": "projets_master.json (sortie Publiciste · derivee de data_room/PXX)",
"base_url": spec["site"]["base_url"],
"langs": langs,
"default_lang": spec["site"]["default_lang"],
"x_default_lang": spec["site"]["x_default_lang"],
"confotur_documented": confotur,
"counts": {
"keywords_total": len(kws),
"keywords_per_lang": per_lang,
"schema_org_nodes": len(sorg["@graph"]),
"listings": len(listings),
"offers": offers,
"hreflang_pages": len(href["pages"]),
"projects": len(master["projets"]),
},
"targets": spec["targets"],
"anti_invention": (
"Mots-cles = compositions de faits documentes (nom/localisation "
"data_room) + lexique editorial generique ; un mot-cle ne peut "
"porter que les chiffres presents dans son champ projet source. "
"schema.org n'emet un prix que pour un projet 'disponible' dont la "
"typologie porte un prix sourcE (USD · #10)."
),
"hors_perimetre_vps": [
"Injection des balises <link hreflang> + <script JSON-LD> dans les pages www/ (Frontend/SEO · VPS #8)",
"Generation + soumission sitemap.xml et Google Search Console (VPS)",
"Traduction editoriale des contenus longs FR/EN/ES (agent SEO)",
],
}
def build_bundle(spec: dict, master: dict) -> dict:
kws = keywords.generate(spec, master)
sorg = schemaorg.generate(spec, master)
href = hreflang.generate(spec, master)
return {
"keywords": kws,
"schema_org": sorg,
"hreflang": href,
"manifest": _manifest(spec, master, kws, sorg, href),
}
+83
View File
@@ -0,0 +1,83 @@
"""Dependances partagees du generateur SEO · reutilisation stricte (#5).
On NE reimplemente rien qui existe deja dans le mandat :
- `validator` (validateur JSON-Schema draft-07 maison, zero pip) et
- `branding` (tokens de marque #4 + STATUTS_SANS_PRIX / devise #10)
sont importes du livrable Publiciste, source unique de verite pour ces deux
briques. Le contrat d'entree consomme est `projets_master.schema.json` (contrat
Faisabilite<->Publiciste), lui aussi partage.
Fonctions locales minimales : chargement des fichiers + `slugify` deterministe
(pas de dependance externe, pas d'horloge).
"""
from __future__ import annotations
import json
import os
import re
import sys
import unicodedata
_HERE = os.path.dirname(os.path.abspath(__file__))
_SEO = os.path.dirname(_HERE)
_DELIVERABLES = os.path.normpath(os.path.join(_SEO, "..")) # 05_deliverables_mvp/
_PUBLICISTE = os.path.join(_DELIVERABLES, "publiciste")
_FAISABILITE = os.path.join(_DELIVERABLES, "faisabilite")
if _PUBLICISTE not in sys.path:
sys.path.insert(0, _PUBLICISTE)
from lib import branding # type: ignore # noqa: E402
from lib import validator as maison # type: ignore # noqa: E402
# Chemins des contrats/entrees par defaut.
SPEC_PATH = os.path.join(_SEO, "seo_spec.json")
SCHEMA_PATH = os.path.join(_SEO, "seo.schema.json")
MASTER_DEFAULT = os.path.join(_SEO, "fixtures", "projets_master.json")
MASTER_SCHEMA_PATH = os.path.join(_FAISABILITE, "projets_master.schema.json")
def load_json(path: str) -> dict:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def load_spec() -> dict:
return load_json(SPEC_PATH)
def load_master(path: str | None = None) -> dict:
return load_json(path or MASTER_DEFAULT)
def validate_master(master: dict) -> list[str]:
"""Verifie que l'entree respecte le contrat Publiciste avant tout usage."""
return list(maison.validate(master, load_json(MASTER_SCHEMA_PATH)))
def validate(instance, schema: dict) -> list[str]:
return list(maison.validate(instance, schema))
_SLUG_STRIP = re.compile(r"[^a-z0-9]+")
def slugify(text: str) -> str:
"""Slug URL deterministe : minuscule, sans accent, mots joints par '-'.
Conserve les chiffres presents dans la donnee source (ex. « 1069 Crisfer »
-> « 1069-crisfer ») : on ne retire ni n'invente aucun chiffre (#6)."""
norm = unicodedata.normalize("NFKD", text)
norm = "".join(c for c in norm if not unicodedata.combining(c))
return _SLUG_STRIP.sub("-", norm.lower()).strip("-")
def digits(text: str) -> set[str]:
"""Ensemble des chiffres presents dans une chaine (audit anti-invention)."""
return set(re.findall(r"\d", text))
# Re-exports pour les modules soeurs (une seule source d'import).
STATUTS_SANS_PRIX = branding.STATUTS_SANS_PRIX
DEVISE_PRIMAIRE = branding.DEVISE_PRIMAIRE
@@ -0,0 +1,53 @@
"""Generation de la carte hreflang (balises `alternate` multilingues + x-default).
Pour chaque page (accueil + une par projet) : une alternate par langue du site
plus un `x-default` pointant vers la langue par defaut. La `canonical` de chaque
page est l'URL de la langue par defaut. URLs construites depuis `site.base_url`
et `site.path_prefix` — deterministe, aucune horloge.
"""
from __future__ import annotations
from . import deps
def _build_url(base: str, prefix: str, rel: str) -> str:
"""base + prefixe de langue + chemin relatif, slashs normalises.
rel == "" -> page d'accueil (slash final conserve)."""
pre = prefix.strip("/")
segs = [s for s in (pre, rel.strip("/")) if s]
path = "/".join(segs)
if not rel:
return base + "/" + (path + "/" if path else "")
return base + "/" + path
def _page(base: str, prefixes: dict, langs: list, default: str, xdef: str,
name: str, rel: str) -> dict:
alternates = [{"hreflang": lang, "href": _build_url(base, prefixes[lang], rel)}
for lang in langs]
alternates.append({"hreflang": "x-default",
"href": _build_url(base, prefixes[xdef], rel)})
return {
"page": name,
"canonical": _build_url(base, prefixes[default], rel),
"alternates": alternates,
}
def generate(spec: dict, master: dict) -> dict:
site = spec["site"]
base = site["base_url"].rstrip("/")
prefixes = site["path_prefix"]
langs = site["langs"]
default = site["default_lang"]
xdef = site["x_default_lang"]
pages = [_page(base, prefixes, langs, default, xdef, "home", "")]
for p in master["projets"]:
slug = deps.slugify(p["nom"])
pages.append(_page(base, prefixes, langs, default, xdef,
p["code"], f"projets/{slug}"))
return {"base_url": base, "x_default_lang": xdef, "pages": pages}
+119
View File
@@ -0,0 +1,119 @@
"""Generation deterministe des mots-cles SEO FR/EN/ES.
PRINCIPE ANTI-INVENTION (#6) : un mot-cle n'est JAMAIS un fait invente. C'est une
composition de :
- tokens factuels issus de `projets_master.json` (nom, localisation) — sourcables ;
- vocabulaire editorial generique du lexique (immobilier / a vendre / ...) —
non chiffre, sans reference a un projet particulier.
Chaque mot-cle porte donc une liste `sources` non vide (soit `lexicon:*`, soit
`projet:<code>.<champ>`), et ne peut contenir que des chiffres deja presents dans
son champ projet source. Cette regle est verifiee par un invariant du generateur.
Sortie : liste triee de dicts {term, lang, scope, projet, intent, category,
sources}. Tri stable (lang, scope, projet, term) -> diffable + re-generable.
"""
from __future__ import annotations
def _confotur_source(projets: list[dict], marker: str) -> str | None:
"""Code du premier projet documentant le regime dans `inclus` (ou None)."""
for p in projets:
if any(marker in (s or "") for s in p.get("inclus", [])):
return p["code"]
return None
def generate(spec: dict, master: dict) -> list[dict]:
site = spec["site"]
lex = spec["lexicon"]
langs = site["langs"]
projets = master["projets"]
property_generic = lex["property_generic"]
property_types = lex["property_types"]
intents = lex["intents"]
intents_for_type = set(lex.get("intents_for_type", [i["key"] for i in intents]))
country = lex["country"]
regime = lex.get("regime")
confotur_code = None
if regime:
confotur_code = _confotur_source(projets, regime["requires_inclus"])
lang_index = {lang: i for i, lang in enumerate(langs)}
acc: list[dict] = []
def add(term, lang, scope, projet, intent, category, sources):
term = " ".join(str(term).split()).strip()
acc.append({
"term": term,
"lang": lang,
"scope": scope,
"projet": projet,
"intent": intent,
"category": category,
"sources": list(sources),
})
for lang in langs:
pg = property_generic[lang]
ctry = country[lang]
# --- Mots-cles GLOBAUX (marque / pays / regime) : independants d'un
# projet mais adosses au lexique editorial (jamais un fait invente).
add(f"{pg} {ctry}", lang, "global", None, None, "generic",
["lexicon:property_generic", "lexicon:country"])
for pt in property_types:
add(f"{pt[lang]} {ctry}", lang, "global", None, None, pt["key"],
["lexicon:property_types", "lexicon:country"])
for it in intents:
add(f"{pg} {it[lang]} {ctry}", lang, "global", None, it["key"], "generic",
["lexicon:property_generic", "lexicon:intents", "lexicon:country"])
if regime and confotur_code:
add(f"{pg} {regime['term'][lang]} {ctry}", lang, "global", None, None,
regime["key"],
["lexicon:regime", "lexicon:country", f"projet:{confotur_code}.inclus"])
# --- Mots-cles PAR PROJET : nom (factuel) + localisation (factuel)
# croises avec le lexique editorial.
for p in projets:
code, nom, loc = p["code"], p["nom"], p["localisation"]
nom_src = f"projet:{code}.nom"
loc_src = f"projet:{code}.localisation"
add(nom, lang, "projet", code, None, None, [nom_src])
add(f"{nom} {ctry}", lang, "projet", code, None, None,
[nom_src, "lexicon:country"])
for it in intents:
add(f"{nom} {it[lang]}", lang, "projet", code, it["key"], None,
[nom_src, "lexicon:intents"])
add(f"{pg} {loc}", lang, "projet", code, None, "generic",
["lexicon:property_generic", loc_src])
for pt in property_types:
add(f"{pt[lang]} {loc}", lang, "projet", code, None, pt["key"],
["lexicon:property_types", loc_src])
for pt in property_types:
for it in intents:
if it["key"] not in intents_for_type:
continue
add(f"{pt[lang]} {it[lang]} {loc}", lang, "projet", code,
it["key"], pt["key"],
["lexicon:property_types", "lexicon:intents", loc_src])
# --- Dedup (term, lang) en conservant la premiere occurrence, puis tri
# deterministe.
seen: set[tuple[str, str]] = set()
deduped: list[dict] = []
for k in acc:
key = (k["lang"], k["term"])
if key in seen:
continue
seen.add(key)
deduped.append(k)
scope_order = {"global": 0, "projet": 1}
deduped.sort(key=lambda k: (
lang_index[k["lang"]], scope_order[k["scope"]], k["projet"] or "", k["term"]
))
return deduped
@@ -0,0 +1,72 @@
"""Generation du graphe JSON-LD schema.org (hand-off pour injection dans les pages).
Un noeud `Organization` (marque publique · #4) + un noeud `listing_type`
(defaut `Residence`) par projet du master.
ANTI-INVENTION (#6 · #10) : un projet n'expose une `offers`/`price` QUE s'il est
`disponible` ET qu'une de ses typologies porte un `prix_depuis_usd` numerique
sourcE dans le master. Pour tout statut de `STATUTS_SANS_PRIX` (en_developpement,
bientot, en_processus) : AUCUN prix (jamais de chiffre fabrique). Devise = USD
(devise primaire #10). Aucune horloge, tri stable -> sortie diffable.
"""
from __future__ import annotations
from . import deps
def _is_number(value) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _offers_for(projet: dict) -> list[dict]:
"""Offres schema.org d'un projet — vide si non `disponible` ou sans prix sourcE."""
if projet["statut"] in deps.STATUTS_SANS_PRIX:
return []
offers = []
for typo in projet.get("typologies", []):
price = typo.get("prix_depuis_usd")
if _is_number(price):
offers.append({
"@type": "Offer",
"name": typo.get("nom"),
"price": price,
"priceCurrency": deps.DEVISE_PRIMAIRE,
"availability": "https://schema.org/InStock",
})
return offers
def generate(spec: dict, master: dict) -> dict:
so = spec["schema_org"]
base = spec["site"]["base_url"].rstrip("/")
org_id = f"{base}/#organization"
org = so["organization"]
graph: list[dict] = [{
"@type": org["type"],
"@id": org_id,
"name": org["name"],
"url": org["url"],
}]
for p in master["projets"]:
slug = deps.slugify(p["nom"])
node = {
"@type": so["listing_type"],
"@id": f"{base}/projets/{slug}#residence",
"name": p["nom"],
"url": f"{base}/projets/{slug}",
"brand": {"@id": org_id},
"address": {
"@type": "PostalAddress",
"addressCountry": so["country_code"],
"addressLocality": p["localisation"],
},
}
offers = _offers_for(p)
if offers:
node["offers"] = offers
graph.append(node)
return {"@context": so["context"], "@graph": graph}