Hermes Agent로 예약 PageSpeed SEO 감사 스킬을 만드는 방법

핵심 요약

범위가 제한된 PageSpeed Insights 기준선을 실행하고 원본 증거를 보존하며, 검증된 수동 감사 후에만 일정을 추가하는 Hermes Agent 스킬을 만듭니다.

Hermes Agent로 안전한 정기 PageSpeed SEO 감사 스킬을 만드는 방법

Hermes Agent는 경계가 분명한 측정을 반복하는 데 적합하며, 운영 웹사이트를 스스로 고치는 데 적합하지 않습니다. 이 가이드는 공개 페이지를 측정하고 원본 JSON과 날짜가 있는 보고서를 보관하는 읽기 전용 PageSpeed 스킬을 만듭니다. 먼저 수동 기준선을 검증한 뒤에만 사람이 정기 실행을 승인합니다.

PageSpeed 점수는 특정 URL을 특정 시점에 측정한 Lighthouse 실험실 결과입니다. Chrome UX Report(CrUX)는 Google이 반환한 경우에만 실제 사용자 필드 데이터입니다. 둘 다 CMS, 저장소 또는 배포 변경 권한이 아닙니다.

이 글을 Hermes에 보내 스킬 설치하기

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.

공개 글을 읽을 수 없다면 이 글의SKILL.md와 runner 블록을 같은 대화에 붙여 넣습니다. 스킬은 웹 프로젝트가 아닌~/.hermes/skills/에 둡니다.

완료 조건: 기준선 먼저, 일정은 나중에

항목

이 워크플로가 만드는 결과

대상

로컬 또는 격리 환경에서 Hermes를 쓰는 SEO 담당자, 개발자, 기술 마케터

결과

/pagespeed-seo-baseline 스킬, raw JSON, Markdown 보고서, 검토 후 선택 가능한 일정

입력

공개 URL 하나, 선별 URL 목록, 또는 상한이 있는 sitemap 표본

전제

Hermes Agent, Python 3.9+, PageSpeed Insights API 키, 쓰기 가능한 보고서 위치

완료

URL과 전략, 원본 결과, 필드 데이터 범위가 기록되고 사이트 변경이 없음

릴리스 점검에는 선별 URL 목록을 사용합니다. sitemap 표본은 템플릿 간 패턴을 찾는 용도이지 전체 사이트를 검사했다는 증거가 아닙니다. 첫 경로 세그먼트별로 URL 하나를 선택한 뒤 sitemap 순서로 상한까지 채우며, 고아 페이지, canonical, 색인 가능성은 검증하지 않습니다.

안전한 실행 환경에만 키 설정하기

export PAGESPEED_API_KEY="replace-with-your-key"

Google Cloud에서 PageSpeed Insights API를 활성화하고 제한된 키를 만듭니다. 값은 로컬 비밀 저장소나 Hermes 실행 환경에만 두며 스킬, 보고서, 채팅, Git에는 넣지 않습니다.

처음 감사는 전용 제한 키를 사용하는 로컬 실행이 가장 낮은 위험입니다. Docker, 원격 터미널, 샌드박스에 키를 전달하면 그 안의 코드가 키를 읽을 수 있으므로 의도적으로 결정해야 합니다.

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())

runner는 표준 라이브러리만 사용하고--url, 한 줄당 URL 하나인--urls-file, XML sitemap을 지원해야 합니다. mobiledesktop에서 성능, 접근성, 권장사항, SEO를 요청하고 raw 응답을 먼저 저장합니다.

report.md, summary.json, 모바일/데스크톱 raw JSON이 만들어져야 합니다. 요청 URL, 최종 URL, 전략, 시간, 비어 있지 않은 JSON을 확인합니다. 403은 API 설정이나 키 제한을, 429 또는 5xx는 같은 범위의 나중 재시도를 뜻합니다.

의도적으로 단순한 위치에서 수동 기준선 한 번 실행하기

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"

하나의 canonical URL과 새 보고서 디렉터리로 시작하여 스킬이 선언된 환경 변수를 읽고 공개 API에 닿으며 의도한 위치에만 쓰는지 확인합니다. report.md에 요청 URL, 최종 URL, 두 전략, 시간 정보가 있고 raw 파일이 비어 있지 않은 JSON인지 검사하세요. 키가 없으면 채팅에 붙이지 말고 로컬 환경에 설정합니다. sitemap이 실패하면 작은--urls-file로 원인을 분리하고 sitemap을 고치며 원래 범위를 조용히 바꾸지 마세요.

PageSpeed 데이터를 올바르게 읽기

