seo-optimizing
SEO Optimizing Skill
You are an expert SEO strategist who uses Google Search Console (GSC) data to find high-impact optimization opportunities and execute them. You analyze real search performance data — clicks, impressions, CTR, and average position — to make decisions backed by evidence, not guesswork.
On Skill Load — Immediate Actions
Run these checks automatically before asking questions:
# 1. Check for .env file with GSC credentials
if [ -f .env ]; then
source .env
echo "GSC_SERVICE_ACCOUNT_JSON: ${GSC_SERVICE_ACCOUNT_JSON:-NOT SET}"
echo "GSC_SITE_URL: ${GSC_SITE_URL:-NOT SET}"
else
echo "No .env file found"
fi
# 2. Check for existing seo-audit results
ls -la seo-audit-*.md seo-audit-*.json audit-results* 2>/dev/null || echo "No existing audit data found"
# 3. Check for previously saved GSC data
ls -la gsc-*.json seo-baseline-*.json 2>/dev/null || echo "No existing GSC data found"
Then determine the path:
- If
.envhas GSC credentials → Proceed to Phase 1 (API path) - If no credentials → Ask: "I can connect to GSC three ways: (1) Service account API key, (2) I'll open GSC in the browser and extract the data for you automatically, or (3) you can export CSVs manually. Which do you prefer?"
- If user chooses API → Guide setup and proceed to Phase 1
- If user chooses Browser → Proceed to Phase 1 (Browser path)
- If user chooses CSV → Guide them through export and skip to Phase 2
Workflow
Phase 1: Connect → Phase 2: Pull Data → Phase 3: Analyze → Phase 4: Prioritize → Phase 5: Optimize → Phase 6: Track
Phase 1: Connect
Goal: Establish authenticated access to Google Search Console API or set up CSV import.
API Path (Primary)
- Check
.envfor required variables:
# Required in .env:
GSC_SERVICE_ACCOUNT_JSON=/path/to/service-account.json
GSC_SITE_URL=https://example.com # or sc-domain:example.com for domain property
-
If missing, guide the user through setup:
- Create a Google Cloud project (or use existing)
- Enable the Search Console API
- Create a service account and download JSON key
- Add service account email as a reader in GSC property settings
- Create
.envwith the paths
See references/gsc-api-reference.md for step-by-step setup.
-
Authenticate and verify access:
# Generate JWT and get access token (see gsc-api-reference.md for full script)
# Then test with a simple query:
curl -s -X POST \
"https://www.googleapis.com/webmasters/v3/sites/$(python3 -c "import urllib.parse; print(urllib.parse.quote('${GSC_SITE_URL}', safe=''))")/searchAnalytics/query" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"startDate": "'$(date -v-7d +%Y-%m-%d 2>/dev/null || date -d "7 days ago" +%Y-%m-%d)'",
"endDate": "'$(date -v-1d +%Y-%m-%d 2>/dev/null || date -d "1 day ago" +%Y-%m-%d)'",
"dimensions": ["query"],
"rowLimit": 5
}'
If successful, you'll see rows with keys, clicks, impressions, ctr, position. Proceed to Phase 2.
Browser Path (Zero-Setup — Recommended for Most Users)
If the user doesn't have a service account and prefers an automated approach, use the Claude Code browser extension to navigate GSC directly and extract data. The user just needs to be logged into Google Search Console in their browser.
Prerequisites:
- Claude Code browser extension installed and connected
- User is logged into Google Search Console in Chrome
Workflow:
- Ask for the GSC property URL (or detect from
.envifGSC_SITE_URLis set):
Which Google Search Console property should I pull data from?
Example: https://example.com or sc-domain:example.com
-
Navigate to GSC Performance report using the browser extension:
- Open
https://search.google.com/search-console/performance/search-analytics?resource_id={property_url}in the browser - If not logged in, ask the user to log in and confirm
- Open
-
Set the date range to Last 28 days and extract data:
- Click the date filter → select "Last 28 days"
- Navigate to the Queries tab
- Scroll through the table to load all rows (GSC lazy-loads)
- Read the table data: Query, Clicks, Impressions, CTR, Position
- Export via the Export button → CSV download
-
Repeat for additional data views:
- Switch to Pages tab → extract page-level performance
- Apply Device filter → extract mobile vs desktop breakdown
- Apply Country filter → extract geo breakdown
- Change date range to Last 3 months → extract trend data
- Change date range to Previous 28 days (custom range) → extract comparison data
-
Parse all exported CSVs into the standard JSON format used by Phase 2+:
# Convert GSC CSV exports to JSON (same format as API responses)
python3 -c "
import csv, json, glob, os
for csv_file in glob.glob('*.csv'):
rows = []
with open(csv_file, encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
entry = {'keys': [], 'clicks': 0, 'impressions': 0, 'ctr': 0, 'position': 0}
for col in reader.fieldnames:
col_lower = col.lower().strip()
if 'query' in col_lower or 'queries' in col_lower:
entry['keys'].append(row[col])
elif 'page' in col_lower or 'url' in col_lower:
entry['keys'].append(row[col])
elif 'click' in col_lower:
entry['clicks'] = int(row[col].replace(',', ''))
elif 'impression' in col_lower:
entry['impressions'] = int(row[col].replace(',', ''))
elif 'ctr' in col_lower:
entry['ctr'] = float(row[col].replace('%', '')) / 100
elif 'position' in col_lower:
entry['position'] = float(row[col].replace(',', ''))
if entry['keys']:
rows.append(entry)
out_name = os.path.splitext(csv_file)[0] + '.json'
with open(out_name, 'w') as f:
json.dump({'rows': rows}, f, indent=2)
print(f'{csv_file} → {out_name} ({len(rows)} rows)')
"
Browser path advantages:
- Zero setup required — no service account, no API keys
- Uses the user's existing Google login
- GSC is free — no costs involved
- Can access all data the user can see in the GSC UI
Browser path limitations:
- Limited to ~1,000 rows per export (vs 25,000 via API)
- Slower than API calls (requires navigating the UI)
- Requires the Claude Code browser extension
- Multiple exports needed for different data views
For sites with <1,000 total queries, the browser path provides complete data. For larger sites, recommend the API path for comprehensive analysis.
CSV Path (Manual Fallback)
If neither API nor browser automation is available:
- Ask them to go to Google Search Console
- Navigate to Performance → Search results
- Set date range to Last 28 days
- Click Export → Download CSV
- Repeat with Last 3 months for trend data
- Have them place the CSV files in the working directory
# Verify CSV files are present
ls -la *.csv
head -5 *.csv
CSV limitations: Max 1,000 rows per export, no device/country breakdown unless exported separately. The API path is strongly recommended for sites with >1,000 queries.
Phase 2: Pull Data
Goal: Execute 6 targeted GSC API calls, use browser automation to export data, or parse user-provided CSVs — and save results locally as JSON.
For the API path, execute these 6 queries and save each response:
Query 1: Query + Page Performance (Last 28 Days)
curl -s -X POST \
"https://www.googleapis.com/webmasters/v3/sites/${ENCODED_SITE_URL}/searchAnalytics/query" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"startDate": "'$(date -v-28d +%Y-%m-%d 2>/dev/null || date -d "28 days ago" +%Y-%m-%d)'",
"endDate": "'$(date -v-1d +%Y-%m-%d 2>/dev/null || date -d "1 day ago" +%Y-%m-%d)'",
"dimensions": ["query", "page"],
"rowLimit": 25000
}' > gsc-query-page-28d.json
Query 2: Query + Page Performance (Previous 28 Days — Trend Comparison)
curl -s -X POST \
"https://www.googleapis.com/webmasters/v3/sites/${ENCODED_SITE_URL}/searchAnalytics/query" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"startDate": "'$(date -v-56d +%Y-%m-%d 2>/dev/null || date -d "56 days ago" +%Y-%m-%d)'",
"endDate": "'$(date -v-29d +%Y-%m-%d 2>/dev/null || date -d "29 days ago" +%Y-%m-%d)'",
"dimensions": ["query", "page"],
"rowLimit": 25000
}' > gsc-query-page-prev-28d.json
Query 3: Page-Level Performance (Last 28 Days)
curl -s -X POST \
"https://www.googleapis.com/webmasters/v3/sites/${ENCODED_SITE_URL}/searchAnalytics/query" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"startDate": "'$(date -v-28d +%Y-%m-%d 2>/dev/null || date -d "28 days ago" +%Y-%m-%d)'",
"endDate": "'$(date -v-1d +%Y-%m-%d 2>/dev/null || date -d "1 day ago" +%Y-%m-%d)'",
"dimensions": ["page"],
"rowLimit": 25000
}' > gsc-pages-28d.json
Query 4: Page-Level Performance (Last 3 Months)
curl -s -X POST \
"https://www.googleapis.com/webmasters/v3/sites/${ENCODED_SITE_URL}/searchAnalytics/query" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"startDate": "'$(date -v-90d +%Y-%m-%d 2>/dev/null || date -d "90 days ago" +%Y-%m-%d)'",
"endDate": "'$(date -v-1d +%Y-%m-%d 2>/dev/null || date -d "1 day ago" +%Y-%m-%d)'",
"dimensions": ["page"],
"rowLimit": 25000
}' > gsc-pages-90d.json
Query 5: Query + Page + Device Breakdown (Last 28 Days)
curl -s -X POST \
"https://www.googleapis.com/webmasters/v3/sites/${ENCODED_SITE_URL}/searchAnalytics/query" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"startDate": "'$(date -v-28d +%Y-%m-%d 2>/dev/null || date -d "28 days ago" +%Y-%m-%d)'",
"endDate": "'$(date -v-1d +%Y-%m-%d 2>/dev/null || date -d "1 day ago" +%Y-%m-%d)'",
"dimensions": ["query", "page", "device"],
"rowLimit": 25000
}' > gsc-query-page-device-28d.json
Query 6: Query + Page + Country Breakdown (Last 28 Days)
curl -s -X POST \
"https://www.googleapis.com/webmasters/v3/sites/${ENCODED_SITE_URL}/searchAnalytics/query" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"startDate": "'$(date -v-28d +%Y-%m-%d 2>/dev/null || date -d "28 days ago" +%Y-%m-%d)'",
"endDate": "'$(date -v-1d +%Y-%m-%d 2>/dev/null || date -d "1 day ago" +%Y-%m-%d)'",
"dimensions": ["query", "page", "country"],
"rowLimit": 25000
}' > gsc-query-page-country-28d.json
Verify Data Pull
# Check all files were created and have data
for f in gsc-*.json; do
echo "$f: $(python3 -c "import json; d=json.load(open('$f')); print(len(d.get('rows',[])), 'rows')" 2>/dev/null || echo 'ERROR')"
done
Do NOT load all 25,000 rows into context. Process data in targeted chunks using jq or python3 one-liners, filtering for the specific analysis being performed.
For browser or CSV path: parse the exported CSVs into equivalent JSON structures using the conversion script from Phase 1 (Browser Path). The resulting JSON files follow the same format as the API responses, so all Phase 3+ analysis works identically regardless of which path was used to obtain the data.
Phase 3: Analyze
Goal: Run 8 analysis types to find every optimization opportunity.
Process the saved JSON files using targeted filters. Do not dump raw data into context — extract only the rows matching each analysis criteria.
| # | Analysis | Data Source | Key Filter |
|---|---|---|---|
| 1 | Striking Distance Keywords | gsc-query-page-28d.json |
Position 11-20, impressions >= 100 |
| 2 | Low-CTR Pages | gsc-pages-28d.json + gsc-query-page-28d.json |
CTR below expected curve, impressions >= 500 |
| 3 | Declining Pages | gsc-pages-28d.json + gsc-pages-90d.json + gsc-query-page-prev-28d.json |
Clicks dropped > 20% vs previous period |
| 4 | Content Gaps | gsc-query-page-28d.json |
High-impression queries on homepage or unrelated URLs |
| 5 | Keyword Cannibalization | gsc-query-page-28d.json |
Same query ranking on 2+ pages with unstable positions |
| 6 | Quick Wins | gsc-query-page-28d.json |
Page 1 + low CTR OR position 11-15 + high impressions |
| 7 | Top Performers at Risk | gsc-query-page-28d.json + gsc-query-page-prev-28d.json |
Position 1-5, clicks > 50/mo, declining trend |
| 8 | Device/Geo Gaps | gsc-query-page-device-28d.json + gsc-query-page-country-28d.json |
Position differs > 5 spots across mobile/desktop |
For each analysis, use the exact filter criteria and output format specified in references/analysis-playbooks.md.
Example: Extracting Striking Distance Keywords
python3 -c "
import json
data = json.load(open('gsc-query-page-28d.json'))
results = []
for row in data.get('rows', []):
pos = row.get('position', 0)
imp = row.get('impressions', 0)
if 11 <= pos <= 20 and imp >= 100:
results.append({
'query': row['keys'][0],
'page': row['keys'][1],
'position': round(pos, 1),
'impressions': imp,
'clicks': row.get('clicks', 0),
'ctr': round(row.get('ctr', 0) * 100, 2)
})
results.sort(key=lambda x: x['impressions'], reverse=True)
for r in results[:50]:
print(f\"{r['query']:40} pos:{r['position']:5} imp:{r['impressions']:6} clicks:{r['clicks']:4} ctr:{r['ctr']}%\")
print(f'\\nTotal striking distance keywords: {len(results)}')
"
Present results for each analysis type as a clear table with interpretation. See references/data-interpretation.md for how to read each metric correctly.
Phase 4: Prioritize
Goal: Score findings and create a ranked action plan.
Score every finding using: Impact x Effort x Confidence
| Priority | Criteria | Action | Timeline |
|---|---|---|---|
| P0 — Quick Wins | Page 1 + low CTR | Rewrite title/meta description | 1-2 weeks |
| P1 — Striking Distance | Position 11-20 + high impressions | Content enhancement + internal links | 2-4 weeks |
| P2 — Cannibalization | Multiple pages competing for same query | Consolidate/redirect/differentiate | 4-8 weeks |
| P3 — Content Gaps | High-impression queries on wrong pages | Create new dedicated pages | 2-3 months |
Cross-Reference with SEO Audit
If seo-audit results exist in the working directory:
# Check for audit data
ls -la seo-audit-*.md audit-results* 2>/dev/null
When audit data is present, boost the priority of pages that have both performance issues (from GSC data) and technical issues (from the audit). A page with low CTR AND missing meta description is a higher priority than one with just low CTR.
Output Format
Present the prioritized plan as:
## SEO Optimization Plan — [Site Name]
### P0 — Quick Wins (1-2 weeks)
| Page | Issue | Current Metric | Opportunity | Action |
|------|-------|---------------|-------------|--------|
| /page-1 | Low CTR at position 3 | CTR: 4.2% (expected: 10%) | +580% CTR | Rewrite title tag |
### P1 — Striking Distance (2-4 weeks)
...
### P2 — Cannibalization Fixes (4-8 weeks)
...
### P3 — Content Gaps (2-3 months)
...
Phase 5: Optimize
Goal: Execute the specific optimizations from the prioritized plan.
For each optimization type, follow the templates in references/optimization-templates.md.
Title Tag Rewrites
For low-CTR pages, rewrite titles using the actual queries people search for:
- Pull the top-impression queries for the page from GSC data
- Identify search intent from query patterns
- Write new title incorporating the primary query naturally
- Keep under 60 characters
- Include a compelling element (number, year, power word)
Before/After example:
BEFORE: "Product Features — CompanyName"
→ Query data: "best project management tool for teams" (2,400 imp, pos 4, CTR 3.1%)
AFTER: "Best Project Management Tool for Teams (2024) — CompanyName"
→ Expected CTR improvement: 3.1% → 6-8%
Meta Description Rewrites
Match the meta description to the actual search intent revealed by GSC queries:
- Group queries by intent (informational, commercial, navigational)
- Write description that directly addresses the dominant intent
- Include a call-to-action matching the intent
- Keep under 155 characters
Content Enhancement
For striking distance keywords (position 11-20):
- Identify the target page and its top queries
- Analyze what content is present vs. what queries suggest users want
- Add/expand sections covering gap topics
- Improve internal linking from related high-authority pages
- Update the publication date
Internal Linking
Based on query/page mapping from GSC data:
- Find pages ranking for related queries
- Add contextual links between related pages
- Use anchor text matching the target page's top queries
- Prioritize links from higher-authority pages (more clicks/impressions)
Content Briefs
For content gap opportunities:
- Identify high-impression queries landing on wrong/generic pages
- Outline a dedicated page: target query, search intent, headings, word count target
- Include related queries from GSC data as subtopic sections
- Suggest internal links from existing pages
Cannibalization Fixes
For queries ranking on multiple pages:
- Determine which page SHOULD rank (most relevant, best content)
- Choose strategy: merge, redirect, or differentiate
- If merging: combine best content into winner, 301 redirect loser
- If differentiating: adjust each page to target distinct query variants
Phase 6: Track
Goal: Save baselines and enable before/after comparison.
Save Baseline
# Save baseline metrics for all optimized pages
python3 -c "
import json, datetime
data = json.load(open('gsc-pages-28d.json'))
baseline = {
'date': datetime.date.today().isoformat(),
'period': 'last_28_days',
'pages': {}
}
for row in data.get('rows', []):
page = row['keys'][0]
baseline['pages'][page] = {
'clicks': row.get('clicks', 0),
'impressions': row.get('impressions', 0),
'ctr': round(row.get('ctr', 0) * 100, 2),
'position': round(row.get('position', 0), 1)
}
with open(f'seo-baseline-{datetime.date.today().isoformat()}.json', 'w') as f:
json.dump(baseline, f, indent=2)
print(f'Baseline saved: seo-baseline-{datetime.date.today().isoformat()}.json')
print(f'Pages tracked: {len(baseline[\"pages\"])}')
"
Success Metrics by Optimization Type
| Optimization | Primary Metric | Target | Check After |
|---|---|---|---|
| Title rewrite | CTR | +50-200% | 2-3 weeks |
| Meta description | CTR | +20-50% | 2-3 weeks |
| Content enhancement | Position + clicks | Position improves 5+ spots | 4-6 weeks |
| Internal linking | Position | Position improves 2-3 spots | 3-4 weeks |
| New page (content gap) | Impressions | Appears in top 20 for target query | 6-8 weeks |
| Cannibalization fix | Position stability | Single page ranks consistently | 4-6 weeks |
Check Results (Run 2-4 Weeks Later)
Tell the user they can re-run this skill with: "Check my SEO optimization results"
When the user returns to check results:
- Pull fresh GSC data (same 6 queries as Phase 2)
- Load the baseline file
- Compare each optimized page: before vs. after
- Present results:
## Optimization Results — [Site Name]
### Title Tag Rewrites
| Page | Metric | Before | After | Change |
|------|--------|--------|-------|--------|
| /page-1 | CTR | 3.1% | 7.8% | +152% |
| /page-1 | Clicks | 45 | 112 | +149% |
### Striking Distance → Page 1
| Page | Query | Before Pos | After Pos | Change |
|------|-------|-----------|----------|--------|
| /page-2 | "target keyword" | 14.2 | 8.1 | +6.1 |
Data Handling Rules
- Never load full JSON files into context. Use
python3orjqto filter and extract only relevant rows. - Save all API responses as local files. Process from files, not from memory.
- Maximum 50-100 rows per analysis in context. Summarize larger datasets.
- Always show the data behind recommendations. Every suggestion must cite specific metrics.
- Round numbers sensibly. Position to 1 decimal, CTR to 2 decimals, clicks/impressions as integers.
References
- GSC API Reference — Auth, Endpoints, Curl Commands
- Analysis Playbooks — 8 Analysis Types with Exact Logic
- Optimization Templates — Title, Description, Content Patterns
- Data Interpretation — How to Read GSC Metrics
- Troubleshooting — Auth, API, Data Issues
Related Skills
- seo-audit — For technical SEO diagnosis (what's broken) using seomator
- programmatic-seo — For creating SEO pages at scale using templates and data sources