Récemment, on m’a demandé de fournir des données CRUX pour plusieurs urls. J’ai un peu tâtonné en demandant la mauvaise information à Google (origin à la place d’url), mais grâce à Bruno Vial, je m’en suis finalement rendu compte après…
Pourquoi récupérer les métriques CRUX Google ?
Pour monitorer la performance web de vos pages, voir ce que les utilisateurs subissent sur votre site et essayer de prioriser vos travaux d’optimisation de webperformances en vous tablant sur les éléments qui semblent compter pour l’utilisateur, en tout cas d’après Google.
Petit Disclaimer : Malgré le fait que ce soit de l’info Google, et que Google encourage les sites à travailler sur ces sujets, améliorer votre webperf n’aura pas d’impact sur votre classement Google (sauf si vous êtes dans les sites les plus lents de la SERP). En fait, avoir un site lent peut désindexer vos urls mais avoir un site rapide n’a pas d’impact.
Qu’est-ce qu’on va récupérer comme information ?
A l’heure où j’écris ces lignes, les métriques Core Web Vitals sont les suivantes :
- Largest Contentful Paint : Contenu Graphique le plus Large. C’est un élément de réassurance pour les utilisateurs de voir un contenu graphique s’afficher vite. Particulièrement utile dans le cas d’expériences utilisateurs sur mobile et dans des régions avec un internet un peu lent. Le LCP peut changer avec le temps (et la vie de vos pages).
- Interaction to Next Paint : C’est le successeur du First Input Delay, et son objectif est de mesurer l’interactivité de vos pages.
- Cumulative Layout Shift : Mesure la stabilité visuelle, et permet d’identifier des problèmes pour l’utilisateurs comme le décalage de mise en page (on n’a pas envie de cliquer sur “Confirmer” alors qu’une fraction de seconde avant, notre souris était sur “annuler un panier”).
Quelles sont les limites du code que je fournis ?
Il faut savoir que les informations récupérées sont utiles actuellement, mais ne le seront peut être plus au moment où vous lirez ces lignes.
Vérifiez que les Core Web Vitals sont toujours les mêmes ici : https://web.dev/articles/vitals?hl=fr : normalement, mon code ne demande pas spécifiquement une métrique en particulier et doit pouvoir récupérer tout ce qui est fournit, mais peut être que ce n’est pas ce que vous voulez. En revanche, je ne crée des graphiques que pour les Core Web Vitals. (Et si ce ne sont plus les bonnes métriques, faites moi signe sur Linkedin ou X).
Il y a des limites d’usages de l’API CRUX Google, actuellement, c’est 150 / min et par utilisateur, et je fais 3 calls par urls, ça fait donc une limite de 50 urls vérifiées par minutes, je découpe donc une liste d’urls en batch et rajoute un délai d’une minute pour passer d’un batch à un suivant.
Où récupérer une clef API “CRUX” ?
Allez ici : https://console.cloud.google.com/, créez un nouveau projet, puis allez dans l’onglet “API et services” à “Bibliothèque”, et cherchez “Chrome UX Report API”, ou allez directement ici : https://console.cloud.google.com/apis/library/chromeuxreport.googleapis.com?hl=fr et activez l’API, puis Créer des Identifiants.
Comment utiliser le code ?
Faites une liste d’urls (et assurez vous de toujours inclure le host du site dans les urls) sous la forme “https://www.exemple.com” dans un fichier texte (une url par ligne) (dans l’exemple “listedurls.txt”).
Ajoutez votre clé API à la fin du code comme renseigné ici (remplacez API_KEY par votre clé API):
if __name__ == "__main__":
api_key = "API_KEY"
main(api_key)
Enregistrez votre fichier Python (“cruxdata.py”) et ouvrez un terminal (en supposant que votre fichier python soit dans le même dossier que votre fichier texte (“listedurls.txt”)) en vous rendant (grâce à cd) jusqu’au dossier en question.
Exécutez le code avec la commande :
python cruxdata.py
Attendez la demande du nom du fichier :
Entrez le nom du fichier d'entrée: listedurls.txt
Et vous aurez vos données dans crux_data.json ainsi que des graphiques pour vos présentations dans un dossier /charts/ qui va se créer
Explications du code :
Ici j’importe les librairies dont j’aurais besoin
import requests
import json
import os
import matplotlib.pyplot as plt
import re
import time
Je définis ensuite 4 fonctions utiles :
- get_crux_data : la récupération des données API en elle-même, prévenant lorsqu’il n’y a pas d’informations renvoyées par l’API (une erreur 404 pour l’url par exemple)
- plot_histogram : la création des graphiques, tout en évitant les erreurs qui pourraient arriver en cas d’absences de données : parfois on n’a pas d’infos pour la totalité des métriques appelées pour un combo url – formFactor (le device)
- normalize_filename : pour les noms des fichiers png en sortie
- treating_crux_data : pour récupérer les données et créer les histogrammes sans faire de doublons.
def get_crux_data(api_key, url, form_factor):
api_url = "https://chromeuxreport.googleapis.com/v1/records:queryRecord"
params = {"key": api_key}
payload = {
"url": url,
"formFactor": form_factor
}
response = requests.post(api_url, params=params, json=payload)
if response.status_code == 200:
return response.json()
elif response.status_code == 404:
print(f"Erreur 404: Pas de données CrUX disponibles pour {url} ({form_factor})")
return None
else:
print(f"Erreur: {response.status_code} pour {url} ({form_factor})")
return None
def plot_histogram(metric_name, histogram_data, labels, title, file_name, url):
if not histogram_data:
print(f"Pas de données disponibles pour {metric_name} dans {title}")
return
try:
values = [data['density'] * 100 for data in histogram_data]
except KeyError:
print(f"Données manquantes pour {metric_name} dans {title} - Clé 'density' absente")
return
core_web_vitals_colors = ['#0CCE6B', '#FFA400', '#FF4E42']
fig, ax = plt.subplots(figsize=(8, 4))
ax.barh(metric_name, values[0], color=core_web_vitals_colors[0], label=labels[0])
ax.barh(metric_name, values[1], left=values[0], color=core_web_vitals_colors[1], label=labels[1])
ax.barh(metric_name, values[2], left=values[0] + values[1], color=core_web_vitals_colors[2], label=labels[2])
ax.set_xlim(0, 100)
ax.set_title(title)
ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.15), ncol=3)
ax.text(values[0] / 2, metric_name, f'{values[0]:.2f}%', va='center', ha='center', color='white', fontsize=10)
ax.text(values[0] + values[1] / 2, metric_name, f'{values[1]:.2f}%', va='center', ha='center', color='white', fontsize=10)
ax.text(values[0] + values[1] + values[2] / 2, metric_name, f'{values[2]:.2f}%', va='center', ha='center', color='white', fontsize=10)
# Add the URL below the legend
plt.figtext(0.5, -0.1, f"URL: {url}", ha="center", fontsize=10, wrap=True)
plt.tight_layout()
plt.savefig(file_name, bbox_inches="tight")
plt.close()
def normalize_filename(url):
filename = re.sub(r'https?://', '', url)
filename = re.sub(r'[^a-zA-Z0-9]', '_', filename)
return filename
def treating_crux_data(crux_data_dict, urls, api_key):
form_factors = ["DESKTOP", "PHONE", "TABLET"]
no_data_records = [] # List to store URLs with no data
for url in urls:
url = url.strip()
for form_factor in form_factors:
crux_data = get_crux_data(api_key, url, form_factor)
if crux_data:
key = f"{url}_{form_factor.lower()}"
crux_data_dict[key] = crux_data
original_url = crux_data.get('urlNormalizationDetails', {}).get('originalUrl', url).strip()
normalized_url = crux_data.get('urlNormalizationDetails', {}).get('normalizedUrl', url).strip()
if original_url == normalized_url or original_url == normalized_url + "/":
metrics = crux_data['record']['metrics']
base_filename = normalize_filename(f"{url}_{form_factor.lower()}")
if 'largest_contentful_paint' in metrics:
lcp_data = metrics['largest_contentful_paint']['histogram']
lcp_labels = ["Good (< 2.5s)", "Needs Improvement (2.5s-4.0s)", "Poor (> 4.0s)"]
plot_histogram("% LCP", lcp_data, lcp_labels,
f"Largest Contentful Paint (LCP) - {form_factor}",
f"charts/{base_filename}_lcp_chart.png", url)
if 'interaction_to_next_paint' in metrics:
inp_data = metrics['interaction_to_next_paint']['histogram']
inp_labels = ["Good (< 200ms)", "Needs Improvement (200ms-500ms)", "Poor (> 500ms)"]
plot_histogram("% INP", inp_data, inp_labels,
f"Interaction to Next Paint (INP) - {form_factor}",
f"charts/{base_filename}_inp_chart.png", url)
if 'cumulative_layout_shift' in metrics:
cls_data = metrics['cumulative_layout_shift']['histogram']
cls_labels = ["Good (< 0.10)", "Needs Improvement (0.10-0.25)", "Poor (> 0.25)"]
plot_histogram("% CLS", cls_data, cls_labels,
f"Cumulative Layout Shift (CLS) - {form_factor}",
f"charts/{base_filename}_cls_chart.png", url)
else:
no_data_records.append([url, form_factor])
print(f"Aucune donnée CrUX disponible pour {url} ({form_factor})")
# Write no data records to CSV
with open("no_data_urls.csv", "w", newline='') as csvfile:
writer = csv.writer(csvfile)
writer.writerow(["URL", "Form Factor"])
writer.writerows(no_data_records)
Enfin la fonction principale, qui articule la totalité des fonctions, lis le fichier texte, appelle les données, crée le fichier json avec toutes les données demandées. :
def main(api_key):
crux_data_dict = {}
input_file = input("Entrez le nom du fichier d'entrée: ")
if not input_file:
print("No file entered")
return
with open(input_file, "r") as file:
urls = file.readlines()
if not os.path.exists('charts'):
os.makedirs('charts')
if len(urls) <= 50:
treating_crux_data(crux_data_dict, urls, api_key)
else:
for i in range(0, len(urls), 50):
chunk = urls[i:i+50]
treating_crux_data(crux_data_dict, chunk, api_key)
time.sleep(60)
with open("crux_data.json", "w") as file:
json.dump(crux_data_dict, file, indent=2)
Et son appel avec la clé API :
if __name__ == "__main__":
api_key = "API_KEY"
main(api_key)
Le code :
(A mettre dans le fichier python que vous allez exécuter).
import requests
import json
import os
import matplotlib.pyplot as plt
import re
import time
import csv
def get_crux_data(api_key, url, form_factor):
api_url = "https://chromeuxreport.googleapis.com/v1/records:queryRecord"
params = {"key": api_key}
payload = {
"url": url,
"formFactor": form_factor
}
response = requests.post(api_url, params=params, json=payload)
if response.status_code == 200:
return response.json()
elif response.status_code == 404:
print(f"Erreur 404: Pas de données CrUX disponibles pour {url} ({form_factor})")
return None
else:
print(f"Erreur: {response.status_code} pour {url} ({form_factor})")
return None
def plot_histogram(metric_name, histogram_data, labels, title, file_name, url):
if not histogram_data:
print(f"Pas de données disponibles pour {metric_name} dans {title}")
return
try:
values = [data['density'] * 100 for data in histogram_data]
except KeyError:
print(f"Données manquantes pour {metric_name} dans {title} - Clé 'density' absente")
return
core_web_vitals_colors = ['#0CCE6B', '#FFA400', '#FF4E42']
fig, ax = plt.subplots(figsize=(8, 4))
ax.barh(metric_name, values[0], color=core_web_vitals_colors[0], label=labels[0])
ax.barh(metric_name, values[1], left=values[0], color=core_web_vitals_colors[1], label=labels[1])
ax.barh(metric_name, values[2], left=values[0] + values[1], color=core_web_vitals_colors[2], label=labels[2])
ax.set_xlim(0, 100)
ax.set_title(title)
ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.15), ncol=3)
ax.text(values[0] / 2, metric_name, f'{values[0]:.2f}%', va='center', ha='center', color='white', fontsize=10)
ax.text(values[0] + values[1] / 2, metric_name, f'{values[1]:.2f}%', va='center', ha='center', color='white', fontsize=10)
ax.text(values[0] + values[1] + values[2] / 2, metric_name, f'{values[2]:.2f}%', va='center', ha='center', color='white', fontsize=10)
# Add the URL below the legend
plt.figtext(0.5, -0.1, f"URL: {url}", ha="center", fontsize=10, wrap=True)
plt.tight_layout()
plt.savefig(file_name, bbox_inches="tight")
plt.close()
def normalize_filename(url):
filename = re.sub(r'https?://', '', url)
filename = re.sub(r'[^a-zA-Z0-9]', '_', filename)
return filename
def treating_crux_data(crux_data_dict, urls, api_key):
form_factors = ["DESKTOP", "PHONE", "TABLET"]
no_data_records = [] # List to store URLs with no data
for url in urls:
url = url.strip()
for form_factor in form_factors:
crux_data = get_crux_data(api_key, url, form_factor)
if crux_data:
key = f"{url}_{form_factor.lower()}"
crux_data_dict[key] = crux_data
original_url = crux_data.get('urlNormalizationDetails', {}).get('originalUrl', url).strip()
normalized_url = crux_data.get('urlNormalizationDetails', {}).get('normalizedUrl', url).strip()
if original_url == normalized_url or original_url == normalized_url + "/":
metrics = crux_data['record']['metrics']
base_filename = normalize_filename(f"{url}_{form_factor.lower()}")
if 'largest_contentful_paint' in metrics:
lcp_data = metrics['largest_contentful_paint']['histogram']
lcp_labels = ["Good (< 2.5s)", "Needs Improvement (2.5s-4.0s)", "Poor (> 4.0s)"]
plot_histogram("% LCP", lcp_data, lcp_labels,
f"Largest Contentful Paint (LCP) - {form_factor}",
f"charts/{base_filename}_lcp_chart.png", url)
if 'interaction_to_next_paint' in metrics:
inp_data = metrics['interaction_to_next_paint']['histogram']
inp_labels = ["Good (< 200ms)", "Needs Improvement (200ms-500ms)", "Poor (> 500ms)"]
plot_histogram("% INP", inp_data, inp_labels,
f"Interaction to Next Paint (INP) - {form_factor}",
f"charts/{base_filename}_inp_chart.png", url)
if 'cumulative_layout_shift' in metrics:
cls_data = metrics['cumulative_layout_shift']['histogram']
cls_labels = ["Good (< 0.10)", "Needs Improvement (0.10-0.25)", "Poor (> 0.25)"]
plot_histogram("% CLS", cls_data, cls_labels,
f"Cumulative Layout Shift (CLS) - {form_factor}",
f"charts/{base_filename}_cls_chart.png", url)
else:
no_data_records.append([url, form_factor])
print(f"Aucune donnée CrUX disponible pour {url} ({form_factor})")
# Write no data records to CSV
with open("no_data_urls.csv", "w", newline='') as csvfile:
writer = csv.writer(csvfile)
writer.writerow(["URL", "Form Factor"])
writer.writerows(no_data_records)
def main(api_key):
crux_data_dict = {}
input_file = input("Entrez le nom du fichier d'entrée: ")
if not input_file:
print("No file entered")
return
with open(input_file, "r") as file:
urls = file.readlines()
if not os.path.exists('charts'):
os.makedirs('charts')
if len(urls) <= 50:
treating_crux_data(crux_data_dict, urls, api_key)
else:
for i in range(0, len(urls), 50):
chunk = urls[i:i+50]
treating_crux_data(crux_data_dict, chunk, api_key)
time.sleep(60)
with open("crux_data.json", "w") as file:
json.dump(crux_data_dict, file, indent=2)
if __name__ == "__main__":
api_key = "API_KEY"
main(api_key)