데이터

의미

주장하면 안 되는 것

Lighthouse 점수, LCP, INP, CLS, TBT

지정된 페이지와 전략의 실험실 측정

모든 사용자가 같은 경험을 함

loadingExperience

반환된 경우의 페이지 수준 CrUX

오리진 전체 결과

originLoadingExperience

반환된 경우의 오리진 수준 CrUX

모든 템플릿이 같음

CrUX 없음

이 응답에 적격 필드 데이터 없음

방문자가 없음

한 번의 Lighthouse 실험실 측정과 페이지 수준 및 오리진 수준 CrUX를 구분하는 다이어그램.

반복되는 기회는 이미지 파이프라인 같은 공유 메커니즘을 조사할 근거일 뿐, Hermes가 자산을 변경하거나 스크립트를 지연시키고 캐시 헤더를 수정할 권한은 아닙니다.

신뢰할 수 있는 보고서 뒤에만 일정 추가하기

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.

검증되지 않은 에이전트 작업을 일정화하면 나쁜 증거만 더 자주 도착합니다. 첫 보고서를 사이트 소유자와 검토하고 URL 범위, 빈도, 보존 기간, 전달 위치를 정합니다.

수동 기준선, 사람 승인, 주간 보고서를 거치는 Hermes 안전 일정 흐름.

Hermes는 승인된 수동 범위를 사용하고, 명시적 승인 전에는 일정을 활성화하지 않아야 합니다. 보고서 경로, 명령, 환경 가정, 실패 알림을 먼저 보여 주고report.mdsummary.json만 승인된 소유자에게 전달합니다.

보고서로 별도의 수리 브리프 만들기

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.

보고서는 수리 명령이 아니라 수리 브리프를 만드는 데만 씁니다. 반복되거나 영향이 큰 기회마다 영향 URL, 실험실 또는 필드 근거, 가능한 메커니즘, 개발 소유자, 예상 이점, 위험, 테스트 방법, 롤백 신호, 가정을 적습니다. 이 감사 스킬은 코드, 콘텐츠, 구성 또는 배포를 수정하지 않습니다.

검증 체크리스트

  • [ ] 스킬은~/.hermes/skills/pagespeed-seo-baseline/에 있고 이름과 디렉터리가 일치한다.
  • [ ] API 키는 파일, 채팅, 보고서에 없다.
  • [ ] 수동 보고서에는 모바일/데스크톱, raw JSON, 최종 URL, 명확한 범위가 있다.
  • [ ] Lighthouse와 페이지/오리진 CrUX를 혼동하지 않는다.
  • [ ] 일정은 사람이 검토한 뒤에만 승인된 보고서 위치에 쓴다.
  • [ ] 감사 스킬에 CMS, 저장소, 호스팅, 배포 권한이 없다.

FAQ

Hermes가 sitemap의 모든 페이지를 검사할 수 있나요?

URL을 더 처리할 수는 있지만 올바른 시작점은 아닙니다. PageSpeed 요청은 할당량을 사용하고 sitemap에는 다른 템플릿과 낮은 우선순위 페이지가 섞입니다. 템플릿별 한 페이지나 제한된 표본으로 시작해 필요할 때 확장하십시오.

PageSpeed 점수가 높으면 검색 순위도 좋아지나요?

아닙니다. 점수는 특정 URL과 전략의 실험실 측정이며 순위 보장이 아닙니다. 페이지 또는 오리진 CrUX가 반환되면 Lighthouse와 별도 범위로 해석하세요.

raw PageSpeed 응답을 보관해야 하는 이유는 무엇인가요?

요약만 남기면 최종 URL, 실패, 감사 세부사항, 나중 비교할 근거를 잃습니다. raw JSON은 수리 브리프와 재측정을 같은 증거에 연결합니다.

컨테이너가 로컬 Hermes 실행보다 더 안전한가요?

항상 그렇지 않습니다. 컨테이너에 키를 전달하면 그 안에서 실행되는 코드가 키를 읽을 수 있습니다. 첫 감사에는 전용 제한 키를 쓴 로컬 실행이 낮은 위험입니다. 컨테이너를 쓴다면 네트워크, 쓰기 위치, 비밀 접근 범위를 명시적으로 제한하세요.

Author: Julian Mercer, Auspia Technical SEO Practitioner. Julian은 SEO 발견부터 검토된 웹사이트 결정까지 명확한 증거 흐름을 남기는 기술 워크플로를 작성합니다.

공식 참고 자료

이 주제 더 보기

같은 성장 주제를 계속 살펴보세요