📋 Overview
What it does: Automatically identifies underperforming keywords with high impressions, analyzes their pages, and generates AI-powered optimization recommendations for title tags, meta descriptions, and content.
Time saved: 90% reduction (from 8 hours to 45 minutes per week)
ROI potential: 20-35% CTR improvement = hundreds of additional clicks per week
Ideal for: SEO managers, Digital marketers, Content strategists, Marketing agencies, In-house SEO teams
✨ Key Features
🤖 AI-Powered Analysis
Smart Filtering — Automatically identifies keywords with ≥100 impressions, <2% CTR, positions 6-20
Page Analysis — Extracts current title, meta description, and content using Jina AI
Claude Optimization — Generates URL-specific recommendations for each underperforming keyword
Batch Processing — Analyzes multiple keywords in a single automated run
Notion Integration — Saves all recommendations in organized database for team review
🎯 Output Quality
Keyword-Specific — Each recommendation is tailored to the exact keyword and URL combination
Actionable — Clear title, meta, and content changes ready to implement
Data-Driven — Includes CTR improvement estimates and hypothesis for each change
Organized — All results tracked in Notion with status tracking
Weekly Reports — Slack notifications with priority rankings and potential impact
💡 How It Helps Digital Marketers & SEOs
For SEO Managers
Challenge: Manually reviewing hundreds of keywords takes days
Solution: Automated weekly analysis surfaces quick-win opportunities
Impact: Focus team on highest-ROI optimizations
For Content Teams
Challenge: Writing compelling meta descriptions at scale is time-consuming
Solution: AI generates optimized copy for each keyword-URL pair
Impact: Consistent, high-quality metadata across entire site
For Marketing Agencies
Challenge: Clients need ongoing SEO improvements with limited budgets
Solution: Deliver weekly optimization reports automatically
Impact: Demonstrate continuous value, improve client retention
For E-commerce Sites
Challenge: Product pages underperform despite high search visibility
Solution: Identify exactly which pages need better titles/descriptions
Impact: Convert more impressions into clicks and sales
🔧 How It Works
Step 1: Automated Weekly Trigger
Every Monday at 8 AM, the workflow automatically:
Connects to Google Search Console
Pulls last 30 days of search performance data
Filters for underperforming keywords (≥100 impressions, <2% CTR, positions 6-20)
Step 2: Page Content Analysis
For each filtered keyword-URL pair:
Fetches page content using Jina AI Reader
Extracts current SEO elements:
Title tag (first 60 characters)
Meta description (first 160 characters)
Content preview (first 500 characters)
Step 3: AI Optimization
Claude 3.5 Haiku analyzes each keyword-URL combination and generates:
Optimized Title — 50-60 chars, keyword-optimized, compelling
Optimized Meta Description — 150-160 chars, includes CTA
Content Recommendations — Specific changes to improve relevance
Hypothesis — Why these changes will improve CTR
CTR Improvement Estimate — Expected percentage lift (e.g., “25-35%”)
Step 4: Results Organization
All recommendations are:
Saved to Notion — Organized database with all fields
Aggregated — Summary statistics calculated
Prioritized — Sorted by potential impact (additional clicks)
Step 5: Team Notification
Slack summary includes:
Total keywords analyzed
High/medium/low priority breakdown
Total potential additional clicks
Average position & CTR
Top 3 opportunities
📊 Technical Architecture
Workflow Nodes (11 total)
⏰ Schedule Trigger → 🔍 Google Search Console → 🎯 Filter Keywords ↓ 💬 Slack Summary ← 📋 Generate Summary ← 📊 Aggregate ← 📝 Notion ↑ 🔍 Parse Response ↑ 🧠 Claude Optimization ↑ 🔄 Loop Over Items ↑ 📄 Parse Page Data ↑ 🌐 Jina AI Fetch
AI Models Used
Model | Purpose | Provider |
|---|---|---|
Claude 3.5 Haiku | SEO optimization recommendations | Anthropic |
Jina AI Reader | Web page content extraction | Jina AI |
Integrations
Google Search Console — Search performance data
Jina AI — Page content extraction
Notion — Results database & tracking
Slack — Weekly summary notifications
n8n — Workflow orchestration
Data Processing
Filter Criteria:
Impressions: ≥100
CTR: <2%
Position: 6-20 (first two pages)
Batch Size: 1 item per Claude API call
Rate Limiting: Managed by n8n loop node
🎯 Use Cases
SEO Agency - Client Reporting
Scenario: Monthly SEO retainer for 10 clients
Run workflow for each client domain weekly
Generate 40+ optimization opportunities/month per client
Deliver data-driven recommendations with CTR projections
Value: Justify retainer fees with concrete deliverables
E-commerce - Product Page Optimization
Scenario: 500-product online store
Identify underperforming product pages
Optimize titles/descriptions for better CTR
Prioritize by potential revenue impact
Impact: 20% CTR increase = 200+ additional daily visitors
SaaS Company - Content Marketing
Scenario: 200+ blog posts competing for keywords
Surface blog posts stuck on page 2
Optimize for target keywords
Improve click-through from search
Result: 30% more organic traffic from existing content
Marketing Team - Quick Wins
Scenario: Limited dev resources, need fast results
Identify no-code optimization opportunities
Implement title/meta changes same day
Track improvement week-over-week
Benefit: High-impact changes with zero dev time
🛠️ Setup Requirements
Prerequisites
n8n Instance (self-hosted or n8n Cloud)
Google Search Console with verified property
Anthropic API Key (Claude access)
Jina AI Account (free tier works)
Notion Account with API access
Slack Workspace (for notifications)
Credentials Needed
Google Search Console OAuth2
Anthropic API Key
Jina AI API Key
Notion Integration Token
Slack OAuth2 (Bot Token)
Notion Database Schema
Create a database with these properties:
URL (Title/URL type)
Keyword (Rich Text)
Optimized Title (Rich Text)
Optimized Meta (Rich Text)
Content Recommendations (Rich Text)
Hypothesis (Rich Text)
CTR Improvement Estimate (Rich Text)
Status (Select: “pending_review”, “implemented”, “testing”)
Installation Time
Import workflow: 2 minutes
Configure credentials: 20 minutes
Set up Notion database: 10 minutes
Configure Slack channel: 5 minutes
Test execution: 10 minutes
Total setup: ~45 minutes
📈 Performance Metrics
Efficiency Gains
90% time reduction (8 hours → 45 minutes per week)
100% consistent analysis (no human error)
Weekly automation (set and forget)
Business Impact
20-35% CTR improvement per optimized keyword
100-500 additional clicks/month (typical)
$0-$5,000+ revenue impact (depends on conversion rates)
Quality Metrics
URL-specific recommendations (not generic advice)
Data-driven prioritization (by potential impact)
Trackable improvements (before/after CTR)
🐛 Troubleshooting
No Keywords Found in Filter
Problem: Filter node returns empty results
Solutions:
✅ Check Google Search Console has data for your domain
✅ Verify date range includes last 30 days
✅ Lower filter thresholds (50 impressions instead of 100)
✅ Expand position range (1-30 instead of 6-20)
✅ Confirm GSC API credentials are working
Jina AI Fetch Errors
Problem: “Failed to fetch URL content”
Solutions:
✅ Verify Jina AI API key is valid
✅ Check URL is publicly accessible (not behind login)
✅ Test URL manually at r.jina.ai/{your-url}
✅ Add delay between fetches (rate limiting)
✅ Switch to alternative: Puppeteer or HTTP Request node
Claude Response Parsing Failed
Problem: “Parsing failed” in Notion results
Solutions:
✅ Check Claude prompt asks for “ONLY valid JSON”
✅ Verify JSON schema matches parsing code
✅ Test prompt manually in Claude.ai console
✅ Add error handling in Parse Claude Response node
✅ Increase max_tokens if response gets cut off
Notion Save Errors
Problem: “Failed to create database page”
Solutions:
✅ Verify Notion database ID is correct
✅ Check all property names match exactly (case-sensitive)
✅ Confirm Notion integration has write access
✅ Test with manual Notion node execution
✅ Check for special characters in data breaking JSON
Slack Notification Not Sending
Problem: Summary doesn’t appear in Slack
Solutions:
✅ Verify Slack OAuth2 credentials
✅ Check bot has permission to post in channel
✅ Confirm channel ID is correct
✅ Test with manual Slack node execution
✅ Check message formatting (invalid JSON)
🚀 Quick Start Guide
Step 1: Import Workflow (2 min)
Download workflow JSON file
Open n8n instance
Click “Import from File”
Upload the JSON
Workflow appears in your canvas
Step 2: Connect Google Search Console (5 min)
Go to “Query search analytics” node
Click “Create New Credential”
Select “Google Search Console OAuth2”
Follow Google OAuth flow
Select your verified property
Test connection
Step 3: Configure Jina AI (2 min)
Sign up at jina.ai (free)
Get API key from dashboard
Go to “Read URL content” node
Add Jina AI credential
Paste API key
Test with sample URL
Step 4: Set Up Anthropic (3 min)
Get API key from console.anthropic.com
Go to “SEO optimization expert” node
Add Anthropic credential
Paste API key (
sk-antVerify model is Claude 3.5 Haiku
Step 5: Create Notion Database (10 min)
In Notion, create new database
Add properties (see schema above)
Get database ID from URL
Create Notion integration
Share database with integration
Go to “Create Notion Records” node
Add Notion credential
Select your database
Map fields to properties
Step 6: Configure Slack (5 min)
Create Slack app in your workspace
Add Bot Token Scopes:
chat:writeInstall app to workspace
Copy Bot User OAuth Token
Go to “Send Slack Notification” node
Add Slack credential
Select notification channel
Test message send
Step 7: Test Execution (10 min)
Disable schedule trigger (to avoid auto-runs)
Click “Execute Workflow” manually
Watch each node execute
Check for errors
Verify Notion records created
Confirm Slack notification sent
Review data quality
Enable schedule trigger when ready
Step 8: Monitor First Week (ongoing)
Let workflow run on Monday
Check Slack for summary
Review Notion database
Implement 2-3 recommendations
Track CTR changes in GSC
Adjust filters if needed
Full JSON Workflow:
{
"name": "🔍 Automated Underperforming Keywords Recovery",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 8 * * 1"
}
]
}
},
"id": "node_1",
"name": "⏰ Weekly Schedule (Monday 8 AM)",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
112,
96
]
},
{
"parameters": {
"resource": "databasePage",
"databaseId": {
"__rl": true,
"value": "{{ NOTION_DATABASE_ID }}",
"mode": "list",
"cachedResultName": "Your Database Name",
"cachedResultUrl": "{{ NOTION_DATABASE_URL }}"
},
"title": "Updated Changes",
"propertiesUi": {
"propertyValues": [
{
"key": "URL|title",
"title": "={{ $json.url }}"
},
{
"key": "Keyword|rich_text",
"textContent": "={{ $json.keyword }}"
},
{
"key": "Optimized Title|rich_text",
"textContent": "={{ $json.optimized_title }}"
},
{
"key": "Optimized Meta|rich_text",
"textContent": "={{ $json.optimized_meta }}"
},
{
"key": "Content Recommendations|rich_text",
"textContent": "={{ $json.content_recommendations }}"
},
{
"key": "Hypothesis|rich_text",
"textContent": "={{ $json.hypothesis }}"
},
{
"key": "Status|rich_text",
"textContent": "={{ $json.status }}"
}
]
},
"options": {}
},
"id": "node_2",
"name": "📝 Create Notion Records",
"type": "n8n-nodes-base.notion",
"typeVersion": 2.2,
"position": [
2128,
-16
],
"credentials": {
"notionApi": {
"id": "{{ NOTION_CREDENTIAL_ID }}",
"name": "Notion account"
}
}
},
{
"parameters": {
"aggregate": "aggregateAllItemData",
"options": {}
},
"id": "node_3",
"name": "📊 Aggregate Batch Results",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
2368,
160
]
},
{
"parameters": {
"jsCode": "// Generate summary statistics for Slack notification\nconst allResults = items.map(item => item.json);\n\nconst summary = {\n total_keywords_analyzed: allResults.length,\n high_priority: allResults.filter(k => k.gsc_data && k.gsc_data.additional_clicks_potential > 50).length,\n medium_priority: allResults.filter(k => k.gsc_data && k.gsc_data.additional_clicks_potential > 20 && k.gsc_data.additional_clicks_potential <= 50).length,\n low_priority: allResults.filter(k => k.gsc_data && k.gsc_data.additional_clicks_potential <= 20).length,\n total_potential_clicks: allResults.reduce((sum, k) => (\n sum + (k.gsc_data && k.gsc_data.additional_clicks_potential ? k.gsc_data.additional_clicks_potential : 0)\n ), 0),\n avg_position: (\n allResults.reduce((sum, k) => (\n sum + (k.gsc_data && k.gsc_data.position ? parseFloat(k.gsc_data.position) : 0)\n ), 0) / allResults.filter(k => k.gsc_data && k.gsc_data.position !== undefined).length\n ).toFixed(1),\n avg_ctr: (\n allResults.reduce((sum, k) => (\n sum + (k.gsc_data && k.gsc_data.ctr ? parseFloat(k.gsc_data.ctr) : 0)\n ), 0) / allResults.filter(k => k.gsc_data && k.gsc_data.ctr !== undefined).length\n ).toFixed(2),\n top_3_keywords: allResults\n .filter(k => k.gsc_data && k.gsc_data.additional_clicks_potential !== undefined)\n .sort((a, b) => b.gsc_data.additional_clicks_potential - a.gsc_data.additional_clicks_potential)\n .slice(0, 3)\n .map(k => ({\n keyword: k.keyword,\n potential_clicks: k.gsc_data.additional_clicks_potential,\n current_position: k.gsc_data.position,\n current_ctr: k.gsc_data.ctr\n }))\n};\n\nreturn [{ json: summary }];"
},
"id": "node_4",
"name": "📋 Generate Weekly Summary",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2592,
160
]
},
{
"parameters": {
"authentication": "oAuth2",
"select": "channel",
"channelId": {
"__rl": true,
"value": "{{ SLACK_CHANNEL_ID }}",
"mode": "list",
"cachedResultName": "your-channel-name"
},
"text": "=Weekly Underperforming Keywords Report:\n\n{{ $json.total_keywords_analyzed }},\n\n{{ $json.high_priority }}, \n\n{{ $json.medium_priority }}\n\n{{ $json.total_potential_clicks }}",
"otherOptions": {}
},
"id": "node_5",
"name": "💬 Send Slack Notification",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.1,
"position": [
2800,
160
],
"credentials": {
"slackOAuth2Api": {
"id": "{{ SLACK_CREDENTIAL_ID }}",
"name": "Slack account"
}
}
},
{
"parameters": {
"operation": "getPageInsights",
"siteUrl": "{{ YOUR_SITE_URL }}",
"dimensions": [
"page",
"date",
"query"
]
},
"type": "n8n-nodes-google-search-console.googleSearchConsole",
"typeVersion": 1,
"position": [
320,
96
],
"id": "node_6",
"name": "Query search analytics",
"credentials": {
"googleSearchConsoleOAuth2Api": {
"id": "{{ GSC_CREDENTIAL_ID }}",
"name": "Google Search Console account"
}
}
},
{
"parameters": {
"url": "={{ $json.page }}",
"options": {},
"requestOptions": {}
},
"type": "n8n-nodes-base.jinaAi",
"typeVersion": 1,
"position": [
880,
-16
],
"id": "node_7",
"name": "Read URL content",
"credentials": {
"jinaAiApi": {
"id": "{{ JINA_CREDENTIAL_ID }}",
"name": "Jina AI account"
}
}
},
{
"parameters": {
"jsCode": "// Filter GSC data for keywords with ≥100 impressions\n// GSC node outputs each row as a separate item, not nested in a 'rows' array\n\nconst filtered = items\n .filter(item => {\n const row = item.json;\n const impressions = parseInt(row.impressions) || 0;\n const ctr = parseFloat(row.ctr) || 0;\n const position = parseFloat(row.position) || 0;\n \n // Apply thresholds: ≥100 impressions, <2% CTR, position 6-20\n return impressions >= 100 && ctr < 0.02 && position > 5 && position <= 20;\n })\n .map(item => {\n const row = item.json;\n return {\n json: {\n query: row.query,\n page: row.page,\n position: parseFloat(row.position).toFixed(1),\n impressions: parseInt(row.impressions),\n clicks: parseInt(row.clicks),\n ctr: (parseFloat(row.ctr) * 100).toFixed(2),\n // Calculate potential\n ctr_potential: (0.04 * parseInt(row.impressions)), // Estimate clicks if CTR improved to 4%\n additional_clicks: Math.round((0.04 * parseInt(row.impressions)) - parseInt(row.clicks)),\n fetch_date: new Date().toISOString().split('T')[0]\n }\n };\n });\n\nreturn filtered;"
},
"id": "node_8",
"name": "Filter",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
640,
-16
],
"alwaysOutputData": true
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Extract title and meta description from Jina response\nconst markdown = $json.content || '';\n\n// Extract title from markdown (usually first line or after metadata)\nconst titleMatch = markdown.match(/^#\\s+(.+?)$/m) || markdown.match(/<title>(.+?)<\\/title>/);\nconst currentTitle = titleMatch ? titleMatch[1].substring(0, 60) : 'Not found';\n\n// Extract meta description if available in content\nconst metaMatch = markdown.match(/Description:\\s*(.+?)(?:\\n|$)/) ||\n markdown.match(/description['\\\"]?:\\s*['\\\"]?(.+?)['\\\"]?\\n/);\nconst currentMeta = metaMatch ? metaMatch[1].substring(0, 160) : 'Not found';\n\n// Get first 500 chars of main content for context\nconst contentPreview = markdown.substring(0, 500);\n\nreturn {\n ...$json,\n current_title: currentTitle,\n current_meta: currentMeta,\n content_preview: contentPreview,\n url_fetched: true\n};"
},
"id": "node_9",
"name": "Parse Page Title Meta",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1296,
-32
]
},
{
"parameters": {
"modelId": {
"__rl": true,
"value": "claude-3-5-haiku-20241022",
"mode": "list",
"cachedResultName": "claude-3-5-haiku-20241022"
},
"messages": {
"values": [
{
"content": "=You are an SEO optimization expert. This keyword is underperforming despite high impressions. Analyze the current page and provide specific optimization recommendations.\n\nKEYWORD: {{ $('Filter').item.json.query }}\nURL: {{ $json.url }}\n\nCURRENT PAGE:\n- Title: {{ $json.current_title }}\n- Meta Description: {{ $json.current_meta }}\n- Content Preview: {{ $json.content_preview }}\n\nPERFORMANCE DATA:\n- Position: {{ $('Filter').item.json.position }}\n- Impressions: {{ $('Filter').item.json.impressions }}\n- Clicks: {{ $('Filter').item.json.clicks }}\n- CTR: {{ $('Filter').item.json.ctr }}%\n- Potential Additional Clicks: {{ $('Filter').item.json.additional_clicks }} \n\nProvide optimization recommendations for THIS SPECIFIC URL to improve CTR for this keyword. Include:\n1. Optimized title tag (50-60 chars, include target keyword naturally)\n2. Optimized meta description (150-160 chars, compelling call-to-action)\n3. Content optimization suggestions (specific changes to page content)\n4. Hypothesis explaining why these changes will improve CTR\n5. Estimated CTR improvement percentage\n\nReturn ONLY valid JSON:\n{\n \"keyword\": \"the keyword\",\n \"url\": \"the url\",\n \"optimized_title\": \"new title here\",\n \"optimized_meta\": \"new meta description here\",\n \"content_recommendations\": \"specific content changes for this page\",\n \"hypothesis\": \"why these changes will improve CTR for this keyword\",\n \"ctr_improvement_estimate\": \"20-30%\"\n}"
}
]
},
"simplify": false,
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.anthropic",
"typeVersion": 1,
"position": [
1536,
80
],
"id": "node_10",
"name": "SEO optimization expert",
"credentials": {
"anthropicApi": {
"id": "{{ ANTHROPIC_CREDENTIAL_ID }}",
"name": "Anthropic account"
}
}
},
{
"parameters": {
"options": {}
},
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [
1072,
96
],
"id": "node_11",
"name": "Loop Over Items"
},
{
"parameters": {
"jsCode": "// Parse Claude's JSON response from Anthropic node - process ALL items\nreturn items.map(item => {\n const claudeResponse = item.json;\n const content = claudeResponse.content[0].text;\n\n let optimization;\n try {\n optimization = JSON.parse(content);\n } catch (e) {\n const jsonMatch = content.match(/```json\\s*([\\s\\S]*?)\\s*```/);\n if (jsonMatch) {\n optimization = JSON.parse(jsonMatch[1]);\n } else {\n optimization = {\n keyword: \"Parsing failed\",\n url: \"\",\n optimized_title: \"Manual review needed\",\n optimized_meta: \"Manual review needed\",\n content_recommendations: \"Manual review needed\",\n hypothesis: \"Parsing failed\",\n ctr_improvement_estimate: \"TBD\"\n };\n }\n }\n\n return {\n json: {\n keyword: optimization.keyword,\n url: optimization.url,\n optimized_title: optimization.optimized_title,\n optimized_meta: optimization.optimized_meta,\n content_recommendations: Array.isArray(optimization.content_recommendations) \n ? optimization.content_recommendations.join('\\n') \n : optimization.content_recommendations,\n hypothesis: optimization.hypothesis,\n ctr_improvement_estimate: optimization.ctr_improvement_estimate,\n analysis_date: new Date().toISOString().split('T')[0],\n status: 'pending_review'\n }\n };\n});"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1888,
64
],
"id": "node_12",
"name": "Parse Claude Response"
}
],
"pinData": {},
"connections": {
"⏰ Weekly Schedule (Monday 8 AM)": {
"main": [
[
{
"node": "Query search analytics",
"type": "main",
"index": 0
}
]
]
},
"Query search analytics": {
"main": [
[
{
"node": "Filter",
"type": "main",
"index": 0
}
]
]
},
"Read URL content": {
"main": [
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
},
"📝 Create Notion Records": {
"main": [
[
{
"node": "📊 Aggregate Batch Results",
"type": "main",
"index": 0
}
]
]
},
"📊 Aggregate Batch Results": {
"main": [
[
{
"node": "📋 Generate Weekly Summary",
"type": "main",
"index": 0
}
]
]
},
"📋 Generate Weekly Summary": {
"main": [
[
{
"node": "💬 Send Slack Notification",
"type": "main",
"index": 0
}
]
]
},
"Filter": {
"main": [
[
{
"node": "Read URL content",
"type": "main",
"index": 0
}
]
]
},
"Parse Page Title Meta": {
"main": [
[
{
"node": "SEO optimization expert",
"type": "main",
"index": 0
}
]
]
},
"SEO optimization expert": {
"main": [
[
{
"node": "Parse Claude Response",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Items": {
"main": [
[
{
"node": "Parse Page Title Meta",
"type": "main",
"index": 0
}
],
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
},
"Parse Claude Response": {
"main": [
[
{
"node": "📝 Create Notion Records",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "",
"meta": {
"templateCredsSetupCompleted": false,
"instanceId": ""
},
"id": "",
"tags": []
}Built with ❤️ for the SEO community
Automate the boring stuff. Focus on strategy.

