Migrer son blog Wordpress vers un blog Quarto statique hébergé gratuitement avec Github Pages

informatique
Author

Kévin Polisano

Published

December 26, 2024

Mon site perso fait peau neuve !

Je vous explique ici quelles étapes j’ai suivi pour effectuer la migration. Au boulot !

Créer son blog avec Quarto

  • Ouvrir RStudio
  • Créer un nouveau projet Create Quarto Blog
  • Cocher Create a git repository

Un blog Quarto vierge est ainsi créé contenant ces fichiers :

  • _quarto.yml : Fichier du projet Quarto
  • index.qmd : Page d’accueil
  • about.qmd : Page “À propos”
  • posts/ : Répertoire contenant les posts en Quarto Markdown (.qmd)
  • posts/_metadata.yml : Options partagées des posts
  • styles.css : CSS customisé pour le style du blog
  • MyBlog.Rproj : Raccourci d’ouverture du projet Quarto

Dans le fichier _quarto.yml ajouter la ligne output-dir pour renseigner le répertoire cible pour la génération du site :

project:
  type: website
  output-dir: docs

Enfin pour le générer et avoir le rendu du blog il suffit d’exécuter build > Render Website.

Héberger son blog avec GitHub pages

Déployer le blog

Après avoir créé un nouveau dépôt sur GitHub à partir du .git local, aller dans Settings > Pages puis dans Build and deployment et choisir comme source Deploy from a branch. Puis sélectionner la branche main et le dossier /docs avant de valider avec save. Après quelques minutes le lien vers la page du blog est généré : kevinpolisano.github.io

Nom de domaine customisé

L’hébergeur de mon site WP était o2switch, que je recommande pour la grande réactivité de leur service technique.

Du côté de ce registar voici les étapes à suivre :

  • Dans Espace client > Commander un service choisir Commander un nom de domaine (pour ma part : kevinpolisano.fr)
  • Dans Espace technique > Domaines configurés remplir Configurer un nom de domaine (pour ma part : kevinpolisano.fr) puis activer Let's Encrypt SSL dans l’onglet Sécurité du cPanel.
  • Dans Espace technique > Zone Editor entrer les champs suivants (à adapter selon le blog) :
Nom TLL Type Enregistrement
www.kevinpolisano.fr. 14400 CNAME kevinpolisano.github.io
kevinpolisano.fr. 14400 A 185.199.108.153
kevinpolisano.fr. 14400 A 185.199.109.153
kevinpolisano.fr. 14400 A 185.199.110.153
kevinpolisano.fr. 14400 A 185.199.111.153
  • J’ai également créé un fichier CNAME à la racine du dépôt git, contenant la ligne www.kevinpolisano.fr.

Du côté de GitHub Pages voici les étapes à suivre :

  • Dans Custom Domain renseigner le nouveau nom de domaine www.kevinpolisano.fr puis cliquer sur save. À ce stade j’ai obtenu à tour de rôle les messages d’erreurs suivants :
Important

DNS check unsuccessful

Both kevinpolisano.fr and its alternate name are improperly configured Domain does not resolve to the GitHub Pages server. For more information, see documentation (NotServedByPagesError).

Warning

DNS valid for primary

kevinpolisano.fr is improperly configured Domain does not resolve to the GitHub Pages server. For more information, see documentation (NotServedByPagesError).

  • J’ai du attendre quelques heures pour que la propagation du DNS soit effective. On peut suivre celle-ci sur DNS Checker en s’assurant pour le domaine racine kevinpolisano.fr que les enregistrements A pointent vers les IP GitHub Pages; et pour le sous-domaine www.kevinpolisano.fr que l’enregistrement CNAME pointe vers kevinpolisano.github.io.

  • Une fois que le bouton save donne DNS check successful on coche Enforce HTTPS et on vérifie qu’en tapant kevinpolisano.fr dans la barre de navigateur on est bien redirigé vers le blog à l’adresse https://www.kevinpolisano.fr/

Exporter ses articles Wordpress en HTML vers Markdown

