example

A rank tracker in a cron job

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

One seofetch call per keyword, kicked off by cron; the rest runs locally against the saved responses — extract your rank, diff against yesterday, alert on drops. No dashboard, no extra API surface.

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, well before anyone's 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_tnq5objwpmflh2poczbwxnfr",
  "request_id": "req_c7leybmgtzholcwoyhozbqsqkq",
  "object": "search",
  "created_at": "2026-08-08T16:50:38Z",
  "elapsed_ms": 142,
  "cache": "miss",
  "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": [
          {
            "type": "sitelink",
            "title": "Best Trail Running Shoes",
            "description": null,
            "url": "https://example.com/best-running-shoes/trail"
          },
          {
            "type": "sitelink",
            "title": "Best Budget Running Shoes",
            "description": null,
            "url": "https://example.com/best-running-shoes/budget"
          }
        ],
        "price": null
      }
    ]
  }
}

Flat per-keyword rate at the default depth — see the cost line above. Save the raw response so the next step can read it.

step 2

Extract your own position.

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

One number per keyword per day — "miss" when you're not in the results, so the row survives instead of vanishing. A miss means not in the top 10 (the default depth); track deeper with depth=100. Append it to a CSV, 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 your Slack webhook or mail command. What's left — rotating snapshots, the miss sentinel — is glue, and it's yours — plus whatever production hardening your setup wants (CSV escaping, overlap locks, retention).

step 4

A long search can 504 — the connection gave up, not the job.

The charge stands and the job keeps running server-side — reconnecting to collect it is an advanced topic covered once, completely, on The Contract, not repeated here.

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 you like. A bad keyword (404, 504, whatever) is skipped and logged, not fatal.

→ 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(.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