
Seo Specialist
- 214 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
Audit sites, fix technical SEO, research keywords, optimize on-page content, and plan link-building for organic search visibility.
About
SEO specialist skill for launch and growth: runs technical audits, prioritizes fix lists, crafts metadata and schema, builds keyword-led content briefs, and recommends internal linking and performance improvements to raise organic traffic for SaaS and content sites.
- Technical crawl and index audits
- Keyword and intent mapping
- On-page title and schema optimization
- Content briefs for ranking targets
- Core Web Vitals and mobile SEO fixes
Seo Specialist by the numbers
- 214 all-time installs (skills.sh)
- Ranked #951 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/borghei/claude-skills --skill seo-specialistAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 214 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Audit sites, fix technical SEO, research keywords, optimize on-page content, and plan link-building for organic search visibility.
Files
SEO Specialist
The agent operates as a senior SEO specialist, delivering technical audits, keyword strategies, on-page optimization, link building plans, and performance analysis for organic search growth.
Workflow
1. Run technical audit - Check crawlability (robots.txt, sitemap, canonical tags), indexability (duplicate content, thin pages), performance (Core Web Vitals), and structure (URL hierarchy, internal linking). Checkpoint: zero critical crawl errors in Search Console. 2. Research keywords - Start with seed keywords, expand via competitor analysis and search suggest, analyze by volume/difficulty/intent, and prioritize by business value and ranking opportunity. Checkpoint: each target keyword has a mapped content asset. 3. Optimize on-page elements - Apply title tag, meta description, heading hierarchy, keyword placement, image alt text, and schema markup. Checkpoint: primary keyword appears in H1, first 100 words, and title tag. 4. Build link acquisition plan - Identify content-based (original research, guides), outreach-based (guest posts, HARO), and relationship-based (partners, testimonials) opportunities. Checkpoint: target links have DA 50+ and topical relevance. 5. Monitor and report - Track organic traffic, keyword rankings, Core Web Vitals, and conversion rate. Review weekly; report monthly. Checkpoint: dashboard covers visibility, engagement, and conversions.
Technical SEO Audit Checklist
Crawlability:
- [ ] Robots.txt properly configured
- [ ] XML sitemap submitted and current
- [ ] No critical crawl errors in Search Console
- [ ] Canonical tags on all indexable pages
- [ ] Noindex/nofollow used correctly
Performance (Core Web Vitals):
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | 2.5s - 4s | > 4s |
| FID (First Input Delay) | < 100ms | 100 - 300ms | > 300ms |
| CLS (Cumulative Layout Shift) | < 0.1 | 0.1 - 0.25 | > 0.25 |
Structure:
- [ ] Clean, descriptive URL slugs
- [ ] Proper heading hierarchy (single H1, logical H2/H3)
- [ ] Internal linking between related content
- [ ] Breadcrumbs implemented
Keyword Research Process
1. Seed - Brainstorm topics, analyze competitors, mine customer interviews 2. Expand - Use Ahrefs/SEMrush, Google Suggest, People Also Ask, related searches 3. Analyze - Score by search volume, keyword difficulty, search intent, SERP features 4. Prioritize - Rank by business value x ranking opportunity
Keyword Metrics Guide
| Metric | Good | Moderate | Difficult |
|---|---|---|---|
| Volume | 1000+ | 100-1000 | < 100 |
| Difficulty | < 30 | 30-60 | > 60 |
| CPC (commercial signal) | > $5 | $1-5 | < $1 |
Search Intent Classification
| Intent | Signal Words | Content Type |
|---|---|---|
| Informational | "how to", "what is", "guide" | Blog posts, tutorials |
| Navigational | Brand names, product names | Homepage, product pages |
| Commercial | "best", "reviews", "vs" | Comparison pages, reviews |
| Transactional | "buy", "discount", "pricing" | Product pages, landing pages |
On-Page Optimization Checklist
Title Tag: primary keyword front-loaded, 50-60 characters, compelling for CTR Meta Description: includes keyword, clear value prop, CTA, 150-160 characters Headings: H1 contains primary keyword, H2s contain secondary keywords, logical hierarchy Content: keyword in first 100 words, natural density, related terms (LSI), comprehensive coverage Images: descriptive filenames, keyword-rich alt text, compressed, lazy-loaded
Example: Optimized Page Structure
<!-- Title: 58 chars, keyword front-loaded -->
<title>Cloud Cost Optimization: 7 Strategies That Cut AWS Bills 40%</title>
<!-- Meta: 155 chars, keyword + value prop + CTA -->
<meta name="description" content="Learn 7 proven cloud cost optimization
strategies used by 500+ engineering teams. Reduce AWS spend by 40% without
sacrificing performance. Free checklist inside.">
<!-- Schema markup for article -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Cloud Cost Optimization: 7 Strategies That Cut AWS Bills 40%",
"author": {"@type": "Person", "name": "Jane Chen"},
"datePublished": "2026-02-15",
"publisher": {
"@type": "Organization",
"name": "CloudOps Weekly"
}
}
</script>
<h1>Cloud Cost Optimization: 7 Strategies That Cut AWS Bills 40%</h1>
<p>Cloud cost optimization is the #1 priority for engineering leaders in 2026...</p>
<h2>1. Right-Size EC2 Instances Using Usage Data</h2>
<h3>How to Identify Oversized Instances</h3>
<h2>FAQ</h2>
<h3>What is cloud cost optimization?</h3>
<h3>How much can cloud optimization save?</h3>Link Quality Assessment
| Factor | High Quality | Low Quality |
|---|---|---|
| Domain Authority | 50+ | < 20 |
| Relevance | Same industry | Unrelated |
| Traffic | Active site | Dead site |
| Link Type | Editorial | Paid/Spam |
| Anchor Text | Natural variation | Exact match spam |
SEO Performance Dashboard
SEO Performance - March 2026
Organic Traffic: 125,432 (+12% MoM)
Rankings: Top 3: 45 | Top 10: 234
Conversions: 542 (+15% MoM)
Top Growing Keywords
1. "cloud cost optimization" - #8 -> #3 (+5)
2. "aws billing alerts" - #15 -> #7 (+8)
3. "kubernetes autoscaling" - New -> #12
Technical Health
Core Web Vitals: Pass | Index: 1,234 pages | Crawl Errors: 3Scripts
# Site audit
python scripts/site_audit.py --url https://example.com --output audit.html
# Keyword research
python scripts/keyword_research.py --seed "cloud computing" --output keywords.csv
# Rank tracker
python scripts/rank_tracker.py --keywords keywords.csv --domain example.com
# Backlink analyzer
python scripts/backlink_analyzer.py --domain example.com --output links.csvReference Materials
references/technical_seo.md- Technical SEO guidereferences/keyword_research.md- Keyword research methodsreferences/link_building.md- Link building playbookreferences/algorithm_updates.md- Google update history
---
Troubleshooting
| Problem | Likely Cause | Fix |
|---|---|---|
| Rankings dropped after Google core update | E-E-A-T signals insufficient or content quality below new thresholds | Audit content against December 2025 / March 2026 core update criteria — add experience signals, author credentials, original data |
| High impressions but low CTR | Title tags and meta descriptions not compelling enough for the SERP | Rewrite titles with numbers, power words, and clear value props; test meta descriptions with hooks |
| FID replaced by INP — pages now failing CWV | INP measures all interactions, not just first — JS-heavy pages fail | Break long JS tasks, defer third-party scripts, audit event handlers; 43% of sites still fail INP in 2026 |
| Indexed pages declining in Search Console | Google tightening quality bar — deindexing thin or duplicate content | Consolidate thin pages, add unique content, improve E-E-A-T signals on remaining pages |
| AI Overviews stealing clicks from position 1 | Google AI Overviews now appear in 50%+ of queries, reducing organic CTR by ~42% | Optimize for AI citation (extractable content blocks), add FAQ schema, target queries less likely to trigger AI Overviews |
| Keyword cannibalization across blog and product pages | Multiple pages competing for same keyword with conflicting intent | Map one primary keyword per page, consolidate or redirect competing pages, differentiate intent |
---
Success Criteria
- Organic traffic growth: 10%+ month-over-month organic traffic growth sustained over 6 months
- Top 10 rankings: 50%+ of target keywords ranking in top 10 positions within 6 months
- Core Web Vitals: All three metrics passing (LCP < 2.5s, INP < 200ms, CLS < 0.1) at 75th percentile — only 47-55% of sites achieve this in 2026
- CTR performance: Position 1 achieving 25%+ CTR, position 3 achieving 10%+ CTR (2026 benchmarks)
- Conversion from organic: Organic traffic converting at 2%+ for B2B, 2.5%+ for e-commerce (industry benchmarks)
- Indexation health: 95%+ of target pages indexed with zero critical crawl errors
- E-E-A-T compliance: Author bylines, credentials, and experience signals on 100% of content pages
---
Scope & Limitations
In scope:
- Technical SEO auditing (crawlability, indexation, Core Web Vitals, site structure)
- Keyword research, intent classification, and prioritization
- On-page optimization (title tags, meta descriptions, headings, content, schema)
- Link building strategy and opportunity identification
- Organic search performance monitoring and reporting
- Algorithm update impact assessment and recovery planning
Out of scope:
- Paid search / Google Ads management
- Social media marketing and optimization
- Content writing and production (use Content Production skill)
- AI-specific search optimization (use AI SEO skill)
- Website development or code deployment
- Brand strategy and positioning
Known limitations:
- Keyword difficulty scores vary significantly across tools — no single source of truth
- Google algorithm changes 500-600 times per year; strategies require continuous adaptation
- AI Overviews are reducing organic CTR — position 1 no longer guarantees high click volume
- Backlink analysis requires third-party tools (Ahrefs, SEMrush, Moz) for comprehensive data
- INP optimization often requires developer involvement for JavaScript refactoring
---
Integration Points
- SEO Audit — Use for comprehensive 85-point site audits when detailed diagnostic is needed.
- AI SEO — Use alongside traditional SEO for AI search citation optimization.
- Schema Markup — Use for structured data implementation after on-page optimization.
- Site Architecture — Use when structural issues (deep nesting, orphan pages) block ranking progress.
- Content Strategy — Use for topic selection and editorial calendar planning before SEO optimization.
- Content Humanizer — Use when content flagged as AI-generated needs authenticity improvement.
---
Scripts
# Analyze keyword list for search intent and difficulty
python scripts/keyword_analyzer.py --keywords keywords.csv --json
# Simulate SERP appearance for a page
python scripts/serp_simulator.py --title "Cloud Cost Optimization Guide" --description "Learn 7 proven strategies..." --url "/guides/cloud-cost" --json
# Score content for on-page SEO quality
python scripts/content_scorer.py article.md --keyword "cloud cost optimization" --json#!/usr/bin/env python3
"""
On-Page SEO Content Scorer
Scores content for on-page SEO quality across keyword optimization,
heading structure, readability, internal linking, and meta content.
Produces a weighted 0-100 score with specific recommendations.
Usage:
python content_scorer.py article.md --keyword "cloud cost optimization"
python content_scorer.py article.md --keyword "SEO audit" --json
python content_scorer.py article.md --verbose
"""
import argparse
import json
import math
import re
import sys
from pathlib import Path
def count_syllables(word):
"""Estimate syllable count."""
word = word.lower().strip()
if len(word) <= 3:
return 1
count = 0
vowels = 'aeiouy'
prev = False
for c in word:
v = c in vowels
if v and not prev:
count += 1
prev = v
if word.endswith('e'):
count -= 1
return max(count, 1)
def flesch_score(text):
"""Calculate Flesch Reading Ease."""
words = text.split()
wc = len(words)
sc = len([s for s in re.split(r'[.!?]+', text) if s.strip()])
if wc == 0 or sc == 0:
return 0
syl = sum(count_syllables(w) for w in words)
score = 206.835 - 1.015 * (wc / sc) - 84.6 * (syl / wc)
return round(max(0, min(100, score)), 1)
def check_keyword(text, keyword):
"""Check keyword optimization."""
if not keyword:
return {}
checks = {}
kw = keyword.lower()
words = text.lower().split()
wc = len(words)
# Density
occurrences = text.lower().count(kw)
density = (occurrences * len(kw.split()) / max(wc, 1)) * 100
checks["keyword_density"] = {
"value": round(density, 2),
"pass": 0.5 <= density <= 2.5,
"detail": f"{round(density, 2)}% density ({occurrences} occurrences)",
}
# In first 100 words
first_100 = " ".join(words[:100])
checks["in_first_100"] = {
"pass": kw in first_100,
"detail": "Present" if kw in first_100 else "Missing from first 100 words",
}
# In H1
h1s = re.findall(r'^#\s+(.+)', text, re.MULTILINE)
checks["in_h1"] = {
"pass": any(kw in h.lower() for h in h1s),
"detail": "In H1" if any(kw in h.lower() for h in h1s) else "Not in H1",
}
# In H2s
h2s = re.findall(r'^##\s+(.+)', text, re.MULTILINE)
h2_count = sum(1 for h in h2s if kw in h.lower())
checks["in_h2s"] = {
"pass": h2_count >= 1,
"count": h2_count,
"detail": f"In {h2_count}/{len(h2s)} H2 headings",
}
# In last 100 words
last_100 = " ".join(words[-100:]) if wc >= 100 else " ".join(words)
checks["in_conclusion"] = {
"pass": kw in last_100,
"detail": "Present in conclusion" if kw in last_100 else "Missing from conclusion",
}
return checks
def check_structure(text):
"""Check content structure."""
checks = {}
wc = len(text.split())
# Word count
checks["word_count"] = {
"value": wc,
"pass": wc >= 800,
"detail": f"{wc} words (800+ recommended)",
}
# H1
h1s = re.findall(r'^#\s+', text, re.MULTILINE)
checks["single_h1"] = {
"pass": len(h1s) == 1,
"detail": f"{len(h1s)} H1(s) found — should be 1",
}
# H2 count
h2s = re.findall(r'^##\s+', text, re.MULTILINE)
checks["h2_count"] = {
"pass": len(h2s) >= 3,
"count": len(h2s),
"detail": f"{len(h2s)} H2 sections",
}
# Heading frequency
if wc > 0 and len(h2s) > 0:
words_per_h2 = wc / len(h2s)
checks["heading_frequency"] = {
"pass": words_per_h2 <= 350,
"value": round(words_per_h2),
"detail": f"~{round(words_per_h2)} words between headings (target: <350)",
}
# Lists
list_items = len(re.findall(r'^\s*[-*\d+\.]\s+', text, re.MULTILINE))
checks["has_lists"] = {
"pass": list_items >= 3,
"count": list_items,
"detail": f"{list_items} list items",
}
# Internal links
int_links = len(re.findall(r'\[.*?\]\(/[^)]+\)', text))
md_links = len(re.findall(r'\[.*?\]\([^)]+\)', text))
checks["internal_links"] = {
"pass": md_links >= 2,
"count": md_links,
"detail": f"{md_links} links found (2+ internal recommended)",
}
# Images
images = len(re.findall(r'!\[.*?\]\(', text))
checks["has_images"] = {
"pass": images >= 1,
"count": images,
"detail": f"{images} images (1+ recommended per 500 words)",
}
return checks
def check_meta(text):
"""Check meta content signals."""
checks = {}
# Meta description in frontmatter
desc_match = re.search(r'^description:\s*(.+)', text, re.MULTILINE)
if desc_match:
desc = desc_match.group(1).strip().strip('"').strip("'")
checks["meta_description"] = {
"pass": 120 <= len(desc) <= 160,
"length": len(desc),
"detail": f"Meta description: {len(desc)} chars (target: 140-160)",
}
else:
checks["meta_description"] = {
"pass": False,
"detail": "No meta description found in frontmatter",
}
# Schema markup
has_schema = bool(re.search(r'schema\.org|application/ld\+json|@type', text, re.IGNORECASE))
checks["schema_markup"] = {
"pass": has_schema,
"detail": "Schema markup detected" if has_schema else "No schema markup found",
}
return checks
def calculate_score(keyword_checks, structure_checks, meta_checks, readability):
"""Calculate weighted overall score."""
def pass_rate(checks):
if not checks:
return 0
return sum(1 for v in checks.values() if v.get("pass", False)) / len(checks)
read_score = 100 if 50 <= readability <= 80 else max(0, 100 - abs(readability - 65) * 2)
score = (
pass_rate(keyword_checks) * 30 +
pass_rate(structure_checks) * 30 +
pass_rate(meta_checks) * 15 +
(read_score / 100) * 25
) * 100 / 100
return round(score, 1)
def main():
parser = argparse.ArgumentParser(description="Score content for on-page SEO")
parser.add_argument("file", help="Content file path")
parser.add_argument("--keyword", help="Target keyword")
parser.add_argument("--json", action="store_true")
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()
fp = Path(args.file)
if not fp.exists():
print(f"Error: {fp} not found", file=sys.stderr)
sys.exit(1)
text = fp.read_text(encoding="utf-8", errors="replace")
readability = flesch_score(text)
kw_checks = check_keyword(text, args.keyword)
struct_checks = check_structure(text)
meta_checks = check_meta(text)
score = calculate_score(kw_checks, struct_checks, meta_checks, readability)
grade = "A" if score >= 85 else "B" if score >= 70 else "C" if score >= 55 else "D" if score >= 40 else "F"
result = {
"file": str(fp),
"word_count": len(text.split()),
"readability": readability,
"score": score,
"grade": grade,
"keyword_checks": kw_checks,
"structure_checks": struct_checks,
"meta_checks": meta_checks,
}
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"\n{'='*55}")
print(f" ON-PAGE SEO SCORE: {score}/100 (Grade: {grade})")
print(f"{'='*55}")
print(f" File: {fp} | Words: {len(text.split())} | Readability: {readability}")
if args.keyword:
print(f" Keyword: {args.keyword}")
if args.verbose:
for label, checks in [("Keyword", kw_checks), ("Structure", struct_checks), ("Meta", meta_checks)]:
if checks:
print(f"\n --- {label} ---")
for k, v in checks.items():
s = "PASS" if v.get("pass") else "FAIL"
print(f" [{s}] {k}: {v['detail']}")
else:
# Summary
all_checks = {**kw_checks, **struct_checks, **meta_checks}
fails = [k for k, v in all_checks.items() if not v.get("pass")]
if fails:
print(f"\n Failing checks: {', '.join(fails)}")
print()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
SEO Keyword Analyzer
Analyzes keywords for search intent, estimates difficulty tier, classifies
commercial value, and prioritizes by SEO opportunity. Supports CSV input
with volume/difficulty data or plain keyword lists.
Usage:
python keyword_analyzer.py --keywords keywords.csv --json
python keyword_analyzer.py --keyword "cloud cost optimization"
python keyword_analyzer.py --keywords keywords.csv --sort opportunity
"""
import argparse
import csv
import json
import re
import sys
from pathlib import Path
INTENT_PATTERNS = {
"informational": [
r'\b(what is|what are|how to|how do|how does|why is|why do|guide|'
r'tutorial|explain|definition|meaning|example|examples|overview|'
r'introduction|difference between|learn)\b'
],
"commercial": [
r'\b(best|top|review|reviews|comparison|vs|versus|alternative|'
r'alternatives|compare|which|rating|recommended|software|tool|'
r'tools|platform|service)\b'
],
"transactional": [
r'\b(buy|purchase|price|pricing|cost|discount|deal|coupon|order|'
r'subscribe|sign up|free trial|download|get started|hire|book)\b'
],
"navigational": [
r'\b(login|log in|sign in|dashboard|account|support|contact|'
r'official|docs|documentation|api|download)\b'
],
}
CONTENT_TYPE_MAP = {
"informational": "Blog post, tutorial, guide, or explainer",
"commercial": "Comparison page, review, or buyer's guide",
"transactional": "Product page, pricing page, or landing page",
"navigational": "Homepage, product page, or documentation",
}
def classify_intent(keyword):
"""Classify search intent of a keyword."""
kw = keyword.lower()
scores = {}
for intent, patterns in INTENT_PATTERNS.items():
score = sum(len(re.findall(p, kw)) for p in patterns)
scores[intent] = score
if max(scores.values()) == 0:
return "informational"
return max(scores, key=scores.get)
def estimate_difficulty_tier(keyword, difficulty=None):
"""Estimate keyword difficulty tier."""
if difficulty is not None:
if difficulty <= 20:
return {"tier": "Easy", "score": difficulty, "note": "Quick win opportunity"}
elif difficulty <= 40:
return {"tier": "Moderate", "score": difficulty, "note": "Achievable with good content"}
elif difficulty <= 60:
return {"tier": "Hard", "score": difficulty, "note": "Requires strong DA + content depth"}
else:
return {"tier": "Very Hard", "score": difficulty, "note": "Needs authority building first"}
# Heuristic based on keyword characteristics
word_count = len(keyword.split())
if word_count >= 4:
return {"tier": "Easy-Moderate", "score": None, "note": "Long-tail — likely lower competition"}
elif word_count == 3:
return {"tier": "Moderate", "score": None, "note": "Mid-tail keyword"}
elif word_count == 2:
return {"tier": "Hard", "score": None, "note": "Short-tail — likely competitive"}
else:
return {"tier": "Very Hard", "score": None, "note": "Single word — extremely competitive"}
def classify_commercial_value(keyword, intent):
"""Estimate commercial value of a keyword."""
kw = keyword.lower()
high_value_signals = len(re.findall(
r'\b(pricing|buy|purchase|cost|enterprise|saas|software|platform|'
r'agency|consultant|service|professional|business)\b', kw
))
if intent == "transactional" or high_value_signals >= 2:
return "High"
elif intent == "commercial" or high_value_signals >= 1:
return "Medium"
elif intent == "informational":
return "Low"
else:
return "Medium"
def calculate_opportunity_score(volume, difficulty_tier, commercial_value, intent):
"""Calculate composite opportunity score."""
# Volume factor (0-30)
if volume is not None:
if volume >= 5000:
vol_score = 30
elif volume >= 1000:
vol_score = 25
elif volume >= 500:
vol_score = 20
elif volume >= 100:
vol_score = 15
elif volume >= 50:
vol_score = 10
else:
vol_score = 5
else:
vol_score = 15 # Unknown, assume moderate
# Difficulty factor (0-30, inverted)
diff_map = {"Easy": 30, "Easy-Moderate": 25, "Moderate": 20, "Hard": 10, "Very Hard": 5}
diff_score = diff_map.get(difficulty_tier, 15)
# Commercial value factor (0-25)
comm_map = {"High": 25, "Medium": 15, "Low": 8}
comm_score = comm_map.get(commercial_value, 10)
# Intent factor (0-15)
intent_map = {"transactional": 15, "commercial": 12, "informational": 8, "navigational": 5}
intent_score = intent_map.get(intent, 8)
return vol_score + diff_score + comm_score + intent_score
def analyze_keyword(keyword, volume=None, difficulty=None):
"""Complete analysis of a single keyword."""
intent = classify_intent(keyword)
diff = estimate_difficulty_tier(keyword, difficulty)
commercial = classify_commercial_value(keyword, intent)
opportunity = calculate_opportunity_score(volume, diff["tier"], commercial, intent)
result = {
"keyword": keyword,
"word_count": len(keyword.split()),
"intent": intent,
"content_type": CONTENT_TYPE_MAP[intent],
"difficulty": diff,
"commercial_value": commercial,
"opportunity_score": opportunity,
}
if volume is not None:
result["volume"] = volume
# Priority
if opportunity >= 75:
result["priority"] = "High"
elif opportunity >= 55:
result["priority"] = "Medium"
else:
result["priority"] = "Low"
return result
def load_csv(filepath):
"""Load keywords from CSV."""
keywords = []
with open(filepath, 'r', encoding='utf-8') as f:
# Try as CSV with headers
sample = f.read(1024)
f.seek(0)
if ',' in sample or '\t' in sample:
dialect = csv.Sniffer().sniff(sample, delimiters=',\t')
reader = csv.DictReader(f, dialect=dialect)
for row in reader:
entry = {}
for key in ['keyword', 'Keyword', 'query', 'Query', 'term']:
if key in row:
entry["keyword"] = row[key].strip()
break
if "keyword" not in entry:
entry["keyword"] = list(row.values())[0].strip()
for key in ['volume', 'Volume', 'search_volume', 'Search Volume', 'vol']:
if key in row and row[key]:
try:
entry["volume"] = int(str(row[key]).replace(',', '').strip())
except (ValueError, AttributeError):
pass
for key in ['difficulty', 'Difficulty', 'KD', 'kd', 'keyword_difficulty']:
if key in row and row[key]:
try:
entry["difficulty"] = int(str(row[key]).replace(',', '').strip())
except (ValueError, AttributeError):
pass
if entry.get("keyword"):
keywords.append(entry)
else:
for line in f:
kw = line.strip()
if kw:
keywords.append({"keyword": kw})
return keywords
def main():
parser = argparse.ArgumentParser(
description="Analyze keywords for SEO opportunity"
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--keywords", help="CSV file with keywords")
group.add_argument("--keyword", help="Single keyword to analyze")
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument(
"--sort", choices=["opportunity", "volume", "keyword", "difficulty"],
default="opportunity", help="Sort by (default: opportunity)"
)
args = parser.parse_args()
if args.keyword:
result = analyze_keyword(args.keyword)
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"\n{'='*60}")
print(f" KEYWORD: {args.keyword}")
print(f"{'='*60}")
print(f" Intent: {result['intent']}")
print(f" Content type: {result['content_type']}")
print(f" Difficulty: {result['difficulty']['tier']}")
print(f" Commercial value: {result['commercial_value']}")
print(f" Opportunity score: {result['opportunity_score']}/100")
print(f" Priority: {result['priority']}")
print()
return
filepath = Path(args.keywords)
if not filepath.exists():
print(f"Error: File not found: {filepath}", file=sys.stderr)
sys.exit(1)
data = load_csv(filepath)
results = [
analyze_keyword(e["keyword"], e.get("volume"), e.get("difficulty"))
for e in data
]
sort_keys = {
"opportunity": lambda x: x["opportunity_score"],
"volume": lambda x: x.get("volume", 0) or 0,
"keyword": lambda x: x["keyword"],
"difficulty": lambda x: x["difficulty"].get("score", 50) or 50,
}
results.sort(key=sort_keys[args.sort], reverse=(args.sort != "keyword"))
if args.json:
print(json.dumps({"total": len(results), "keywords": results}, indent=2))
else:
print(f"\n{'='*75}")
print(f" KEYWORD ANALYSIS — {len(results)} keywords (sorted by {args.sort})")
print(f"{'='*75}")
print(f" {'Keyword':<35} {'Intent':<14} {'Difficulty':<12} {'Value':<8} {'Score'}")
print(f" {'-'*35} {'-'*14} {'-'*12} {'-'*8} {'-'*5}")
for r in results:
kw = r['keyword'][:34]
print(f" {kw:<35} {r['intent']:<14} {r['difficulty']['tier']:<12} {r['commercial_value']:<8} {r['opportunity_score']}")
high = sum(1 for r in results if r["priority"] == "High")
med = sum(1 for r in results if r["priority"] == "Medium")
low = sum(1 for r in results if r["priority"] == "Low")
print(f"\n Priority: {high} High / {med} Medium / {low} Low")
print()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
SERP Appearance Simulator
Simulates how a page would appear in Google search results. Validates title
tag length, meta description, URL structure, and rich snippet eligibility.
Previews desktop and mobile SERP snippets with character truncation warnings.
Usage:
python serp_simulator.py --title "Cloud Cost Guide" --description "Learn strategies..." --url "/guides/cloud"
python serp_simulator.py --file page.md --json
python serp_simulator.py --title "Title" --description "Desc" --url "/path" --keyword "cloud cost"
"""
import argparse
import json
import re
import sys
from pathlib import Path
# SERP display limits (2026 standards)
TITLE_MAX_PIXELS = 600 # ~60 characters
TITLE_MAX_CHARS = 60
DESC_MAX_CHARS_DESKTOP = 160
DESC_MAX_CHARS_MOBILE = 120
URL_MAX_DISPLAY_CHARS = 75
def estimate_pixel_width(text):
"""Rough pixel width estimation for SERP titles."""
# Approximate: uppercase ~9px, lowercase ~7px, space ~4px, numbers ~7px
width = 0
for char in text:
if char.isupper():
width += 9
elif char.islower():
width += 7
elif char.isdigit():
width += 7
elif char == ' ':
width += 4
else:
width += 7
return width
def analyze_title(title, keyword=None):
"""Analyze title tag for SERP optimization."""
issues = []
char_count = len(title)
pixel_width = estimate_pixel_width(title)
# Length check
if char_count > TITLE_MAX_CHARS:
truncated = title[:TITLE_MAX_CHARS - 3] + "..."
issues.append({
"type": "title_too_long",
"severity": "High",
"detail": f"Title is {char_count} chars — will truncate at ~{TITLE_MAX_CHARS} chars",
"truncated_preview": truncated,
})
elif char_count < 30:
issues.append({
"type": "title_too_short",
"severity": "Medium",
"detail": f"Title is only {char_count} chars — aim for 50-60 chars to maximize SERP real estate",
})
# Keyword placement
if keyword:
kw_lower = keyword.lower()
title_lower = title.lower()
if kw_lower not in title_lower:
issues.append({
"type": "keyword_missing",
"severity": "High",
"detail": f"Primary keyword '{keyword}' not found in title",
})
elif not title_lower.startswith(kw_lower) and title_lower.find(kw_lower) > 30:
issues.append({
"type": "keyword_not_frontloaded",
"severity": "Medium",
"detail": f"Keyword appears late in title (position {title_lower.find(kw_lower)}) — front-load for CTR",
})
# Power words for CTR
power_words = ['guide', 'how to', 'best', 'top', 'free', 'new', 'proven',
'step', 'easy', 'ultimate', 'complete', 'simple', 'fast']
has_power = any(w in title.lower() for w in power_words)
# Numbers in title
has_number = bool(re.search(r'\d+', title))
return {
"title": title,
"char_count": char_count,
"pixel_width_estimate": pixel_width,
"will_truncate": char_count > TITLE_MAX_CHARS,
"has_power_word": has_power,
"has_number": has_number,
"issues": issues,
}
def analyze_description(description, keyword=None):
"""Analyze meta description for SERP optimization."""
issues = []
char_count = len(description)
# Length check
if char_count > DESC_MAX_CHARS_DESKTOP:
issues.append({
"type": "desc_too_long",
"severity": "Medium",
"detail": f"Description is {char_count} chars — may truncate on desktop (>{DESC_MAX_CHARS_DESKTOP}) and will truncate on mobile (>{DESC_MAX_CHARS_MOBILE})",
})
elif char_count < 80:
issues.append({
"type": "desc_too_short",
"severity": "Medium",
"detail": f"Description is only {char_count} chars — aim for 140-160 chars",
})
# Keyword in description
if keyword and keyword.lower() not in description.lower():
issues.append({
"type": "keyword_missing_desc",
"severity": "Medium",
"detail": f"Keyword '{keyword}' not in meta description — Google bolds matching terms",
})
# CTA presence
cta_patterns = r'\b(learn|discover|find out|get|try|start|read|see|check|download)\b'
has_cta = bool(re.search(cta_patterns, description, re.IGNORECASE))
# Mobile truncation preview
mobile_preview = description[:DESC_MAX_CHARS_MOBILE]
if len(description) > DESC_MAX_CHARS_MOBILE:
mobile_preview = mobile_preview[:DESC_MAX_CHARS_MOBILE - 3] + "..."
return {
"description": description,
"char_count": char_count,
"desktop_truncates": char_count > DESC_MAX_CHARS_DESKTOP,
"mobile_truncates": char_count > DESC_MAX_CHARS_MOBILE,
"mobile_preview": mobile_preview,
"has_cta": has_cta,
"issues": issues,
}
def analyze_url(url):
"""Analyze URL for SERP appearance."""
issues = []
# Clean display URL
display_url = url.lstrip('/')
if len(display_url) > URL_MAX_DISPLAY_CHARS:
issues.append({
"type": "url_too_long",
"severity": "Low",
"detail": f"URL is {len(display_url)} chars — may truncate in SERP",
})
# Check for parameters
if '?' in url:
issues.append({
"type": "url_has_params",
"severity": "Medium",
"detail": "URL contains query parameters — clean URLs perform better",
})
# Check for underscores
if '_' in url:
issues.append({
"type": "url_underscores",
"severity": "Low",
"detail": "URL uses underscores — hyphens are preferred by Google",
})
# Depth
depth = len([s for s in url.split('/') if s and s != 'https:' and s != 'http:'])
if depth > 4:
issues.append({
"type": "url_too_deep",
"severity": "Medium",
"detail": f"URL has {depth} path segments — shallower URLs are preferred",
})
breadcrumbs = " › ".join(s for s in url.split('/') if s and not s.startswith(('http', 'https')))
return {
"url": url,
"display_url": display_url,
"breadcrumb_preview": breadcrumbs,
"depth": depth,
"issues": issues,
}
def extract_from_file(filepath):
"""Extract title, description, and content from a markdown file."""
text = filepath.read_text(encoding="utf-8", errors="replace")
# Extract title from H1 or frontmatter
title = ""
h1_match = re.search(r'^#\s+(.+)', text, re.MULTILINE)
if h1_match:
title = h1_match.group(1).strip()
# Extract description from frontmatter or first paragraph
desc = ""
desc_match = re.search(r'^description:\s*(.+)', text, re.MULTILINE)
if desc_match:
desc = desc_match.group(1).strip().strip('"').strip("'")
else:
paragraphs = [p.strip() for p in re.split(r'\n\s*\n', text)
if p.strip() and not p.strip().startswith('#') and not p.strip().startswith('---')]
if paragraphs:
desc = paragraphs[0][:160]
return title, desc
def generate_serp_preview(title_analysis, desc_analysis, url_analysis):
"""Generate text-based SERP preview."""
lines = []
lines.append("")
lines.append(" ┌─────────────────────────────────────────────────────┐")
# URL breadcrumb
breadcrumb = url_analysis["breadcrumb_preview"][:50]
lines.append(f" │ {breadcrumb:<51} │")
# Title (blue, truncated if needed)
title = title_analysis["title"]
if title_analysis["will_truncate"]:
title = title[:57] + "..."
lines.append(f" │ {title:<51} │")
# Description
desc = desc_analysis.get("mobile_preview", desc_analysis["description"][:120])
# Wrap at 51 chars
while desc:
chunk = desc[:51]
lines.append(f" │ {chunk:<51} │")
desc = desc[51:]
lines.append(" └─────────────────────────────────────────────────────┘")
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Simulate SERP appearance and optimize"
)
parser.add_argument("--title", help="Page title tag")
parser.add_argument("--description", help="Meta description")
parser.add_argument("--url", help="Page URL path")
parser.add_argument("--file", help="Markdown file to extract from")
parser.add_argument("--keyword", help="Target keyword for optimization check")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
title = args.title or ""
description = args.description or ""
url = args.url or "/"
if args.file:
filepath = Path(args.file)
if not filepath.exists():
print(f"Error: File not found: {filepath}", file=sys.stderr)
sys.exit(1)
file_title, file_desc = extract_from_file(filepath)
title = title or file_title
description = description or file_desc
url = url or f"/{filepath.stem}"
if not title:
print("Error: No title provided. Use --title or --file", file=sys.stderr)
sys.exit(1)
title_analysis = analyze_title(title, args.keyword)
desc_analysis = analyze_description(description, args.keyword)
url_analysis = analyze_url(url)
all_issues = title_analysis["issues"] + desc_analysis["issues"] + url_analysis["issues"]
result = {
"title": title_analysis,
"description": desc_analysis,
"url": url_analysis,
"total_issues": len(all_issues),
"passed": all(i["severity"] not in ("High", "Critical") for i in all_issues),
}
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"\n{'='*60}")
print(f" SERP PREVIEW")
print(f"{'='*60}")
print(generate_serp_preview(title_analysis, desc_analysis, url_analysis))
print(f" Title: {title_analysis['char_count']} chars {'(will truncate)' if title_analysis['will_truncate'] else '(OK)'}")
print(f" Description: {desc_analysis['char_count']} chars {'(will truncate mobile)' if desc_analysis['mobile_truncates'] else '(OK)'}")
print(f" URL depth: {url_analysis['depth']} levels")
print(f" Power word: {'Yes' if title_analysis['has_power_word'] else 'No'}")
print(f" Number in title: {'Yes' if title_analysis['has_number'] else 'No'}")
print(f" CTA in description: {'Yes' if desc_analysis['has_cta'] else 'No'}")
if all_issues:
print(f"\n Issues ({len(all_issues)}):")
for issue in all_issues:
print(f" [{issue['severity']}] {issue['detail']}")
else:
print(f"\n All checks passed.")
print()
if __name__ == "__main__":
main()