J’ai expérimenté trois solutions :

  1. GitHub - SchumacherFM/wordpress-to-hugo-exporter
  2. GitHub - lonekorean/wordpress-export-to-markdown
  3. GitHub - palaniraja/blog2md

La première conserve certaines balises HTML (typiquement pour l’usage de la couleur et la mise en forme), tandis que les deux suivantes exportent en pur Markdown.

Wordpress to Hugo Exporter

La première solution consiste à uploader l’archive sur le site WP dans le dossier wp-content/plugins à activer le plugin et utiliser Outils > Export Hugo. Ce dernier n’a pas fonctionné donc j’ai utilisé le Terminal de mon serveur d’hébergement (O2switch) (comme expliqué ici) :

cd wp-content/plugins/wordpress-to-hugo-exporter/
php hugo-export-cli.php

Le script créé un fichier /tmp/wp-hugo.zip (cela peut prendre quelques minutes). Dans le gestionnaire de fichiers (via le Cpanel d’O2switch) j’ai effectué une recherche du fichier, qui m’a indiquée que celui-ci se trouvait dans le dossier caché .cagefs/tmp/.

Wordpress export to Markdown

Premièrement on effectue un export du contenu complet de WP au format export.xml (dans Outils > Exporter), que l’on place dans l’archive du code téléchargé. Puis on exécute le script :

npm install && node index.js

Blog2md

De même on exécute :

npm install && node index.js w export.xml out

Nettoyage des articles exportés, liens, images, …

Lorsque mes billets WP contenaient du texte brut, du code ou du \(\LaTeX\), j’ai utilisé l’export Markdown de Wordpress export to Markdown; tandis que pour mes billets contenant une mise en forme travaillée (avec notamment l’usage de couleurs), j’ai utilisé l’export Markdown + HTML de Wordpress to Hugo Exporter, qui a aussi le bon goût d’exporter toutes les images dans wp-content/uploads/. Pour ces fichiers Markdown + HTML, j’ai écrit un script Python permettant entre autres de :

  • Remplacer les liens HTML par des liens Markdown, pour les images en particulier par l’insertion de ![](images/img) en ayant préalablement copié l’image courante img (privée de sa dimension) du dossier uploads/ vers le dossier images/du répertoire courant correspondant au billet.
  • Remplacer les footnotes HTML par des footnotes Markdown.
  • Remplacer les blocs de code HTML par des balises de code Markdown (ici précisant le language Matlab)
import re
import os
import shutil

# Dossiers source et destination pour les images
UPLOADS_DIR = "../uploads"
IMAGES_DIR = "images"
INPUT_FILE = "index_prev.qmd"
OUTPUT_FILE = "index.qmd"

# Créer le dossier images s'il n'existe pas
if not os.path.exists(IMAGES_DIR):
    os.makedirs(IMAGES_DIR)

# Lire le fichier d'entrée
with open(INPUT_FILE, "r", encoding="utf-8") as file:
    content = file.read()

# Fonction pour traiter les images
def process_images(content):
    image_pattern = re.compile(
        r'<a [^>]*?href="[^"]*?/([^/"]+)"[^>]*?><img [^>]*?src="[^"]*?/([^/"]+)"[^>]*?></a>'
    )
    
    def replace_image(match):
        image_file = match.group(1)
        base_name, ext = os.path.splitext(image_file)
        
        # Vérifier si le nom contient les dimensions WxH
        dimension_pattern = re.compile(r"^(?P<name>.+)-\d+x\d+$")
        dimension_match = dimension_pattern.match(base_name)
        
        if dimension_match:
            base_name = dimension_match.group("name")  # Nom sans dimensions
            original_image_file = base_name + ext
            original_path = os.path.join(UPLOADS_DIR, original_image_file)
            
            # Si l'image sans dimensions existe, utiliser celle-ci
            if os.path.exists(original_path):
                image_file = original_image_file
        
        # Copier l'image
        src_path = os.path.join(UPLOADS_DIR, image_file)
        dest_path = os.path.join(IMAGES_DIR, image_file)
        if os.path.exists(src_path):
            shutil.copy2(src_path, dest_path)
        
        return f"![](images/{image_file})"
    
    return image_pattern.sub(replace_image, content)

