Build an AI SEO Content Optimizer with Claude and SERP API: 2026 Tutorial
Overview
SEO content optimization typically requires expensive tools like SurferSEO, Frase, or NeuronWriter. But with AI models that understand search intent and SERP APIs that provide real ranking data, you can build your own optimizer that matches or exceeds paid tools — at a fraction of the cost.
This tutorial builds an SEO content optimizer that:
- Fetches real SERP results for any keyword (position, title, snippet)
- Analyzes top-ranking content structure and gaps
- Scores your existing content against competitors
- Generates an optimized outline with exact word counts, headings, and entities
- Rewrites sections to target specific keywords with proper density
The tool costs roughly $0.05 per keyword analysis — versus $50+/month for SaaS tools that lock you into their workflows.
Architecture
┌──────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Keyword │────▶│ SERP API │────▶│ Claude Sonnet │
│ Input │ │ (top 10) │ │ 4 Analysis │
└──────────────┘ └──────────────┘ └────────┬────────┘
│
┌─────────────────────────┘
│
┌─────▼──────┐ ┌─────────────────┐
│ Content │ │ Optimized │
│ Gap │────▶│ Outline + │
│ Analysis │ │ Rewrite │
└────────────┘ └─────────────────┘
Prerequisites
- Python 3.10+
- Anthropic API key (Claude Sonnet 4)
- SerpAPI key (or any SERP API provider; we use SerpAPI for this tutorial)
- Basic understanding of SEO concepts (TF-IDF, keyword density, content gap)
Step 1: Setup
mkdir seo-optimizer && cd seo-optimizer
python -m venv .venv
source .venv/bin/activate
pip install anthropic requests beautifulsoup4 python-dotenv readability-lxml
Create .env:
ANTHROPIC_API_KEY=sk-ant-...
SERPAPI_KEY=your-serpapi-key
Step 2: SERP Data Fetcher
We use SerpAPI to get real Google search results. This is critical — optimization without real SERP data is just guessing.
Create serp_fetcher.py:
import os
import requests
from typing import Optional
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class SERPResult:
"""Represents a single search result from Google."""
position: int
title: str
url: str
snippet: str
displayed_link: str
@dataclass
class SERPData:
"""Complete SERP data for a keyword."""
keyword: str
total_results: int
results: list[SERPResult]
featured_snippet: Optional[str] = None
people_also_ask: list[str] = field(default_factory=list)
related_questions: list[str] = field(default_factory=list)
fetched_at: str = ""
def fetch_serp(keyword: str, location: str = "us", num_results: int = 10) -> SERPData:
"""
Fetch real Google SERP data for a keyword using SerpAPI.
Args:
keyword: The search keyword
location: Two-letter country code (us, uk, de, etc.)
num_results: Results to fetch (max 10 on basic plan)
Returns:
SERPData object with parsed results
"""
params = {
"q": keyword,
"api_key": os.getenv("SERPAPI_KEY"),
"num": num_results,
"gl": location,
"hl": "en",
"engine": "google",
}
resp = requests.get("https://serpapi.com/search", params=params)
resp.raise_for_status()
data = resp.json()
results = []
for item in data.get("organic_results", []):
results.append(SERPResult(
position=item.get("position", 99),
title=item.get("title", ""),
url=item.get("link", ""),
snippet=item.get("snippet", ""),
displayed_link=item.get("displayed_link", ""),
))
# Extract featured snippet
featured = data.get("organic_results", [{}])[0].get("snippet") if data.get("organic_results") else None
# People Also Ask
paa = []
for item in data.get("related_questions", []):
if isinstance(item, dict):
paa.append(item.get("question", ""))
return SERPData(
keyword=keyword,
total_results=int(data.get("search_information", {}).get("total_results", 0)),
results=results,
featured_snippet=featured,
people_also_ask=paa,
fetched_at=datetime.now().isoformat(),
)
def fetch_page_content(url: str) -> str:
"""Fetch and extract main content from a URL using readability."""
from bs4 import BeautifulSoup
from readability import Document
try:
resp = requests.get(url, timeout=10, headers={
"User-Agent": "Mozilla/5.0 (compatible; SEOOptimizer/1.0)"
})
resp.raise_for_status()
doc = Document(resp.text)
content_html = doc.summary()
soup = BeautifulSoup(content_html, "html.parser")
# Remove non-content elements
for tag in soup.find_all(["script", "style", "nav", "footer", "header"]):
tag.decompose()
text = soup.get_text(separator="\n", strip=True)
# Limit to first 3000 words
words = text.split()
return " ".join(words[:3000])
except Exception as e:
print(f" ⚠ Failed to fetch {url}: {e}")
return ""
if __name__ == "__main__":
# Test with a keyword
serp = fetch_serp("best AI writing tools 2026")
print(f"Keyword: {serp.keyword}")
print(f"Total results: {serp.total_results:,}")
print(f"Top results:")
for r in serp.results[:5]:
print(f" #{r.position}: {r.title[:60]}")
if serp.featured_snippet:
print(f"Featured snippet: {serp.featured_snippet[:100]}...")
if serp.people_also_ask:
print(f"People also ask: {serp.people_also_ask[:3]}")
Step 3: Content Analysis & Scoring
This is where Claude analyzes the competitor content against your keyword and produces actionable scores.
Create content_analyzer.py:
import json
import os
from dotenv import load_dotenv
from anthropic import Anthropic
load_dotenv()
anthropic = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
ANALYSIS_PROMPT = """You are an expert SEO content analyst. Analyze the
following SERP data and competitor content for the keyword "{keyword}".
Return a JSON object with:
1. search_intent (informational/commercial/transactional/navigational)
2. avg_word_count (average word count of top 5 results)
3. common_headings (list of H2/H3 heading topics found across competitors)
4. content_gaps (list of topics NOT covered by top results that would add value)
5. keyword_opportunities (list of related long-tail keywords with search_volume_estimate of low/medium/high)
6. entity_list (key entities/topics that should be in the article)
7. readability_target (recommended grade level: 8-10 for general, 12+ for technical)
8. top_content_score (score 0-100): evaluate the best competitor page against:
- Keyword usage in H1, H2, first 100 words
- Entity coverage
- Content depth and uniqueness
- Readability
SERP DATA:
{serp_json}
COMPETITOR CONTENT:
{competitor_content}
Be specific. Name exact headings, exact word counts, and exact terms missing.
"""
def analyze_keyword_opportunity(
keyword: str,
serp_data: dict,
competitor_content: str = "",
) -> dict:
"""Analyze keyword opportunity and content gaps using Claude."""
serp_json = {
"keyword": serp_data.get("keyword", keyword),
"total_results": serp_data.get("total_results", 0),
"featured_snippet": serp_data.get("featured_snippet", None),
"people_also_ask": serp_data.get("people_also_ask", []),
"top_results": [
{"position": r.get("position"), "title": r.get("title"),
"snippet": r.get("snippet")}
for r in serp_data.get("results", [])[:5]
],
}
# Truncate competitor content to avoid token limit
limited_content = competitor_content[:5000]
response = anthropic.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
system="You are an SEO content strategist. Output only valid JSON.",
messages=[{
"role": "user",
"content": ANALYSIS_PROMPT.format(
keyword=keyword,
serp_json=json.dumps(serp_json, indent=2),
competitor_content=limited_content or "No competitor content provided",
),
}],
)
text = response.content[0].text
try:
result = json.loads(text.strip().removeprefix("```json").removesuffix("```").strip())
except json.JSONDecodeError:
result = {"raw_analysis": text, "parse_error": True}
return result
def score_existing_content(content: str, keyword: str, analysis: dict) -> dict:
"""Score existing content against target keyword and SERP analysis."""
response = anthropic.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2048,
system="You are an SEO content scorer. Output only valid JSON.",
messages=[{
"role": "user",
"content": f"""Score this content for the keyword "{keyword}" (0-100).
Content:
{content[:4000]}
Analysis context:
- Search intent: {analysis.get('search_intent', 'unknown')}
- Entity coverage needed: {analysis.get('entity_list', [])}
- Avg competitor word count: {analysis.get('avg_word_count', 'N/A')}
Return JSON:
{{
"overall_score": 0-100,
"keyword_usage": {{"in_title": bool, "in_h1": bool, "in_first_100_words": bool, "density_pct": "X%"}},
"entity_coverage": {{"covered": ["entity1"], "missing": ["entity2"]}},
"readability_grade": "X",
"improvement_suggestions": ["suggestion1", "suggestion2"]
}}""",
}],
)
try:
return json.loads(response.content[0].text.strip().removeprefix("```json").removesuffix("```").strip())
except json.JSONDecodeError:
return {"score": 0, "error": "parse_failed"}
Step 4: Outline & Content Generator
Create outline_generator.py:
import json
import os
from dotenv import load_dotenv
from anthropic import Anthropic
load_dotenv()
anthropic = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
OUTLINE_PROMPT = """Generate an optimized SEO content outline for the keyword
"{keyword}" targeting {intent} intent.
Analysis data:
- Avg competitor word count: {avg_word_count}
- Content gaps to cover: {content_gaps}
- Entities to include: {entities}
- Related questions to answer: {related_questions}
Requirements:
- Target word count: {target_word_count} words (10-20% longer than avg)
- Include {entities_count} identified entities naturally
- Each H2 section should have exact word count target
- Include FAQ schema opportunity at the end
- Cover all content gaps identified
Return JSON:
{{
"title_options": ["Option 1", "Option 2", "Option 3"],
"meta_description": "SEO meta description under 160 chars",
"sections": [
{{
"heading": "H2 or H3 heading text",
"type": "intro/body/conclusion/faq",
"target_words": 200,
"key_points": ["point1", "point2"],
"entities_to_include": ["entity1"]
}}
],
"faq_schema_items": [{{"question": "Q", "answer": "A"}}],
"internal_linking_suggestions": ["link to /reviews/...", "link to /comparisons/..."]
}}
"""
def generate_optimized_outline(
keyword: str,
analysis: dict,
target_word_count: int | None = None,
) -> dict:
"""Generate an SEO-optimized content outline based on SERP analysis."""
avg_word_count = analysis.get("avg_word_count", 1500)
if not isinstance(avg_word_count, (int, float)):
avg_word_count = 1500
tgt = target_word_count or int(avg_word_count * 1.3)
content_gaps = analysis.get("content_gaps", [])
entities = analysis.get("entity_list", [])
related_qs = analysis.get("people_also_ask", [])
response = anthropic.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
system="You are an SEO content strategist. Output only valid JSON.",
messages=[{
"role": "user",
"content": OUTLINE_PROMPT.format(
keyword=keyword,
intent=analysis.get("search_intent", "informational"),
avg_word_count=avg_word_count,
content_gaps=", ".join(content_gaps[:5]) if content_gaps else "None identified",
entities=", ".join(entities[:8]) if entities else "General relevant terms",
entities_count=min(len(entities), 8) if entities else 5,
target_word_count=tgt,
related_questions=", ".join(related_qs[:5]) if related_qs else "None",
),
}],
)
try:
return json.loads(response.content[0].text.strip().removeprefix("```json").removesuffix("```").strip())
except json.JSONDecodeError:
return {"title_options": ["Optimized: " + keyword], "sections": [], "_error": "parse_failed"}
def rewrite_section(
original_text: str,
keyword: str,
target_entity: str,
target_word_count: int,
) -> str:
"""Rewrite a content section to be more SEO-optimized."""
response = anthropic.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=target_word_count + 200,
system="You are an SEO content writer. Write naturally — keyword stuffing is penalized.",
messages=[{
"role": "user",
"content": f"""Rewrite this section for the keyword "{keyword}".
Make sure to naturally include the entity "{target_entity}".
Target: {target_word_count} words.
Improve readability and add specific details.
Original:
{original_text[:3000]}""",
}],
)
return response.content[0].text
Step 5: The Main Application
Create main.py:
import json
import sys
from pathlib import Path
from serp_fetcher import fetch_serp, fetch_page_content
from content_analyzer import analyze_keyword_opportunity, score_existing_content
from outline_generator import generate_optimized_outline
import os
def analyze_keyword(keyword: str, existing_content: str = ""):
"""Full SEO analysis pipeline for a single keyword."""
print(f"\n{'='*60}")
print(f"🔍 Analyzing keyword: {keyword}")
print(f"{'='*60}")
# Step 1: Fetch SERP
print("\n1. Fetching SERP data...")
serp = fetch_serp(keyword)
print(f" {serp.total_results:,} total results")
print(f" Top results:")
for r in serp.results[:3]:
print(f" #{r.position}: {r.title[:70]}")
# Step 2: Fetch top competitor content
print("\n2. Fetching top competitor content...")
competitor_content = ""
if serp.results:
for r in serp.results[:3]:
print(f" Fetching #{r.position}: {r.url}")
content = fetch_page_content(r.url)
if content:
word_count = len(content.split())
competitor_content += f"\n\n=== COMPETITOR #{r.position} ===\n\n{content[:2000]}"
print(f" → {word_count} words extracted")
break # Just analyze the top result
# Step 3: Analyze
print("\n3. Analyzing with Claude...")
analysis = analyze_keyword_opportunity(keyword, {
"keyword": serp.keyword,
"total_results": serp.total_results,
"featured_snippet": serp.featured_snippet,
"people_also_ask": serp.people_also_ask,
"results": [r.__dict__ for r in serp.results],
}, competitor_content)
print(f" Search intent: {analysis.get('search_intent', '?')}")
print(f" Avg word count: {analysis.get('avg_word_count', '?')}")
gaps = analysis.get("content_gaps", [])
if gaps:
print(f" Content gaps: {len(gaps)} identified")
for g in gaps[:3]:
print(f" • {g}")
# Step 4: Score existing content
if existing_content:
print("\n4. Scoring existing content...")
score = score_existing_content(existing_content, keyword, analysis)
print(f" Score: {score.get('overall_score', '?')}/100")
missing = score.get("entity_coverage", {}).get("missing", [])
if missing:
print(f" Missing entities: {', '.join(missing[:5])}")
else:
score = None
# Step 5: Generate outline
print("\n5. Generating optimized outline...")
outline = generate_optimized_outline(keyword, analysis)
titles = outline.get("title_options", [])
if titles:
print(f" Title options:")
for t in titles:
print(f" • {t}")
sections = outline.get("sections", [])
total_words = sum(s.get("target_words", 0) for s in sections)
print(f" Outline: {len(sections)} sections, ≈{total_words} words total")
# Save results
output = {
"keyword": keyword,
"serp": {
"total_results": serp.total_results,
"featured_snippet": serp.featured_snippet,
"people_also_ask": serp.people_also_ask,
},
"analysis": analysis,
"existing_score": score,
"optimized_outline": outline,
}
output_path = f"{keyword.replace(' ', '_')}_seo_analysis.json"
with open(output_path, "w") as f:
json.dump(output, f, indent=2, ensure_ascii=False)
print(f"\n✓ Full analysis saved to {output_path}")
return output
if __name__ == "__main__":
if len(sys.argv) < 2:
keyword = input("Enter keyword to analyze: ").strip()
else:
keyword = " ".join(sys.argv[1:])
existing = ""
if Path("existing_content.txt").exists():
with open("existing_content.txt") as f:
existing = f.read()
print(f"Found existing_content.txt ({len(existing.split())} words)")
result = analyze_keyword(keyword, existing)
Step 6: Testing
# Analyze a keyword
python main.py "best AI code editor 2026"
# With existing content to score
echo "Your existing article content here..." > existing_content.txt
python main.py "AI code editor comparison"
Expected output snippet:
🔍 Analyzing keyword: best AI code editor 2026
1. Fetching SERP data...
14,200,000 total results
Top results:
#1: 10 Best AI Code Editors (2026) - ToolsDepth
#2: Best AI Coding Assistants Compared - TechRepublic
#3: 7 AI Code Editors I Tested for 6 Months - Medium
2. Analyzing with Claude...
Search intent: commercial
Avg word count: 2,350
Content gaps: 4 identified
• Performance benchmarks comparison
• Pricing for team plans
• Offline vs cloud latency comparison
Tips
- Use location-specific SERP data: Add
location="us"for US results,"de"for Germany. Content competitiveness varies significantly by region. - Analyze the featured snippet: Pages ranking in position 0 get 8-30% CTR. Always analyze whether your content can target the featured snippet.
- Track over time: Run the same keyword weekly and track score changes. The
fetched_attimestamp helps correlate score changes with Google algorithm updates. - Combine with Google Search Console: Export your underperforming keywords (positions 10-20) and run them through this pipeline to get specific rewrite suggestions.
Common Pitfalls
- ❌ SERP API rate limits: SerpAPI free tier is 100 queries/month. For production, budget $50/month for 5,000 queries or use an alternative provider.
- ❌ Content fetching blocked: Some sites block scrapers. Add rotating User-Agent headers or use a headless browser for JavaScript-rendered content.
- ❌ Over-optimization: Claude can produce content that aggressively targets keywords. Use
temperature=0.3for analysis and0.7for generation to balance optimization and natural language. - ❌ Ignoring user intent: If the SERP shows “best X” lists but your content is a beginner tutorial, no amount of optimization will rank it. The
search_intentfield catches this — pivot entirely if misaligned.
Conclusion
You’ve built a professional-grade SEO content optimizer that would cost $500+/year as a SaaS subscription. The tool combines real search data, competitor analysis, content scoring, and AI-powered outline generation into a single pipeline.
The key insight: SEO tools don’t need to be expensive. Claude Sonnet 4 + SERP API gives you 90% of what tools like SurferSEO offer, with the flexibility to customize every aspect of the analysis. Run this weekly on your content backlog, and you’ll see consistent ranking improvements as you close content gaps and target the right entities.