L'obfuscation des liens souvent demandée pour l'optimisation SEO pose de sérieux problèmes pour l'accessibilité.
Le remplacement de balises de liens par des span, button ou autres joyeusetés approximatives bloque des utilisateurs car les interactions ne sont plus gérées.
Exemples parfaits de ce qu'IL NE FAUT PAS FAIRE.
Par exemple, un lien, même s'il n'est pas codé avec un attribut target="_blank", doit pouvoir s'ouvrir dans une nouvelle fenêtre (ou nouvel onglet) à la demande d'un utilisateur.
Pour satisfaire tout le monde, une solution simple à mettre en œuvre est possible.
Au-delà des besoins liés au référencement, c'est assez pratique pour masquer aux robots malveillants un numéro de téléphone ou une adresse de courriel sur une page.
Tout en restant parfaitement conforme pour l'accessibilité !
Le concept
Déposer des éléments sans sémantique particulière, un span par exemple.
Sur cet élément, on va poser un attribut data-obscure avec une valeur JSON.
Les valeurs obligatoires sont :
labelqui donnera le libellé du lien ;urlqui fournit la cible du lien.
Valeurs possibles : une url en clair mais aussi une url encodée.
Encodages
PHP
- url encodée avec
rawurlencode(URI) - url encodée avec
base64_encode(rawurlencode(URI)), plus "masqué"
Java
Avec URLEncoder.encode() et Base64.getEncoder().encodeToString()
import java.net.URLEncoder;
import java.util.Base64;
public class Encoder {
public static String encodeUrlBase64(String input) {
try {
String urlEncoded = URLEncoder.encode(input, "UTF-8"); // Encodage URL
return Base64.getEncoder().encodeToString(urlEncoded.getBytes()); // Encodage Base64
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
public static void main(String[] args) {
String encoded = encodeUrlBase64("https://www.example.com/?search=search content");
System.out.println(encoded);
}
}
Vue 3 (Javascript)
Avec encodeURIComponent() et btoa()
function encodeUrlBase64(input) {
return btoa(encodeURIComponent(input));
}
// Exemple
const encoded = encodeUrlBase64("https://www.example.com/?search=Bonjour");
Options
Les valeurs optionnelles du JSON data-obscure :
targetqui permet d'ouvrir le lien dans une nouvelle fenêtre. A renseigner par_blank,_parentou_top,_selfne servant à rien puisque c'est la valeur par défaut pour les liens.relpour ajouter des options commealternate,noopener,noreferrer, etc. ;position: par défautafterbegin. Remplacer par"position": "afterend"pour placer le lien après le contenu (un pictogramme par exemple) ;css: permet d'ajouter une ou des classes CSS sur le lien ;title: ajoute unaria-labelsur le lien généré, histoire d'avoir des choses plus explicites (voir les liens de contact dans le pied de page) ;replace: mis àtrueremplace le contenu (utile pour éviter les « sauts » de layout).
Au chargement de la page, les éléments ne contiennent qu'un espace insécable pour éviter les sauts de page, ou un texte pouvant être remplacé. Ou rien, c'est vous qui voyez.
Le script est facilement adaptable pour ajouter d'autres attributs, si nécessaire.
Pour ce qui est d'une éventuelle mise en forme spécifique, il suffit de cibler les éléments avec un sélecteur de style comme [data-obscure] > a { /* propriétés */ }, ce qui permet de s'affranchir de tout autre classe.
Le fonctionnement
Quand la page est chargée, elle n'affiche rien d'autre que des éléments vides, ou un texte placeholder, ou un pictogramme, ou autre.
Pour l'activation des liens « réels », il y a 3 options :
- Dès que le curseur du terminal se déplace, les liens sont construits à la volée ;
- A la navigation clavier, dès que le document reçoit le
focus, les liens sont construits ; - Au
scroll(défilement) de la page.
Comme un explorateur de site n'exécute aucune de ces 3 actions, les éléments sont inchangés.
Pour les utilisateurs, c'est totalement transparent.
Exemples
Un lien
Le lien ouvre une nouvelle fenêtre et contient un texte remplacé.
———————
<span data-nosnippet data-obscure='{
"label": "Un lien de base",
"url": "https://www.kortic.com",
"target": "_blank",
"replace": true}'>———————</span>
Un lien téléphone avec encodage
Le paramètre "title" ajoute un aria-label explicite au lien.
<span data-nosnippet data-obscure='{
"label": "Téléphoner",
"url": "dGVsJTNBJTJCMzMwMTIzNDU2Nzgw",
"title": "Cliquer pour appeler le n°",
"position": "afterend"}'>
<svg class="svg-symbol margin-right-5 vertical-align-sub" role="img" aria-label="Téléphone" height="20" width="20"><use href="#icon-phone"/></svg>
</span>
Un lien mail
<span data-nosnippet data-obscure='{
"label":"Envoyer un courriel",
"url":"mailto:bonjour@example.com",
"replace": true}'>Lorem ipsum dolore</span>
Le script
Utilitaire (de fainéant) qui permet d'ajouter des attributs par paquet dans une seule fonction.
Utile pour éviter de faire des setAttribute(attr, value) à-à-à la queue leu-leu (la chanson dans la tête maintenant ?).
Ça risque de servir.
if (!Element.prototype.setAttributes) {
Element.prototype.setAttributes = function (object) {
Object.keys(object).forEach((property) => {
object[property] !== null && this.setAttribute(property, object[property]);
});
};
}
Et la fonction.
Code à placer en bas de page, par exemple.
Taille brute : 3,09 kilo-octets (~1,27 kilo-octets gzip)
/**
* Source Kortic https://www.kortic.com/le-blog-kortic/2025/01/23/obfuscation-des-liens-et-accessibilite.html
* licence Creative Commons CC BY-NC-SA 4.0
* https://creativecommons.org/licenses/by-nc-sa/4.0/deed.fr
* Attribution, partage dans les mêmes conditions, pas d'utilisation commerciale
*/
/**
* Obfuscation de liens pour SEO avec préservation de l'accessibilité
*
* Convertit <span data-obscure="..."> en <a> au premier événement utilisateur.
*
* JSON attendu :
* {
* "label": "Texte", // Obligatoire
* "url": "https://...", // Obligatoire (clair, rawurlencode ou base64)
* "target": "_blank", // Optionnel
* "rel": "noopener", // Optionnel
* "css": "classe", // Optionnel
* "title": "Description", // Optionnel (aria-label)
* "position": "afterend", // Optionnel (beforebegin|afterbegin|beforeend|afterend)
* "replace": true // Optionnel (remplace le contenu)
* }
*/
(() => {
'use strict';
const elements = document.querySelectorAll('[data-obscure]');
if (!elements.length) return;
const events = window.matchMedia("(any-hover:hover)").matches
? ['mouseover', 'click', 'focusin', 'scroll']
: ['click', 'focusin', 'scroll'];
const isBase64 = (s) => s.length % 4 === 0 && /^[A-Za-z0-9+/=]+$/.test(s) && (() => {
try {
return btoa(atob(s)) === s;
} catch {
return false;
}
})();
const obfuscation = () => {
elements.forEach((el) => {
const debug = false;
try {
const data = JSON.parse(el.dataset.obscure),
anchor = document.createElement('a'),
href = (() => {
try {
return data.url.includes('%') ? decodeURIComponent(data.url) :
isBase64(data.url) ? decodeURIComponent(atob(data.url)) : data.url;
} catch (e) {
console.warn('Échec décodage:', e, data.url);
return data.url;
}
})(),
replace = data.replace || false;
anchor.setAttributes({
href: href,
target: data.target || null,
rel: data.rel || null,
class: data.css || null,
'aria-label': data.title || null,
});
if (replace) {
el.textContent = '';
el.appendChild(anchor);
anchor.textContent = data.label;
} else {
anchor.insertAdjacentText('afterbegin', data.label);
el.insertAdjacentElement(
/^(beforebegin|afterbegin|beforeend|afterend)$/.test(data.position) ? data.position : 'afterbegin',
anchor
);
}
!debug && el.removeAttribute('data-obscure');
// Fonction callback propriétaire pour gestion des liens s'ouvrant dans une nouvelle fenêtre
// (pictogramme, aria-label additionnel, etc.)
if (data.target === '_blank') {
anchor.classList.add('js-blank');
toggleBlank?.(el);
}
} catch (e) {
console.error('Erreur data-obscure:', e, el);
}
});
// Nettoyage des listeners après traitement de tous les éléments
events.forEach(e => document.removeEventListener(e, obfuscation));
};
events.forEach(e => {
document.addEventListener(e, obfuscation, {passive: e === 'scroll', once: true});
});
})();
Télécharger kortic-obfuscation.js.js
Et c'est tout !
C'est d'ailleurs ce qui vient de se produire sur cette page, tout en bas et sur les exemples…
Helper PHP
Ça peut toujours servir !
Un utilitaire PHP pour générer les blocs d'obfuscation. Tout est expliqué dans la classe.
<?php
/**
* Gestion obfuscation de liens pour SEO avec préservation de l'accessibilité
*
* Génère des éléments HTML obfusqués qui seront convertis en liens accessibles
* par JavaScript au premier événement utilisateur (hover, focus, clic, scroll).
*
*/
class Obfuscation
{
/**
* Modes d'encodage disponibles
*/
const ENCODE_NONE = 'none';
const ENCODE_BASE64 = 'base64';
/**
* Options par défaut
*/
private static array $defaults = [
'target' => null,
'rel' => null,
'css' => null,
'title' => null,
'position' => null,
'replace' => false,
'encode' => self::ENCODE_BASE64,
'placeholder' => '',
'nosnippet' => true,
'tag' => 'span'
];
/**
* Génère un élément obfusqué
*
* @param string $label Texte du lien (obligatoire)
* @param string $url URL cible (obligatoire)
* @param array $options Options de configuration
* @return string HTML de l'élément avec data-obscure
* @throws InvalidArgumentException Si label ou url vide
*
* @example
* echo Obfuscation::link('Mon site', 'https://example.com');
* echo Obfuscation::link('Google', 'https://google.com', [
* 'target' => '_blank',
* 'encode' => Obfuscation::ENCODE_BASE64
* ]);
* echo Obfuscation::link('Article', 'https://example.com', ['tag' => 'div']);
*/
public static function link(string $label, string $url, array $options = []): string
{
if (empty($label) || empty($url)) {
throw new InvalidArgumentException('Label and URL cannot be empty');
}
$opts = array_merge(self::$defaults, $options);
// Encodage de l'URL selon la méthode choisie
$encodedUrl = self::encodeUrl($url, $opts['encode']);
// Construction du JSON data-obscure
$data = array_filter([
'label' => $label,
'url' => $encodedUrl,
'target' => $opts['target'],
'rel' => $opts['rel'],
'css' => $opts['css'],
'title' => $opts['title'],
'position' => $opts['position'],
'replace' => $opts['replace'] ? true : null
], fn($v) => $v !== null);
$json = htmlspecialchars(
json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
ENT_QUOTES,
'UTF-8'
);
$nosnippet = $opts['nosnippet'] ? ' data-nosnippet' : '';
$placeholder = htmlspecialchars($opts['placeholder'], ENT_QUOTES, 'UTF-8');
$tag = htmlspecialchars($opts['tag'], ENT_QUOTES, 'UTF-8');
return sprintf(
'<%s%s data-obscure=\'%s\'>%s</%s>',
$tag,
$nosnippet,
$json,
$placeholder,
$tag
);
}
/**
* Génère un lien email obfusqué
*
* @param string $email Adresse email
* @param string|null $label Texte du lien (défaut : l'adresse email)
* @param array $options Options supplémentaires
* @return string HTML obfusqué
*
* @example
* echo Obfuscation::email('contact@example.com');
* echo Obfuscation::email('hello@site.com', 'Nous contacter');
*/
public static function email(string $email, ?string $label = null, array $options = []): string
{
return self::link(
$label ?: $email,
'mailto:' . $email,
array_merge(['encode' => self::ENCODE_BASE64, 'replace' => true], $options)
);
}
/**
* Génère un lien téléphone obfusqué
*
* @param string $phone Numéro de téléphone
* @param string|null $label Texte du lien (défaut : le n° de téléphone)
* @param array $options Options supplémentaires
* @return string HTML obfusqué
*
* @example
* echo Obfuscation::phone('+33123456789');
* echo Obfuscation::phone('+33123456789', 'Appeler', ['title' => 'Cliquer pour appeler']);
*/
public static function phone(string $phone, ?string $label = null, array $options = []): string
{
// Garde uniquement les chiffres et le + pour l'indicatif international
$cleanPhone = preg_replace('/[^\d+]/', '', $phone);
return self::link(
$label ?: $phone,
'tel:' . $cleanPhone,
array_merge(['encode' => self::ENCODE_BASE64], $options)
);
}
/**
* Génère un lien SMS obfusqué
*
* @param string $phone Numéro de téléphone
* @param string|null $label Texte du lien (défaut: "Envoyer un SMS")
* @param string|null $body Message pré-rempli (optionnel)
* @param array $options Options supplémentaires
* @return string HTML obfusqué
*
* @example
* echo Obfuscation::sms('+33123456789');
* echo Obfuscation::sms('+33123456789', 'SMS', 'Bonjour, je souhaite...');
*/
public static function sms(string $phone, ?string $label = null, ?string $body = null, array $options = []): string
{
$cleanPhone = preg_replace('/[^\d+]/', '', $phone);
// Utilise & pour compatibilité iOS/Android
$url = 'sms:' . $cleanPhone . ($body ? '&body=' . rawurlencode($body) : '');
return self::link(
$label ?: 'Envoyer un SMS',
$url,
array_merge(['encode' => self::ENCODE_BASE64], $options)
);
}
/**
* Génère un lien WhatsApp obfusqué
*
* Utilise l'API wa.me pour ouvrir une conversation WhatsApp.
* Le numéro doit être au format international.
*
* @param string $phone Numéro de téléphone (format international)
* @param string|null $label Texte du lien (défaut: "WhatsApp")
* @param string|null $message Message pré-rempli (optionnel)
* @param array $options Options supplémentaires
* @return string HTML obfusqué
*
* @example
* echo Obfuscation::whatsapp('+33123456789');
* echo Obfuscation::whatsapp('33123456789', 'Nous contacter');
* echo Obfuscation::whatsapp('+33 1 23 45 67 89', 'WhatsApp', 'Bonjour');
*/
public static function whatsapp(string $phone, ?string $label = null, ?string $message = null, array $options = []): string
{
// wa.me n'accepte pas le +, uniquement les chiffres
$cleanPhone = preg_replace('/[^\d]/', '', $phone);
$url = 'https://wa.me/' . $cleanPhone . ($message ? '?text=' . rawurlencode($message) : '');
return self::link(
$label ?: 'WhatsApp',
$url,
array_merge(['encode' => self::ENCODE_BASE64, 'target' => '_blank'], $options)
);
}
/**
* Génère un lien externe obfusqué (target="_blank" avec rel sécurisé)
*
* @param string $label Texte du lien
* @param string $url URL cible
* @param array $options Options supplémentaires
* @return string HTML obfusqué
*
* @example
* echo Obfuscation::external('Google', 'https://google.com');
*/
public static function external(string $label, string $url, array $options = []): string
{
return self::link(
$label,
$url,
array_merge([
'target' => '_blank',
'rel' => 'noopener noreferrer',
'encode' => self::ENCODE_BASE64
], $options)
);
}
/**
* Encode une URL selon la méthode spécifiée
*
* @param string $url URL à encoder
* @param string $method Méthode d'encodage (none, base64)
* @return string URL encodée
*/
private static function encodeUrl(string $url, string $method): string
{
return match($method) {
self::ENCODE_BASE64 => base64_encode($url),
default => $url
};
}
/**
* Génère plusieurs liens obfusqués d'un coup
*
* @param array $links Tableau de liens ['label' => 'url'] ou [['label' => '', 'url' => '', 'options' => []]]
* @param array $defaultOptions Options par défaut pour tous les liens
* @return array Tableau de HTML obfusqués
*
* @example
* $links = Obfuscation::batch([
* ['label' => 'Google', 'url' => 'https://google.com'],
* ['label' => 'Facebook', 'url' => 'https://facebook.com', 'options' => ['target' => '_blank']]
* ]);
*/
public static function batch(array $links, array $defaultOptions = []): array
{
$result = [];
foreach ($links as $key => $link) {
try {
// Format simple : ['Label' => 'url']
if (is_string($key)) {
$result[] = self::link($key, $link, $defaultOptions);
}
// Format avancé : [['label' => '', 'url' => '', 'options' => []]]
elseif (is_array($link) && isset($link['label'], $link['url'])) {
$options = array_merge($defaultOptions, $link['options'] ?? []);
$result[] = self::link($link['label'], $link['url'], $options);
} else {
trigger_error("Invalid link format at index $key", E_USER_WARNING);
}
} catch (InvalidArgumentException $e) {
trigger_error("Batch link error at index $key: " . $e->getMessage(), E_USER_WARNING);
}
}
return $result;
}
/**
* Configure les options par défaut globales
*
* @param array $options Options à merger avec les défauts actuels
* @return void
*
* @example
* Obfuscation::setDefaults(['encode' => Obfuscation::ENCODE_NONE]);
*/
public static function setDefaults(array $options): void
{
self::$defaults = array_merge(self::$defaults, $options);
}
/**
* Récupère les options par défaut actuelles
*
* @return array Options par défaut
*/
public static function getDefaults(): array
{
return self::$defaults;
}
/**
* Reset les options par défaut
*
* @return void
*/
public static function resetDefaults(): void
{
self::$defaults = [
'target' => null,
'rel' => null,
'css' => null,
'title' => null,
'position' => null,
'replace' => false,
'encode' => self::ENCODE_BASE64,
'placeholder' => '',
'nosnippet' => true,
'tag' => 'span'
];
}
}
Comment tester ?
En principe, c'est assez difficile à tester, puisque justement ça doit être transparent pour les utilisateurs.
Une solution consiste à utiliser le navigateur Firefox et son outil de capture d'écran de page complète.
Activer l'option dans la configuration de la console. Ensuite, un appareil photo apparait sur la barre d'outils de la console.
Étape suivante, s'assurer que la page est bien à son début.
Prendre soin de ne pas placer le curseur de la souris au dessus de la page mais bien sur la console.
Recharger la page avec le clavier (Ctrl + R ou Command R) et capturer la page.
Normalement, les endroits obfusqués n'affichent pas de lien, comme sur la page de contact de ce site.
Un robot « verra » ça :
That's all Folks !