# Fonction pour traiter les blocs de code
def process_code_blocks(content):
    code_block_pattern = re.compile(
        r'<pre class="brush:[^"]+">(.*?)</pre>', re.DOTALL
    )
    
    def replace_code_block(match):
        code_content = match.group(1).replace("&gt;", ">").replace("&lt;", "<").replace("&amp;", "&")
        return f"```matlab\n{code_content}\n```"
    
    return code_block_pattern.sub(replace_code_block, content)

# Fonction pour traiter les footnotes
def process_footnotes(content):
    footnote_body_pattern = re.compile(
        r'<sup id="(?P<id>[^"]+)"><a href="#[^"]+" title="(?P<title>[^"]+)"[^>]*>\d+</a></sup>'
    )
    
    def replace_footnote_body(match):
        footnote_id = match.group("id")
        footnote_content = match.group("title").replace("&#039;", "'").replace("&quot;", '"')
        markdown_footnote = f"[^{footnote_id}]"
        markdown_definition = f"[^{footnote_id}]: {footnote_content}"
        return markdown_footnote, markdown_definition
    
    paragraphs = content.split("\n\n")  # Diviser en paragraphes
    updated_paragraphs = []
    
    for paragraph in paragraphs:
        matches = list(footnote_body_pattern.finditer(paragraph))
        if matches:
            definitions = []
            for match in matches:
                footnote_markdown, footnote_definition = replace_footnote_body(match)
                paragraph = paragraph.replace(match.group(0), footnote_markdown)
                definitions.append(footnote_definition)
            updated_paragraphs.append(paragraph)
            updated_paragraphs.extend(definitions)
        else:
            updated_paragraphs.append(paragraph)
    
    return "\n\n".join(updated_paragraphs)

# Fonction pour supprimer les balises <li id=X>
def remove_list_items(content):
    list_item_pattern = re.compile(r'<li id="[^"]+">.*?</li>', re.DOTALL)
    return list_item_pattern.sub("", content)

# Fonction pour supprimer l’indentation des balises de paragraphes
def remove_paragraph_indentation(content):
    paragraph_pattern = re.compile(r"\s*<(/?p)>")
    return paragraph_pattern.sub(r"<\1>", content)

# Appliquer les transformations
content = process_images(content)
content = process_code_blocks(content)
content = process_footnotes(content)
content = remove_list_items(content)
content = remove_paragraph_indentation(content)

# Écrire le fichier de sortie
with open(OUTPUT_FILE, "w", encoding="utf-8") as file:
    file.write(content)

print(f"Transformation terminée. Résultat enregistré dans {OUTPUT_FILE}.")

Bien sûr j’ai aussi modifié les exports à la main lorsque je repérais des rectifications ponctuelles (non automatisables). L’export en pur Markdown a quant à lui le bon goût de renseigner les liens cassés, ce qui permet au passage de faire un nettoyage des liens morts témoins du pourrissement des liens du web. À l’avenir, je vais tâcher de privilégier la bibliographie (.bib) renseignant les métadonnées des sources (titre, journal, date, …) pour que celles-ci restent identifiables en cas de liens morts, et donc potentiellement trouvables ailleurs si l’article a été hébergé à une autre adresse.

Gestion des commentaires

J’ai opté pour giscus, qui est un dispositif relativement léger, basé sur les discussions GitHub, open-source et sans publicité. Le seul bémol est qu’il faut disposer d’un compte Github pour être autorisé à commenter. L’avantage par ailleurs est que l’on est moins sujet aux spams et aux commentaires injurieux (l’authentification, plus coûteuse en temps, en dissuadent plus d’un).

Mise en place de la fonctionnalité

Pour activer les commentaires il faut remplir la page giscus détaillant la marche à suivre :

  • Installer l’application giscus
  • Activer les discussions Github dans l’onglet Settings > Features du dépôt en cochant la case Discussions.
  • Renseigner le dépôt public (dans mon cas kevinpolisano/blog) ainsi que les propriétés escomptées.

