Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

Auditer le SEO de son site Hugo avec un outil Go maison : ce qu'il a trouvé

Ou : Comment visiter 561 pages sans sortir de chez soi

Un crawler SEO, dans l’imaginaire collectif, c’est un robot qui frappe à la porte de chaque page, attend la réponse du serveur et note ce qu’il voit. Celui que j’utilise sur ce site ne frappe à aucune porte. Il lit le site là où Hugo vient de le construire, sur le disque, fichier par fichier, et il croise ce qu’il trouve avec les sources Markdown. Le 12 octobre 2026, il a parcouru 561 pages en un peu moins de six secondes, compté 13 708 liens internes, n’en a trouvé aucun cassé, et a levé 806 signalements. Parmi eux, quatre étaient faux, et c’est la partie la plus intéressante de l’histoire, avec une seconde famille de faux positifs que la publication programmée de ce blog allait bientôt fabriquer.

Cet article ouvre le capot de l’outil, écrit en Go : comment il découvre les pages, comment il lit le HTML, comment il calcule un PageRank interne, et ce qu’il a réellement trouvé sur ce site. Puis il fait la liste de ce qu’il ne voit pas, parce qu’un outil d’audit qui ne connaît pas ses angles morts finit par en devenir un.

Crawler SEO en Go : auditer un site Hugo depuis le disque

Un crawler qui ne quitte pas le disque

L’outil s’appelle sobrement seo-audit, et sa commande principale tient en une ligne : seo-audit crawl --serve. Elle déroule un pipeline en huit étapes, plus trois facultatives : découverte du contenu, analyse du HTML, contrôle du sitemap, graphe des liens internes, extraction des mots-clés, classement de l’intention de recherche, détection des problèmes, puis calcul des scores et du PageRank. Viennent ensuite, si les jetons sont disponibles, l’interrogation de Search Console et l’inspection d’URL, et enfin la lecture des journaux du serveur. Le résultat atterrit dans une base SQLite, et l’option --serve ouvre une interface web sur le port 8090 pour l’explorer.

La découverte se fait en deux passes. La première parcourt content/, lit le frontmatter YAML de chaque fichier Markdown, écarte les brouillons, et déduit l’URL et le fichier HTML attendu. La seconde parcourt public/ à la recherche des index.html qu’aucun fichier Markdown n’explique : pages de pagination, listes de tags, pages de catégories. Hugo les fabrique tout seul, elles sont servies, donc elles comptent.

Il faut juste éviter un piège. Hugo génère aussi des alias, de minuscules pages qui ne font que rediriger ailleurs avec une balise meta refresh. Les compter comme des pages ferait remonter une centaine de pages « vides » dans l’audit. L’outil les reconnaît à leur taille et à leur contenu :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// isAliasPage reports whether an HTML file is a Hugo-generated meta-refresh alias.
// Such files are small and contain a <meta http-equiv="refresh"> in the head.
func isAliasPage(path string) bool {
	info, err := os.Stat(path)
	if err != nil || info.Size() > 4096 {
		return false
	}
	data, err := os.ReadFile(path)
	if err != nil {
		return false
	}
	// Cheap substring check: alias pages are always tiny and match this pattern.
	return strings.Contains(strings.ToLower(string(data)), `http-equiv="refresh"`) ||
		strings.Contains(strings.ToLower(string(data)), `http-equiv=refresh`)
}

La seconde comparaison n’est pas une redite. Hugo minifie le HTML et retire les guillemets autour des valeurs d’attribut quand il le peut : http-equiv=refresh est du HTML valide, et une recherche limitée à la forme avec guillemets ne le verrait pas. Sur le build du 12 octobre, 662 fichiers index.html existaient, et 101 étaient des alias. Il reste 561 pages : 486 venues d’un fichier Markdown, 75 fabriquées par Hugo.

Lire le disque plutôt que le réseau a deux avantages concrets. Le premier est la vitesse : pas de serveur à lancer, pas de latence, aucune politesse à respecter envers un hébergeur. Le second, plus subtil, est l’accès aux sources. Un crawler HTTP voit la balise <title> ; celui-ci voit aussi le champ metatitle du frontmatter dont elle est issue, et peut dire d’où vient un problème, pas seulement qu’il existe.

Lire le HTML comme un arbre, pas comme une chaîne

