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 the aggregate for weeks. A daily Lighthouse run shows it within 24 hours, with rank history next to it for the postmortem.

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_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.

step 2

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

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

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_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.

script

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.

→ 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; 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"