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 SERP scraping, no parsing HTML for a title tag, so the loop can run unattended. The four calls are the data layer. The filtering between them (what counts as winnable, where your volume floor sits) is yours to set, and it 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_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"
        ]
      }
    ]
  }
}

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. Where the cutoff 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": 2840, "language": "en"}'
← response200
{
  "id": "keyw_rpiva3tl5joh3bvfqxrxqfr5",
  "request_id": "req_7gmesd2cijjfbe6outlsx7472m",
  "object": "keyword_volume",
  "created_at": "2026-07-29T12:00:00Z",
  "elapsed_ms": 244,
  "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 counts 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_y4zlhnwzb5oofgqslo64leja",
  "request_id": "req_wtumkpqpubojpcq43dh5hehjja",
  "object": "keyword_difficulty",
  "created_at": "2026-07-29T12:00:00Z",
  "elapsed_ms": 244,
  "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 the low-difficulty, high-volume pairs. That is the shortlist. Set the ceiling and floor yourself; a keyword still "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_h5zw44vkkvjsdjvve6j66okl",
  "request_id": "req_bjo6ki2fhvkazev6rucec7bk7y",
  "object": "crawl",
  "created_at": "2026-07-29T12:00:00Z",
  "elapsed_ms": 244,
  "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
  }
}

A missing title, meta or canonical is the cheapest win. The crawl reports presence; judging quality is up to your agent. Either way it now has a ranked to-do list with evidence.

script

Run the whole thing.

Each language below is the full pipeline. Save it, export SEOFETCH_KEY, run it (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 knobs; ours, not gospel. Every entry carries its rank, volume, difficulty and gaps, which is what 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(.type == "organic" and .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."