La balise HTML générée est la suivante dans mon cas :

<script src="https://giscus.app/client.js"
        data-repo="kevinpolisano/blog"
        data-repo-id="R_kgDOM9ReCA"
        data-category="Announcements"
        data-category-id="DIC_kwDOM9ReCM4CmGgm"
        data-mapping="pathname"
        data-strict="0"
        data-reactions-enabled="1"
        data-emit-metadata="0"
        data-input-position="bottom"
        data-theme="preferred_color_scheme"
        data-lang="fr"
        data-loading="lazy"
        crossorigin="anonymous"
        async>
</script>

Ce qui me permet de remplir les options de commentaires dans le fichier _quarto.yml comme suit :

project:
  type: website
  output-dir: docs

website:
  title: "Blog de Kévin Polisano"
  navbar:
    right:
      - about.qmd
  comments:
    giscus:
      repo: "kevinpolisano/blog"
      repo-id: "R_kgDOM9ReCA"
      category: "Announcements"
      category-id: "DIC_kwDOM9ReCM4CmGgm"
      mapping: "pathname"
      reactions-enabled: true
      input-position: "bottom"
      theme: "light"
      language: "fr"
      loading: "lazy"
format:
  html:
    theme: flatly
    css: styles.css

editor: visual

Extraction des commentaires Wordpress

On commence par extraire les commentaires dans un fichier comments.csv à partir de export.xml grâce à ce script python qui parse les commentaires :

import xml.etree.ElementTree as ET
import csv
from datetime import datetime

# Chemin vers le fichier XML exporté
xml_file = "wp.xml"
output_file = "comments.csv"

# Charger le fichier XML
tree = ET.parse(xml_file)
root = tree.getroot()

# Namespace de WordPress
ns = {"wp": "http://wordpress.org/export/1.2/"}

# Extraire les commentaires
comments = []
for item in root.findall(".//item"):
    post_title = item.find("title").text
    post_url = item.find("link").text
    for comment in item.findall("wp:comment", ns):
        author = comment.find("wp:comment_author", ns).text
        content = comment.find("wp:comment_content", ns).text
        date = comment.find("wp:comment_date", ns).text
        comments.append([post_title, post_url, author, content, date])

# Trier les commentaires par date croissante
comments.sort(key=lambda x: datetime.strptime(x[4], "%Y-%m-%d %H:%M:%S"))

# Écrire dans un fichier CSV
with open(output_file, "w", newline="", encoding="utf-8") as f:
    writer = csv.writer(f, delimiter='$')
    writer.writerow(["Post Title", "Post URL", "Author", "Comment", "Date"])
    writer.writerows(comments)

print(f"Commentaires exportés dans {output_file}")

Création d’un Token GitHub

Voici les étapes pour créer un token personnel GitHub avec l’autorisation pour gérer les discussions (comme les commentaires pour Giscus) :

  • Allez dans Settings > Developer Settings (général pas dans un dépôt) puis Personal access tokens > Tokens (classic).
  • Sélectionnez Generate new token > Generate new token (classic).
  • Donnez un nom au token : Giscus Discussions Token.
  • Définissez une durée d’expiration : choisissez une durée (par exemple 30 jours) ou No expiration pour un usage prolongé.
  • Sélectionnez les autorisations nécessaires :
    • public_repo : Accéder aux dépôts publics
    • read:discussion : Lire les discussions.
    • write:discussion : Ajouter ou modifier des discussions.
  • Générer et sauvegarder le token (par exemple, dans un gestionnaire de mots de passe)
  • Dans les paramètres de votre projet où Giscus est configuré, ajoutez le token comme variable d’environnement dans Settings > Secrets and variables > Actions avec le nom GH_TOKEN (ou un autre nom significatif) et collez le token.

Création des discussions GitHub

Voici un script Python qui utilise l’API graphql pour créer automatiquement toutes les discussions avec les titres correspondant au pathname (pour être raccord avec mapping: pathname dans _quarto.yml).

