Track page performance over time
Lighthouse scores and lab vitals in a CSV, with rank history sitting next to them.
Traffic reports lag — a regression can hide in aggregate for weeks. A daily Lighthouse run is a separate signal within 24 hours, and the rank history sits next to it when you need the postmortem graph.
Export your key once.
export SEOFETCH_KEY=sof_live_… # the scripts below use jq -- brew install jq / apt-get install jq
export SEOFETCH_KEY=sof_live_… pip install requests
export SEOFETCH_KEY=sof_live_… # Node 18+ -- built-in fetch, no npm install needed
Grab the scores and the vitals for the page you care about.
curl https://api.seofetch.com/v1/page/lighthouse \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/", "device": "mobile"}' \
-o lighthouse.json
import json
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/page/lighthouse",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"url": "https://example.com/", "device": "mobile"},
)
with open("lighthouse.json", "w") as f:
json.dump(resp.json(), f, indent=2)
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
import { writeFileSync } from "node:fs";
const resp = await fetch("https://api.seofetch.com/v1/page/lighthouse", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com/", device: "mobile" }),
});
const body = await resp.json();
writeFileSync("lighthouse.json", JSON.stringify(body, null, 2));
{
"id": "ligh_pd7kokgl6j2qiv7lkvh4nn3y",
"request_id": "req_xpmr7kocmrfptdg54t4iuoydue",
"object": "lighthouse",
"created_at": "2026-08-09T10:05:00Z",
"elapsed_ms": 180,
"cache": "miss",
"credits": {
"charged": 2,
"balance": 9857
},
"data": {
"url": "https://example.com/",
"device": "mobile",
"scores": {
"performance": 100,
"accessibility": 96,
"best_practices": 96,
"seo": 80
},
"metrics": {
"lcp_ms": 762,
"fcp_ms": 611,
"cls": 0.02,
"tbt_ms": 40,
"si_ms": 900,
"tti_ms": 1100
},
"fetched_at": "2026-07-26T10:15:00Z",
"audits": {
"largest-contentful-paint": {
"id": "largest-contentful-paint",
"title": "Largest Contentful Paint",
"description": "Largest Contentful Paint marks the time at which the largest text or image is painted.",
"score": 1,
"scoreDisplayMode": "numeric",
"numericValue": 762.4,
"numericUnit": "millisecond",
"displayValue": "0.8 s",
"scoringOptions": {
"p10": 2500,
"median": 4000
}
},
"…": "…"
},
"screenshots": {
"full_page": {
"data": "data:image/webp;base64,…",
"width": 412,
"height": 6200
},
"final": {
"data": "data:image/webp;base64,…"
},
"thumbnails": [
{
"data": "data:image/webp;base64,…",
"timing": 375
},
"…"
]
}
}
}
Lighthouse wobbles a little between runs — the trend is the signal, a single reading is noise.
One CSV row per day; alert on deltas, not absolutes.
# fetched_at is the measurement time -- a cache hit keeps its original date
mdate=$(jq -r '.data.fetched_at[0:10]' lighthouse.json)
row=$(jq -r --arg d "$mdate" '[$d, .data.scores.performance, .data.scores.seo, .data.metrics.lcp_ms, .data.metrics.cls] | @csv' lighthouse.json)
prev=$(tail -n1 perf.csv 2>/dev/null | cut -d, -f2)
echo "$row" >> perf.csv
new=$(echo "$row" | cut -d, -f2)
[ -n "$prev" ] && awk -v p="$prev" -v n="$new" 'BEGIN { if (p - n > 5) print "ALERT: performance dropped from " p " to " n }'
import csv
import json
from pathlib import Path
lighthouse = json.load(open("lighthouse.json"))["data"]
# fetched_at is the measurement time -- a cache hit keeps its original date
mdate = lighthouse["fetched_at"][:10]
row = [mdate, lighthouse["scores"]["performance"], lighthouse["scores"]["seo"],
lighthouse["metrics"]["lcp_ms"], lighthouse["metrics"]["cls"]]
path = Path("perf.csv")
prev = path.read_text().splitlines()[-1].split(',') if path.exists() and path.read_text().strip() else None
with open(path, "a", newline="") as f:
csv.writer(f).writerow(row)
if prev and float(prev[1]) - row[1] > 5:
print(f"ALERT: performance dropped from {prev[1]} to {row[1]}")
import { existsSync, readFileSync, appendFileSync } from "node:fs";
const lighthouse = JSON.parse(readFileSync("lighthouse.json")).data;
// fetched_at is the measurement time -- a cache hit keeps its original date
const mdate = lighthouse.fetched_at.slice(0, 10);
const row = [mdate, lighthouse.scores.performance, lighthouse.scores.seo,
lighthouse.metrics.lcp_ms, lighthouse.metrics.cls];
const prevLine = existsSync("perf.csv") ? readFileSync("perf.csv", "utf8").trim().split("\n").pop() : null;
appendFileSync("perf.csv", row.join(",") + "\n");
if (prevLine) {
const prevPerf = Number(prevLine.split(",")[1]);
if (prevPerf - row[1] > 5) {
console.log(`ALERT: performance dropped from ${prevPerf} to ${row[1]}`);
}
}
A 3-point wobble is Tuesday. A 15-point cliff the day after a deploy is a ticket. The 5-point default is a starting threshold — measure your own page's variance first.
Put rank history next to the vitals.
curl https://api.seofetch.com/v1/serp/history \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "example.com", "keyword": "best running shoes", "engine": "google", "location": 2840, "language": "en", "device": "desktop", "date_from": "2026-05-20", "date_to": "2026-07-28"}'
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/serp/history",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"domain": "example.com", "keyword": "best running shoes", "engine": "google", "location": 2840, "language": "en", "device": "desktop", "date_from": "2026-05-20", "date_to": "2026-07-28"},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
const resp = await fetch("https://api.seofetch.com/v1/serp/history", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ domain: "example.com", keyword: "best running shoes", engine: "google", location: 2840, language: "en", device: "desktop", date_from: "2026-05-20", date_to: "2026-07-28" }),
});
const { data } = await resp.json();
{
"id": "serp_pgv2qihyiojkyowtwllhagvn",
"request_id": "req_na26ivmtljdwbfxrthzjqgonci",
"object": "serp_history",
"created_at": "2026-08-09T10:05:00Z",
"elapsed_ms": 180,
"cache": "miss",
"credits": {
"charged": 5,
"balance": 9857
},
"data": {
"domain": "example.com",
"keyword": "best running shoes",
"engine": "google",
"location": 2840,
"language": "en",
"device": "desktop",
"points": [
{
"date": "2026-06-03",
"rank": 9,
"url": "https://example.com/best-running-shoes",
"type": "organic"
},
{
"date": "2026-06-17",
"rank": 6,
"url": "https://example.com/best-running-shoes",
"type": "organic"
}
],
"first_seen": "2026-06-03",
"best_rank": 6,
"coverage": {
"from": "2026-05-20",
"observations": 14
}
}
}
Did the LCP regression precede the rank slide? Correlation, not proof — but it's the graph you want open in the postmortem.
Run the whole thing.
Each language below is the full daily habit, cron-ready — save it, run it after exporting SEOFETCH_KEY. perf.csv gets one new row a day, rank goes to ranks.csv next to it, and a >5-point performance drop prints an alert.
#!/usr/bin/env bash
# perf.sh -- daily performance + rank habit, cron-ready.
# cron: 20 6 * * * cd /path/to/perf && ./perf.sh
set -euo pipefail
: "${SEOFETCH_KEY:?export SEOFETCH_KEY first}"
URL="${TRACK_URL:-https://yoursite.com/}"
DOMAIN="${TRACK_DOMAIN:-yoursite.com}"
KEYWORD="${TRACK_KEYWORD:-your keyword}"
TODAY=$(date -u +%F)
FROM=$(date -u -v-60d +%F 2>/dev/null || date -u -d '60 days ago' +%F)
api() { curl -sS --fail-with-body "https://api.seofetch.com$1" \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d "$2"; }
# 1. today's scores + vitals
api /v1/page/lighthouse \
"$(jq -cn --arg u "$URL" '{url: $u, device: "mobile"}')" > lighthouse.json
# fetched_at is the measurement time -- a cache hit keeps its original date
MDATE=$(jq -r '.data.fetched_at[0:10]' lighthouse.json)
# 2. append to perf.csv; alert on a >5pt performance drop -- skip the append
# if today already has a row, so a same-day rerun can't duplicate it
row=$(jq -r --arg d "$MDATE" \
'[$d, .data.scores.performance, .data.scores.seo, .data.metrics.lcp_ms, .data.metrics.cls] | @csv' \
lighthouse.json)
new_perf=$(echo "$row" | cut -d, -f2)
if [ -f perf.csv ] && grep -qF "\"$MDATE\"," perf.csv; then
echo "perf.csv already has a row for $MDATE, skipping"
else
prev_perf=$(tail -n1 perf.csv 2>/dev/null | cut -d, -f2 || true)
echo "$row" >> perf.csv
if [ -n "${prev_perf:-}" ]; then
awk -v p="$prev_perf" -v n="$new_perf" 'BEGIN { if (p - n > 5) print "ALERT: performance dropped from " p " to " n }'
fi
fi
# 3. rank history next to it -- did the regression precede the slide? Same
# same-day guard as perf.csv above.
api /v1/serp/history \
"$(jq -cn --arg d "$DOMAIN" --arg k "$KEYWORD" --arg f "$FROM" --arg t "$TODAY" \
'{domain: $d, keyword: $k, engine: "google", location: 2840, language: "en",
device: "desktop", date_from: $f, date_to: $t}')" > history.json
rank=$(jq -r '(.data.points | sort_by(.date) | last | .rank) // "n/a"' history.json)
if [ -f ranks.csv ] && grep -qF "$TODAY," ranks.csv; then
echo "ranks.csv already has a row for $TODAY, skipping"
else
echo "$TODAY,$rank" >> ranks.csv
fi
echo "logged: performance=$new_perf rank=$rank"
#!/usr/bin/env python3
"""perf.py -- daily performance + rank habit, cron-ready.
cron: 20 6 * * * cd /path/to/perf && python3 perf.py
"""
import csv
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
URL = os.environ.get("TRACK_URL", "https://yoursite.com/")
DOMAIN = os.environ.get("TRACK_DOMAIN", "yoursite.com")
KEYWORD = os.environ.get("TRACK_KEYWORD", "your keyword")
NOW = datetime.now(timezone.utc)
TODAY = NOW.strftime("%Y-%m-%d")
FROM = (NOW - timedelta(days=60)).strftime("%Y-%m-%d")
def api(path, payload):
resp = requests.post(
f"https://api.seofetch.com{path}",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json=payload,
)
resp.raise_for_status()
return resp.json()["data"]
# 1. today's scores + vitals
lighthouse = api("/v1/page/lighthouse", {"url": URL, "device": "mobile"})
# fetched_at is the measurement time -- a cache hit keeps its original date
MDATE = lighthouse["fetched_at"][:10]
# 2. append to perf.csv; alert on a >5pt performance drop -- skip the append
# if today already has a row, so a same-day rerun can't duplicate it
row = [MDATE, lighthouse["scores"]["performance"], lighthouse["scores"]["seo"],
lighthouse["metrics"]["lcp_ms"], lighthouse["metrics"]["cls"]]
path = Path("perf.csv")
lines = path.read_text().splitlines() if path.exists() else []
if any(line.split(",", 1)[0] == MDATE for line in lines):
print(f"perf.csv already has a row for {MDATE}, skipping")
else:
prev = lines[-1].split(",") if lines else None
with open(path, "a", newline="") as f:
csv.writer(f).writerow(row)
if prev and float(prev[1]) - row[1] > 5:
print(f"ALERT: performance dropped from {prev[1]} to {row[1]}")
# 3. rank history next to it -- did the regression precede the slide? Same
# same-day guard as perf.csv above.
history = api("/v1/serp/history",
{"domain": DOMAIN, "keyword": KEYWORD, "engine": "google", "location": 2840,
"language": "en", "device": "desktop", "date_from": FROM, "date_to": TODAY})
points = sorted(history.get("points", []), key=lambda p: p["date"])
rank = points[-1]["rank"] if points else "n/a"
ranks_path = Path("ranks.csv")
ranks_lines = ranks_path.read_text().splitlines() if ranks_path.exists() else []
if any(line.split(",", 1)[0] == TODAY for line in ranks_lines):
print(f"ranks.csv already has a row for {TODAY}, skipping")
else:
with open(ranks_path, "a", newline="") as f:
csv.writer(f).writerow([TODAY, rank])
print(f"logged: performance={row[1]} rank={rank}")
#!/usr/bin/env node
// perf.mjs -- daily performance + rank habit, cron-ready.
// cron: 20 6 * * * cd /path/to/perf && node perf.mjs
import { appendFileSync, existsSync, readFileSync } from "node:fs";
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
if (!SEOFETCH_KEY) throw new Error("export SEOFETCH_KEY first");
const URL = process.env.TRACK_URL || "https://yoursite.com/";
const DOMAIN = process.env.TRACK_DOMAIN || "yoursite.com";
const KEYWORD = process.env.TRACK_KEYWORD || "your keyword";
const NOW = new Date();
const TODAY = NOW.toISOString().slice(0, 10);
const FROM = new Date(NOW - 60 * 86400000).toISOString().slice(0, 10);
async function api(path, payload) {
const resp = await fetch(`https://api.seofetch.com${path}`, {
method: "POST",
headers: {
"Authorization": `Bearer ${SEOFETCH_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(`${resp.status} ${await resp.text()}`);
return (await resp.json()).data;
}
// 1. today's scores + vitals
const lighthouse = await api("/v1/page/lighthouse", { url: URL, device: "mobile" });
// fetched_at is the measurement time -- a cache hit keeps its original date
const MDATE = lighthouse.fetched_at.slice(0, 10);
// 2. append to perf.csv; alert on a >5pt performance drop -- skip the append
// if today already has a row, so a same-day rerun can't duplicate it
const row = [MDATE, lighthouse.scores.performance, lighthouse.scores.seo,
lighthouse.metrics.lcp_ms, lighthouse.metrics.cls];
const perfLines = existsSync("perf.csv") ? readFileSync("perf.csv", "utf8").trim().split("\n").filter(Boolean) : [];
if (perfLines.some((l) => l.split(",")[0] === MDATE)) {
console.log(`perf.csv already has a row for ${MDATE}, skipping`);
} else {
const prevLine = perfLines.length ? perfLines[perfLines.length - 1] : null;
appendFileSync("perf.csv", row.join(",") + "\n");
if (prevLine) {
const prevPerf = Number(prevLine.split(",")[1]);
if (prevPerf - row[1] > 5) console.log(`ALERT: performance dropped from ${prevPerf} to ${row[1]}`);
}
}
// 3. rank history next to it -- did the regression precede the slide? Same
// same-day guard as perf.csv above.
const history = await api("/v1/serp/history",
{ domain: DOMAIN, keyword: KEYWORD, engine: "google", location: 2840, language: "en",
device: "desktop", date_from: FROM, date_to: TODAY });
const points = [...(history.points || [])].sort((a, b) => a.date.localeCompare(b.date));
const rank = points.length ? points.at(-1).rank : "n/a";
const rankLines = existsSync("ranks.csv") ? readFileSync("ranks.csv", "utf8").trim().split("\n").filter(Boolean) : [];
if (rankLines.some((l) => l.split(",")[0] === TODAY)) {
console.log(`ranks.csv already has a row for ${TODAY}, skipping`);
} else {
appendFileSync("ranks.csv", `${TODAY},${rank}\n`);
}
console.log(`logged: performance=${row[1]} rank=${rank}`);