example

Let an LLM pick what to optimize next

An agent ranks your pages by winnable upside — positions, volume, difficulty, on-page gaps.

Every request and response here is JSON with a fixed envelope — no scraping a SERP, no parsing HTML for a title tag — which is what makes the loop automatable. The four calls below are the data layer; the filtering between them (what counts as winnable, where your volume floor sits) is policy you give the agent, and yours will differ from ours.

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

Ask where you currently rank for each target query.

→ requestcurl
curl https://api.seofetch.com/v1/search \
  -H "Authorization: Bearer $SEOFETCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "best running shoes"}'
← response200
{
  "id": "srch_vaqa36dz2qble5iobr3wz2eb",
  "request_id": "req_kp6yi3y2ojevfnzoqeemujf2my",
  "object": "search",
  "created_at": "2026-08-08T17:16:27Z",
  "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
      }
    ]
  }
}

Feed the agent your query list. A common filter is positions 4-10 — close enough that on-page work can move them (the default depth returns the top 10; depth 100 widens the window at 10x the price). Treat the cutoff as a heuristic; where it sits for you depends on the SERP.

step 2

Size the demand: how many searches does each query actually get?

→ requestcurl
curl https://api.seofetch.com/v1/keywords/volume \
  -H "Authorization: Bearer $SEOFETCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"keywords": ["running shoes", "trail running shoes"], "location": "US", "language": "en"}'
← response200
{
  "id": "keyw_cposmrn4bmy3737zpzl5maxh",
  "request_id": "req_oxlt6fak5bccle4bv3a6oidbhm",
  "object": "keyword_volume",
  "created_at": "2026-08-09T10:01:41Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 20,
    "balance": 9857
  },
  "data": {
    "location": {
      "code": 2840,
      "name": "United States"
    },
    "language": {
      "code": "en",
      "name": "English"
    },
    "network": "google_search",
    "items_count": 2,
    "items": [
      {
        "keyword": "running shoes",
        "avg_monthly_searches": 90500,
        "competition": "HIGH",
        "competition_index": 88,
        "cpc": 1.24,
        "low_bid": 0.42,
        "high_bid": 2.1,
        "monthly": [
          {
            "month": "2026-06",
            "search_volume": 91000
          },
          "…"
        ]
      },
      {
        "keyword": "trail running shoes",
        "avg_monthly_searches": 8100,
        "competition": "MEDIUM",
        "competition_index": 54,
        "cpc": 0.87,
        "low_bid": 0.31,
        "high_bid": 1.55,
        "monthly": [
          {
            "month": "2026-06",
            "search_volume": 8300
          },
          "…"
        ]
      }
    ]
  }
}

Drop queries under your volume floor before paying for difficulty. Volume is searches, not visits — what you'd capture depends on rank and what else is on the SERP.

step 3

Filter to winnable: keyword difficulty for the survivors.

→ requestcurl
curl https://api.seofetch.com/v1/keywords/difficulty \
  -H "Authorization: Bearer $SEOFETCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"keywords": ["buy running shoes", "cold keyword"], "location": 2840, "language": "en"}'
← response200
{
  "id": "keyw_cusvwio7pbnsxv7ga2a4fboo",
  "request_id": "req_lkxquvfxvvfq7ni47st4oc4fzq",
  "object": "keyword_difficulty",
  "created_at": "2026-08-09T10:01:41Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 110,
    "balance": 9857
  },
  "data": {
    "items_count": 2,
    "items": [
      {
        "keyword": "buy running shoes",
        "difficulty": 42,
        "status": "available"
      },
      {
        "keyword": "cold keyword",
        "difficulty": null,
        "status": "pending"
      }
    ]
  }
}

The agent keeps low-difficulty/high-volume pairs — the shortlist. The ceiling and floor are your policy, not ours; a keyword still status "pending" gets its score on a later call.

step 4

Find the on-page gaps on each shortlisted URL.

→ requestcurl
curl https://api.seofetch.com/v1/page/crawl \
  -H "Authorization: Bearer $SEOFETCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/"}'
← response200
{
  "id": "craw_75lbej7eur5d5fnusum4rftm",
  "request_id": "req_bwtdp3cobjgwlhpekwzduc5zt4",
  "object": "crawl",
  "created_at": "2026-08-09T10:01:41Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 2,
    "balance": 9857
  },
  "data": {
    "url": "https://example.com/",
    "final_url": "https://example.com/",
    "http_status": 200,
    "title": "Example Domain",
    "meta_description": "An example page used for documentation.",
    "canonical": "https://example.com/",
    "headings": {
      "h1_count": 1,
      "h2_count": 2
    },
    "links": {
      "internal": 2,
      "external": 1
    },
    "images": {
      "total": 2,
      "missing_alt": 1
    },
    "is_html": true,
    "load_ms": 812
  }
}

