[DTP-Worker 20260730_085719] Auto exec · session 20260730_085719
CI / Contraintes NON-NÉGOCIABLES (CLAUDE.md) (push) Has been cancelled
CI / Validation JSON (schémas Faisabilité) (push) Has been cancelled
CI / Qualité documentaire (liens + 4Big) (push) Has been cancelled
CI / Publiciste · parser + schéma + generator (unittest) (push) Has been cancelled
CI / RBAC · 50 rôles + schéma (unittest) (push) Has been cancelled
CI / Faisabilité · générateur 4 volets + round-trip (unittest) (push) Has been cancelled
CI / RBAC · fixtures ERPNext (Role + Custom DocPerm) (push) Has been cancelled
CI / RBAC · plan User Permission (row-level) (push) Has been cancelled
CI / RBAC · Role Profile (bundles par portail) (push) Has been cancelled
CI / RBAC · run-book d'application unifié (agrégat 3 volets) (push) Has been cancelled
CI / Faisabilité · dossier bancable trilingue FR/EN/ES (push) Has been cancelled
CI / CRM · workflow vente ERPNext (lead → CONFOTUR) (push) Has been cancelled
CI / CRM · DocType porteur OTO Dossier Vente (push) Has been cancelled
CI / CRM · barème commissions vendeurs (push) Has been cancelled
CI / Fiscal · e-CF DGII (Compupar) (push) Has been cancelled
CI / Frontend · Workspaces 5 portails rôle (push) Has been cancelled
CI / Legal · DocType CONFOTUR Application (push) Has been cancelled
CI / QA · Audit 5D conformité (push) Has been cancelled
CI / SEO · mots-clés trilingues + schema.org + hreflang (push) Has been cancelled
CI / E2E baseline Playwright (manuel) (push) Has been cancelled
CI / Gate qualité (agrégat) (push) Has been cancelled

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