J'ai mentionné l'autre jour (et vous avez dû le remarquer en parcourant ces pages) que j'étais passé d'un blog Wordpress à un site internet fait de pages statiques. Le tout en utilisant l'outil Pelican.

Ce billet est le premier d'une série. Une série où je vais un peu raconter mon process, mes difficultés, les outils utilisés et comment je mets tout ça en place. Ce sera un peu décousu, parce que rien n'est encore figé. Y a pleins de choses encore bancales. D'autres qui seront révisées. D'autres en cours d'essai.

Si vous souhaitez échanger sur les sujets évoqués ici, n'hésitez pas à me contacter via les réseaux sociaux ou sur les plateformes où je traîne. J'échangerai publiquement avec plaisir.

De Wordpress au Markdown

Tout commence donc dans Wordpress. Parce que c'est là qu'étaient mes données et ce blog. Dans le panneau d'administration, une option permet d'exporter tout ou presque le contenu du blog sous la forme d'un fichier xml. Just do it.

Fenêtre d

L'objectif ensuite était de pouvoir transformer tout ça en fichiers markdown bien organisés, et d'avoir, si possible, récupéré l'ensemble des images et autres données hébergées. Heureusement, pas besoin de réinventer la roue, puisque que quelqu'un s'en est déjà chargé, et que tout est disponible sur github : https://github.com/lonekorean/wordpress-export-to-markdown.

J'utilise donc cet outil pour transformer tous mes billets et toutes mes pages au format Markdown. Cerise sur le gateau : le script s'occupe d'aller chercher l'ensemble des images, et je me retrouve avec une arborescence de fichiers qui correspond bien à ce que j'envisageais :

Arborescence des fichiers

Une première passe de nettoyage

J'ai tenté de passer tout ça à Pelican directement, mais autant le dire : c'était un échec monumental. Les raisons étaient multiples : format des métadonnées, parsing de certains éléments ayant gardé le html, options mal réglées pour la localisation des éléments.

Donc, quel est la solution ? Et bien, nettoyer tout ça ! Et c'est parti pour utiliser deux outils à ma disposition : regex et Python ! Oui, si la programmation et vous ça fait deux, ça va commencer à piquer...

[lang:python] [PreProcess]pelicanPreProcess.pydownload
import re
import os
import pelicanPreProcessLink as anteOp
import shutil

# Nettoyage des fichiers Markdown pour que ça colle
# Lancé à partir du fichier 
# S'occupe de prendre la sortie "brute" tirée du Wordpress, pour la basculer en format "obsidian"

##########################
## Gestion des metadata ##
##########################
def cleanup_metadata_billets(fichierIn):
    contenuFichier = ''
    with open(fichierIn, "r", encoding="utf8") as f:
        contenuFichier = f.read()

    # Titre
    contenuFichier = re.sub(r'^title: "(.*)"$', r'Title: \g<1>', contenuFichier, flags=re.MULTILINE)
    
    # Ajout du Slug, avec une hiérarchie YY/MM/dd/billets
    resSlug = re.search(r'.+[0-9][0-9][0-9][0-9]\\[0-9][0-9]\\(.+)\.md', fichierIn)
    
    # Date
    resDate = re.search(r'^date: "([0-9]+)-([0-9]+)-([0-9]+)"$', contenuFichier, flags=re.MULTILINE)
    slug = 'Slug: ' + resDate.group(1) + '/' + resDate.group(2) + '/' + resSlug.group(1)
    # Old name, to be redirected via pelican-redirect
    originalUrl = 'original_url: ' + resDate.group(1) + '/' + resDate.group(2) + '/' + resDate.group(3) + '/' + resSlug.group(1) + '.html'
    dateToReplace = '\ndate: "' + resDate.group(1) + '-' + resDate.group(2) + '-' + resDate.group(3) + '"'
    dateReplacing = r'Date: ' + resDate.group(1) + '-' + resDate.group(2) + '-' + resDate.group(3)
    contenuFichier = contenuFichier.replace(dateToReplace, '\n' + slug + '\n' + originalUrl + '\n' + dateReplacing, 1)

    # Inside Links
    contenuFichier = anteOp.correctInsideLinks(contenuFichier, '../../')

    # Category
    # Je ne gère pas de catégorie à proprement parler, mais je vais en créer une "Billets"
    if fichierIn.find('\post\\') != -1:
        contenuFichier = re.sub(r'^categories: \n(  - ".+"\n)+', r'Category: Billets\n', contenuFichier, flags=re.MULTILINE)
    
    with open(fichierIn, 'w',encoding="utf8") as fw:
        fw.write(contenuFichier)