import os
import requests

# Configuration
GITHUB_TOKEN = "ghp_"  # Remplacez par votre token GitHub
REPO_OWNER = "kevinpolisano"  # Votre nom d'utilisateur GitHub
REPO_NAME = "blog"  # Nom du dépôt GitHub
CATEGORY_ID = "DIC_kwDOM9ReCM4CmGgm"  # ID de la catégorie des discussions
POSTS_DIR = "/posts/"  # Dossier contenant vos articles Quarto

GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"

# Fonction pour envoyer une requête GraphQL
def send_graphql_query(query, variables=None):
    headers = {
        "Authorization": f"Bearer {GITHUB_TOKEN}",
        "Content-Type": "application/json",
    }
    payload = {"query": query, "variables": variables}
    response = requests.post(GITHUB_GRAPHQL_URL, json=payload, headers=headers)
    response.raise_for_status()
    return response.json()

# Vérifie si une discussion existe déjà pour un post
def discussion_exists(title):
    query = """
    query($owner: String!, $name: String!) {
        repository(owner: $owner, name: $name) {
            discussions(first: 100) {
                nodes {
                    title
                }
            }
        }
    }
    """
    variables = {"owner": REPO_OWNER, "name": REPO_NAME}
    result = send_graphql_query(query, variables)
    discussions = result.get("data", {}).get("repository", {}).get("discussions", {}).get("nodes", [])

    return any(d["title"] == title for d in discussions)


def get_repository_id():
    query = """
    query($owner: String!, $name: String!) {
        repository(owner: $owner, name: $name) {
            id
        }
    }
    """
    variables = {"owner": REPO_OWNER, "name": REPO_NAME}
    result = send_graphql_query(query, variables)
    return result.get("data", {}).get("repository", {}).get("id", None)


# Crée une discussion pour un post donné
def create_discussion(title, repository_id):
    mutation = """
    mutation($title: String!, $body: String!, $categoryId: ID!, $repositoryId: ID!) {
        createDiscussion(input: {title: $title, body: $body, categoryId: $categoryId, repositoryId: $repositoryId}) {
            discussion {
                id
                title
                url
            }
        }
    }
    """
    variables = {
        "title": title,
        "body": "Discussion for this post",
        "categoryId": CATEGORY_ID,
        "repositoryId": repository_id
    }
    
    print(f"Tentative de création de la discussion pour : {title}")
    
    result = send_graphql_query(mutation, variables)
    print("Réponse GitHub :", result)  # Debugging

    if "errors" in result:
        print(f"Échec : {result['errors']}")
    else:
        discussion = result.get("data", {}).get("createDiscussion", {}).get("discussion", {})
        if discussion:
            print(f"Discussion créée : {discussion['title']} ({discussion['url']})")
        else:
            print(f"Échec de la création pour {title}")


# Parcours des fichiers posts/ et création des discussions
if __name__ == "__main__":
    repository_id = get_repository_id()
    if not repository_id:
        print("Impossible de récupérer l'ID du dépôt. Vérifie ton token et le nom du repo.")
        exit(1)

    for folder in os.listdir(POSTS_DIR):
        post_path = os.path.join(POSTS_DIR, folder)
        if os.path.isdir(post_path):  # Vérifie que c'est bien un dossier
            title = f"posts/{folder}/"
            if discussion_exists(title):
                print(f"Discussion déjà existante pour : {title}")
            else:
                print(f"Création de la discussion pour : {title}")
                create_discussion(title, repository_id)

À noter que j’ai du déjà faire apparaître la section commentaire en local (avec quarto preview) pour chaque post en défilant, afin que le fichier html contienne la portion de code suivante :

<script src="https://giscus.app/client.js" 
data-repo="kevinpolisano/blog" 
data-repo-id="R_kgDOM9ReCA" 
data-category="Announcements" 
data-category-id="DIC_kwDOM9ReCM4CmGgm" 
data-mapping="pathname" 
data-reactions-enabled="1" 
data-emit-metadata="0" 
data-input-position="bottom" 
data-theme="light" 
data-lang="fr" 
crossorigin="anonymous" 
data-loading="lazy" async="">
</script> 