Pour le HTML, l’outil s’appuie sur golang.org/x/net/html, que sa documentation présente comme un analyseur conforme à HTML51. Il construit l’arbre du document, puis une fonction récursive le parcourt en notant au passage ce qui intéresse le référencement : le titre, les H1, la meta description, la balise canonique, les images sans texte alternatif, les liens, et la présence de données structurées JSON-LD.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
case "img", "amp-img":
	imgCount++
	hasAlt := false
	for _, a := range n.Attr {
		if a.Key == "alt" && strings.TrimSpace(a.Val) != "" {
			hasAlt = true
			break
		}
	}
	if !hasAlt {
		imgNoAlt++
	}

Le détail qui trahit le contexte est dans la première ligne. Ce site est en AMP, et AMP remplace la balise <img> par son composant <amp-img>2. Un analyseur générique ne compterait aucune image sur la plupart des pages, et jugerait donc qu’aucune ne manque de texte alternatif. C’est le genre de faux négatif qu’aucun tableau de bord ne signale : tout est vert, parce que rien n’a été mesuré.

Le parcours garde quelques drapeaux d’état, « je suis dans le titre », « je suis dans un H1 », « je suis dans un script », pour savoir à quoi rattacher chaque nœud de texte. Le texte d’un script n’est pas du contenu, le texte d’un script JSON-LD est des données structurées, et le texte du corps sert ensuite au comptage des mots et à l’extraction des mots-clés par TF-IDF, la vieille pondération qui fait ressortir les termes fréquents dans une page et rares dans les autres.

Tout cela tourne sans une goroutine. L’outil traite les pages une par une, et c’est très bien ainsi : à six secondes pour le site entier, paralléliser n’aurait apporté que des occasions de courses de données, sans gain qu’un humain puisse percevoir.

Le PageRank, version maison

Chaque lien <a> rencontré devient une entrée du graphe, sans dédoublonnage : un lien vers la page de contact présent dans le pied de page et dans le menu mobile compte deux fois. Le commentaire du code explique pourquoi, et l’argument tient : Google ne compte qu’un vote par paire de pages, mais l’audit veut voir chaque occurrence pour repérer, par exemple, un lien nofollow dans le pied de page doublé d’un lien normal dans la barre latérale. Un lien est « cassé » quand sa cible ne figure pas parmi les pages découvertes. Sur le build du 12 octobre : 13 708 liens internes, dont 3 922 en nofollow, et zéro cassé.

Le PageRank, lui, ne garde qu’un lien par paire et ignore les nofollow. C’est l’algorithme du papier fondateur de Brin et Page, avec le même facteur d’amortissement : « We usually set d to 0.85 »3.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
for _, p := range pages {
	// Base: teleport + sink redistribution
	r := (1-dampingFactor)/float64(n) + dampingFactor*sinkRank/float64(n)

	// Contributions from pages linking here
	for _, srcID := range inbound[p.ID] {
		r += dampingFactor * rank[srcID] / float64(outCount[srcID])
	}

	newRank[p.ID] = r
	if diff := math.Abs(r - rank[p.ID]); diff > maxDiff {
		maxDiff = diff
	}
}

Chaque page reçoit une part fixe, plus ce que lui transmettent les pages qui pointent vers elle, chacune divisant son propre rang par son nombre de liens sortants. Les pages sans lien sortant, les « puits », ne gardent pas leur rang pour elles : il est redistribué à tout le monde, sinon il s’évaporerait à chaque itération. Le calcul s’arrête quand plus aucune page ne bouge de plus d’un dix-millième, ou après cent itérations. Le résultat est ramené sur une échelle de 0 à 100, la page la plus forte valant 100.

Ce chiffre ne prédit rien du classement dans Google, qui ne voit pas que mes liens internes. Il mesure autre chose, et c’est ce qu’il a trouvé de plus utile.

Ce que l’audit a trouvé chez moi

Commençons par les chiffres bruts du 12 octobre.

ProblèmeGravitéPages
Meta description de plus de 170 caractèresavertissement311
Titre de plus de 70 caractèresavertissement190
Titre de moins de 25 caractèresavertissement114
Page absente du sitemapavertissement76
Moins de 300 motsinformation74
Page orphelineavertissement14
Meta description dupliquéeavertissement13
Titre dupliquéavertissement11
Meta description de moins de 70 caractèresavertissement3
Erreur (titre, H1, canonique ou description absents, lien cassé)erreur0

Zéro erreur, 732 avertissements, 74 informations, un score moyen de 93 sur 100, et 130 pages à 100. Les avertissements méritent quand même un tri, parce que tous ne pèsent pas pareil.