def cleanup_metadata_pages(fichierIn):
    contenuFichier = ''
    with open(fichierIn, "r", encoding="utf8") as f:
        contenuFichier = f.read()

    # Titre
    contenuFichier = re.sub(r'^title: "(.*)"$', r'Title: \g<1>', contenuFichier, flags=re.MULTILINE)
    
    # Ajout du Slug, qui correspond au nom du fichier
    # Information de date est supprimée, car pas pertinente
    # Statut "caché" pour ne pas apparaître, par défaut, dans l'en-tête
    resSlug = re.search(r'.+page\\(.+)\.md', fichierIn)
    resDate = re.search(r'^date: "([0-9]+)-([0-9]+)-([0-9]+)"$', contenuFichier, flags=re.MULTILINE)
        
    # url: actu-6
    # save_as: actu-6.html
    saveAsUrl = 'url: ' + resSlug.group(1) + '\nsave_as: ' + resSlug.group(1) + '.html'
    dateToErase = '\ndate: "' + resDate.group(1) + '-' + resDate.group(2) + '-' + resDate.group(3) + '"'
    status = 'status: hidden'
    contenuFichier = contenuFichier.replace(dateToErase, '\n' + saveAsUrl + '\n' + status, 1)
    
    # Pas de redirection, on garde la même url
    
    # Inside Links
    contenuFichier = anteOp.correctInsideLinks(contenuFichier, './')

    # Category
    # Je ne gère pas de catégorie à proprement parler, mais je vais en créer une "Page"
    #if fichierIn.find('\post\\') != -1:
    #    contenuFichier = re.sub(r'^categories: \n(  - ".+"\n)+', r'Category: Pages\n', contenuFichier, flags=re.MULTILINE)
    
    with open(fichierIn, 'w',encoding="utf8") as fw:
        fw.write(contenuFichier)

## Suppression de tout ce qu'il y a dans outputCleaned en md
print("### Suppression des Markdown 'Cleaned' pour régénération")
for root, dirs, files in os.walk("node\outputCleaned"):
    for name in files:
        if name.endswith(".md"):
            os.remove(root + os.sep + name)

## Copie de tout ce qu'il y a (en md) depuis output vers outputCleaned
print("### Copie des Markdown 'Original' pour nettoyage")
for root, dirs, files in os.walk("node\output"):
    for name in files:
        if name.endswith(".md"):
            # Pour les billets, on garde la structure YYYY/MM
            if root.find('\post\\') != -1:
                shutil.copy(root + os.sep + name, root.replace('output', 'outputCleaned') + os.sep + name)
            # Pour les pages, on met tout dans le même répertoire
            if root.find('\page\\') != -1:
                shutil.copy(root + os.sep + name, "node\outputCleaned\page" + os.sep + name)


print("### Nettoyage des Markdown")
for root, dirs, files in os.walk("node\outputCleaned"):
    for name in files:
        if name.endswith(".md"):
            # Pour les billets
            if root.find('\post\\') != -1:
                cleanup_metadata_billets(root + os.sep + name)
            # Pour les pages, on met tout dans le même répertoire
            if root.endswith('\page'):
                cleanup_metadata_pages(root + os.sep + name)


## Post-processing (de outputCleaned/obsidian vers pelican/content)
## Réalisé dans un script à part

Je ne rentrerai pas dans le détail du script mais, en gros, il corrige du formatage sur le titre du billet, il rajoute un slug, une URL d'origine (pour des redirections), il met en place une Catégorie dédiée et pas mal de petits trucs.

Ce script est un peu à l'arrache parce qu'il a été mis en place au tout début du process, et il ne sera, sauf catastrophe, jamais relancé.

Une question d'images

Bon, ça marche pas mal tout ça quand je le passe dans Pelican. Par contre, je me retrouve avec un poids total assez monstrueux. Et tout ça, ça vient des images en multiples exemplaires dans les répertoires, avec différentes tailles parce que Wordpress a généré différentes versions pour la même image. Et parfois, des images ne sont même plus utilisées. Comment faire ? Et bien... Python à la rescousse !

[lang:python] [PreProcess]pelicanPreProcessImages.pydownload
import os
import re
from PIL import Image

def listAllExtensions(watchedDir: str):
    """returns a list of all the files extensions in a given directory"""
    lsExt = [ ]
    for root, dirs, files in os.walk(watchedDir):
        for name in files:
            for result in re.findall(r'.*\.([a-zA-Z10-9]+)', name):
                if result not in lsExt:
                    lsExt.append(result)
    return lsExt

def listAllImages(watchedDir: str):
    """returns a list of all the images referenced in a given directory"""
    lsImages = [ ]
    for root, dirs, files in os.walk(watchedDir):
        for name in files:
            if name.endswith(".md"):
                with open(root + os.sep + name, "r", encoding="utf8") as f:
                    fileContent = f.read()
                    for result in re.findall(r'\[.*?\]\(images\/(.*?\.(?:png|jpg|gif|jpeg|webp))\)', fileContent):
                        lsImages.append(result)
    return lsImages

