example

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.

language
prerequisites

Export your key once.

$ setupcurl
export SEOFETCH_KEY=sof_live_…
# the scripts below use jq -- brew install jq / apt-get install jq
step 1

Grab the scores and the vitals for the page you care about.

→ requestcurl
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
← response200
{
  "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.

step 2

One CSV row per day; alert on deltas, not absolutes.

$ localrun locally
# 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 }'

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.

step 3

Put rank history next to the vitals.

→ requestcurl
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"}'
← response200
{
  "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.

script

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.

→ runperf.sh
#!/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"