Les deux plus gros postes, titres et descriptions trop longs, reposent sur des seuils que Google ne publie pas. Sa documentation dit l’inverse : il n’y a pas de limite de longueur, ni pour la balise <title>, ni pour la meta description, et l’affichage est simplement tronqué selon la largeur de l’écran4. Les seuils de l’outil, 70 et 170 caractères, sont des repères pour savoir ce qui risque d’être coupé, pas des fautes. Les pages de guides signalées tournent d’ailleurs autour de 171 à 176 caractères : un dépassement d’une poignée de lettres, que je préfère connaître, sans en faire une urgence.

Les titres trop courts viennent presque tous des pages de tags, dont le titre est le nom du tag : « go », « sql ». Les doublons sont du même tonneau. Les pages de liste des tags, de leur pagination, des catégories et des auteurs partagent une même meta description, celle que hugo.toml définit par défaut pour le site. C’est sans gravité pour des pages que personne ne cherche, et c’est typiquement le genre de détail qu’on ne voit qu’avec un outil.

La trouvaille la plus utile n’est pas dans le tableau. Elle est dans le PageRank des articles. Les quatre plus récents se situent entre 9,8 et 16,2 sur 100, quand les plus anciens tombent à 1,2. La raison est mécanique : la page d’accueil pointe vers les trois derniers articles, et la première page du blog vers les neuf derniers. Ces deux pages valent 99. Un article perd ces deux liens en vieillissant, et ne garde que ceux des pages de pagination et des tags, qui pèsent peu. L’article qui ouvre la série sur les magic links, censé servir de point d’entrée à quatre autres, plafonne à 1,3. Avec un article par jour, un billet quitte désormais la page d’accueil en trois jours. Le maillage entre articles cesse d’être une politesse, il devient la seule chose qui les garde à flot.

Le PageRank interne coule depuis l'accueil : plus un article vieillit, plus il s'éloigne de la source.

Quatre faux positifs, un seul caractère

Parmi les 76 pages « absentes du sitemap », la plupart sont des pages de pagination, /blog/page/2/ ou /guides/page/17/, que Hugo n’inscrit pas dans son sitemap. L’avertissement est exact, et sans conséquence. Mais quatre lignes m’ont arrêté : /categories/développement-web/, /categories/développement-backend/, /categories/sécurité-informatique/ et /categories/expérience-professionnelle/. Quatre pages qui figurent bel et bien dans le sitemap.

Elles y figurent, mais encodées. Le protocole des sitemaps exige des URL échappées selon la norme des URI, et donne l’exemple d’un ü devenu %C3%BC5. Hugo écrit donc /categories/d%C3%A9veloppement-web/. De son côté, l’outil décode les liens qu’il trouve dans le HTML, et le commentaire du code cite même l’exemple qui a motivé ce choix :

1
2
3
4
5
// URL-decode (ex: /tags/c%C3%A9gep/ -> /tags/cégep/) pour matcher avec
// les paths stockés en DB (Hugo garde les accents dans le path).
if decoded, err := url.PathUnescape(href); err == nil {
	href = decoded
}

La fonction qui normalise les URL du sitemap, elle, n’a pas reçu ces trois lignes. D’un côté développement, de l’autre d%C3%A9veloppement : la comparaison échoue, et la page est déclarée absente. Le bogue est invisible sur un site en anglais, et sur toutes les URL sans accent, soit la quasi-totalité de ce site. Il ne se montre que sur les quatre catégories qui ont eu l’audace d’être françaises.

Je l’ai vérifié par un test isolé, avec Go 1.27.1, qui reprend les deux normalisations et la correction :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func TestAccentedCategory(t *testing.T) {
	page := fromLink("/categories/d%C3%A9veloppement-web/") // what the page graph stores
	loc := base + "/categories/d%C3%A9veloppement-web/"       // what Hugo writes in sitemap.xml

	if got := fromSitemap(loc); got == page {
		t.Fatalf("expected the mismatch, got %q on both sides", got)
	}
	if got := fromSitemapFixed(loc); got != page {
		t.Fatalf("fixed: %q != %q", got, page)
	}
}

Le test passe : la version actuelle compare deux chaînes différentes, la version corrigée les réconcilie. La leçon dépasse le SEO. Quand deux sources doivent être comparées, elles doivent passer par la même normalisation, écrite une seule fois. Deux fonctions qui font presque la même chose finissent toujours par diverger, et c’est rarement sur le cas que l’on teste.

Ce qu’il ne voit pas

Lire le disque a un prix, et il faut le connaître pour ne pas confondre « aucun problème trouvé » avec « aucun problème ».

