[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
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:
@@ -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.
|
||||
"""
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user