Pour lister l’ensemble des discussions GitHub et afficher leur IDs correspondant j’utilise le script suivant :

import requests

# Configuration
GITHUB_TOKEN = "ghp_"  # Remplacez par votre token GitHub
REPO_OWNER = "kevinpolisano"  # Nom d'utilisateur ou organisation GitHub
REPO_NAME = "blog"  # Nom du dépôt GitHub
GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"

# Fonction pour envoyer une requête GraphQL
def send_graphql_query(query, variables=None):
    headers = {
        "Authorization": f"Bearer {GITHUB_TOKEN}",
        "Content-Type": "application/json",
    }
    payload = {"query": query, "variables": variables}
    response = requests.post(GITHUB_GRAPHQL_URL, json=payload, headers=headers)
    response.raise_for_status()
    return response.json()

# Requête pour récupérer les discussions avec categoryId
def fetch_discussions(repo_owner, repo_name):
    query = """
    query($owner: String!, $name: String!) {
        repository(owner: $owner, name: $name) {
            discussions(first: 50) {
                nodes {
                    id
                    title
                    url
                    category {
                        id
                        name
                    }
                }
            }
        }
    }
    """
    variables = {"owner": repo_owner, "name": repo_name}
    result = send_graphql_query(query, variables)
    return result.get("data", {}).get("repository", {}).get("discussions", {}).get("nodes", [])

# Exécuter la requête
if __name__ == "__main__":
    discussions = fetch_discussions(REPO_OWNER, REPO_NAME)
    for discussion in discussions:
        category_id = discussion["category"]["id"]
        category_name = discussion["category"]["name"]
        print(f"Title: {discussion['title']}")
        print(f"ID: {discussion['id']}")
        print(f"URL: {discussion['url']}")
        print(f"Category ID: {category_id} (Name: {category_name})")
        print("-" * 40)

Ajouter les commentaires existants

Parmi mes anciens billets, peu possédaient des commentaires, à l’exception de quelques uns qui en affichaient plus d’une centaine. Le script qui suit s’applique donc à un billet donné, mais la procédure est facilement automatisable sur tous les billets si vous avez conservé une correspondance entre le post_url de l’extraction de commentaires et le pathname du titre de la discussion.

import csv
import requests
import time
from datetime import datetime

# Configurations
GITHUB_TOKEN = "ghp_"  # À remplacer par votre TOKEN
REPO_OWNER = "kevinpolisano"
REPO_NAME = "blog"
DISCUSSION_ID = "D_kwDOM9ReCM4AfHpQ"
POST_TITLE = "Comprendre son bulletin de paie" # À remplacer par le nom du billet (ou le POST_URL)
CSV_FILE = "comments.csv" # Fichier stockant les commentaires
GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"

# Nombre maximum de tentatives par commentaire
MAX_RETRIES = 3
RETRY_DELAY = 5  # secondes

# Fonction pour envoyer une requête GraphQL
def send_graphql_query(query, variables=None):
    headers = {
        "Authorization": f"Bearer {GITHUB_TOKEN}",
        "Content-Type": "application/json",
    }
    payload = {"query": query, "variables": variables}

    for attempt in range(1, MAX_RETRIES + 1):
        try:
            response = requests.post(GITHUB_GRAPHQL_URL, json=payload, headers=headers)
            if response.status_code == 200:
                return response.json()
            else:
                print(f"Erreur {response.status_code} : {response.text} (tentative {attempt}/{MAX_RETRIES})")
            time.sleep(RETRY_DELAY)  # Attendre avant de réessayer
        except requests.RequestException as e:
            print(f"Erreur de connexion : {e} (tentative {attempt}/{MAX_RETRIES})")
            time.sleep(RETRY_DELAY)

    return None  # Échec après toutes les tentatives