L’outil ne voit pas le serveur. Les codes de réponse HTTP, les redirections, les en-têtes, le fichier robots.txt : rien de tout cela n’existe dans public/. Les redirections de mon raccourcisseur d’URL vivent dans la configuration de Caddy, et l’audit ne les rencontrera jamais. Il ne lit pas non plus la balise <meta name="robots">. Les 38 pages de pagination du hub des guides sont signalées absentes du sitemap, ce qui est vrai, mais l’outil ne peut pas dire si elles sont indexables. Elles le sont : pas de noindex, et une balise canonique qui pointe sur elles-mêmes. Une réflexion sur la navigation des guides, encore au stade de l’étude, envisage d’ailleurs de les supprimer.

Il ne vérifie pas les liens externes, qu’un autre script contrôle avant chaque publication, ni les performances, que la mesure faite pour l’article sur AMP a montrées moins flatteuses que ce tableau de 93 sur 100. Enfin, il ne connaît pas le calendrier. Il écarte les brouillons, mais pas les articles datés dans le futur, que Hugo ne génère pas encore. J’ai ajouté un faux article programmé au 31 décembre dans une copie du site : l’outil l’a compté comme une page, a cherché son HTML, ne l’a pas trouvé, et l’a déclaré sans H1 et sans balise canonique. Deux erreurs et quatre avertissements pour une page qui n’existe pas encore. Depuis que ce blog publie un article par jour, rédigé à l’avance, la semaine programmée produit donc à chaque audit une petite file de fausses erreurs. Il suffirait d’ignorer les fichiers sans HTML, ou de lire la date.

C’est pour combler une partie de ces angles morts que les dernières étapes interrogent Search Console. L’API d’inspection d’URL dit, page par page, ce que Google a réellement vu : l’état d’indexation, la canonique qu’il a choisie, la date de son dernier passage. L’outil local dit ce que je publie, Google dit ce qu’il en a retenu, et l’écart entre les deux est exactement ce qu’un audit doit chercher.

DevOps Dave : 806 problèmes. On arrête tout et on corrige.

Security Sarah : Combien d’erreurs ?

DevOps Dave : Zéro. Mais 806 signalements.

Security Sarah : Dont 311 descriptions trop longues d’après un seuil que Google ne publie pas, et quatre pages absentes d’un sitemap où elles figurent.

DevOps Dave : Donc l’outil ment ?

Security Sarah : Non. Il te donne une liste. C’est toi qui décides lesquelles sont des problèmes.

Un outil d’audit ne remplace pas le jugement, il lui donne de quoi travailler. Le mien a passé six secondes à lire 561 pages et m’a appris deux choses que je n’aurais pas trouvées à l’œil : mes articles s’enfoncent dans le site à mesure qu’ils vieillissent, et une fonction de normalisation avait oublié que le français a des accents. Il n’a fait aucune requête HTTP pour ça. Il a simplement lu ce que j’avais écrit, plus attentivement que moi.



  1. La documentation du paquet golang.org/x/net/html le présente ainsi : « Package html implements an HTML5-compliant tokenizer and parser ». Le stockage, lui, repose sur modernc.org/sqlite, « a CGo-free port of the C SQLite3 library », ce qui permet de compiler l’outil en un seul binaire sans compilateur C. ↩︎

  2. La documentation du composant amp-img explique que AMP remplace la balise HTML <img> par ce composant, qui gère lui-même le chargement et la mise en page. Les raisons pour lesquelles ce site reste en AMP sont dans l’article qui lui est consacré. ↩︎

  3. Le papier de Sergey Brin et Lawrence Page, « The Anatomy of a Large-Scale Hypertextual Web Search Engine », publié en 1998, décrit d comme un facteur d’amortissement compris entre 0 et 1, puis précise : « We usually set d to 0.85 ». ↩︎

  4. Pour le titre, la documentation de Google sur les liens de titre écrit qu’il n’y a pas de limite à la longueur d’une balise <title>, mais que le lien de titre est tronqué dans les résultats « as needed, typically to fit the device width ». Pour la description, la page sur les extraits dit la même chose : « There’s no limit on how long a meta description can be, but the snippet is truncated in Google Search results as needed, typically to fit the device width. » ↩︎

  5. Le protocole des sitemaps exige que les URL respectent la norme RFC 3986 des URI et soient « URL-escaped and encoded for readability by the web server ». Son exemple transforme un ü en %C3%BC en UTF-8. Hugo applique la règle à la lettre, et c’est l’outil qui l’avait oubliée d’un côté. ↩︎