Comment creer une skill d'audit SEO PageSpeed planifiee avec Hermes Agent
Hermes Agent doit repeter une mesure bornee, et non improviser des changements en production. Ce guide cree une skill en lecture seule qui appelle Google PageSpeed Insights, conserve le JSON brut et produit un rapport date. Validez d'abord une ligne de base manuelle; une personne decide ensuite seulement si un calendrier est approprie.
Envoyez cet article a Hermes pour installer la skill
Read [THIS ARTICLE URL] and install the Hermes skill described there.
First inspect my local Hermes configuration and any project guidance. Tell me
the destination you will use. Create only:
~/.hermes/skills/pagespeed-seo-baseline/SKILL.md
~/.hermes/skills/pagespeed-seo-baseline/scripts/pagespeed_baseline.py
Copy the complete code blocks from the article. Do not invent a different
workflow. Verify the Python file with a syntax check and report the two paths.
Tell me how to configure PAGESPEED_API_KEY locally, but never ask me to paste
the value into this chat and never display it.
Do not run PageSpeed, schedule a task, write to my site repository, use CMS or
hosting credentials, edit a skill other than this one, or change a live site.
Stop after installation and verification.
La skill se place dans~/.hermes/skills/, hors du projet web: c'est un outil de mesure, pas une fonction de l'application.
Resultat: ligne de base avant calendrier
Element | Resultat |
|---|---|
Public | SEO, developpement ou marketing technique utilisant Hermes localement ou de maniere isolee |
Livrable | Skill, JSON brut, rapport Markdown et planification optionnelle revue |
Entrees | URL publique, liste choisie ou echantillon de sitemap limite |
Termine lorsque | URL, strategie, evidence originale et portee des donnees sont conservees sans modifier le site |
Un echantillon de sitemap sert a voir des tendances entre modeles; il ne prouve pas que toutes les URL, pages orphelines, canonicals ou conditions d'indexation ont ete verifies.
Configurez la cle uniquement dans un environnement sur
export PAGESPEED_API_KEY="replace-with-your-key"
Activez PageSpeed Insights API dans Google Cloud et utilisez une cle restreinte. Conservez-la uniquement dans un mecanisme local approuve ou l'environnement qui lance Hermes, jamais dans la skill, un rapport, un chat ou Git.
Contrat de securite de SKILL.md
~/.hermes/skills/pagespeed-seo-baseline/
SKILL.md
scripts/
pagespeed_baseline.py
---
name: pagespeed-seo-baseline
description: Create a read-only PageSpeed Insights baseline for one public URL, a supplied URL list, or a controlled XML sitemap sample. Save raw JSON and a dated report that distinguishes Lighthouse lab data from CrUX field data. Use for website speed, Core Web Vitals, Lighthouse, and recurring performance-baseline requests. Never edit a site, repository, CMS, hosting configuration, or deployment.
required_environment_variables:
- PAGESPEED_API_KEY
---
# PageSpeed SEO Baseline
This is an evidence-collection skill. It may call the public PageSpeed Insights API and public sitemap URLs, then write only inside the report directory chosen by the operator.
## Boundaries
- Read `PAGESPEED_API_KEY` from the runtime environment only. Never print, message, save, or commit it.
- Do not use browser logins, SSH, CMS, hosting, Git write, deployment, or website-editing tools. A performance report is not approval to repair a site.
- Run both `mobile` and `desktop`. Preserve each successful response under `raw/` before summarizing it.
- Describe Lighthouse as a point-in-time lab measurement. Treat `loadingExperience` as page-level CrUX only when returned, and `originLoadingExperience` as origin-level CrUX only when returned. Do not substitute one for the other.
- For `--sitemap`, say how URLs were sampled and how many were excluded. For important templates, prefer `--urls-file`.
- Never create or change a schedule until a human has reviewed one successful manual report and named the recurring scope, cadence, report path, and delivery destination.
## Commands
Run from this skill directory. The output path must be outside a repository unless the operator explicitly chooses an ignored evidence directory.
python3 scripts/pagespeed_baseline.py \
--url "https://www.example.com/pricing/" \
--out "$HOME/hermes-pagespeed-reports/pricing-baseline"
python3 scripts/pagespeed_baseline.py \
--sitemap "https://www.example.com/sitemap.xml" \
--max-urls 12 \
--out "$HOME/hermes-pagespeed-reports/site-sample"
## Required report handoff
Return the report path and a compact table: requested URL, final URL, device strategy, performance score, LCP, INP, CLS, TBT, and field-data scope. Name failed requests and skipped URLs. Group repeated opportunities by likely mechanism, but label every repair as a hypothesis until a developer verifies it.
When the operator asks for a schedule, show the proposed command, cadence, report retention rule, and delivery target. Wait for explicit confirmation before creating it. If running on a messaging surface, send the report path or attachment; never send the API key or raw command environment.
#!/usr/bin/env python3
"""Collect bounded PageSpeed Insights evidence without third-party packages."""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from collections import OrderedDict
from datetime import datetime, timezone
from pathlib import Path
API = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed"
CATEGORIES = ("performance", "accessibility", "best-practices", "seo")
STRATEGIES = ("mobile", "desktop")
AUDITS = ("largest-contentful-paint", "interaction-to-next-paint", "cumulative-layout-shift", "total-blocking-time")
def get_bytes(url: str) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": "Hermes-PageSpeed-Baseline/1.0"})
with urllib.request.urlopen(request, timeout=45) as response:
return response.read()
def sitemap_urls(url: str) -> list[str]:
try:
root = ET.fromstring(get_bytes(url))
except (urllib.error.URLError, ET.ParseError) as error:
raise RuntimeError(f"Could not read sitemap {url}: {error}") from error
locs = [n.text.strip() for n in root.findall(".//{*}loc") if n.text and n.text.strip()]
if root.tag.lower().endswith("sitemapindex"):
locs = [item for child in locs for item in sitemap_urls(child)]
return list(OrderedDict((u, None) for u in locs if urllib.parse.urlparse(u).scheme in {"http", "https"}))
def select_urls(urls: list[str], maximum: int) -> list[str]:
groups: OrderedDict[str, list[str]] = OrderedDict()
for url in urls:
parts = [part for part in urllib.parse.urlparse(url).path.split("/") if part]
groups.setdefault(parts[0] if parts else "root", []).append(url)
chosen = [values[0] for values in groups.values()]
chosen.extend(url for url in urls if url not in chosen)
return chosen[:maximum]
def run_api(url: str, strategy: str, key: str) -> dict:
params = [("url", url), ("strategy", strategy), ("key", key)]
params.extend(("category", category) for category in CATEGORIES)
endpoint = API + "?" + urllib.parse.urlencode(params)
last_error = "unknown error"
for attempt in range(3):
try:
request = urllib.request.Request(endpoint, headers={"User-Agent": "Hermes-PageSpeed-Baseline/1.0"})
with urllib.request.urlopen(request, timeout=150) as response:
return json.load(response)
except urllib.error.HTTPError as error:
last_error = f"HTTP {error.code}: {error.read().decode('utf-8', 'replace')[:240]}"
if error.code not in {429, 500, 502, 503, 504}:
break
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
last_error = str(error)
time.sleep(2 ** attempt)
raise RuntimeError(last_error)
def value(result: dict, audit_id: str) -> str:
return result.get("lighthouseResult", {}).get("audits", {}).get(audit_id, {}).get("displayValue", "n/a")
def score(result: dict) -> str:
raw = result.get("lighthouseResult", {}).get("categories", {}).get("performance", {}).get("score")
return "n/a" if raw is None else str(round(raw * 100))
def field(result: dict, scope: str) -> str:
metrics = result.get(scope, {}).get("metrics", {})
names = ("LARGEST_CONTENTFUL_PAINT_MS", "INTERACTION_TO_NEXT_PAINT", "CUMULATIVE_LAYOUT_SHIFT_SCORE")
return " / ".join(str(metrics.get(name, {}).get("percentile", "n/a")) for name in names) if metrics else "not returned"
def report(records: list[dict], scope: str, selected: int, total: int) -> str:
lines = ["# Hermes PageSpeed baseline", "", f"- Generated (UTC): {datetime.now(timezone.utc).isoformat(timespec='seconds')}", f"- Scope: {scope}", f"- URLs selected: {selected} of {total}", "- Lighthouse is lab data. CrUX appears only when Google returned it.", "", "| URL | Final URL | Device | Perf | LCP | INP | CLS | TBT | Page CrUX (LCP / INP / CLS) | Origin CrUX (LCP / INP / CLS) | Status |", "| --- | --- | --- | ---: | --- | --- | --- | --- | --- | --- | --- |"]
for item in records:
if item["error"]:
row = [item["url"], "n/a", item["strategy"], "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", item["error"]]
else:
data = item["result"]
final_url = data.get("lighthouseResult", {}).get("finalUrl", item["url"])
row = [item["url"], final_url, item["strategy"], score(data), *(value(data, name) for name in AUDITS), field(data, "loadingExperience"), field(data, "originLoadingExperience"), "ok"]
lines.append("| " + " | ".join(str(cell).replace("|", "/") for cell in row) + " |")
return "
".join(lines) + "
"
def main() -> int:
parser = argparse.ArgumentParser()
scope = parser.add_mutually_exclusive_group(required=True)
scope.add_argument("--url")
scope.add_argument("--urls-file")
scope.add_argument("--sitemap")
parser.add_argument("--max-urls", type=int, default=10)
parser.add_argument("--out", required=True)
args = parser.parse_args()
key = os.environ.get("PAGESPEED_API_KEY")
if not key:
parser.error("PAGESPEED_API_KEY is not set in the environment")
if args.url:
urls, label = [args.url], "single URL"
total = len(urls)
elif args.urls_file:
urls = [line.strip() for line in Path(args.urls_file).read_text(encoding="utf-8").splitlines() if line.strip() and not line.startswith("#")]
label = "supplied URL list"
total = len(urls)
else:
all_urls = sitemap_urls(args.sitemap)
urls, label = select_urls(all_urls, args.max_urls), f"sitemap sample from {args.sitemap}"
total = len(all_urls)
out = Path(args.out)
raw = out / "raw"
raw.mkdir(parents=True, exist_ok=True)
records = []
for number, url in enumerate(urls, start=1):
for strategy in STRATEGIES:
try:
result = run_api(url, strategy, key)
(raw / f"{number:03d}-{strategy}.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
records.append({"url": url, "strategy": strategy, "result": result, "error": ""})
except RuntimeError as error:
records.append({"url": url, "strategy": strategy, "result": {}, "error": str(error)})
(out / "report.md").write_text(report(records, label, len(urls), total), encoding="utf-8")
(out / "summary.json").write_text(json.dumps({"scope": label, "urls": urls, "records": [{k: v for k, v in item.items() if k != "result"} for item in records]}, indent=2), encoding="utf-8")
print(out / "report.md")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Le runner accepte une URL, un fichier de URL ou un sitemap XML; il mesure mobile et bureau, enregistrereport.md, summary.json et les reponses raw, et conserve les echecs.
Executez une ligne de base manuelle dans un emplacement volontairement simple
mkdir -p "$HOME/hermes-pagespeed-reports"
cd ~/.hermes/skills/pagespeed-seo-baseline
python3 scripts/pagespeed_baseline.py \
--url "https://www.example.com/" \
--out "$HOME/hermes-pagespeed-reports/homepage-2026-07-24"
Commencez avec une URL canonique et un nouveau repertoire de rapport. Vous verifiez ainsi que la skill lit la variable declaree, atteint l API publique et ecrit seulement au bon endroit. Verifiez URL demandee et finale, les deux strategies et des JSON non vides. Configurez une cle absente localement; si le sitemap echoue, utilisez un petit --urls-file pour isoler le probleme sans remplacer silencieusement la portee.
Lire ce que PageSpeed a reellement mesure
Signal | Signification | Ne pas affirmer |
|---|---|---|
Lighthouse, LCP, INP, CLS, TBT | Mesure de laboratoire ponctuelle | Tous les visiteurs vivent cela |
| CrUX de page si renvoye | Comportement de tout le domaine |
| CrUX d'origine si renvoye | Chaque modele est identique |
Pas de CrUX | Pas de donnee eligible dans cette reponse | Personne ne visite la page |

Une opportunite repetee invite a enqueter; elle n'autorise pas Hermes a modifier automatiquement images, scripts ou cache.
Planifiez seulement une mesure credible
Create a proposed weekly PageSpeed baseline schedule, but do not activate it yet.
Use the exact manual scope and command from my approved report. Run in the same
isolated environment. Store each run beneath
$HOME/hermes-pagespeed-reports/weekly/YYYY-MM-DD/ and retain reports for 90
days. Deliver only report.md and summary.json to the approved owner channel.
Show the schedule, command, environment assumptions, report path, and failure
notification behavior. Do not include PAGESPEED_API_KEY in any output. Wait for
my explicit approval before writing or enabling the schedule.
Revoyez le premier rapport avec le proprietaire du site. Definissez portee, frequence, retention et destination. Hermes doit afficher commande, hypotheses et alertes, puis attendre une approbation explicite.

Utilisez le rapport pour creer un brief de reparation distinct
Read this PageSpeed evidence folder: [REPORT PATH]. Create a repair brief only.
For each repeated or high-impact opportunity, state the affected URLs, the lab
or field evidence, a likely mechanism, the developer owner, expected benefit,
risk, test method, and rollback signal. Flag assumptions. Do not edit code,
content, configuration, or a deployment.
Le rapport sert uniquement a creer un brief: URL affectees, preuve laboratoire ou terrain, mecanisme probable, responsable, benefice, risque, test, rollback et incertitudes. La skill ne modifie ni code, contenu, configuration ni deploiement.
Liste de verification
- [ ] La cle API n'apparait pas dans les fichiers, le chat ou les rapports.
- [ ] Chaque URL a mobile, bureau, JSON brut, URL finale et portee claire.
- [ ] Lighthouse et CrUX de page ou d'origine ne sont pas confondus.
- [ ] Le calendrier est revu avant activation et la skill n'a aucun droit CMS, depot ou deploiement.
FAQ
Hermes peut-il mesurer tout le sitemap?
Il peut traiter davantage d'URL, mais ce n'est pas le bon premier geste. Commencez par une page par modele ou un echantillon limite, puis etendez lorsque l'evidence le justifie.
Author: Julian Mercer, Technical SEO Practitioner at Auspia. Julian ecrit des flux techniques qui conservent une chaine de preuves claire avant tout changement de production.
References officielles
Un meilleur score PageSpeed garantit-il un meilleur classement?
Non. C est une mesure de laboratoire pour une URL et une strategie, pas une promesse de classement. Traitez CrUX de page et d origine comme des portees distinctes lorsqu elles sont renvoyees.
Pourquoi conserver les reponses PageSpeed raw?
Le JSON raw conserve URL finale, echecs et detail de comparaison. Il relie le brief et la nouvelle mesure a la meme preuve.
Un conteneur est-il plus sur qu Hermes local?
Pas necessairement. Le code du conteneur peut lire une cle qui lui est transmise. Pour le premier audit, une execution locale avec cle restreinte est souvent moins risquee; limitez reseau, ecriture et secrets si vous utilisez un conteneur.
Fichiers et prompts complets pour l installation
Les blocs complets a copier de l original anglais sont conserves ci-dessous. Utilisez-les aux emplacements et dans l ordre expliques; ne resumez ni le code, ni les commandes, ni les prompts pendant l installation.