Missing title, meta, or canonical are the cheapest wins — the crawl reports presence, judging quality is your agent's policy. Either way it now has a ranked TODO with evidence.

script

Run the whole thing.

Each language below is the full pipeline — save it, run it after exporting SEOFETCH_KEY (chmod +x first for the shell version). queries.txt in, todo.json out. The 4-10 window, the 1,000 volume floor, and the 50 difficulty ceiling are the knobs — ours, not gospel. Every entry carries its rank, volume, difficulty, and gaps — the evidence your agent ranks with.

→ runoptimize.sh
#!/usr/bin/env bash
# optimize.sh -- queries -> your ranks -> demand -> difficulty -> on-page gaps.
# queries.txt: one query per line. Output: todo.json, most gaps first.
set -euo pipefail
: "${SEOFETCH_KEY:?export SEOFETCH_KEY first}"
DOMAIN="${TARGET_DOMAIN:-yourdomain.com}"
# SEARCH_DEPTH=100 MAX_RANK=15 widens the window -- 10 credits per query instead of 1
SEARCH_DEPTH="${SEARCH_DEPTH:-10}"
MAX_RANK="${MAX_RANK:-10}"
api() { curl -sS --fail-with-body "https://api.seofetch.com$1" \
  -H "Authorization: Bearer $SEOFETCH_KEY" \
  -H "Content-Type: application/json" \
  -d "$2"; }

# 1. where you rank; keep 4-10 (a heuristic -- tune the window to your SERP)
: > ranked.jsonl
while IFS= read -r q; do
  [ -z "$q" ] && continue
  api /v1/search "$(jq -cn --arg q "$q" --argjson d "$SEARCH_DEPTH" '{query: $q, depth: $d}')" |
    jq -c --arg d "$DOMAIN" --arg q "$q" \
      '.data.items[]? | select(.domain == $d) | {query: $q, rank, url}' >> ranked.jsonl
done < queries.txt
jq -c --argjson m "$MAX_RANK" 'select(.rank >= 4 and .rank <= $m)' ranked.jsonl > window.jsonl
[ -s window.jsonl ] || { echo "nothing in the 4-$MAX_RANK window today"; exit 0; }

# 2. demand for the window; drop what's under your floor (1000 here -- policy knob)
api /v1/keywords/volume \
  "$(jq -s '{keywords: [.[].query], location: 2840, language: "en"}' window.jsonl)" > volume.json
jq -c '.data.items[] | select(.avg_monthly_searches >= 1000)' volume.json > loud.jsonl
[ -s loud.jsonl ] || { echo "window is all low-volume; lower the floor or add queries"; exit 0; }

# 3. difficulty; keep < 50. status "pending" keywords score on a later run -- same flow.
# winnable.jsonl carries volume through so the final TODO doesn't lose it.
api /v1/keywords/difficulty \
  "$(jq -s '{keywords: [.[].keyword], location: 2840, language: "en"}' loud.jsonl)" > difficulty.json
jq -c --slurpfile loud <(jq -s '.' loud.jsonl) \
  '($loud[0] | map({(.keyword): .avg_monthly_searches}) | add) as $vmap |
   .data.items[] | select(.status == "available" and .difficulty < 50) |
   {keyword, avg_monthly_searches: $vmap[.keyword], difficulty, status}' \
  difficulty.json > winnable.jsonl
jq -c '.data.items[] | select(.status == "pending")' difficulty.json > pending.jsonl

# 4. crawl each shortlisted URL; name the gaps; rank the TODO -- every entry
#    carries query, url, rank, avg_monthly_searches, difficulty, and gaps
: > todo.jsonl
while IFS= read -r w; do
  kw=$(jq -r '.keyword' <<<"$w")
  match=$(jq -c --arg q "$kw" 'select(.query == $q)' window.jsonl | head -n1)
  url=$(jq -r '.url // empty' <<<"$match")
  [ -n "$url" ] || continue
  api /v1/page/crawl "$(jq -cn --arg u "$url" '{url: $u}')" |
    jq -c --argjson w "$w" --argjson m "$match" '{
        query: $w.keyword, url: .data.url, rank: $m.rank,
        avg_monthly_searches: $w.avg_monthly_searches, difficulty: $w.difficulty,
        gaps: [
          (if .data.title == null then "title" else empty end),
          (if .data.meta_description == null then "meta_description" else empty end),
          (if .data.canonical == null then "canonical" else empty end)
        ]
      }' >> todo.jsonl
done < winnable.jsonl
jq -s 'sort_by(-(.gaps | length), -.avg_monthly_searches)' todo.jsonl > todo.json
echo "todo.json: $(jq length todo.json) pages, most gaps first (volume breaks ties). pending.jsonl re-scores tomorrow."