# Fonction pour poster un commentaire
def post_comment(discussion_id, body):
    mutation = """
    mutation($discussionId: ID!, $body: String!) {
        addDiscussionComment(input: {discussionId: $discussionId, body: $body}) {
            comment {
                id
                body
            }
        }
    }
    """
    variables = {"discussionId": discussion_id, "body": body}
    result = send_graphql_query(mutation, variables)
    
    if result:
        return result.get("data", {}).get("addDiscussionComment", {}).get("comment")
    return None  # Échec

# Fonction pour convertir la date en toutes lettres (en français)
def convert_date(date_str):
    try:
        date_obj = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
        return date_obj.strftime('%d %B %Y à %Hh%M')
    except ValueError as e:
        print(f"Erreur de conversion de la date : {date_str} ({e})")
        return date_str  # Retourne la date brute si erreur

# Importation des commentaires
def import_comments(csv_file, post_title, discussion_id):
    print(f"Début de l'importation des commentaires pour : {post_title}")
    failed_comments = []  # Stocke les commentaires non importés

    try:
        with open(csv_file, "r", encoding="utf-8") as file:
            reader = csv.DictReader(file, delimiter='$')
            for row in reader:
                try:
                    if row.get("Post Title") == post_title:
                        comment = row.get("Comment")
                        author = row.get("Author", "Inconnu")
                        date = row.get("Date", "")
                        date = convert_date(date)

                        if comment:
                            formatted_comment = f"{comment}\n\n*Commenté par **{author}**, le {date}.*"
                            print(f"Importation : {formatted_comment[:50]}...")  # Affiche le début du commentaire
                            
                            result = post_comment(discussion_id, formatted_comment)
                            if result:
                                print(f"Commentaire importé avec succès (ID {result['id']})")
                            else:
                                print("Échec de l'importation, commentaire ajouté à la liste des échecs.")
                                failed_comments.append(row)
                        else:
                            print(" Aucun commentaire trouvé dans cette ligne.")
                    else:
                        print(f"Ignoré : {row.get('Post Title')}")

                except Exception as e:
                    print(f"Erreur sur une ligne : {e}")
                    failed_comments.append(row)

    except FileNotFoundError:
        print(f"Fichier introuvable : {csv_file}")
        return
    except Exception as e:
        print(f"Erreur de lecture du CSV : {e}")
        return

    # Sauvegarde des commentaires non importés
    if failed_comments:
        failed_file = "failed_comments.csv"
        with open(failed_file, "w", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=["Post Title", "Post URL", "Author", "Comment", "Date"], delimiter='$')
            writer.writeheader()
            writer.writerows(failed_comments)
        print(f"{len(failed_comments)} commentaires non importés. Sauvegardés dans {failed_file}.")

# Exécution
if __name__ == "__main__":
    import_comments(CSV_FILE, POST_TITLE, DISCUSSION_ID)

Parfois il y a un échec d’importation lorsqu’il y a beaucoup de commentaires, certainement du à un quota d’utilisation de l’API, dans ce cas les commentaires qui n’ont pas été importés sont sauvegardés dans un fichier failed_comments.csv, et il suffit de relancer le script sur ce fichier (à renseigner dans CSV_FILE) autant de fois que nécessaire.

Finaliser la migration

Redirection des URL

Dans le fichier .htaccess à la racine du serveur où j’hébergeais mon ancien blog (kevinpolisano.com), il faut temporairement effectuer des redirections des anciennes URL de mes billets vers les nouvelles, comme ceci par exemple :

Redirection 301 /analyse-des-arguments-de-sophie-cluzel-opposee-a-la-deconjugualisation-de-laah/ https://www.kevinpolisano.fr/posts/2021-03-07-analyse-des-arguments-de-sophie-cluzel-opposee-a-la-deconjugualisation-de-laah/

Et pour toutes les autres URL non spécifiées :

RedirectMatch 301 ^/(.*)$ https://kevinpolisano.fr/

Mise à jour dans Google Search Console

