[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:
+12
-1
@@ -310,10 +310,21 @@ jobs:
|
|||||||
- name: Tests générateur Audit 5D
|
- name: Tests générateur Audit 5D
|
||||||
run: python3 -m unittest discover -s tests -v
|
run: python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
seo-tests:
|
||||||
|
name: SEO · mots-clés trilingues + schema.org + hreflang
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: 05_deliverables_mvp/seo
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Tests générateur SEO
|
||||||
|
run: python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
gate:
|
gate:
|
||||||
name: Gate qualité (agrégat)
|
name: Gate qualité (agrégat)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, fiscal-ecf-tests, frontend-portails-tests, legal-confotur-tests, qa-audit-5d-tests]
|
needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, fiscal-ecf-tests, frontend-portails-tests, legal-confotur-tests, qa-audit-5d-tests, seo-tests]
|
||||||
steps:
|
steps:
|
||||||
- name: Résultat
|
- name: Résultat
|
||||||
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
|
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
|
||||||
|
|||||||
@@ -1,5 +1,60 @@
|
|||||||
# Activity Log · 2026-07-30 · Claude Code DTP
|
# Activity Log · 2026-07-30 · Claude Code DTP
|
||||||
|
|
||||||
|
## Session `20260730_085719` (session 18)
|
||||||
|
|
||||||
|
**Tâche** : **Sprint 6 · SEO** — Générateur **SEO trilingue** (roadmap L60 :
|
||||||
|
« Refactor mission `seo_autonome/` → **200+ mots-clés FR/EN/ES · schema.org ·
|
||||||
|
hreflang** »). Premier volet Sprint 6 réalisable en repo — les volets OTOIA voice
|
||||||
|
Amélie (pilote AEC) et chat OTOIA embarqué dépendent d'API externes / desk VPS
|
||||||
|
(hors périmètre worker · #8).
|
||||||
|
|
||||||
|
**Décision d'architecture** : livrable de **second niveau** — la matière première
|
||||||
|
est **`projets_master.json`**, la sortie canonique du **Publiciste** (dérivée de
|
||||||
|
`data_room/PXX/`). Le générateur ne fabrique aucun fait de projet ; il **réutilise**
|
||||||
|
(zéro duplication · #5) le validateur maison + les tokens de marque du Publiciste
|
||||||
|
(`lib/validator.py`, `lib/branding.py`).
|
||||||
|
|
||||||
|
**Fichiers créés** — `05_deliverables_mvp/seo/` :
|
||||||
|
- `seo_spec.json` (config site + **lexique éditorial générique** FR/EN/ES + org
|
||||||
|
schema.org + cibles · zéro donnée projet, zéro chiffre)
|
||||||
|
- `seolib/{__init__,deps,keywords,schemaorg,hreflang,builder}.py` (`deps` réutilise
|
||||||
|
validateur + branding Publiciste ; `keywords`/`schemaorg`/`hreflang` purs et
|
||||||
|
déterministes ; `builder` assemble bundle + manifeste)
|
||||||
|
- `seo_gen.py` (CLI `build`/`validate` · **15 invariants**)
|
||||||
|
- `seo.schema.json` (contrat de sortie draft-07)
|
||||||
|
- `fixtures/projets_master.json` (test only · 9 projets P01..P09, noms sourcés
|
||||||
|
`CLAUDE.md §Projets`, tous `en_developpement`, **zéro chiffre**)
|
||||||
|
- `out/{seo_keywords,seo_schema_org,seo_hreflang,MANIFEST}.json` (hand-off) ·
|
||||||
|
`tests/test_seo.py` (**36 tests** dont 8 injections négatives) · `README.md` ·
|
||||||
|
`.gitignore`
|
||||||
|
|
||||||
|
**Fichiers modifiés** :
|
||||||
|
- `.gitea/workflows/ci.yml` : job `seo-tests` + ajout au `gate`.
|
||||||
|
|
||||||
|
**Anti-invention (cœur · #6)** : un mot-clé = composition de tokens factuels
|
||||||
|
(`projet:<code>.nom`/`.localisation`, sourçables) + lexique éditorial générique
|
||||||
|
non chiffré (`lexicon:*`) ; un invariant vérifie que chaque mot-clé est sourcé et
|
||||||
|
résoluble ; un mot-clé ne peut porter que les chiffres de son champ source
|
||||||
|
(« 1069 Crisfer » passe ; un prix injecté est refusé). schema.org n'émet un prix
|
||||||
|
que pour un projet `disponible` à typologie **sourcée** (USD · #10) — la fixture
|
||||||
|
`en_developpement` produit donc **0 offre**, aucun chiffre inventé dans le hand-off.
|
||||||
|
|
||||||
|
**Résultat** : **258 mots-clés** (fr=87 · en=87 · es=84 · cible 200 dépassée) ·
|
||||||
|
schema.org 10 nœuds (1 Organization + 9 Residence) · hreflang 10 pages (accueil +
|
||||||
|
9 projets) × 4 alternates (FR/EN/ES + x-default).
|
||||||
|
|
||||||
|
**Vérifs** : 36/36 tests ; gate CI local vert (guard + JSON + docs + YAML) ;
|
||||||
|
régression **377 tests verts** au total (341 → +36) ; build déterministe.
|
||||||
|
|
||||||
|
**Hors périmètre worker (VPS · #8)** : injection balises hreflang/JSON-LD dans
|
||||||
|
`www/` + sitemap + Google Search Console + branchement sur la vraie sortie
|
||||||
|
Publiciste (9 projets réels) → agent SEO / Frontend.
|
||||||
|
|
||||||
|
**Détail complet** : voir
|
||||||
|
[`05_deliverables_mvp/daily_reports/2026-07-30-session18.md`](../05_deliverables_mvp/daily_reports/2026-07-30-session18.md).
|
||||||
|
|
||||||
|
**Auto-score 4Big** : 96/100.
|
||||||
|
|
||||||
## Session `20260730_082714` (session 17)
|
## Session `20260730_082714` (session 17)
|
||||||
|
|
||||||
**Tâche** : **Sprint 5 · QA** — Générateur de l'**Audit 5D de conformité**
|
**Tâche** : **Sprint 5 · QA** — Générateur de l'**Audit 5D de conformité**
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Rapport de session · 2026-07-30 · session 18
|
||||||
|
|
||||||
|
## Tâche
|
||||||
|
|
||||||
|
**Sprint 6 · SEO** — Générateur **SEO trilingue** (mots-clés FR/EN/ES +
|
||||||
|
schema.org + hreflang). Roadmap L60 : « Refactor mission `seo_autonome/` →
|
||||||
|
**200+ mots-clés FR/EN/ES · schema.org · hreflang** ».
|
||||||
|
|
||||||
|
Sprint 5 étant clos en repo (ONAPI/Legal session 16 · QA Audit 5D session 17,
|
||||||
|
Mobile hors périmètre VPS · #8), c'est le premier volet Sprint 6 réalisable dans
|
||||||
|
le repo. Les deux autres volets Sprint 6 (OTOIA voice Amélie pilote AEC · chat
|
||||||
|
OTOIA embarqué) dépendent d'API externes / du desk VPS → hors périmètre worker.
|
||||||
|
|
||||||
|
## Décision d'architecture
|
||||||
|
|
||||||
|
Le SEO est un livrable de **second niveau** : sa matière première est
|
||||||
|
**`projets_master.json`**, la sortie canonique du **Publiciste** (elle-même
|
||||||
|
dérivée de `data_room/PXX/` via le contrat `projets_master.schema.json`). Le
|
||||||
|
générateur **ne fabrique aucun fait de projet** — il compose du SEO à partir de
|
||||||
|
données déjà sourcées + un lexique éditorial générique non chiffré.
|
||||||
|
|
||||||
|
Ce choix respecte #6 (zéro invention) et #5 (zéro duplication) : le générateur
|
||||||
|
**réutilise** le validateur maison et les tokens de marque du Publiciste
|
||||||
|
(`lib/validator.py`, `lib/branding.py` → `STATUTS_SANS_PRIX`, devise USD), sans
|
||||||
|
les réimplémenter.
|
||||||
|
|
||||||
|
## Fichiers créés — `05_deliverables_mvp/seo/`
|
||||||
|
|
||||||
|
- `seo_spec.json` — config site (base_url, langs FR/EN/ES, préfixes, x-default)
|
||||||
|
+ **lexique éditorial générique** (immobilier / à vendre / résidence / pays /
|
||||||
|
régime CONFOTUR) + org schema.org + cibles. **Zéro donnée de projet, zéro
|
||||||
|
chiffre.**
|
||||||
|
- `seolib/{__init__,deps,keywords,schemaorg,hreflang,builder}.py`
|
||||||
|
- `deps` réutilise validateur + branding Publiciste ; `slugify`/`digits`
|
||||||
|
déterministes.
|
||||||
|
- `keywords` : génération déterministe, chaque mot-clé porte `scope`, `projet`,
|
||||||
|
`intent`, `category` et une liste `sources` non vide.
|
||||||
|
- `schemaorg` : graphe JSON-LD (`Organization` + une `Residence`/projet),
|
||||||
|
`offers` **uniquement** si projet disponible + prix sourcé (USD).
|
||||||
|
- `hreflang` : `alternate` FR/EN/ES + `x-default` par page.
|
||||||
|
- `seo_gen.py` — CLI `build`/`validate` · **15 invariants** de cross-cohérence
|
||||||
|
et d'anti-invention.
|
||||||
|
- `seo.schema.json` — contrat de sortie draft-07 (bundle keywords + schema.org +
|
||||||
|
hreflang + manifest).
|
||||||
|
- `fixtures/projets_master.json` — entrée **de test** : 9 projets P01..P09 (noms
|
||||||
|
sourcés de `CLAUDE.md §Projets`), tous `en_developpement`, **sans aucun chiffre**.
|
||||||
|
- `out/{seo_keywords,seo_schema_org,seo_hreflang,MANIFEST}.json` (hand-off) ·
|
||||||
|
`tests/test_seo.py` (**36 tests** dont 8 injections négatives) · `README.md` ·
|
||||||
|
`.gitignore`.
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
- `.gitea/workflows/ci.yml` : job `seo-tests` + ajout au `gate`.
|
||||||
|
|
||||||
|
## Anti-invention (cœur · #6)
|
||||||
|
|
||||||
|
- Un **mot-clé** = composition de *tokens factuels* (`projet:<code>.nom` /
|
||||||
|
`.localisation`, sourçables) et de *lexique éditorial* (`lexicon:*`, générique
|
||||||
|
non chiffré). Un invariant vérifie que chaque mot-clé est **sourcé et
|
||||||
|
résoluble**.
|
||||||
|
- Un mot-clé **ne peut porter que les chiffres présents dans son champ projet
|
||||||
|
source** : « 1069 Crisfer » (P09) passe ; un prix injecté (`… 250000`) est
|
||||||
|
**refusé** par l'invariant #7 (test négatif dédié).
|
||||||
|
- **schema.org** n'émet une `offers`/`price` **que** pour un projet `disponible`
|
||||||
|
dont une typologie porte un `prix_depuis_usd` **numérique sourcé** ; devise
|
||||||
|
**USD** (#10). Pour tout statut sans prix : **aucun chiffre** (invariant #11 +
|
||||||
|
tests). La fixture livrée (9 projets `en_developpement`) produit donc
|
||||||
|
**0 offre** — aucune valeur inventée dans le hand-off committé.
|
||||||
|
|
||||||
|
## Résultat
|
||||||
|
|
||||||
|
- **258 mots-clés** — fr=87 · en=87 · es=84 (cible roadmap 200 dépassée) ;
|
||||||
|
- schema.org : **10 nœuds** (1 Organization + 9 Residence), 0 offre ;
|
||||||
|
- hreflang : **10 pages** (accueil + 9 projets), 4 `alternate`/page (FR/EN/ES +
|
||||||
|
x-default).
|
||||||
|
|
||||||
|
## Vérifs
|
||||||
|
|
||||||
|
- **36/36 tests** SEO verts (dont oracle `jsonschema` si présent) ;
|
||||||
|
- gate CI local vert : guard contraintes · JSON bien formés · docs (liens +
|
||||||
|
score) · YAML valide ;
|
||||||
|
- **régression 377 tests verts** au total (341 → +36), 0 module en échec ;
|
||||||
|
- build déterministe (test dédié : deux builds identiques).
|
||||||
|
|
||||||
|
## Hors périmètre worker (VPS · #8)
|
||||||
|
|
||||||
|
- Injection des balises `<link hreflang>` + `<script JSON-LD>` dans les pages
|
||||||
|
`www/` (agent Frontend/SEO) ;
|
||||||
|
- Génération + soumission `sitemap.xml` et Google Search Console ;
|
||||||
|
- Traduction éditoriale des contenus longs FR/EN/ES ;
|
||||||
|
- Branchement du générateur sur la **vraie** sortie Publiciste (9 projets réels
|
||||||
|
`data_room/PXX/`) via `--master`.
|
||||||
|
|
||||||
|
## Auto-score 4Big : 96/100.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
tests/_tmp_out/
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Générateur SEO trilingue · Sprint 6 · SEO
|
||||||
|
|
||||||
|
Roadmap Sprint 6 · SEO (L60) : **« Refactor mission `seo_autonome/` → 200+
|
||||||
|
mots-clés FR/EN/ES · schema.org · hreflang »**.
|
||||||
|
|
||||||
|
Produit un **bundle de hand-off SEO** consommé ensuite par l'agent SEO/Frontend
|
||||||
|
sur le VPS. Le worker n'écrit **jamais** sur le VPS (#8) : il émet des artefacts
|
||||||
|
diffables, l'injection réelle des balises et la soumission Google Search Console
|
||||||
|
restent côté serveur.
|
||||||
|
|
||||||
|
## Ce que ça fait
|
||||||
|
|
||||||
|
| Volet roadmap | Sortie |
|
||||||
|
|---|---|
|
||||||
|
| 200+ mots-clés FR/EN/ES | `out/seo_keywords.json` — **258** mots-clés (fr=87 · en=87 · es=84), chacun **sourcé** |
|
||||||
|
| schema.org | `out/seo_schema_org.json` — graphe JSON-LD (`Organization` + une `Residence` par projet) |
|
||||||
|
| hreflang | `out/seo_hreflang.json` — carte `alternate` FR/EN/ES + `x-default` par page |
|
||||||
|
| compte-rendu | `out/MANIFEST.json` |
|
||||||
|
|
||||||
|
## Source de vérité (zéro invention · #6)
|
||||||
|
|
||||||
|
La matière première est **`projets_master.json`**, la sortie canonique du
|
||||||
|
[Publiciste](../publiciste/README.md) (elle-même dérivée de `data_room/PXX/` via
|
||||||
|
le contrat [`projets_master.schema.json`](../faisabilite/projets_master.schema.json)).
|
||||||
|
Le worker ne fabrique **aucun** fait de projet :
|
||||||
|
|
||||||
|
- un **mot-clé** est une composition de *tokens factuels* (nom / localisation du
|
||||||
|
projet, sourçables `projet:<code>.<champ>`) et de *vocabulaire éditorial
|
||||||
|
générique* (lexique `immobilier` / `à vendre` / … , non chiffré, `lexicon:*`) ;
|
||||||
|
- un mot-clé **ne peut porter que les chiffres déjà présents dans son champ
|
||||||
|
projet source** (ex. « 1069 Crisfer » est admis ; un prix inventé est refusé) ;
|
||||||
|
- **schema.org** n'expose une `offers`/`price` **que** pour un projet
|
||||||
|
`disponible` dont une typologie porte un `prix_depuis_usd` **numérique sourcé**
|
||||||
|
dans le master ; devise **USD** (devise primaire · #10). Pour tout statut sans
|
||||||
|
prix (`en_developpement` / `bientot` / `en_processus`) : **aucun chiffre**.
|
||||||
|
|
||||||
|
Le générateur **réutilise** (jamais ne réimplémente · #5) le validateur maison
|
||||||
|
et les tokens de marque du Publiciste (`lib/validator.py`, `lib/branding.py` :
|
||||||
|
`STATUTS_SANS_PRIX`, devise USD).
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Génère le bundle depuis la fixture de test (9 projets P01..P09)
|
||||||
|
python3 seo_gen.py build
|
||||||
|
|
||||||
|
# Ou depuis la vraie sortie Publiciste (VPS) :
|
||||||
|
python3 seo_gen.py build --master /chemin/projets_master.json -o out
|
||||||
|
|
||||||
|
# Valide (schéma de sortie + 15 invariants) sans écrire
|
||||||
|
python3 seo_gen.py validate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Garanties (15 invariants)
|
||||||
|
|
||||||
|
Volume ≥ cible roadmap (200) · 3 langues couvertes, minimum par langue ·
|
||||||
|
unicité `(term, lang)` · chaque mot-clé sourcé et résoluble · anti-invention de
|
||||||
|
chiffre · un listing schema.org par projet (nom + localité = donnée source) ·
|
||||||
|
offres présentes **ssi** projet disponible + prix sourcé (USD) · aucun prix pour
|
||||||
|
un statut sans prix · hreflang : une page par projet + accueil, `alternate`
|
||||||
|
complètes + `x-default` = langue par défaut = `canonical` · toutes les URLs sous
|
||||||
|
`base_url` en `https` · compteurs du manifeste cohérents.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover -s tests -v # 36 tests (dont 8 injections négatives)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hors périmètre worker (VPS · #8)
|
||||||
|
|
||||||
|
- Injection des balises `<link rel="alternate" hreflang>` + `<script
|
||||||
|
type="application/ld+json">` dans les pages `www/` (agent Frontend/SEO) ;
|
||||||
|
- Génération + soumission `sitemap.xml` et Google Search Console ;
|
||||||
|
- Traduction éditoriale des contenus longs FR/EN/ES.
|
||||||
|
|
||||||
|
## Fixture
|
||||||
|
|
||||||
|
`fixtures/projets_master.json` est une entrée **de test** : 9 projets P01..P09
|
||||||
|
(noms sourcés de `CLAUDE.md §Projets`), tous `en_developpement`, **sans aucun
|
||||||
|
chiffre** de prix/surface. En production, le générateur lit la vraie sortie du
|
||||||
|
Publiciste.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Auto-score 4Big : 96/100.** Livrable déterministe, testé, sans invention (#6),
|
||||||
|
aligné ERPNext-natif-first et contraintes CLAUDE.md (#4 marque · #10 USD).
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"generated_at": "2026-07-30T00:00:00Z",
|
||||||
|
"template_version": "1.0.0",
|
||||||
|
"projets": [
|
||||||
|
{
|
||||||
|
"code": "P01",
|
||||||
|
"nom": "Structure",
|
||||||
|
"statut": "en_developpement",
|
||||||
|
"localisation": "Republica Dominicana",
|
||||||
|
"typologies": [],
|
||||||
|
"inclus": ["Regime CONFOTUR (exoneration fiscale)", "Structure Fideicomiso"],
|
||||||
|
"source": { "template_version": "1.0.0", "score_4big": 96, "fichiers": ["CLAUDE.md#Projets"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "P02",
|
||||||
|
"nom": "Coral del Sur",
|
||||||
|
"statut": "en_developpement",
|
||||||
|
"localisation": "Republica Dominicana",
|
||||||
|
"typologies": [],
|
||||||
|
"inclus": ["Regime CONFOTUR (exoneration fiscale)", "Structure Fideicomiso"],
|
||||||
|
"source": { "template_version": "1.0.0", "score_4big": 96, "fichiers": ["CLAUDE.md#Projets"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "P03",
|
||||||
|
"nom": "Nakua",
|
||||||
|
"statut": "en_developpement",
|
||||||
|
"localisation": "Republica Dominicana",
|
||||||
|
"typologies": [],
|
||||||
|
"inclus": ["Regime CONFOTUR (exoneration fiscale)"],
|
||||||
|
"source": { "template_version": "1.0.0", "score_4big": 96, "fichiers": ["CLAUDE.md#Projets"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "P04",
|
||||||
|
"nom": "Xamana Cantiles",
|
||||||
|
"statut": "en_developpement",
|
||||||
|
"localisation": "Xamana, Republica Dominicana",
|
||||||
|
"typologies": [],
|
||||||
|
"inclus": ["Regime CONFOTUR (exoneration fiscale)"],
|
||||||
|
"source": { "template_version": "1.0.0", "score_4big": 96, "fichiers": ["CLAUDE.md#Projets"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "P05",
|
||||||
|
"nom": "Las Colinas Najayo Arriba",
|
||||||
|
"statut": "en_developpement",
|
||||||
|
"localisation": "Najayo Arriba, Republica Dominicana",
|
||||||
|
"typologies": [],
|
||||||
|
"inclus": ["Regime CONFOTUR (exoneration fiscale)"],
|
||||||
|
"source": { "template_version": "1.0.0", "score_4big": 96, "fichiers": ["CLAUDE.md#Projets", "data_room/P05/enregistrement_onapi_20260728/"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "P06",
|
||||||
|
"nom": "Coco Real",
|
||||||
|
"statut": "en_developpement",
|
||||||
|
"localisation": "Republica Dominicana",
|
||||||
|
"typologies": [],
|
||||||
|
"inclus": ["Regime CONFOTUR (exoneration fiscale)"],
|
||||||
|
"source": { "template_version": "1.0.0", "score_4big": 96, "fichiers": ["CLAUDE.md#Projets"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "P07",
|
||||||
|
"nom": "Aqua Terra Las Terrenas",
|
||||||
|
"statut": "en_developpement",
|
||||||
|
"localisation": "Las Terrenas, Republica Dominicana",
|
||||||
|
"typologies": [],
|
||||||
|
"inclus": ["Regime CONFOTUR (exoneration fiscale)"],
|
||||||
|
"source": { "template_version": "1.0.0", "score_4big": 96, "fichiers": ["CLAUDE.md#Projets", "data_room/P07/enregistrement_onapi_20260728/"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "P08",
|
||||||
|
"nom": "Fasano Espirilla",
|
||||||
|
"statut": "en_developpement",
|
||||||
|
"localisation": "Espirilla, Republica Dominicana",
|
||||||
|
"typologies": [],
|
||||||
|
"inclus": ["Regime CONFOTUR (exoneration fiscale)"],
|
||||||
|
"source": { "template_version": "1.0.0", "score_4big": 96, "fichiers": ["CLAUDE.md#Projets"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "P09",
|
||||||
|
"nom": "1069 Crisfer",
|
||||||
|
"statut": "en_developpement",
|
||||||
|
"localisation": "Republica Dominicana",
|
||||||
|
"typologies": [],
|
||||||
|
"inclus": ["Regime CONFOTUR (exoneration fiscale)"],
|
||||||
|
"source": { "template_version": "1.0.0", "score_4big": 96, "fichiers": ["CLAUDE.md#Projets"] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"deliverable": "seo",
|
||||||
|
"sprint": "6",
|
||||||
|
"roadmap_line": "L60",
|
||||||
|
"source_input": "projets_master.json (sortie Publiciste · derivee de data_room/PXX)",
|
||||||
|
"base_url": "https://vente.otov7.com",
|
||||||
|
"langs": [
|
||||||
|
"fr",
|
||||||
|
"en",
|
||||||
|
"es"
|
||||||
|
],
|
||||||
|
"default_lang": "fr",
|
||||||
|
"x_default_lang": "fr",
|
||||||
|
"confotur_documented": true,
|
||||||
|
"counts": {
|
||||||
|
"keywords_total": 258,
|
||||||
|
"keywords_per_lang": {
|
||||||
|
"fr": 87,
|
||||||
|
"en": 87,
|
||||||
|
"es": 84
|
||||||
|
},
|
||||||
|
"schema_org_nodes": 10,
|
||||||
|
"listings": 9,
|
||||||
|
"offers": 0,
|
||||||
|
"hreflang_pages": 10,
|
||||||
|
"projects": 9
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"min_keywords_total": 200,
|
||||||
|
"min_keywords_per_lang": 50
|
||||||
|
},
|
||||||
|
"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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
{
|
||||||
|
"base_url": "https://vente.otov7.com",
|
||||||
|
"x_default_lang": "fr",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"page": "home",
|
||||||
|
"canonical": "https://vente.otov7.com/",
|
||||||
|
"alternates": [
|
||||||
|
{
|
||||||
|
"hreflang": "fr",
|
||||||
|
"href": "https://vente.otov7.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "en",
|
||||||
|
"href": "https://vente.otov7.com/en/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "es",
|
||||||
|
"href": "https://vente.otov7.com/es/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "x-default",
|
||||||
|
"href": "https://vente.otov7.com/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "P01",
|
||||||
|
"canonical": "https://vente.otov7.com/projets/structure",
|
||||||
|
"alternates": [
|
||||||
|
{
|
||||||
|
"hreflang": "fr",
|
||||||
|
"href": "https://vente.otov7.com/projets/structure"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "en",
|
||||||
|
"href": "https://vente.otov7.com/en/projets/structure"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "es",
|
||||||
|
"href": "https://vente.otov7.com/es/projets/structure"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "x-default",
|
||||||
|
"href": "https://vente.otov7.com/projets/structure"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "P02",
|
||||||
|
"canonical": "https://vente.otov7.com/projets/coral-del-sur",
|
||||||
|
"alternates": [
|
||||||
|
{
|
||||||
|
"hreflang": "fr",
|
||||||
|
"href": "https://vente.otov7.com/projets/coral-del-sur"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "en",
|
||||||
|
"href": "https://vente.otov7.com/en/projets/coral-del-sur"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "es",
|
||||||
|
"href": "https://vente.otov7.com/es/projets/coral-del-sur"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "x-default",
|
||||||
|
"href": "https://vente.otov7.com/projets/coral-del-sur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "P03",
|
||||||
|
"canonical": "https://vente.otov7.com/projets/nakua",
|
||||||
|
"alternates": [
|
||||||
|
{
|
||||||
|
"hreflang": "fr",
|
||||||
|
"href": "https://vente.otov7.com/projets/nakua"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "en",
|
||||||
|
"href": "https://vente.otov7.com/en/projets/nakua"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "es",
|
||||||
|
"href": "https://vente.otov7.com/es/projets/nakua"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "x-default",
|
||||||
|
"href": "https://vente.otov7.com/projets/nakua"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "P04",
|
||||||
|
"canonical": "https://vente.otov7.com/projets/xamana-cantiles",
|
||||||
|
"alternates": [
|
||||||
|
{
|
||||||
|
"hreflang": "fr",
|
||||||
|
"href": "https://vente.otov7.com/projets/xamana-cantiles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "en",
|
||||||
|
"href": "https://vente.otov7.com/en/projets/xamana-cantiles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "es",
|
||||||
|
"href": "https://vente.otov7.com/es/projets/xamana-cantiles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "x-default",
|
||||||
|
"href": "https://vente.otov7.com/projets/xamana-cantiles"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "P05",
|
||||||
|
"canonical": "https://vente.otov7.com/projets/las-colinas-najayo-arriba",
|
||||||
|
"alternates": [
|
||||||
|
{
|
||||||
|
"hreflang": "fr",
|
||||||
|
"href": "https://vente.otov7.com/projets/las-colinas-najayo-arriba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "en",
|
||||||
|
"href": "https://vente.otov7.com/en/projets/las-colinas-najayo-arriba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "es",
|
||||||
|
"href": "https://vente.otov7.com/es/projets/las-colinas-najayo-arriba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "x-default",
|
||||||
|
"href": "https://vente.otov7.com/projets/las-colinas-najayo-arriba"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "P06",
|
||||||
|
"canonical": "https://vente.otov7.com/projets/coco-real",
|
||||||
|
"alternates": [
|
||||||
|
{
|
||||||
|
"hreflang": "fr",
|
||||||
|
"href": "https://vente.otov7.com/projets/coco-real"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "en",
|
||||||
|
"href": "https://vente.otov7.com/en/projets/coco-real"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "es",
|
||||||
|
"href": "https://vente.otov7.com/es/projets/coco-real"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "x-default",
|
||||||
|
"href": "https://vente.otov7.com/projets/coco-real"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "P07",
|
||||||
|
"canonical": "https://vente.otov7.com/projets/aqua-terra-las-terrenas",
|
||||||
|
"alternates": [
|
||||||
|
{
|
||||||
|
"hreflang": "fr",
|
||||||
|
"href": "https://vente.otov7.com/projets/aqua-terra-las-terrenas"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "en",
|
||||||
|
"href": "https://vente.otov7.com/en/projets/aqua-terra-las-terrenas"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "es",
|
||||||
|
"href": "https://vente.otov7.com/es/projets/aqua-terra-las-terrenas"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "x-default",
|
||||||
|
"href": "https://vente.otov7.com/projets/aqua-terra-las-terrenas"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "P08",
|
||||||
|
"canonical": "https://vente.otov7.com/projets/fasano-espirilla",
|
||||||
|
"alternates": [
|
||||||
|
{
|
||||||
|
"hreflang": "fr",
|
||||||
|
"href": "https://vente.otov7.com/projets/fasano-espirilla"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "en",
|
||||||
|
"href": "https://vente.otov7.com/en/projets/fasano-espirilla"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "es",
|
||||||
|
"href": "https://vente.otov7.com/es/projets/fasano-espirilla"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "x-default",
|
||||||
|
"href": "https://vente.otov7.com/projets/fasano-espirilla"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"page": "P09",
|
||||||
|
"canonical": "https://vente.otov7.com/projets/1069-crisfer",
|
||||||
|
"alternates": [
|
||||||
|
{
|
||||||
|
"hreflang": "fr",
|
||||||
|
"href": "https://vente.otov7.com/projets/1069-crisfer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "en",
|
||||||
|
"href": "https://vente.otov7.com/en/projets/1069-crisfer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "es",
|
||||||
|
"href": "https://vente.otov7.com/es/projets/1069-crisfer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hreflang": "x-default",
|
||||||
|
"href": "https://vente.otov7.com/projets/1069-crisfer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@type": "Organization",
|
||||||
|
"@id": "https://vente.otov7.com/#organization",
|
||||||
|
"name": "Helios RD",
|
||||||
|
"url": "https://vente.otov7.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Residence",
|
||||||
|
"@id": "https://vente.otov7.com/projets/structure#residence",
|
||||||
|
"name": "Structure",
|
||||||
|
"url": "https://vente.otov7.com/projets/structure",
|
||||||
|
"brand": {
|
||||||
|
"@id": "https://vente.otov7.com/#organization"
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressCountry": "DO",
|
||||||
|
"addressLocality": "Republica Dominicana"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Residence",
|
||||||
|
"@id": "https://vente.otov7.com/projets/coral-del-sur#residence",
|
||||||
|
"name": "Coral del Sur",
|
||||||
|
"url": "https://vente.otov7.com/projets/coral-del-sur",
|
||||||
|
"brand": {
|
||||||
|
"@id": "https://vente.otov7.com/#organization"
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressCountry": "DO",
|
||||||
|
"addressLocality": "Republica Dominicana"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Residence",
|
||||||
|
"@id": "https://vente.otov7.com/projets/nakua#residence",
|
||||||
|
"name": "Nakua",
|
||||||
|
"url": "https://vente.otov7.com/projets/nakua",
|
||||||
|
"brand": {
|
||||||
|
"@id": "https://vente.otov7.com/#organization"
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressCountry": "DO",
|
||||||
|
"addressLocality": "Republica Dominicana"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Residence",
|
||||||
|
"@id": "https://vente.otov7.com/projets/xamana-cantiles#residence",
|
||||||
|
"name": "Xamana Cantiles",
|
||||||
|
"url": "https://vente.otov7.com/projets/xamana-cantiles",
|
||||||
|
"brand": {
|
||||||
|
"@id": "https://vente.otov7.com/#organization"
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressCountry": "DO",
|
||||||
|
"addressLocality": "Xamana, Republica Dominicana"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Residence",
|
||||||
|
"@id": "https://vente.otov7.com/projets/las-colinas-najayo-arriba#residence",
|
||||||
|
"name": "Las Colinas Najayo Arriba",
|
||||||
|
"url": "https://vente.otov7.com/projets/las-colinas-najayo-arriba",
|
||||||
|
"brand": {
|
||||||
|
"@id": "https://vente.otov7.com/#organization"
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressCountry": "DO",
|
||||||
|
"addressLocality": "Najayo Arriba, Republica Dominicana"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Residence",
|
||||||
|
"@id": "https://vente.otov7.com/projets/coco-real#residence",
|
||||||
|
"name": "Coco Real",
|
||||||
|
"url": "https://vente.otov7.com/projets/coco-real",
|
||||||
|
"brand": {
|
||||||
|
"@id": "https://vente.otov7.com/#organization"
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressCountry": "DO",
|
||||||
|
"addressLocality": "Republica Dominicana"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Residence",
|
||||||
|
"@id": "https://vente.otov7.com/projets/aqua-terra-las-terrenas#residence",
|
||||||
|
"name": "Aqua Terra Las Terrenas",
|
||||||
|
"url": "https://vente.otov7.com/projets/aqua-terra-las-terrenas",
|
||||||
|
"brand": {
|
||||||
|
"@id": "https://vente.otov7.com/#organization"
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressCountry": "DO",
|
||||||
|
"addressLocality": "Las Terrenas, Republica Dominicana"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Residence",
|
||||||
|
"@id": "https://vente.otov7.com/projets/fasano-espirilla#residence",
|
||||||
|
"name": "Fasano Espirilla",
|
||||||
|
"url": "https://vente.otov7.com/projets/fasano-espirilla",
|
||||||
|
"brand": {
|
||||||
|
"@id": "https://vente.otov7.com/#organization"
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressCountry": "DO",
|
||||||
|
"addressLocality": "Espirilla, Republica Dominicana"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Residence",
|
||||||
|
"@id": "https://vente.otov7.com/projets/1069-crisfer#residence",
|
||||||
|
"name": "1069 Crisfer",
|
||||||
|
"url": "https://vente.otov7.com/projets/1069-crisfer",
|
||||||
|
"brand": {
|
||||||
|
"@id": "https://vente.otov7.com/#organization"
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressCountry": "DO",
|
||||||
|
"addressLocality": "Republica Dominicana"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://oto.dtp/schemas/seo/seo.schema.json",
|
||||||
|
"title": "Contrat de sortie du generateur SEO (bundle keywords + schema.org + hreflang)",
|
||||||
|
"description": "Valide par le validateur maison Publiciste (draft-07, sous-ensemble). La cross-coherence fine (sources resolubles, anti-invention, mapping projets) est verifiee par les invariants de seo_gen.py.",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["keywords", "schema_org", "hreflang", "manifest"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"keywords": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["term", "lang", "scope", "projet", "intent", "category", "sources"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"term": { "type": "string", "minLength": 1 },
|
||||||
|
"lang": { "type": "string", "enum": ["fr", "en", "es"] },
|
||||||
|
"scope": { "type": "string", "enum": ["global", "projet"] },
|
||||||
|
"projet": { "type": ["string", "null"] },
|
||||||
|
"intent": { "type": ["string", "null"] },
|
||||||
|
"category": { "type": ["string", "null"] },
|
||||||
|
"sources": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema_org": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["@context", "@graph"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"@context": { "type": "string", "const": "https://schema.org" },
|
||||||
|
"@graph": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["@type", "@id"],
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"@type": { "type": "string", "minLength": 1 },
|
||||||
|
"@id": { "type": "string", "pattern": "^https://" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hreflang": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["base_url", "x_default_lang", "pages"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"base_url": { "type": "string", "pattern": "^https://" },
|
||||||
|
"x_default_lang": { "type": "string", "minLength": 1 },
|
||||||
|
"pages": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["page", "canonical", "alternates"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"page": { "type": "string", "minLength": 1 },
|
||||||
|
"canonical": { "type": "string", "pattern": "^https://" },
|
||||||
|
"alternates": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 2,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["hreflang", "href"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"hreflang": { "type": "string", "minLength": 1 },
|
||||||
|
"href": { "type": "string", "pattern": "^https://" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["deliverable", "base_url", "langs", "default_lang", "counts", "targets"],
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"deliverable": { "type": "string", "const": "seo" },
|
||||||
|
"base_url": { "type": "string", "pattern": "^https://" },
|
||||||
|
"langs": { "type": "array", "minItems": 1, "items": { "type": "string" } },
|
||||||
|
"default_lang": { "type": "string", "minLength": 1 },
|
||||||
|
"counts": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["keywords_total", "schema_org_nodes", "hreflang_pages", "projects"],
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"keywords_total": { "type": "integer", "minimum": 0 },
|
||||||
|
"schema_org_nodes": { "type": "integer", "minimum": 0 },
|
||||||
|
"hreflang_pages": { "type": "integer", "minimum": 0 },
|
||||||
|
"projects": { "type": "integer", "minimum": 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["min_keywords_total", "min_keywords_per_lang"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"min_keywords_total": { "type": "integer", "minimum": 0 },
|
||||||
|
"min_keywords_per_lang": { "type": "integer", "minimum": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
#!/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())
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"deliverable": "seo",
|
||||||
|
"sprint": "6",
|
||||||
|
"roadmap_line": "L60",
|
||||||
|
"description": "Configuration du generateur SEO trilingue. Roadmap Sprint 6 SEO : « Refactor mission seo_autonome/ -> 200+ mots-cles FR/EN/ES · schema.org · hreflang ». La MATIERE PREMIERE est projets_master.json (sortie Publiciste, elle-meme derivee de data_room/PXX). Ce spec ne contient AUCUNE donnee de projet : uniquement la config du site et le lexique editorial generique (vocabulaire marketing non chiffre). Anti-invention #6 : les faits projet (nom/localisation/prix) viennent EXCLUSIVEMENT de l'entree consommee.",
|
||||||
|
"site": {
|
||||||
|
"base_url": "https://vente.otov7.com",
|
||||||
|
"langs": ["fr", "en", "es"],
|
||||||
|
"default_lang": "fr",
|
||||||
|
"x_default_lang": "fr",
|
||||||
|
"path_prefix": { "fr": "", "en": "en", "es": "es" },
|
||||||
|
"source": "vente.otov7.com trilingue FR/EN/ES (AGENTS_EXISTING_ASSETS.md §7 · Google Search Console configure)"
|
||||||
|
},
|
||||||
|
"lexicon": {
|
||||||
|
"_source": "Lexique editorial SEO — vocabulaire marketing immobilier GENERIQUE et NON chiffre. Aucune donnee de projet ni aucun chiffre n'est encode ici (CLAUDE.md #6).",
|
||||||
|
"property_generic": { "fr": "immobilier", "en": "real estate", "es": "inmobiliaria" },
|
||||||
|
"property_types": [
|
||||||
|
{ "key": "appartement", "fr": "appartement", "en": "apartment", "es": "apartamento" },
|
||||||
|
{ "key": "residence", "fr": "residence", "en": "residence", "es": "residencia" }
|
||||||
|
],
|
||||||
|
"intents": [
|
||||||
|
{ "key": "achat", "fr": "a vendre", "en": "for sale", "es": "en venta" },
|
||||||
|
{ "key": "investissement", "fr": "investissement", "en": "investment", "es": "inversion" },
|
||||||
|
{ "key": "location", "fr": "location", "en": "rental", "es": "alquiler" }
|
||||||
|
],
|
||||||
|
"intents_for_type": ["achat", "investissement"],
|
||||||
|
"country": { "fr": "Republique Dominicaine", "en": "Dominican Republic", "es": "Republica Dominicana" },
|
||||||
|
"regime": {
|
||||||
|
"key": "confotur",
|
||||||
|
"requires_inclus": "CONFOTUR",
|
||||||
|
"term": { "fr": "immobilier CONFOTUR", "en": "CONFOTUR real estate", "es": "inmobiliaria CONFOTUR" },
|
||||||
|
"note": "Terme de regime fiscal touristique (Ley 158-01). N'est emis QUE si au moins un projet documente CONFOTUR dans `inclus` — sinon omis (tracabilite #6)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema_org": {
|
||||||
|
"context": "https://schema.org",
|
||||||
|
"listing_type": "Residence",
|
||||||
|
"country_code": "DO",
|
||||||
|
"organization": {
|
||||||
|
"type": "Organization",
|
||||||
|
"name": "Helios RD",
|
||||||
|
"url": "https://vente.otov7.com",
|
||||||
|
"source": "CLAUDE.md §Entites (Helios RD = marque publique sous WAG) · #4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"min_keywords_total": 200,
|
||||||
|
"min_keywords_per_lang": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests du generateur SEO trilingue (Sprint 6 · SEO).
|
||||||
|
|
||||||
|
Stdlib pur (`unittest`) -> aucune installation pip requise sur le runner Gitea.
|
||||||
|
La bibliotheque `jsonschema` sert d'*oracle* quand elle est presente.
|
||||||
|
|
||||||
|
Axes centraux :
|
||||||
|
- VOLUME/COUVERTURE : 200+ mots-cles, 3 langues, min par langue (roadmap L60) ;
|
||||||
|
- ANTI-INVENTION (#6) : chaque mot-cle est sourcE ; aucun chiffre fabrique ;
|
||||||
|
schema.org n'expose un prix que pour un projet 'disponible' sourcE (USD #10) ;
|
||||||
|
- hreflang : alternates completes + x-default + canonical coherents.
|
||||||
|
Chaque invariant anti-invention a une injection negative dediee.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_MODULE = os.path.normpath(os.path.join(_HERE, ".."))
|
||||||
|
_DELIVERABLES = os.path.normpath(os.path.join(_MODULE, ".."))
|
||||||
|
|
||||||
|
sys.path.insert(0, _MODULE)
|
||||||
|
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
|
||||||
|
|
||||||
|
from seolib import builder, deps, keywords, schemaorg # noqa: E402
|
||||||
|
import seo_gen as gen # noqa: E402
|
||||||
|
|
||||||
|
try:
|
||||||
|
import jsonschema # type: ignore
|
||||||
|
_HAS_JSONSCHEMA = True
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
_HAS_JSONSCHEMA = False
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh():
|
||||||
|
spec = deps.load_spec()
|
||||||
|
master = deps.load_master()
|
||||||
|
bundle = builder.build_bundle(spec, master)
|
||||||
|
return spec, master, bundle
|
||||||
|
|
||||||
|
|
||||||
|
class BuildAndSchema(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.spec, self.master, self.bundle = _fresh()
|
||||||
|
|
||||||
|
def test_validate_passes_on_fixture(self):
|
||||||
|
self.assertEqual(gen._validate(self.spec, self.master, self.bundle), [])
|
||||||
|
|
||||||
|
def test_fixture_conforms_to_publiciste_contract(self):
|
||||||
|
self.assertEqual(deps.validate_master(self.master), [])
|
||||||
|
|
||||||
|
def test_output_schema_valid(self):
|
||||||
|
schema = deps.load_json(deps.SCHEMA_PATH)
|
||||||
|
self.assertEqual(deps.validate(self.bundle, schema), [])
|
||||||
|
|
||||||
|
@unittest.skipUnless(_HAS_JSONSCHEMA, "jsonschema absent")
|
||||||
|
def test_output_schema_valid_oracle(self):
|
||||||
|
schema = deps.load_json(deps.SCHEMA_PATH)
|
||||||
|
jsonschema.validate(self.bundle, schema) # ne leve pas => OK
|
||||||
|
|
||||||
|
def test_deterministic(self):
|
||||||
|
b2 = builder.build_bundle(self.spec, self.master)
|
||||||
|
self.assertEqual(json.dumps(self.bundle, sort_keys=True),
|
||||||
|
json.dumps(b2, sort_keys=True))
|
||||||
|
|
||||||
|
def test_cli_build_and_validate(self):
|
||||||
|
env = dict(os.environ)
|
||||||
|
out = os.path.join(_HERE, "_tmp_out")
|
||||||
|
r = subprocess.run([sys.executable, os.path.join(_MODULE, "seo_gen.py"),
|
||||||
|
"build", "-o", out], capture_output=True, text=True, env=env)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
for f in ("seo_keywords.json", "seo_schema_org.json", "seo_hreflang.json",
|
||||||
|
"MANIFEST.json"):
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(out, f)))
|
||||||
|
r2 = subprocess.run([sys.executable, os.path.join(_MODULE, "seo_gen.py"),
|
||||||
|
"validate"], capture_output=True, text=True, env=env)
|
||||||
|
self.assertEqual(r2.returncode, 0, r2.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
class Keywords(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.spec, self.master, self.bundle = _fresh()
|
||||||
|
self.kws = self.bundle["keywords"]
|
||||||
|
|
||||||
|
def test_volume_target(self):
|
||||||
|
self.assertGreaterEqual(len(self.kws), self.spec["targets"]["min_keywords_total"])
|
||||||
|
|
||||||
|
def test_three_langs_min_each(self):
|
||||||
|
for lang in self.spec["site"]["langs"]:
|
||||||
|
n = sum(1 for k in self.kws if k["lang"] == lang)
|
||||||
|
self.assertGreaterEqual(n, self.spec["targets"]["min_keywords_per_lang"], lang)
|
||||||
|
|
||||||
|
def test_unique_term_lang(self):
|
||||||
|
keys = [(k["term"], k["lang"]) for k in self.kws]
|
||||||
|
self.assertEqual(len(keys), len(set(keys)))
|
||||||
|
|
||||||
|
def test_every_keyword_sourced_and_resolvable(self):
|
||||||
|
by_code = {p["code"] for p in self.master["projets"]}
|
||||||
|
for k in self.kws:
|
||||||
|
self.assertTrue(k["sources"], k["term"])
|
||||||
|
for s in k["sources"]:
|
||||||
|
if s.startswith("projet:"):
|
||||||
|
self.assertIn(s.split(".")[0].split(":")[1], by_code)
|
||||||
|
else:
|
||||||
|
self.assertTrue(s.startswith("lexicon:"), s)
|
||||||
|
|
||||||
|
def test_no_fabricated_digit_in_terms(self):
|
||||||
|
by_code = {p["code"]: p for p in self.master["projets"]}
|
||||||
|
for k in self.kws:
|
||||||
|
td = deps.digits(k["term"])
|
||||||
|
if not td:
|
||||||
|
continue
|
||||||
|
allowed = set()
|
||||||
|
if k["projet"] in by_code:
|
||||||
|
p = by_code[k["projet"]]
|
||||||
|
allowed = deps.digits(p["nom"] + " " + p["localisation"])
|
||||||
|
self.assertTrue(td <= allowed, k["term"])
|
||||||
|
|
||||||
|
def test_numeric_project_name_survives(self):
|
||||||
|
# P09 « 1069 Crisfer » : les chiffres du nom documente sont autorises.
|
||||||
|
terms = {k["term"] for k in self.kws if k["projet"] == "P09"}
|
||||||
|
self.assertIn("1069 Crisfer", terms)
|
||||||
|
|
||||||
|
def test_confotur_global_keyword_present_when_documented(self):
|
||||||
|
fr = {k["term"] for k in self.kws if k["lang"] == "fr" and k["category"] == "confotur"}
|
||||||
|
self.assertTrue(any("CONFOTUR" in t for t in fr))
|
||||||
|
|
||||||
|
def test_confotur_absent_when_not_documented(self):
|
||||||
|
master = copy.deepcopy(self.master)
|
||||||
|
for p in master["projets"]:
|
||||||
|
p["inclus"] = []
|
||||||
|
kws = keywords.generate(self.spec, master)
|
||||||
|
self.assertFalse([k for k in kws if k["category"] == "confotur"])
|
||||||
|
|
||||||
|
def test_negative_fabricated_digit_flagged(self):
|
||||||
|
bundle = copy.deepcopy(self.bundle)
|
||||||
|
bundle["keywords"][0]["term"] = bundle["keywords"][0]["term"] + " 250000"
|
||||||
|
errs = gen._validate(self.spec, self.master, bundle)
|
||||||
|
self.assertTrue(any("invention interdite" in e for e in errs), errs)
|
||||||
|
|
||||||
|
def test_negative_unresolvable_source_flagged(self):
|
||||||
|
bundle = copy.deepcopy(self.bundle)
|
||||||
|
bundle["keywords"][0]["sources"] = ["projet:P99.nom"]
|
||||||
|
errs = gen._validate(self.spec, self.master, bundle)
|
||||||
|
self.assertTrue(any("non resoluble" in e for e in errs), errs)
|
||||||
|
|
||||||
|
def test_negative_duplicate_flagged(self):
|
||||||
|
bundle = copy.deepcopy(self.bundle)
|
||||||
|
bundle["keywords"].append(copy.deepcopy(bundle["keywords"][0]))
|
||||||
|
errs = gen._validate(self.spec, self.master, bundle)
|
||||||
|
self.assertTrue(any("duplique" in e for e in errs), errs)
|
||||||
|
|
||||||
|
def test_negative_below_target_flagged(self):
|
||||||
|
bundle = copy.deepcopy(self.bundle)
|
||||||
|
bundle["keywords"] = bundle["keywords"][:10]
|
||||||
|
bundle["manifest"]["counts"]["keywords_total"] = 10
|
||||||
|
errs = gen._validate(self.spec, self.master, bundle)
|
||||||
|
self.assertTrue(any("cible" in e for e in errs), errs)
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaOrg(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.spec, self.master, self.bundle = _fresh()
|
||||||
|
self.graph = self.bundle["schema_org"]["@graph"]
|
||||||
|
|
||||||
|
def test_context(self):
|
||||||
|
self.assertEqual(self.bundle["schema_org"]["@context"], "https://schema.org")
|
||||||
|
|
||||||
|
def test_one_org_and_one_listing_per_project(self):
|
||||||
|
orgs = [n for n in self.graph if n["@type"] == "Organization"]
|
||||||
|
listings = [n for n in self.graph if n["@type"] == "Residence"]
|
||||||
|
self.assertEqual(len(orgs), 1)
|
||||||
|
self.assertEqual(len(listings), len(self.master["projets"]))
|
||||||
|
names = {n["name"] for n in listings}
|
||||||
|
self.assertEqual(names, {p["nom"] for p in self.master["projets"]})
|
||||||
|
|
||||||
|
def test_locality_and_url_from_source(self):
|
||||||
|
by_name = {n["name"]: n for n in self.graph if n["@type"] == "Residence"}
|
||||||
|
for p in self.master["projets"]:
|
||||||
|
node = by_name[p["nom"]]
|
||||||
|
self.assertEqual(node["address"]["addressLocality"], p["localisation"])
|
||||||
|
self.assertTrue(node["url"].startswith(self.spec["site"]["base_url"]))
|
||||||
|
|
||||||
|
def test_no_offers_for_en_developpement(self):
|
||||||
|
offers = sum(len(n.get("offers", [])) for n in self.graph)
|
||||||
|
self.assertEqual(offers, 0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _master_with_dispo(price=199000.0):
|
||||||
|
"""Rend P02 'disponible' avec une typologie a prix sourcE (reste conforme
|
||||||
|
au contrat Publiciste : disponible => prix numerique)."""
|
||||||
|
spec = deps.load_spec()
|
||||||
|
master = deps.load_master()
|
||||||
|
p = next(x for x in master["projets"] if x["code"] == "P02")
|
||||||
|
p["statut"] = "disponible"
|
||||||
|
p["typologies"] = [{"nom": "Studio", "prix_depuis_usd": price}]
|
||||||
|
return spec, master
|
||||||
|
|
||||||
|
def test_disponible_with_sourced_price_emits_usd_offer(self):
|
||||||
|
spec, master = self._master_with_dispo()
|
||||||
|
bundle = builder.build_bundle(spec, master)
|
||||||
|
self.assertEqual(gen._validate(spec, master, bundle), [])
|
||||||
|
node = [n for n in bundle["schema_org"]["@graph"]
|
||||||
|
if n.get("name") == "Coral del Sur"][0]
|
||||||
|
self.assertEqual(len(node["offers"]), 1)
|
||||||
|
self.assertEqual(node["offers"][0]["priceCurrency"], "USD")
|
||||||
|
self.assertEqual(node["offers"][0]["price"], 199000.0)
|
||||||
|
|
||||||
|
def test_disponible_without_price_emits_no_offer(self):
|
||||||
|
# Cas de bordure au niveau du generateur schema.org (le contrat amont
|
||||||
|
# interdit deja disponible sans prix) : aucune offre fabriquee.
|
||||||
|
projet = {"code": "P02", "nom": "X", "statut": "disponible",
|
||||||
|
"localisation": "Y", "typologies": [{"nom": "S", "prix_depuis_usd": None}]}
|
||||||
|
self.assertEqual(schemaorg._offers_for(projet), [])
|
||||||
|
|
||||||
|
def test_negative_offer_on_en_developpement_flagged(self):
|
||||||
|
bundle = copy.deepcopy(self.bundle)
|
||||||
|
node = [n for n in bundle["schema_org"]["@graph"] if n["@type"] == "Residence"][0]
|
||||||
|
node["offers"] = [{"@type": "Offer", "price": 100000, "priceCurrency": "USD",
|
||||||
|
"availability": "https://schema.org/InStock"}]
|
||||||
|
errs = gen._validate(self.spec, self.master, bundle)
|
||||||
|
self.assertTrue(any("offre interdite" in e or "presence d'offre" in e for e in errs), errs)
|
||||||
|
|
||||||
|
def test_negative_wrong_currency_flagged(self):
|
||||||
|
spec, master = self._master_with_dispo()
|
||||||
|
bundle = builder.build_bundle(spec, master)
|
||||||
|
node = [n for n in bundle["schema_org"]["@graph"]
|
||||||
|
if n.get("name") == "Coral del Sur"][0]
|
||||||
|
node["offers"][0]["priceCurrency"] = "EUR"
|
||||||
|
errs = gen._validate(spec, master, bundle)
|
||||||
|
self.assertTrue(any("devise" in e for e in errs), errs)
|
||||||
|
|
||||||
|
def test_negative_fabricated_price_flagged(self):
|
||||||
|
spec, master = self._master_with_dispo()
|
||||||
|
bundle = builder.build_bundle(spec, master)
|
||||||
|
node = [n for n in bundle["schema_org"]["@graph"]
|
||||||
|
if n.get("name") == "Coral del Sur"][0]
|
||||||
|
node["offers"][0]["price"] = 999999 # non sourcE
|
||||||
|
errs = gen._validate(spec, master, bundle)
|
||||||
|
self.assertTrue(any("non sourcE" in e for e in errs), errs)
|
||||||
|
|
||||||
|
|
||||||
|
class Hreflang(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.spec, self.master, self.bundle = _fresh()
|
||||||
|
self.href = self.bundle["hreflang"]
|
||||||
|
|
||||||
|
def test_one_page_per_project_plus_home(self):
|
||||||
|
names = {pg["page"] for pg in self.href["pages"]}
|
||||||
|
self.assertEqual(names, {"home"} | {p["code"] for p in self.master["projets"]})
|
||||||
|
|
||||||
|
def test_alternates_complete_and_xdefault(self):
|
||||||
|
langs = self.spec["site"]["langs"]
|
||||||
|
default = self.spec["site"]["default_lang"]
|
||||||
|
for pg in self.href["pages"]:
|
||||||
|
alts = {a["hreflang"]: a["href"] for a in pg["alternates"]}
|
||||||
|
self.assertEqual(set(alts), set(langs) | {"x-default"})
|
||||||
|
self.assertEqual(alts["x-default"], alts[default])
|
||||||
|
self.assertEqual(pg["canonical"], alts[default])
|
||||||
|
|
||||||
|
def test_all_urls_under_base(self):
|
||||||
|
base = self.spec["site"]["base_url"].rstrip("/")
|
||||||
|
for pg in self.href["pages"]:
|
||||||
|
self.assertTrue(pg["canonical"].startswith(base))
|
||||||
|
for a in pg["alternates"]:
|
||||||
|
self.assertTrue(a["href"].startswith(base))
|
||||||
|
|
||||||
|
def test_lang_prefix_applied(self):
|
||||||
|
home = [pg for pg in self.href["pages"] if pg["page"] == "home"][0]
|
||||||
|
alts = {a["hreflang"]: a["href"] for a in home["alternates"]}
|
||||||
|
self.assertEqual(alts["fr"], "https://vente.otov7.com/")
|
||||||
|
self.assertEqual(alts["en"], "https://vente.otov7.com/en/")
|
||||||
|
self.assertEqual(alts["es"], "https://vente.otov7.com/es/")
|
||||||
|
|
||||||
|
def test_negative_broken_xdefault_flagged(self):
|
||||||
|
bundle = copy.deepcopy(self.bundle)
|
||||||
|
for a in bundle["hreflang"]["pages"][0]["alternates"]:
|
||||||
|
if a["hreflang"] == "x-default":
|
||||||
|
a["href"] = "https://vente.otov7.com/zz/"
|
||||||
|
errs = gen._validate(self.spec, self.master, bundle)
|
||||||
|
self.assertTrue(any("x-default" in e for e in errs), errs)
|
||||||
|
|
||||||
|
def test_negative_missing_alternate_flagged(self):
|
||||||
|
bundle = copy.deepcopy(self.bundle)
|
||||||
|
bundle["hreflang"]["pages"][0]["alternates"] = [
|
||||||
|
a for a in bundle["hreflang"]["pages"][0]["alternates"] if a["hreflang"] != "es"
|
||||||
|
]
|
||||||
|
errs = gen._validate(self.spec, self.master, bundle)
|
||||||
|
self.assertTrue(any("alternates" in e for e in errs), errs)
|
||||||
|
|
||||||
|
|
||||||
|
class Manifest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.spec, self.master, self.bundle = _fresh()
|
||||||
|
|
||||||
|
def test_counts_coherent(self):
|
||||||
|
c = self.bundle["manifest"]["counts"]
|
||||||
|
self.assertEqual(c["keywords_total"], len(self.bundle["keywords"]))
|
||||||
|
self.assertEqual(c["projects"], len(self.master["projets"]))
|
||||||
|
self.assertEqual(c["hreflang_pages"], len(self.bundle["hreflang"]["pages"]))
|
||||||
|
self.assertEqual(sum(c["keywords_per_lang"].values()), c["keywords_total"])
|
||||||
|
|
||||||
|
def test_negative_tampered_count_flagged(self):
|
||||||
|
bundle = copy.deepcopy(self.bundle)
|
||||||
|
bundle["manifest"]["counts"]["keywords_total"] = 7
|
||||||
|
errs = gen._validate(self.spec, self.master, bundle)
|
||||||
|
self.assertTrue(any("manifest.counts" in e for e in errs), errs)
|
||||||
|
|
||||||
|
def test_slugify(self):
|
||||||
|
self.assertEqual(deps.slugify("Aqua Terra Las Terrenas"), "aqua-terra-las-terrenas")
|
||||||
|
self.assertEqual(deps.slugify("1069 Crisfer"), "1069-crisfer")
|
||||||
|
self.assertEqual(deps.slugify("Xamana Cantiles"), "xamana-cantiles")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
Reference in New Issue
Block a user