def removeAllUnusedImages(watchedDir: str):
    print("### Cleaning up images by removing all the unused ones")
    for root, dirs, files in os.walk(watchedDir):
        for name in dirs:
            fullDirName = root + os.sep + name
            ## Working on a monthly-based directory
            if re.match('.*post\\\\[0-9]{4}\\\\[0-9]{2}$', fullDirName):
                lsImagesUsed = listAllImages(fullDirName)
                for rootImg, imgDirs, imgFiles in os.walk(fullDirName + os.sep + "images"):
                    for imageFile in imgFiles:
                        if imageFile not in lsImagesUsed:
                            os.remove(os.path.join(fullDirName, "images", imageFile))

def resizeAllImages(watchedDir: str):
    base_width = 600
    for root, dirs, files in os.walk(watchedDir):
        for name in dirs:
            fullDirName = root + os.sep + name
            ## Working on a monthly-based directory
            if re.match('.*post\\\\[0-9]{4}\\\\[0-9]{2}$', fullDirName):
                for rootImg, imgDirs, imgFiles in os.walk(fullDirName + os.sep + "images"):
                    for imageFile in imgFiles:
                        if imageFile.endswith(("png", "jpg", "jpeg")):
                            fullImageName = os.path.join(fullDirName, "images", imageFile)
                            img = Image.open(fullImageName)
                            if (img.size[0] > base_width):
                                wpercent = (base_width / float(img.size[0]))
                                hsize = int((float(img.size[1]) * float(wpercent)))
                                img = img.resize((base_width, hsize), Image.Resampling.LANCZOS)
                                img.save(fullImageName)
                                print("Resized : " + imageFile)


#removeAllUnusedImages("node\outputCleaned")
resizeAllImages("node\outputCleaned")

Là encore, n'attendez pas un script optimisé : c'est pour un tir en one-shot ! Surtout que j'ai ici quatre fonctions distinctes, dont deux d'analyse que j'ai lancé en amont pour identifier ce que j'avais à faire.

Ensuite, une fonction me permet de faire le nettoyage nécessaire en supprimant tous les fichiers non référencés. Puis un dernier script va rationaliser la taille des images (600 pixels max de largeur). Pourquoi ? Pourquoi pas ! Cela garde une qualité d'image correcte pour l'usage que j'en ai, et si besoin (ce script n'étant pas intégré au process final), je pourrais revenir sur les images après-coup.

Bon, on arrête là pour ce soir ? La prochaine fois, je parlerai un peu des débuts de la publication, de Pelican lui-même et de quelques scripts de "post" que j'ai mis en place.

Et pour finir, voici un script nécessaire pour le premier, qui nettoie un peu les liens des billets. Très utile pour les liens internes au blog.

[lang:python] [PreProcess]pelicanPreProcessLink.pydownload
import re

def correctInsideLinks(fileContent: str, suffix: str):
    """
    Get the content of the file, find the "inside links" (to other posts/pages), and create an inside link
    """

    newContent = fileContent
    #######################
    ## Images avec liens ##
    #rxFind = r'\[\!\[\]\(([^]]*?)\)]\((.*)\)'
    #rxReplace = '[![\g<2>](\g<1>)]'
    #newContent = re.sub(rxFind, rxReplace, fileContent)

    ###########
    ## Liens ##

    # Liens vers un billet de blog
    rxFind = r'\(https:\/\/www\.gulix\.fr\/blog\/([0-9]{4})\/([0-9]{2})\/([0-9]{2})\/(.*?)\/\)'
    rxReplace = r'(' + suffix + r'\g<1>/\g<2>/\g<4>)'
    newContent = re.sub(rxFind, rxReplace, newContent)

    # Liens vers une page de blog
    rxFind = r'\(https:\/\/www\.gulix\.fr\/blog\/(.*?)\/\)'
    rxReplace = r'(' + suffix + r'\g<1>)'
    newContent = re.sub(rxFind, rxReplace, newContent)


    # Les FigCaption
    rxFind = r'<figure>[\s\n]*(.*)[\s|\n]*<figcaption>[\s|\n]*(.*)[\s|\n]*<\/figcaption>[\s|\n]*<\/figure>'
    rxReplace = r'\g<1>\n::\g<2>'
    newContent = re.sub(rxFind, rxReplace, newContent)

    # Les <figure></figure>
    newContent = newContent.replace('<figure>', '')
    newContent = newContent.replace('</figure>', '')
    
    return newContent


# Quelques tests
print("Test sur un lien d'image")
original = '[![](images/IMG_20220213_145917.jpg)](https://www.gulix.fr/blog/face-au-titan/)'
print(correctInsideLinks(original, './'))

Tags

Contact