Vérification de propriété

  1. Depuis Google Search Console, télécharger le fichier googleXXXXXXXXXXXX.html.
  2. Placer ce fichier directement à la racine du dossier Quarto (là où se trouve index.qmd ou _quarto.yml).
  3. Ajouter une ligne dans _quarto.yml pour inclure le fichier dans le site généré :
resources:
  - googleXXXXXXXXXXXX.html
  1. Faire un build avec quarto render puis déployer avec git commit/push et vérifier que la page https://www.kevinpolisano.fr/googleXXXXXXXXXXXX.html est en ligne.
  2. Valider sur Google Search Console pour terminer la vérification de propriété.

Cette étape permet de vérifier que je suis bien propriétaire du domaine kevinpolisano.fr.

Sitemap

Le fichier sitemap.xml sert en continu à indiquer à Google quelles pages existent sur mon site, afin d’améliorer le référencement (SEO). Pour générer ce fichier (ainsi qu’un fichier robots.txt) il faut ajouter dans le fichier _quarto.yml :

website:
  site-url: https://www.kevinpolisano.fr
  page-navigation: true

puis recompiler, déployer, et vérifier que https://www.kevinpolisano.fr/sitemap.xml et https://www.kevinpolisano.fr/robots.txt sont accessibles.

Enfin dans Google Search Console dans le menu Sitemaps à gauche, ajouter sitemap.xml.

Google Analytics

  1. Créer un compte sur Google Analytics
  2. Créer une propriété
  3. Donner un nom (ex: “Blog Quarto”)
  4. Choisir le fuseau horaire et la devise
  5. Choisir Web comme plateforme.
  6. Indique l’URL du site : https://www.kevinpolisano.fr

On obtient un ID de mesure du type : G-XXXXXXXXXX que l’on ajoute à _quarto.yml :

website:
  title: "Blog de Kévin Polisano"
  site-url: https://www.kevinpolisano.fr
  page-navigation: true
  google-analytics: G-XXXXXXXXXX

Cette méthode ajoute automatiquement le script de tracking dans le <head> du site HTML.

Conclusion

La conversion de mon ancien blog WordPress en un site statique généré avec Quarto est maintenant terminée. À mes yeux un blog statique possède plusieurs avantages :

  • Performance du site : pas de base de données à interroger, temps de chargement plus court, moins de dépendances serveur…
  • Sécurité : pas de surface d’attaque serveur (thèmes ou plugins WordPress vulnérables)
  • Contrôle du contenu et portabilité : fichiers markdown lisibles à vie, indépendants de tout CMS; migration possible vers un autre générateur statique (Hugo, Jekyll…) et possibilité de rédiger localement sans dépendance à un service tiers; gestion facile des médias; structure logique des fichiers, qui sont versionnés avec git.
  • Automatisation et reproductibilité : intégration fluide avec GitHub Actions et GitHub Pages; insertion de code reproductible (R, Python, Julia, Bash) avec Quarto, parfait pour des billets scientifiques.
  • Gratuité et perenité : hébergé gratuitement sur GitHub Pages, seul le nom de domaine nécessite un achat si l’on en veut un customisable (kevinpolisano.fr plutôt que celui par défaut kevinpolisano.github.io).

La principale restriction réside dans la limite de stockage offerte par le dépôt Git (5 Go) et surtout le déploiement du site avec GitHub Pages (1 Go). Raison pour laquelle j’utilise deux dépôts GitHub : le premier qui héberge à proprement parlé le blog Quarto contenant les posts et les images; le second qui stocke des documents PDF annexes, et qui est également déployé avec GitHub Pages pour obtenir des URL plus jolies et des PDF visualisables dans le navigateur.

L’alternative aurait consisté à conserver mon hébergement chez o2switch et transférer avec GitHub Actions le site généré par Quarto via FTP. Cela a l’avantage d’offrir un espace de stockage bien plus conséquent, de centraliser le blog et les assets au même endroit et donc d’avoir une URL plus consistante avec le même nom de domaine. J’y reviendrai peut-être, mais pour le moment la solution proposée dans ce billet satisfait mes attentes pour un blogging du dimanche…