DataForSEO API Keyword Research Agent with SandBase
Build a DataForSEO API keyword research agent in Python: keyword volume and difficulty, live SERPs, competitor rankings and an LLM plan, all via SandBase.

DataForSEO returned seven keywords at exactly 49,500 monthly searches for the seed “ai agent”: “ai agent”, “agent ai”, “agent in ai”, “ai intelligent agent” and three more. Their 12-month volume series were identical. If you add those rows up you get 346,500 searches that don’t exist.
That’s the first thing a DataForSEO API keyword research agent has to handle, and it’s a good example of why the numbers belong in code. This tutorial builds one in about 170 lines of Python. You give it a seed topic and a competitor domain. It returns a prioritized keyword list with search volume and difficulty, the current top organic results for the top three keywords, and the keywords the competitor already ranks for. Then openai/gpt-6.1-sol writes a short plan from those computed numbers. Every call goes through SandBase with one API key.
Key takeaway
- Three DataForSEO endpoints cover the job:
keyword_suggestions(ideas, volume, difficulty, intent),ranked_keywords(what the competitor ranks for) andserp/google/organic/live/advanced(live top results). All three are enabled on SandBase as of 2026-10-03.- One full run made 5 data calls and 1 LLM call. The data calls were billed $0.00 on 2026-10-03 (model pages show Free during the site’s “API Free Week”); the LLM call cost $0.0104.
- Close variants share one volume figure. Grouping by the 12-month series collapsed 50 suggestions into fewer, honest rows; “ai agent” alone absorbed 7.
- Live SERPs moved fast: two checks about six minutes apart shared none of the top five organic domains for “ai agent”. Treat one SERP pull as a sample.
- The data is third-party SEO estimates from DataForSEO, not Google’s own data.
What the agent produces
| Output | Source | Price on SandBase (2026-10-03) |
|---|---|---|
Keyword ideas containing the seed, with search_volume, keyword_difficulty, main_intent | dataforseo_labs/google/keyword_suggestions/live | Free (model page) |
| Keywords the competitor ranks for, with its position and URL | dataforseo_labs/google/ranked_keywords/live | Free (model page) |
| Top organic domains for the 3 best keywords | serp/google/organic/live/advanced | Free (model page) |
| Variant grouping, opportunity score, merge, CSV | Python | — |
| A plan of up to 8 bullets | openai/gpt-6.1-sol | $2/M input, $10/M output tokens |
The output is keyword_plan.csv plus a printed table and plan.
The DataForSEO catalog on SandBase lists 74 endpoints as of 2026-10-03. The selected card (an OnPage endpoint this agent doesn’t call) shows Available and Free:
Caption: The SandBase DataForSEO catalog lists 74 endpoints, including SERP Google Organic Live, under an “API Free Week” banner (captured 2026-10-03).
The catalog shows a GET /apis/v1/... surface. This tutorial uses the Model API route POST https://api.sandbase.ai/v1/api/dataforseo/<path> from the endpoint references.
Data boundary, and DataForSEO direct vs SandBase
This uses public, read-only SEO data. Keyword volumes, difficulty scores and SERP snapshots are third-party estimates produced by DataForSEO, the upstream provider. They are not Google’s own data, and SandBase is not affiliated with Google. The agent needs a SandBase API key. It doesn’t touch Google Search Console, Google Ads accounts, site owner analytics, or anything that changes a site or account.
You can call DataForSEO directly too. The difference is mostly plumbing:
| DataForSEO directly | DataForSEO through SandBase | |
|---|---|---|
| Auth | HTTP Basic with your DataForSEO API login and password (auth docs) | Authorization: Bearer $SANDBASE_API_KEY |
| Request body | A JSON array of task objects | One task object with the same fields |
| Response | DataForSEO’s tasks[].result[] | The same DataForSEO body, inside SandBase’s outputs[0] |
| LLM step | A separate provider, key and bill | Same key, POST /v1/chat/completions |
| Choose it when | You only need DataForSEO, at volume, on your own contract | You want SEO data and a model behind one key and one contract |
I didn’t compare prices between the two; DataForSEO publishes its own pricing.
Step 1: Keyword ideas with volume and difficulty
The SandBase references for DataForSEO don’t list request fields; the model page shows “0 input fields” and the body is forwarded to DataForSEO. So the parameters come from DataForSEO’s keyword_suggestions docs: keyword (required), location_code, language_code, limit, filters, order_by, include_seed_keyword.
Caption: The keyword_suggestions/live model page shows Base price Free, sync execution and 0 input fields, so request fields follow DataForSEO’s own docs (captured 2026-10-03).
Tested on 2026-10-03 (UTC)
Public inputs: the generic seed “ai agent”, Google United States (location_code 2840), English, and Microsoft Learn (learn.microsoft.com) as the competitor domain.
curl -s https://api.sandbase.ai/v1/api/dataforseo/v3/dataforseo_labs/google/keyword_suggestions/live \
-H "Authorization: Bearer $SANDBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"keyword": "ai agent", "location_code": 2840, "language_code": "en",
"include_seed_keyword": true, "limit": 50,
"filters": [["keyword_info.search_volume", ">=", 100]],
"order_by": ["keyword_info.search_volume,desc"]}'
Trimmed response (run id 6ed0cb41-ad1f-4bf3-a10e-bb1c046e2223, one of 50 items):
{
"id": "6ed0cb41-ad1f-4bf3-a10e-bb1c046e2223",
"status": "completed",
"model": "dataforseo/v3/dataforseo_labs/google/keyword_suggestions/live",
"outputs": [{
"status_code": 20000,
"tasks": [{"status_code": 20000, "result": [{
"total_count": 615, "items_count": 50,
"items": [{
"keyword": "ai agent",
"keyword_info": {"search_volume": 49500, "competition_level": "MEDIUM", "cpc": 20.9,
"monthly_searches": [{"year": 2026, "month": 8, "search_volume": 49500}, "…"]},
"keyword_properties": {"core_keyword": "agentic ai", "keyword_difficulty": 70},
"search_intent_info": {"main_intent": "commercial"}
}]
}]}]
}]
}
One thing to know before you copy the parsing code. The endpoint reference documents a completed response as outputs[0].data, and that is the contract the helper reads first. On 2026-10-03, though, every DataForSEO call I made (22 of them) returned DataForSEO’s own body directly as outputs[0] (version, status_code, tasks) with no data key. So the helper uses outputs[0].data when it holds a DataForSEO body and falls back to outputs[0] otherwise, and raises if neither has tasks. I’ve reported the mismatch to the SandBase team.
The business fields above (search_volume, keyword_difficulty, monthly_searches, core_keyword, main_intent) are observed in these runs and in DataForSEO’s docs. They are not guarantees from SandBase. The code reads each with .get(). The payload also carries DataForSEO’s own cost field; what your SandBase account is charged is what GET /v1/tasks/<id>/cost reports.
Step 2: What the competitor already ranks for
ranked_keywords takes a target domain. Its data is a DataForSEO snapshot that their docs say updates weekly, so these positions are not a same-day Google check (the SERP step below is the live one). I filtered to keywords containing the seed and sorted by volume:
{"target": "learn.microsoft.com", "location_code": 2840, "language_code": "en", "limit": 50,
"filters": [["keyword_data.keyword", "like", "%ai agent%"]],
"order_by": ["keyword_data.keyword_info.search_volume,desc"]}
Run id d38178c1-e21d-4610-9763-02c222363b14 reported total_count 254 and returned 50 items. One, trimmed:
{"keyword_data": {"keyword": "ai agent course",
"keyword_info": {"search_volume": 1000},
"keyword_properties": {"keyword_difficulty": 12}},
"ranked_serp_element": {"serp_item": {"type": "organic", "rank_group": 2, "rank_absolute": 3,
"domain": "learn.microsoft.com",
"url": "https://learn.microsoft.com/en-us/shows/ai-agents-for-beginners/"}}}
keyword_data uses the same field names as the suggestion items, which is why one row_from() function flattens both. rank_group is the position among organic results; rank_absolute counts every SERP element.
Caption: The ranked_keywords/live reference shows POST /v1/api/dataforseo/v3/dataforseo_labs/google/ranked_keywords/live and a completed example with outputs[0].data, the shape that differed from the live calls (captured 2026-10-03).
The reference’s generated curl sample puts a model field in the body. The path already names the model, so the code sends only DataForSEO’s fields.
Before I settled on this step, I tried the obvious shortcut: pass 30 suggestions from an earlier test call to ranked_keywords with an in filter (run id 75cad2ec-fd7d-4eda-ace5-8274c295eff0). Microsoft Learn ranked for exactly one of them (“no code ai agent builder”, position 58). The keywords it does rank for, like “build ai agents” and “ai agent course”, never showed up as suggestions. A competitor gap comes from merging both lists, not intersecting them.
Step 3: Live SERP for the top keywords
The SERP endpoint (DataForSEO docs) takes keyword, location_code, language_code and depth. With depth: 10, the three calls returned 12 or 13 items each, of which 6 to 8 were organic. The first item was an ai_overview every time, so the code filters on type == "organic". Run id a6618702-02a4-47bd-8f85-e848f2309252 for “ai voice agent”:
[{"type": "ai_overview", "rank_group": 1, "rank_absolute": 1},
{"type": "organic", "rank_group": 1, "rank_absolute": 2, "domain": "www.retellai.com"},
{"type": "organic", "rank_group": 2, "rank_absolute": 3, "domain": "elevenlabs.io"},
{"type": "organic", "rank_group": 3, "rank_absolute": 4, "domain": "www.reddit.com"}]
Then I ran the whole program again six minutes later. For “ai agent”, the 04:10 UTC check had IBM, aiagent.app, AWS, Wikipedia and Meta in the top five organic results. The 04:16 check had Reddit, LinkedIn, retresco.de, airia.com and airbyte.com. No overlap at all. “what is an ai agent” had none either; “ai voice agent” shared only Reddit. I can’t tell from two samples whether that’s Google’s own churn, AI Overview layouts, or DataForSEO’s collection, so the plan treats SERP domains as a hint about page type, not a ranking target list.
Step 4: Scoring in code
Three rules, all in build_table():
- Merge both lists by keyword and copy the competitor’s
rank_groupand URL onto matching rows. - Drop rows with volume under 100, no difficulty, or
navigationalintent (brand lookups like “n8n ai agent”). - Group close variants by their identical 12-month volume series. The representative contains the seed if possible, then is the shortest.
variantsrecords how many rows were folded in.
Then opportunity = volume × (100 − difficulty) / 100. It’s a heuristic that discounts volume by DataForSEO’s 0–100 difficulty. It is not a traffic forecast. The table keeps the 12 best rows plus up to 5 more where the competitor ranks, so the gap list never falls off the bottom.
Why the volume series and not DataForSEO’s core_keyword? In this run core_keyword put “ai agent” with “agentic ai” but put “ai intelligent agent” in a different group, even though both reported the same 49,500 and identical monthly numbers. The series match is cruder but it targets the actual problem: double-counted volume. Two unrelated keywords could share a series by coincidence; at volume ≥ 100 over 12 months I didn’t see it happen.
Step 5: The LLM writes the plan
openai/gpt-6.1-sol gets the table and the SERP domains as JSON and a strict instruction: quote numbers only from the data and don’t compute new ones. I picked it because our Claude Sonnet 5.5 vs GPT-6.1 Sol agent test found it answered 30 of 30 short tool tasks correctly at about $0.0066 per task. The misses in that test were arithmetic over tool output, which is exactly why the averaging, grouping and scoring stay in Python here. The call uses the OpenAI-compatible Chat Completions route.
Complete code
#!/usr/bin/env python3
"""SEO keyword-research agent: DataForSEO data through SandBase, numbers in code, plan by an LLM.
Usage: python3 seo_research_agent.py "ai agent" learn.microsoft.com
Needs SANDBASE_API_KEY in the environment. Writes keyword_plan.csv and prints a plan.
"""
import csv
import json
import os
import sys
import requests
FIELDS = ["keyword", "variants", "volume", "difficulty", "intent", "opportunity",
"competitor_rank", "competitor_url", "serp_top3"]
API = "https://api.sandbase.ai"
HEADERS = {
"Authorization": f"Bearer {os.environ['SANDBASE_API_KEY']}",
"Content-Type": "application/json",
}
LOCALE = {"location_code": 2840, "language_code": "en"} # United States, English
TOP_N = 12 # best rows by opportunity
COMPETITOR_EXTRA = 5 # extra rows where the competitor ranks, even below the cut
SERP_CHECKS = 3 # live SERP lookups for the top keywords
def dataforseo(path: str, params: dict) -> dict:
"""POST /v1/api/dataforseo/<path> and return the first DataForSEO task result."""
resp = requests.post(f"{API}/v1/api/dataforseo/{path}", headers=HEADERS, json=params, timeout=120)
resp.raise_for_status()
body = resp.json()
outputs = body.get("outputs") or []
if body.get("status") != "completed" or not outputs:
raise RuntimeError(f"SandBase call did not complete: {body.get('status')} {body.get('error')}")
# Documented contract: outputs[0].data. Observed on 2026-10-03: DataForSEO's body sat directly in outputs[0].
data = outputs[0].get("data")
payload = data if isinstance(data, dict) and "tasks" in data else outputs[0]
tasks = payload.get("tasks") or []
if not tasks or tasks[0].get("status_code") != 20000:
task = tasks[0] if tasks else payload
raise RuntimeError(f"DataForSEO task failed: {task.get('status_code')} {task.get('status_message')}")
results = tasks[0].get("result") or []
return results[0] if results else {}
def keyword_ideas(seed: str, limit: int = 50) -> list[dict]:
"""Keywords that contain the seed, with volume >= 100, biggest first."""
result = dataforseo("v3/dataforseo_labs/google/keyword_suggestions/live", {
"keyword": seed, **LOCALE, "include_seed_keyword": True, "limit": limit,
"filters": [["keyword_info.search_volume", ">=", 100]],
"order_by": ["keyword_info.search_volume,desc"],
})
return result.get("items") or []
def competitor_keywords(domain: str, seed: str, limit: int = 50) -> list[dict]:
"""Keywords containing the seed that the competitor domain ranks for."""
result = dataforseo("v3/dataforseo_labs/google/ranked_keywords/live", {
"target": domain, **LOCALE, "limit": limit,
"filters": [["keyword_data.keyword", "like", f"%{seed}%"]],
"order_by": ["keyword_data.keyword_info.search_volume,desc"],
})
return result.get("items") or []
def top_organic(keyword: str, n: int = 5) -> list[dict]:
"""Live Google organic results (first page) for one keyword."""
result = dataforseo("v3/serp/google/organic/live/advanced", {"keyword": keyword, **LOCALE, "depth": 10})
organic = [i for i in result.get("items") or [] if i.get("type") == "organic"]
return [{"rank": i.get("rank_group"), "domain": i.get("domain"), "url": i.get("url")} for i in organic[:n]]
def row_from(kw: dict) -> dict:
"""Flatten the keyword fields both Labs endpoints share."""
info = kw.get("keyword_info") or {}
props = kw.get("keyword_properties") or {}
# Close variants ("ai agent", "agent ai") came back with identical 12-month volume series.
series = tuple(m.get("search_volume") for m in info.get("monthly_searches") or [])
return {
"keyword": kw.get("keyword"),
"cluster": series or kw.get("keyword"),
"volume": info.get("search_volume") or 0,
"difficulty": props.get("keyword_difficulty"),
"intent": (kw.get("search_intent_info") or {}).get("main_intent"),
"competitor_rank": None,
"competitor_url": "",
}
def build_table(seed: str, ideas: list[dict], ranked: list[dict]) -> list[dict]:
"""Merge both sources, collapse close variants, score, and keep the top rows."""
rows = {}
for kw in ideas:
rows[kw.get("keyword")] = row_from(kw)
for item in ranked:
kw = item.get("keyword_data") or {}
serp_item = (item.get("ranked_serp_element") or {}).get("serp_item") or {}
row = rows.setdefault(kw.get("keyword"), row_from(kw))
row["competitor_rank"] = serp_item.get("rank_group")
row["competitor_url"] = serp_item.get("url") or ""
# One row per variant group. Representative: contains the seed, then the shortest keyword.
keep = [r for r in rows.values()
if r["volume"] >= 100 and r["difficulty"] is not None and r["intent"] != "navigational"]
keep.sort(key=lambda r: (seed not in r["keyword"], len(r["keyword"])))
best = {}
for row in keep:
winner = best.setdefault(row["cluster"], {**row, "variants": 0})
winner["variants"] += 1
if row["competitor_rank"] and not winner["competitor_rank"]:
winner["competitor_rank"], winner["competitor_url"] = row["competitor_rank"], row["competitor_url"]
# Opportunity = volume discounted by difficulty (0-100). A heuristic, not a traffic forecast.
for row in best.values():
row["opportunity"] = round(row["volume"] * (100 - row["difficulty"]) / 100)
ranked_rows = sorted(best.values(), key=lambda r: r["opportunity"], reverse=True)
# Top rows overall, plus the competitor's best keywords even if they fall below the cut.
table = ranked_rows[:TOP_N]
table += [r for r in ranked_rows if r["competitor_rank"] and r not in table][:COMPETITOR_EXTRA]
return table
def write_plan(seed: str, domain: str, table: list[dict], serps: dict) -> tuple[str, dict]:
"""Ask openai/gpt-6.1-sol for a short plan. It gets computed numbers and must not invent new ones."""
prompt = (
f"Seed topic: {seed}. Competitor: {domain}. Market: Google US, English.\n"
f"Keyword table (opportunity = volume x (100 - difficulty) / 100, computed in code):\n"
f"{json.dumps([{k: r.get(k) for k in FIELDS} for r in table], ensure_ascii=False)}\n"
f"Current top organic results for the top keywords:\n{json.dumps(serps, ensure_ascii=False)}\n\n"
"Write a content plan in at most 8 bullets: which 3-5 keywords to target first and why, "
"where the competitor already ranks, and what page type each target needs based on the SERP. "
"Quote numbers only from the data above; do not compute or estimate new ones."
)
resp = requests.post(f"{API}/v1/chat/completions", headers=HEADERS, timeout=120, json={
"model": "openai/gpt-6.1-sol",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 800,
})
resp.raise_for_status()
body = resp.json()
return body["choices"][0]["message"]["content"], body.get("usage") or {}
def main(seed: str, domain: str) -> None:
ideas = keyword_ideas(seed)
ranked = competitor_keywords(domain, seed)
print(f"{len(ideas)} keyword suggestions, {len(ranked)} competitor keywords containing '{seed}'")
table = build_table(seed, ideas, ranked)
serps = {row["keyword"]: top_organic(row["keyword"]) for row in table[:SERP_CHECKS]}
for row in table[:SERP_CHECKS]:
row["serp_top3"] = " | ".join(r["domain"] for r in serps[row["keyword"]][:3])
with open("keyword_plan.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS, extrasaction="ignore")
writer.writeheader()
writer.writerows(table)
print(f"\n{'keyword':28} {'var':>3} {'volume':>7} {'KD':>3} {'opp':>6} {'comp':>4} intent")
for row in table:
rank = row["competitor_rank"] or "-"
print(f"{row['keyword'][:28]:28} {row['variants']:>3} {row['volume']:>7} {row['difficulty']:>3} "
f"{row['opportunity']:>6} {rank:>4} {row['intent']}")
plan, usage = write_plan(seed, domain, table, serps)
print("\nPlan (openai/gpt-6.1-sol):\n" + plan)
print(f"\nLLM usage: {usage.get('prompt_tokens')} input / {usage.get('completion_tokens')} output tokens")
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit('usage: python3 seo_research_agent.py "<seed topic>" <competitor domain>')
main(sys.argv[1], sys.argv[2])
It needs Python 3.9+ and requests. Run it with python3 seo_research_agent.py "ai agent" learn.microsoft.com.
What the run printed
This is the output of the program above, run on 2026-10-03 at 04:15 UTC (all six calls completed):
50 keyword suggestions, 50 competitor keywords containing 'ai agent'
keyword var volume KD opp comp intent
ai agent 7 49500 70 14850 - commercial
ai voice agent 1 22200 38 13764 - commercial
what is an ai agent 1 18100 49 9231 - informational
open source ai agent 2 9900 20 7920 - commercial
ai code agent 3 8100 30 5670 - commercial
ai powered coding agent 1 8100 32 5508 - commercial
gemini spark ai agent 1 6600 18 5412 - informational
autonomous ai agent 1 6600 22 5148 - informational
ai agent moltbook 1 6600 43 3762 - transactional
open-source ai coding agent 2 5400 31 3726 - commercial
self-hosted ai agent 1 3600 0 3600 - commercial
no-code ai agent builder 2 3600 10 3240 58 commercial
build ai agent 4 2400 20 1920 4 commercial
how to build ai agents 1 1600 41 944 4 informational
ai agent course 2 1000 12 880 2 commercial
ai agent frameworks 1 1000 27 730 2 commercial
build an ai agent 2 1000 28 720 4 informational
LLM usage: 1957 input / 551 output tokens
The plan named “open source ai agent”, “ai voice agent”, “what is an ai agent”, “self-hosted ai agent” and “no-code ai agent builder” as first targets. It pointed out that Microsoft Learn’s AI Agents for Beginners page already ranks 4 for “build ai agent” and 2 for “ai agent course”, and suggested deferring those head-to-head fights. One bullet I liked: for keywords without a SERP check, it called the page type “provisional” instead of inventing one. It also said Microsoft’s rank was “not reported” for rows without a match, which is the right reading. ranked_keywords only returns what DataForSEO has indexed.
Two rows in the table are a reminder to read before you publish. “self-hosted ai agent” shows difficulty 0, and “ai agent moltbook” and “gemini spark ai agent” look like product names that happen to be tagged informational or transactional. The code doesn’t catch those. A human should.
Cost
| Call | Count per run | Billed on 2026-10-03 |
|---|---|---|
keyword_suggestions/live | 1 | $0.000000 |
ranked_keywords/live | 1 | $0.000000 |
serp/google/organic/live/advanced | 3 | $0.000000 each |
openai/gpt-6.1-sol | 1 | $0.010401 |
Those are the figures GET /v1/tasks/<id>/cost returned for the run above. On 2026-10-03 the three DataForSEO model pages show Base price Free, and the site carries an “API Free Week” banner, so read Free as the current listing, not a lasting price. Check the model page before you schedule this daily.
The LLM figure is what the cost record reported, not a number I derived. The model page lists $2/M input and $10/M output below 272K prompt tokens, which gives 1,957 × $2/M + 551 × $10/M = $0.0094. The billed $0.010401 was about $0.001 higher. The same cost record’s usage block shows cache_creation_tokens: 1954, but the published formula doesn’t price cache writes at this prompt size, so I can’t reconcile the difference from public pricing; I’ve raised it with the SandBase team. Budget from the cost record, not the formula. Across my three full runs the billed LLM call was $0.0104 to $0.0116.
Limitations
- One seed, one market (US English), one competitor, one day. Volumes and difficulty are DataForSEO estimates and will differ from Google Keyword Planner or Search Console.
limit: 50on both Labs calls.keyword_suggestionsreported 615 matches andranked_keywords254, so this is the head of each list. DataForSEO’s docs describeoffsetfor paging; I didn’t test it here.- The
like "%ai agent%"filter is a substring match. It also matched “openai agent”, which the navigational-intent filter happened to drop. - SERP domains changed almost completely between two runs six minutes apart. One pull is not a ranking.
- The opportunity score is a heuristic I chose. Change the formula if you weight intent or CPC.
FAQ
Can I use the DataForSEO API for keyword research without a DataForSEO account?
Through SandBase, yes. You send the same task fields with a SandBase API key, and the response is DataForSEO’s own body inside outputs[0] (documented under outputs[0].data; on 2026-10-03 it arrived directly in outputs[0]). If you call DataForSEO directly, you need their API login and password for Basic auth.
Is DataForSEO keyword data the same as Google’s?
No. It’s DataForSEO’s third-party estimate. Treat volume and difficulty as relative signals for ranking your own list, not as Google’s numbers.
How do I avoid double-counting search volume?
Group close variants. In this run, seven “ai agent” variants all reported 49,500 with identical 12-month series. Grouping by the series turned them into one row with variants = 7.
Why not let the LLM do the scoring?
Because the arithmetic is where models slip. In our GPT-6.1 Sol vs Claude Sonnet 5.5 test, all three wrong answers were averages or counts over tool output. Python does it exactly for free; the model only writes the plan.
Which other search APIs fit an agent like this?
For general web search inside an agent, see our web search API roundup and the Exa vs Tavily vs Firecrawl vs SerpAPI comparison. DataForSEO’s advantage here is the SEO metrics: volume, difficulty and ranked keywords per domain.
Next steps
Get a SandBase API key, open the DataForSEO catalog, or read the ranked_keywords reference. Swap the seed and the competitor domain for your own, and run it twice before you trust the SERP column.