Scripts Python SEO pour PrestaShop : balises, sitemap, GSC
PrestaShop

Scripts Python SEO pour PrestaShop : balises, sitemap, GSC

Trois scripts Python en lecture seule pour PrestaShop 8 et 9 : balises meta via le webservice, URL du sitemap, opportunités de la Search Console.

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

ScriptSource lueCe qu’il signale
controle_meta.pyWebservice PrestaShopTitres et descriptions vides, trop longs, en double, marqueurs oubliés
verif_sitemap.pySitemap 1_index_sitemap.xmlURL qui ne répondent pas 200, canonical vers une autre page, noindex, blocs Product multiples
gsc_opportunites.pyAPI Search ConsoleCouples page-requête en position 8 à 20 avec des impressions

Trois règles avant de lancer un script

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

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 :

  1. Créez un compte de service dans Google Cloud et activez l’API Google Search Console.
  2. Ajoutez l’adresse e-mail du compte de service comme utilisateur de la propriété, dans la Search Console.
  3. 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

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

Agence web PrestaShop & WordPress

Agence web spécialisée PrestaShop et WordPress : création, maintenance, sécurité, performance et référencement. Nos tutoriels sont issus de nos interventions sur de vraies boutiques.

L'agence Genisoft → LinkedIn →