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 the aggregate for weeks. A daily Lighthouse run shows it within 24 hours, with rank history next to it for the postmortem.
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_5et7ukoc4foy7aiqbdlnwm2b",
"request_id": "req_k4rjf4ql3rjrlfxbdyyt5nc67i",
"object": "lighthouse",
"created_at": "2026-07-29T12:00:00Z",
"elapsed_ms": 244,
"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 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; use it, not the current 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; use it, not the current 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; use it, not the current 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_dz66m7eqjbpfblkclv6jye2g",
"request_id": "req_id5g52qgwzmmnoafbmteiscgvi",
"object": "serp_history",
"created_at": "2026-07-29T12:00:00Z",
"elapsed_ms": 244,
"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 job, cron-ready. Save it, export SEOFETCH_KEY, run it. perf.csv gets one row a day, rank goes to ranks.csv, and a drop of more than 5 points 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; use it, not the current 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 | select(.type == "organic") | .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; use it, not the current 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 and points[-1]["type"] == "organic" 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; use it, not the current 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).type === "organic" ? 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}`);