Mis à jour le 1er novembre 2026. Cet article regroupe désormais notre article « Balises méta PrestaShop : contrôler longueurs et doublons ». Les scripts ont été réécrits et testés avec Python 3.12. Retirés : l’appel à l’API Mobile-Friendly Test, arrêtée par Google en décembre 2023, et la lecture d’un export produit qui ne contient pas les balises meta.
Un script Python ne remplace pas un crawler ni la Search Console. Il sert à répéter chaque semaine un contrôle précis, sur tout le catalogue, sans erreur de saisie. Cet article donne trois scripts prêts à adapter. Aucun n’écrit dans la boutique : ils lisent, comparent et produisent un rapport CSV à relire.
Ce que font les trois scripts
| Script | Source lue | Ce qu’il signale |
|---|---|---|
controle_meta.py | Webservice PrestaShop | Titres et descriptions vides, trop longs, en double, marqueurs oubliés |
verif_sitemap.py | Sitemap 1_index_sitemap.xml | URL qui ne répondent pas 200, canonical vers une autre page, noindex, blocs Product multiples |
gsc_opportunites.py | API Search Console | Couples page-requête en position 8 à 20 avec des impressions |
Trois règles avant de lancer un script
- Lecture seule. Une clé de webservice limitée à la méthode GET ne peut rien modifier, même en cas d’erreur dans le code.
- Aucune clé dans le code. Les clés et chemins passent par des variables d’environnement. Un script partagé ou versionné ne divulgue alors rien.
- Ménager le serveur. Une pause entre deux requêtes évite de ralentir la boutique pendant les heures d’affluence.
Script 1 : contrôler les balises meta du catalogue
Pourquoi le webservice plutôt qu’un export
Sur PrestaShop 9.2, l’export CSV de la liste des produits contient l’identifiant, le nom, la référence, la catégorie, les prix et la quantité. Il ne contient pas les balises meta. Le webservice les fournit, langue par langue.
Pour l’activer : Paramètres avancés > Webservice, activez le webservice, puis ajoutez une clé. Cochez uniquement la permission de lecture (GET) sur la ressource products. La clé s’envoie comme nom d’utilisateur en authentification HTTP Basic, avec un mot de passe vide.
Les règles appliquées
- Titre : 60 caractères au maximum. C’est un repère éditorial, pas une règle de Google, qui coupe selon la largeur des caractères.
- Description : 150 à 160 caractères, soit une phrase complète sans coupure.
- Doublons : deux produits actifs ne doivent pas porter le même titre ni la même description. La comparaison ignore la casse.
- Marqueurs : un crochet signale souvent un texte provisoire, comme
[INFORMATION MANQUANTE], resté dans une balise générée. - Source pauvre : sous 80 caractères de description courte, un modèle de langage complète le texte lui-même, donc il invente.
Un champ vide n’est pas sans effet. Si le titre meta est vide, PrestaShop 9.2 affiche le nom du produit. Si la description meta est vide, il reprend la description courte, sans balises HTML. Le script signale ces cas pour que vous décidiez.
Le code
Le script n’utilise que la bibliothèque standard de Python.
"""Contrôle des balises meta des produits PrestaShop, en lecture seule via le webservice."""
import base64
import csv
import json
import os
import re
import sys
import urllib.request
from collections import Counter
TITRE_MAX = 60
DESC_MIN = 150
DESC_MAX = 160
SOURCE_MIN = 80 # description courte trop pauvre en dessous (texte brut)
PAR_PAGE = 100
CHAMPS = "[id,active,name,description_short,meta_title,meta_description]"
def texte(valeur):
"""Le webservice renvoie une chaîne pour une langue, une liste sinon."""
if isinstance(valeur, list):
valeur = valeur[0].get("value", "") if valeur else ""
return re.sub(r"<[^>]+>", " ", valeur or "").strip()
def lire_produits(boutique, cle, id_lang):
"""Récupère les produits page par page (méthode GET uniquement)."""
auth = base64.b64encode(f"{cle}:".encode()).decode()
produits, debut = [], 0
while True:
url = (f"{boutique}/api/products?display={CHAMPS}&language={id_lang}"
f"&output_format=JSON&limit={debut},{PAR_PAGE}")
req = urllib.request.Request(url, headers={"Authorization": f"Basic {auth}"})
with urllib.request.urlopen(req, timeout=30) as rep:
donnees = json.loads(rep.read().decode("utf-8") or "[]")
lot = donnees.get("products", []) if isinstance(donnees, dict) else []
if not lot:
return produits
produits.extend(lot)
debut += PAR_PAGE
def controler(produits, exclus):
actifs = [p for p in produits if str(p.get("active")) == "1"]
titres = Counter(texte(p.get("meta_title")).casefold() for p in actifs)
descs = Counter(texte(p.get("meta_description")).casefold() for p in actifs)
for p in produits:
pid = str(p.get("id"))
if str(p.get("active")) != "1":
yield pid, "exclu", "produit inactif"
continue
if pid in exclus:
yield pid, "exclu", "liste d'exclusion"
continue
titre = texte(p.get("meta_title"))
desc = texte(p.get("meta_description"))
alertes = []
if not titre:
alertes.append("titre vide (nom du produit repris)")
elif len(titre) > TITRE_MAX:
alertes.append(f"titre de {len(titre)} caractères")
if titre and titres[titre.casefold()] > 1:
alertes.append("titre en double")
if not desc:
alertes.append("description vide (description courte reprise)")
elif not DESC_MIN <= len(desc) <= DESC_MAX:
alertes.append(f"description de {len(desc)} caractères")
if desc and descs[desc.casefold()] > 1:
alertes.append("description en double")
if "[" in titre + desc:
alertes.append("marqueur entre crochets")
if len(texte(p.get("description_short"))) < SOURCE_MIN:
alertes.append("description courte trop pauvre pour rédiger")
yield pid, "a_corriger" if alertes else "sans_alerte", " ; ".join(alertes)
def main():
if len(sys.argv) != 2:
sys.exit("Usage : python controle_meta.py rapport.csv")
boutique = os.environ.get("PS_BOUTIQUE", "").rstrip("/")
cle = os.environ.get("PS_WS_KEY", "")
if not boutique or not cle:
sys.exit("Définissez PS_BOUTIQUE et PS_WS_KEY dans l'environnement.")
id_lang = os.environ.get("PS_ID_LANG", "1")
exclus = set()
if os.path.exists("exclusions.txt"):
with open("exclusions.txt", encoding="utf-8") as f:
exclus = {ligne.strip() for ligne in f if ligne.strip()}
produits = lire_produits(boutique, cle, id_lang)
with open(sys.argv[1], "w", newline="", encoding="utf-8-sig") as f:
ecrivain = csv.writer(f, delimiter=";")
ecrivain.writerow(["id_product", "statut", "details"])
ecrivain.writerows(controler(produits, exclus))
print(f"{len(produits)} produits contrôlés.")
if __name__ == "__main__":
main()
Lancement, sous Linux ou macOS :
export PS_BOUTIQUE="https://votre-boutique.com"
export PS_WS_KEY="votre-cle-en-lecture-seule"
export PS_ID_LANG="1"
python controle_meta.py rapport_meta.csv
Un fichier facultatif exclusions.txt, dans le dossier de lancement, liste un identifiant de produit par ligne. Mettez-y les fiches rédigées à la main et validées, et les produits à mentions réglementées (santé, alcool, enfants) : leur relecture demande une personne compétente.
Le rapport donne trois statuts : exclu, a_corriger et sans_alerte. Une fiche sans_alerte a passé les contrôles de forme, pas la relecture. Comparez chaque affirmation à la fiche du fabricant avant de publier.
Script 2 : vérifier les URL du sitemap
Le module Google sitemap (gsitemap) produit un index 1_index_sitemap.xml, le chiffre étant le numéro de la boutique. Chaque URL listée doit répondre 200, sans redirection, et être sa propre canonical. Une URL qui redirige ou qui porte un noindex envoie à Google des signaux contradictoires.
Le script lit l’index puis les sitemaps enfants. Il ne suit pas les redirections : une 301 ou une 302 apparaît comme telle dans le rapport. Il compte aussi les blocs JSON-LD de type Product, car un module d’avis en ajoute souvent un second.
"""Vérifie les URL d'un sitemap PrestaShop : code HTTP, canonical, noindex, blocs Product."""
import csv
import json
import sys
import time
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from html.parser import HTMLParser
NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
PAUSE = 1.0 # secondes entre deux requêtes, pour ne pas charger la boutique
MAX_URL = 500
AGENT = "controle-seo-interne/1.0"
class SansRedirection(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None # une redirection doit apparaître comme telle
OUVREUR = urllib.request.build_opener(SansRedirection)
class Tete(HTMLParser):
def __init__(self):
super().__init__()
self.canonical, self.robots = "", ""
self.jsonld, self._dans_jsonld = [], False
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "link" and (a.get("rel") or "").lower() == "canonical":
self.canonical = a.get("href") or ""
elif tag == "meta" and (a.get("name") or "").lower() == "robots":
self.robots = (a.get("content") or "").lower()
elif tag == "script" and a.get("type") == "application/ld+json":
self._dans_jsonld = True
self.jsonld.append("")
def handle_data(self, data):
if self._dans_jsonld:
self.jsonld[-1] += data
def handle_endtag(self, tag):
if tag == "script":
self._dans_jsonld = False
def telecharger(url):
req = urllib.request.Request(url, headers={"User-Agent": AGENT})
try:
with OUVREUR.open(req, timeout=20) as rep:
return rep.status, rep.headers.get("Location", ""), rep.read().decode("utf-8", "replace")
except urllib.error.HTTPError as err:
return err.code, err.headers.get("Location", ""), ""
def urls_du_sitemap(url):
"""Lit un index de sitemaps ou un sitemap simple, et renvoie les <loc> des pages."""
_, _, xml = telecharger(url)
racine = ET.fromstring(xml)
if racine.tag == NS + "sitemapindex":
urls = []
for loc in racine.iter(NS + "loc"):
urls.extend(urls_du_sitemap(loc.text.strip()))
return urls
return [loc.text.strip() for loc in racine.iter(NS + "loc")]
def types_jsonld(blocs):
types = []
for bloc in blocs:
try:
donnees = json.loads(bloc)
except json.JSONDecodeError:
types.append("JSON invalide")
continue
for objet in donnees if isinstance(donnees, list) else [donnees]:
if isinstance(objet, dict):
types.append(str(objet.get("@type", "?")))
return types
def verifier(url):
code, cible, html = telecharger(url)
if code != 200:
return [url, code, cible, "", "", "", "URL du sitemap qui ne répond pas 200"]
tete = Tete()
tete.feed(html)
types = types_jsonld(tete.jsonld)
alertes = []
if tete.canonical and tete.canonical != url:
alertes.append("canonical vers une autre URL")
if "noindex" in tete.robots:
alertes.append("noindex dans une URL du sitemap")
if types.count("Product") > 1:
alertes.append("plusieurs blocs Product")
return [url, code, "", tete.canonical, tete.robots, ",".join(types), " ; ".join(alertes)]
def main():
if len(sys.argv) != 3:
sys.exit("Usage : python verif_sitemap.py https://boutique/1_index_sitemap.xml rapport.csv")
urls = urls_du_sitemap(sys.argv[1])[:MAX_URL]
with open(sys.argv[2], "w", newline="", encoding="utf-8-sig") as f:
ecrivain = csv.writer(f, delimiter=";")
ecrivain.writerow(["url", "code", "redirige_vers", "canonical", "robots", "jsonld", "alertes"])
for url in urls:
ecrivain.writerow(verifier(url))
time.sleep(PAUSE)
print(f"{len(urls)} URL vérifiées.")
if __name__ == "__main__":
main()
python verif_sitemap.py https://votre-boutique.com/1_index_sitemap.xml rapport_sitemap.csv
Avec une pause d’une seconde, 500 URL demandent un peu plus de huit minutes. Augmentez MAX_URL par paliers et lancez le contrôle complet la nuit.
Sur notre installation de test PrestaShop 9.2, ce contrôle a confirmé le comportement attendu : une ancienne adresse de produit redirige vers la nouvelle, une page de liste filtrée porte un noindex et une canonical vers la catégorie. Une URL filtrée ne doit donc jamais figurer dans le sitemap.
Script 3 : repérer les pages proches de la première page
Les pages en position 8 à 20 avec des impressions sont souvent les plus rentables à retravailler : Google les juge déjà pertinentes. Le script interroge l’API Search Console sur 28 jours, en s’arrêtant trois jours avant la date du jour pour lire des données finalisées.
Préparation :
- Créez un compte de service dans Google Cloud et activez l’API Google Search Console.
- Ajoutez l’adresse e-mail du compte de service comme utilisateur de la propriété, dans la Search Console.
- Installez les bibliothèques :
pip install google-api-python-client google-auth.
"""Pages en position 8 à 20 avec des impressions : export depuis l'API Search Console."""
import csv
import datetime
import os
import sys
from google.oauth2 import service_account
from googleapiclient.discovery import build
SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]
IMPRESSIONS_MIN = 50
POSITION_MIN, POSITION_MAX = 8, 20
def lire_lignes(service, propriete, debut, fin):
"""Parcourt toutes les lignes, par paquets de 25 000 (maximum de l'API)."""
lignes, depart = [], 0
while True:
corps = {
"startDate": debut,
"endDate": fin,
"dimensions": ["page", "query"],
"type": "web",
"dimensionFilterGroups": [{"filters": [
{"dimension": "country", "operator": "equals", "expression": "FRA"}
]}],
"rowLimit": 25000,
"startRow": depart,
}
reponse = service.searchanalytics().query(siteUrl=propriete, body=corps).execute()
paquet = reponse.get("rows", [])
lignes.extend(paquet)
if len(paquet) < 25000:
return lignes
depart += 25000
def main():
if len(sys.argv) != 2:
sys.exit("Usage : python gsc_opportunites.py opportunites.csv")
fichier_cle = os.environ.get("GSC_CLE_JSON", "")
propriete = os.environ.get("GSC_PROPRIETE", "") # ex. sc-domain:exemple.fr
if not fichier_cle or not propriete:
sys.exit("Définissez GSC_CLE_JSON et GSC_PROPRIETE dans l'environnement.")
identifiants = service_account.Credentials.from_service_account_file(fichier_cle, scopes=SCOPES)
service = build("searchconsole", "v1", credentials=identifiants)
fin = datetime.date.today() - datetime.timedelta(days=3) # données finalisées
debut = fin - datetime.timedelta(days=27)
lignes = lire_lignes(service, propriete, debut.isoformat(), fin.isoformat())
retenues = [
l for l in lignes
if l["impressions"] >= IMPRESSIONS_MIN and POSITION_MIN <= l["position"] <= POSITION_MAX
]
retenues.sort(key=lambda l: l["impressions"], reverse=True)
with open(sys.argv[1], "w", newline="", encoding="utf-8-sig") as f:
ecrivain = csv.writer(f, delimiter=";")
ecrivain.writerow(["page", "requete", "impressions", "clics", "ctr", "position"])
for l in retenues:
page, requete = l["keys"]
ecrivain.writerow([page, requete, int(l["impressions"]), int(l["clicks"]),
f"{l['ctr']:.3f}", f"{l['position']:.1f}"])
print(f"{len(retenues)} couples page-requête retenus sur {len(lignes)}.")
if __name__ == "__main__":
main()
export GSC_CLE_JSON="/chemin/hors-du-site/compte-service.json"
export GSC_PROPRIETE="sc-domain:votre-boutique.com"
python gsc_opportunites.py opportunites.csv
L’API renvoie au plus 25 000 lignes par appel : le script enchaîne les appels avec startRow. Le filtre pays utilise le code ISO à trois lettres (FRA). Gardez le fichier de clé hors du dossier public du site.
Adapter les scripts à votre boutique
- Plusieurs langues : lancez
controle_meta.pyune fois par langue, en changeantPS_ID_LANG. Les identifiants de langue figurent dans International > Localisation > Langues. Un doublon entre deux langues n’est pas une erreur : la comparaison se fait langue par langue. - Plusieurs boutiques : le sitemap de la boutique 2 s’appelle
2_index_sitemap.xml. Lancezverif_sitemap.pysur chaque index. - Gros catalogue : le webservice renvoie les produits par paquets de 100. Pour plusieurs dizaines de milliers de fiches, lancez le contrôle hors des heures d’affluence.
- Autres seuils : les constantes en tête de chaque script se modifient sans toucher au reste du code.
Planifier les contrôles
# crontab -e : contrôles hebdomadaires du lundi matin
0 5 * * 1 cd /opt/seo-scripts && . ./env.sh && python3 controle_meta.py rapports/meta.csv >> logs/seo.log 2>&1
0 6 * * 1 cd /opt/seo-scripts && python3 verif_sitemap.py https://votre-boutique.com/1_index_sitemap.xml rapports/sitemap.csv >> logs/seo.log 2>&1
0 7 * * 1 cd /opt/seo-scripts && . ./env.sh && python3 gsc_opportunites.py rapports/gsc.csv >> logs/seo.log 2>&1
Le fichier env.sh contient les export vus plus haut. Réservez sa lecture au compte qui lance les tâches (chmod 600 env.sh).
Publier les corrections sans casser les URL
Ces scripts ne publient rien. Pour écrire, utilisez l’import CSV du back-office en ne gardant que l’identifiant et les colonnes de balises, ou le webservice avec une clé distincte autorisée en écriture. Sauvegardez la base avant, testez sur quelques fiches et contrôlez le rendu.
Ne modifiez pas l’URL simplifiée (link_rewrite) dans la même opération. La règle des fiches de PrestaShop 9, {id}{-:id_product_attribute}-{rewrite}.html, garde l’identifiant : avec l’option Rediriger vers l’URL canonique réglée en 301 (Paramètres de la boutique > Trafic et SEO), l’ancienne adresse redirige. PrestaShop n’a pas de gestionnaire de redirections natif pour les autres cas.
La méthode d’écriture par lots est détaillée dans Automatiser les fiches produits PrestaShop avec l’IA. Pour la rédaction des balises elles-mêmes, voir IA et SEO produit PrestaShop : la méthode en 5 étapes.
Mesurer l’effet
Notez la date de publication de chaque lot. Relancez gsc_opportunites.py quatre semaines plus tard et comparez les pages modifiées à des pages témoins non touchées, sur des périodes de même durée. La saisonnalité et les mises à jour de Google brouillent la lecture : aucun résultat n’est garanti.
Ces scripts complètent un audit, ils ne le remplacent pas. La méthode complète est dans Audit SEO PrestaShop : méthode, outils et checklist 2026. Pour les filtres et la pagination, voir Facettes, pagination et canonical sur PrestaShop, et pour l’ensemble des réglages, SEO technique PrestaShop : guide avancé 2026. Pour une vérification ponctuelle, l’analyseur de balises meta et le vérificateur de code HTTP suffisent.
Questions fréquentes
Ces scripts peuvent-ils abîmer la boutique ?
Non, s’ils utilisent une clé de webservice limitée à la lecture. Ils ne font que des requêtes GET. La pause entre les requêtes limite aussi la charge sur le serveur.
Pourquoi ne pas lire directement la base de données ?
C’est possible avec une requête SQL en lecture seule, mais il faut alors un accès au serveur et gérer les préfixes de tables. Le webservice fonctionne à distance et respecte les permissions de la clé.
Les scripts rédigent-ils les balises ?
Non. Ils contrôlent des balises déjà écrites, à la main ou par un modèle. Un texte généré doit toujours être relu avant publication.
Faut-il suivre les positions chaque jour ?
Non. Les positions varient d’un jour à l’autre et les données de la Search Console arrivent avec quelques jours de décalage. Une lecture hebdomadaire sur 28 jours est plus stable.
Sources
- PrestaShop : paramètres de liste du webservice (display, filter, sort, limit)
- Google : méthode searchanalytics.query de l’API Search Console
- Google : limites d’utilisation de l’API Search Console
- Google Search Central : note de retrait de l’API Mobile-Friendly Test (décembre 2023)
- Google : regrouper les URL en double (canonicalisation)
- Code source de PrestaShop 9.2.0 :
classes/webservice/WebserviceRequest.php,classes/webservice/WebserviceOutputJSON.php,classes/Meta.php,src/PrestaShopBundle/Controller/Admin/Sell/Catalog/Product/ProductController.php(export CSV).