example

A rank tracker in a cron job

Daily positions, diffed and alerted, for 1 credit per keyword per day.

One call per keyword from cron. The rest runs locally against the saved responses: extract your rank, diff against yesterday, alert on drops. No dashboard.

language
prerequisites

Export your key once.

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

A cron entry. One run per day, before anyone is watching:

$ croncrontab -e
15 6 * * * cd /path/to/tracker && ./tracker.sh
step 1

Run one search per tracked keyword from cron.

→ requestcurl
curl https://api.seofetch.com/v1/search \
  -H "Authorization: Bearer $SEOFETCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "best running shoes"}' \
  -o today.json
← response200
{
  "id": "srch_7f34stnlffnsbalonq66dpuj",
  "request_id": "req_7e5465ge5fjmziqx5dyueaqyvy",
  "object": "search",
  "created_at": "2026-07-29T12:00:00Z",
  "elapsed_ms": 244,
  "credits": {
    "charged": 1,
    "balance": 9857
  },
  "data": {
    "query": "best running shoes",
    "engine": "google",
    "location": 2840,
    "language": "en",
    "device": "desktop",
    "total_results": 84900000,
    "serp_url": "https://www.google.com/search?q=best+running+shoes",
    "result_types": [
      "organic"
    ],
    "results_count": 10,
    "items": [
      {
        "type": "organic",
        "rank": 1,
        "page": 1,
        "domain": "example.com",
        "title": "The 12 Best Running Shoes",
        "url": "https://example.com/best-running-shoes",
        "description": "Our team tested 40 pairs...",
        "displayed_link": "example.com › reviews › shoes",
        "date": null,
        "site_name": "Example Running Co.",
        "rating": {
          "value": 4.6,
          "votes": 1284,
          "max": 5
        },
        "sitelinks": [
          {
            "title": "Best Trail Running Shoes",
            "url": "https://example.com/best-running-shoes/trail",
            "description": null
          },
          {
            "title": "Best Budget Running Shoes",
            "url": "https://example.com/best-running-shoes/budget",
            "description": null
          }
        ],
        "price": null,
        "highlighted_words": [
          "running shoes"
        ]
      }
    ]
  }
}

Flat rate per keyword at the default depth (see the cost line above). Save the raw response for the next step.

step 2

Extract your own position.

$ localrun locally
jq --arg d yourdomain.com '[.data.items[] | select(.type == "organic" and .domain == $d) | .rank][0] // "miss"' today.json

One number per keyword per day: your organic rank -- ads and SERP features are never counted. "miss" when you're not in the results, so the row survives. A miss means outside the top 10 at the default depth; track deeper with depth=100. Append it to a CSV, a SQLite table, wherever.

step 3

Diff against yesterday and alert on drops.

$ localrun locally
awk -F, 'NR==FNR { prev[$1]=$2; next }
  ($1 in prev) && prev[$1] != "miss" && $2 != "miss" && ($2+0) > (prev[$1]+0) { print $1 ": " prev[$1] " -> " $2 }
  ($1 in prev) && prev[$1] != "miss" && $2 == "miss" { print $1 ": " prev[$1] " -> miss (dropped out)" }' yesterday.csv today.csv

Pipe the output to a Slack webhook or your mail command. The rest is glue: rotating snapshots, the miss sentinel, and whatever hardening your setup wants (CSV escaping, overlap locks, retention).

step 4

A long search can 504. The connection gave up; the job didn't.

The charge stands and the job keeps running. Reconnecting to collect it is covered once, on The Contract.

script

Run the whole thing.

Each language below is the full tracker, cron-ready. keywords.txt in, a dated snapshot out, misses recorded, drops printed for you to pipe wherever. A bad keyword (404, 504, whatever) is logged and skipped.

→ runtracker.sh
#!/usr/bin/env bash
# tracker.sh -- daily rank tracker, cron-ready. keywords.txt: one keyword per line.
# cron: 15 6 * * *  cd /path/to/tracker && ./tracker.sh
set -euo pipefail
: "${SEOFETCH_KEY:?export SEOFETCH_KEY first}"
DOMAIN="${TRACK_DOMAIN:-yourdomain.com}"
TODAY=$(date -u +%F)
mkdir -p snapshots
OUT="snapshots/$TODAY.csv"
: > "$OUT"

while IFS= read -r kw; do
  [ -z "$kw" ] && continue
  # -w tacks the status code onto the last line.
  resp=$(curl -sS https://api.seofetch.com/v1/search \
    -H "Authorization: Bearer $SEOFETCH_KEY" \
    -H "Content-Type: application/json" \
    -d "$(jq -cn --arg q "$kw" '{query: $q}')" \
    -w $'\n%{http_code}')
  code=${resp##*$'\n'}
  body=${resp%$'\n'*}
  if [ "$code" != "200" ]; then
    # a 504 means the job kept running server-side past your connection --
    # the charge stands either way. Any non-2xx (401/429/504/500/...) is
    # never a "miss" -- skip it rather than parse the error body as data.
    echo "warning: $kw returned HTTP $code, skipping" >&2
    continue
  fi
  # "miss" = not in the returned results (top 10 at the default depth) --
  # the row survives so the diff below can see you drop out entirely
  rank=$(printf '%s' "$body" | jq -r --arg d "$DOMAIN" \
    '[.data.items[]? | select(.type == "organic" and .domain == $d) | .rank][0] // "miss"')
  printf '%s,%s\n' "$kw" "$rank" >> "$OUT"
done < keywords.txt

# diff against yesterday, alert on drops (macOS date -v / GNU date -d both handled)
PREV="snapshots/$(date -u -v-1d +%F 2>/dev/null || date -u -d yesterday +%F).csv"
if [ -f "$PREV" ]; then
  awk -F, 'NR==FNR { prev[$1]=$2; next }
    ($1 in prev) && prev[$1] != "miss" && $2 != "miss" && ($2+0) > (prev[$1]+0) \
      { print $1 ": " prev[$1] " -> " $2 }
    ($1 in prev) && prev[$1] != "miss" && $2 == "miss" \
      { print $1 ": " prev[$1] " -> miss (dropped out)" }' "$PREV" "$OUT" |
    sh -c "${ALERT_CMD:-cat}"   # e.g. ALERT_CMD="mail -s 'rank drops' you@example.com"
fi