From 3b51952336a0fa61ee512d2c2a0b0214d7e9dab5 Mon Sep 17 00:00:00 2001 From: Kevin Bataille Date: Mon, 16 Feb 2026 05:25:16 +0400 Subject: [PATCH] Initial commit: Clean SEO analysis system --- .env.example | 23 ++ .gitignore | 48 ++++ PROJECT_GUIDE.md | 310 ++++++++++++++++++++++++ README.md | 474 +++++++++++++++++++++++++++++++++++++ analytics_importer.py | 427 +++++++++++++++++++++++++++++++++ config.py | 71 ++++++ content_gap_analyzer.py | 348 +++++++++++++++++++++++++++ input/README.md | 49 ++++ input/new-propositions.ods | Bin 0 -> 53771 bytes opportunity_analyzer.py | 347 +++++++++++++++++++++++++++ report_generator.py | 436 ++++++++++++++++++++++++++++++++++ requirements.txt | 5 + run_analysis.sh | 73 ++++++ 13 files changed, 2611 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 PROJECT_GUIDE.md create mode 100644 README.md create mode 100644 analytics_importer.py create mode 100644 config.py create mode 100644 content_gap_analyzer.py create mode 100644 input/README.md create mode 100644 input/new-propositions.ods create mode 100644 opportunity_analyzer.py create mode 100644 report_generator.py create mode 100644 requirements.txt create mode 100755 run_analysis.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..31f8f18 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# WordPress Configuration +WORDPRESS_URL=https://yoursite.com +WORDPRESS_USERNAME=your_username +WORDPRESS_APP_PASSWORD=your_application_password + +# OpenRouter API Configuration +OPENROUTER_API_KEY=your_openrouter_api_key + +# AI Model Selection (choose one) +# Recommended: anthropic/claude-3.5-sonnet (best quality, $3/$15 per 1M tokens) +# Budget: meta-llama/llama-3.1-70b-instruct (free tier available) +# Alternative: openai/gpt-4-turbo ($10/$30 per 1M tokens) +AI_MODEL=anthropic/claude-3.5-sonnet + +# Script Configuration +BATCH_SIZE=100 +API_DELAY_SECONDS=0.5 + +# Analysis Settings +ANALYSIS_MIN_POSITION=11 +ANALYSIS_MAX_POSITION=30 +ANALYSIS_MIN_IMPRESSIONS=50 +ANALYSIS_TOP_N_POSTS=20 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa44c2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Configuration +.env +.env.local + +# Virtual Environment +venv/ +env/ +ENV/ +.venv + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ + +# Input files (sensitive/large) +input/analytics/ +input/**/*.csv +input/**/*.txt + +# Output files (generated results) +output/results/ +output/logs/ +output/**/*.csv +output/**/*.txt +output/**/*.log +output/**/*.md + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Backup/rollback files +*.bak +rollback_*.csv +*_backup.csv diff --git a/PROJECT_GUIDE.md b/PROJECT_GUIDE.md new file mode 100644 index 0000000..1bc2de2 --- /dev/null +++ b/PROJECT_GUIDE.md @@ -0,0 +1,310 @@ +# SEO Analysis & Improvement System - Project Guide + +## πŸ“‹ Overview + +A complete 4-phase SEO analysis pipeline that: +1. **Integrates** Google Analytics, Search Console, and WordPress data +2. **Identifies** high-potential keywords for optimization (positions 11-30) +3. **Discovers** new content opportunities using AI +4. **Generates** a comprehensive report with 90-day action plan + +## πŸ“‚ Project Structure + +``` +seo/ +β”œβ”€β”€ input/ # SOURCE DATA (your exports) +β”‚ β”œβ”€β”€ new-propositions.csv # WordPress posts +β”‚ β”œβ”€β”€ README.md # How to export data +β”‚ └── analytics/ +β”‚ β”œβ”€β”€ ga4_export.csv # Google Analytics +β”‚ └── gsc/ +β”‚ β”œβ”€β”€ Pages.csv # GSC pages (required) +β”‚ β”œβ”€β”€ RequΓͺtes.csv # GSC queries (optional) +β”‚ └── ... +β”‚ +β”œβ”€β”€ output/ # RESULTS (auto-generated) +β”‚ β”œβ”€β”€ results/ +β”‚ β”‚ β”œβ”€β”€ seo_optimization_report.md # πŸ“ PRIMARY OUTPUT +β”‚ β”‚ β”œβ”€β”€ posts_with_analytics.csv +β”‚ β”‚ β”œβ”€β”€ posts_prioritized.csv +β”‚ β”‚ β”œβ”€β”€ keyword_opportunities.csv +β”‚ β”‚ └── content_gaps.csv +β”‚ β”‚ +β”‚ β”œβ”€β”€ logs/ +β”‚ β”‚ β”œβ”€β”€ import_log.txt +β”‚ β”‚ β”œβ”€β”€ opportunity_analysis_log.txt +β”‚ β”‚ └── content_gap_analysis_log.txt +β”‚ β”‚ +β”‚ └── README.md # Output guide +β”‚ +β”œβ”€β”€ πŸš€ run_analysis.sh # Run entire pipeline +β”œβ”€β”€ analytics_importer.py # Phase 1: Merge data +β”œβ”€β”€ opportunity_analyzer.py # Phase 2: Find wins +β”œβ”€β”€ content_gap_analyzer.py # Phase 3: Find gaps +β”œβ”€β”€ report_generator.py # Phase 4: Generate report +β”œβ”€β”€ config.py +β”œβ”€β”€ requirements.txt +β”œβ”€β”€ .env.example +└── .gitignore +``` + +## πŸš€ Getting Started + +### Step 1: Prepare Input Data + +**Place WordPress posts CSV:** +``` +input/new-propositions.csv +``` + +**Export Google Analytics 4:** +1. Go to: Analytics > Reports > Engagement > Pages and Screens +2. Set date range: Last 90 days +3. Download CSV β†’ Save as: `input/analytics/ga4_export.csv` + +**Export Google Search Console (Pages):** +1. Go to: Performance +2. Set date range: Last 90 days +3. Export CSV β†’ Save as: `input/analytics/gsc/Pages.csv` + +### Step 2: Run Analysis + +```bash +# Run entire pipeline +./run_analysis.sh + +# OR run steps individually +./venv/bin/python analytics_importer.py +./venv/bin/python opportunity_analyzer.py +./venv/bin/python content_gap_analyzer.py +./venv/bin/python report_generator.py +``` + +### Step 3: Review Report + +Open: **`output/results/seo_optimization_report.md`** + +Contains: +- Executive summary with current metrics +- Top 20 posts ranked by opportunity (with AI recommendations) +- Keyword opportunities breakdown +- Content gap analysis +- 90-day phased action plan + +## πŸ“Š What Each Script Does + +### `analytics_importer.py` (Phase 1) +**Purpose:** Merge analytics data with WordPress posts + +**Input:** +- `input/new-propositions.csv` (WordPress posts) +- `input/analytics/ga4_export.csv` (Google Analytics) +- `input/analytics/gsc/Pages.csv` (Search Console) + +**Output:** +- `output/results/posts_with_analytics.csv` (enriched dataset) +- `output/logs/import_log.txt` (matching report) + +**Handles:** French and English column names, URL normalization, multi-source merging + +### `opportunity_analyzer.py` (Phase 2) +**Purpose:** Identify high-potential optimization opportunities + +**Input:** +- `output/results/posts_with_analytics.csv` + +**Output:** +- `output/results/keyword_opportunities.csv` (26 opportunities) +- `output/logs/opportunity_analysis_log.txt` + +**Features:** +- Filters posts at positions 11-30 (page 2-3) +- Calculates opportunity scores (0-100) +- Generates AI recommendations for top 20 posts + +### `content_gap_analyzer.py` (Phase 3) +**Purpose:** Discover new content opportunities + +**Input:** +- `output/results/posts_with_analytics.csv` +- `input/analytics/gsc/RequΓͺtes.csv` (optional) + +**Output:** +- `output/results/content_gaps.csv` +- `output/logs/content_gap_analysis_log.txt` + +**Features:** +- Topic cluster extraction +- Gap identification +- AI-powered content suggestions + +### `report_generator.py` (Phase 4) +**Purpose:** Create comprehensive report with action plan + +**Input:** +- All analysis results from phases 1-3 + +**Output:** +- `output/results/seo_optimization_report.md` ← **PRIMARY DELIVERABLE** +- `output/results/posts_prioritized.csv` + +**Features:** +- Comprehensive markdown report +- All 262 posts ranked +- 90-day action plan with estimated gains + +## πŸ“ˆ Understanding Your Report + +### Key Metrics (Executive Summary) +- **Total Posts:** All posts analyzed +- **Monthly Traffic:** Current organic traffic +- **Total Impressions:** Search visibility (90 days) +- **Average Position:** Current ranking position +- **Opportunities:** Posts ready to optimize + +### Top 20 Posts to Optimize +Each post shows: +- **Title** (the post name) +- **Current Position** (search ranking) +- **Impressions** (search visibility) +- **Traffic** (organic visits) +- **Priority Score** (0-100 opportunity rating) +- **Status** (page 1 vs page 2-3) +- **Recommendations** (how to improve) + +### Priority Scoring (0-100) +Higher scores = more opportunity for gain with less effort + +Calculated from: +- **Position (35%)** - How close to page 1 +- **Traffic Potential (30%)** - Search impressions +- **CTR Gap (20%)** - Improvement opportunity +- **Content Quality (15%)** - Existing engagement + +## 🎯 Action Plan + +### Week 1-2: Quick Wins (+100 visits/month) +- Focus on posts at positions 11-15 +- Update SEO titles and meta descriptions +- 30-60 minutes per post + +### Week 3-4: Core Optimization (+150 visits/month) +- Posts 6-15 in priority list +- Add content sections +- Improve structure with headers +- 2-3 hours per post + +### Week 5-8: New Content (+300 visits/month) +- Create 3-5 new posts from gap analysis +- Target high-search-demand topics +- 4-6 hours per post + +### Week 9-12: Refinement (+100 visits/month) +- Monitor ranking improvements +- Refine underperforming optimizations +- Prepare next round of analysis + +**Total: +650 visits/month potential gain** + +## πŸ”§ Configuration + +Edit `.env` to customize analysis: +```bash +# Position range for opportunities +ANALYSIS_MIN_POSITION=11 +ANALYSIS_MAX_POSITION=30 + +# Minimum impressions to consider +ANALYSIS_MIN_IMPRESSIONS=50 + +# Posts for AI recommendations +ANALYSIS_TOP_N_POSTS=20 +``` + +## πŸ› Troubleshooting + +### Missing Input Files +``` +❌ Error: File not found: input/... +``` +β†’ Check that all files are in the correct locations + +### Empty Report Titles +βœ“ FIXED - Now correctly loads post titles from multiple column names + +### No Opportunities Found +``` +⚠️ No opportunities found in specified range +``` +β†’ Try lowering `ANALYSIS_MIN_IMPRESSIONS` in `.env` + +### API Errors +``` +❌ AI generation failed: ... +``` +β†’ Check `OPENROUTER_API_KEY` in `.env` and account balance + +## πŸ“š Additional Resources + +- **`input/README.md`** - How to export analytics data +- **`output/README.md`** - Output files guide +- **`QUICKSTART_ANALYSIS.md`** - Step-by-step tutorial +- **`ANALYSIS_SYSTEM.md`** - Technical documentation + +## βœ… Success Checklist + +- [ ] All input files placed in `input/` directory +- [ ] `.env` file configured with API key +- [ ] Ran `./run_analysis.sh` successfully +- [ ] Reviewed `output/results/seo_optimization_report.md` +- [ ] Identified 5-10 quick wins to start with +- [ ] Created action plan for first week + +## πŸŽ“ Key Learnings + +### Why Positions 11-30 Matter +- **Page 1** posts are hard to move +- **Page 2-3** posts are easy wins (small improvements move them up) +- **Quick gains:** 1-2 position improvements = CTR increases 20-30% + +### CTR Expectations by Position +- Position 1: ~30% CTR +- Position 5-10: 4-7% CTR +- Position 11-15: 1-2% CTR (quick wins) +- Position 16-20: 0.8-1% CTR +- Position 21-30: ~0.5% CTR + +### Content Quality Signals +- Higher bounce rate = less relevant content +- Low traffic = poor CTR or position +- Low impressions = insufficient optimization + +## πŸ“ž Support + +### Check Logs First +``` +output/logs/import_log.txt +output/logs/opportunity_analysis_log.txt +output/logs/content_gap_analysis_log.txt +``` + +### Common Issues +1. **Empty titles** β†’ Fixed with flexible column name mapping +2. **File not found** β†’ Check file locations match structure +3. **API errors** β†’ Verify API key and account balance +4. **No opportunities** β†’ Lower minimum impressions threshold + +## πŸš€ Ready to Optimize? + +1. Prepare your input data +2. Run `./run_analysis.sh` +3. Open the report +4. Start with quick wins +5. Track improvements in 4 weeks + +Good luck boosting your SEO! πŸ“ˆ + +--- + +**Last Updated:** February 2026 +**System Status:** Production Ready βœ… diff --git a/README.md b/README.md new file mode 100644 index 0000000..741e896 --- /dev/null +++ b/README.md @@ -0,0 +1,474 @@ +# WordPress SEO Automation Tool + +Programmatically optimize SEO titles and meta descriptions across all WordPress posts using AI-powered generation and a CSV review workflow. + +## Features + +- **AI-Powered SEO Generation**: Uses OpenRouter API (Claude, GPT-4, Llama, etc.) to create optimized titles and descriptions +- **Plugin Support**: Auto-detects and works with both Yoast SEO and Rank Math +- **CSV Review Workflow**: Generate proposals, review in Excel/Sheets, approve changes before applying +- **Safety Features**: Dry-run mode, rollback CSV generation, detailed logging +- **SEO Best Practices**: Enforces 50-60 char titles, 150-160 char descriptions, keyword optimization +- **Batch Processing**: Handle hundreds or thousands of posts efficiently + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Installation](#installation) +- [WordPress Configuration](#wordpress-configuration) +- [OpenRouter API Setup](#openrouter-api-setup) +- [Usage](#usage) +- [Workflow](#workflow) +- [SEO Plugin Comparison](#seo-plugin-comparison) +- [Troubleshooting](#troubleshooting) +- [Cost Estimates](#cost-estimates) + +## Prerequisites + +- WordPress site with Yoast SEO or Rank Math plugin installed +- Python 3.8 or higher +- WordPress Application Password (for REST API access) +- OpenRouter API key (for AI-powered generation) + +## Installation + +### 1. Clone or Download + +```bash +cd /Users/acid/Documents/seo +``` + +### 2. Create Virtual Environment + +```bash +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +### 3. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### 4. Configure Environment Variables + +Copy the example environment file: + +```bash +cp .env.example .env +``` + +Edit `.env` with your credentials: + +```env +WORDPRESS_URL=https://yoursite.com +WORDPRESS_USERNAME=your_username +WORDPRESS_APP_PASSWORD=your_application_password +OPENROUTER_API_KEY=your_openrouter_api_key +AI_MODEL=anthropic/claude-3.5-sonnet +``` + +## WordPress Configuration + +### Step 1: Create Application Password + +1. Log in to WordPress Admin +2. Go to **Users β†’ Profile** +3. Scroll to **Application Passwords** section +4. Enter application name: "SEO Automation" +5. Click **Add New Application Password** +6. Copy the generated password (it will only be shown once) +7. Add to `.env` file as `WORDPRESS_APP_PASSWORD` + +### Step 2: Verify REST API Access + +Test your authentication: + +```bash +curl --user "your_username:your_app_password" \ + https://yoursite.com/wp-json/wp/v2/posts?per_page=1&context=edit +``` + +You should receive a JSON response with post data. + +### Step 3: SEO Plugin Requirements + +**For Yoast SEO:** +- Yoast SEO Free or Premium installed and activated +- Meta fields automatically accessible via REST API + +**For Rank Math:** +- Rank Math Free or Pro installed and activated +- Meta fields automatically accessible via REST API + +**Both plugins are supported** - the scripts auto-detect which one you're using. + +## OpenRouter API Setup + +### Why OpenRouter? + +OpenRouter provides access to multiple AI models through a single API: +- **Claude 3.5 Sonnet** (recommended): Best quality, $3/$15 per 1M tokens +- **GPT-4 Turbo**: Strong performance, $10/$30 per 1M tokens +- **Llama 3.1 70B**: Free tier available, $0/$0 per 1M tokens +- **Gemini Pro 1.5**: Good balance, $1.25/$5 per 1M tokens + +### Get API Key + +1. Visit [https://openrouter.ai/](https://openrouter.ai/) +2. Sign up or log in +3. Go to **API Keys** section +4. Create new API key +5. Add to `.env` file as `OPENROUTER_API_KEY` + +### Choose AI Model + +Edit `AI_MODEL` in `.env`: + +```env +# Best quality (recommended) +AI_MODEL=anthropic/claude-3.5-sonnet + +# Budget option (free) +AI_MODEL=meta-llama/llama-3.1-70b-instruct + +# OpenAI +AI_MODEL=openai/gpt-4-turbo +``` + +## Usage + +### Step 1: Generate SEO Proposals + +Fetch all posts and generate AI-powered SEO suggestions: + +```bash +python fetch_posts_and_generate_seo.py +``` + +**Options:** + +```bash +# Test with first 5 posts +python fetch_posts_and_generate_seo.py --limit 5 + +# Specify output file +python fetch_posts_and_generate_seo.py --output my_proposals.csv + +# Use rule-based generation (no AI/API costs) +python fetch_posts_and_generate_seo.py --no-ai +``` + +This creates a CSV file in `output/` directory with proposals for all posts. + +### Step 2: Review Proposals + +1. Open the generated CSV file in Excel or Google Sheets +2. Review each row: + - Check `proposed_seo_title` (should be 50-60 chars) + - Check `proposed_meta_description` (should be 150-160 chars) + - Edit proposals if needed +3. Set `status` column to `approved` for changes you want to apply +4. Set `status` column to `rejected` for posts to skip +5. Save the CSV file + +**CSV Columns:** + +| Column | Description | +|--------|-------------| +| post_id | WordPress post ID | +| post_url | Post permalink | +| post_title | Original post title | +| current_seo_title | Current SEO title (from Yoast/Rank Math) | +| current_meta_description | Current meta description | +| proposed_seo_title | AI-generated SEO title | +| proposed_meta_description | AI-generated meta description | +| primary_keyword | Detected primary keyword | +| title_length | Character count of proposed title | +| description_length | Character count of proposed description | +| title_validation | Validation message | +| description_validation | Validation message | +| generation_method | 'ai' or 'rule-based' | +| status | Set to 'approved' to apply changes | +| notes | Your notes (optional) | + +### Step 3: Test with Dry Run + +Before applying changes, test with dry-run mode: + +```bash +python apply_approved_changes.py --input output/seo_proposals_YYYYMMDD_HHMMSS.csv --dry-run +``` + +This shows what would be updated without actually making changes. + +### Step 4: Apply Approved Changes + +Apply the approved changes to WordPress: + +```bash +python apply_approved_changes.py --input output/seo_proposals_YYYYMMDD_HHMMSS.csv +``` + +The script will: +1. Create a rollback CSV with original values +2. Ask for confirmation +3. Apply all approved changes +4. Generate detailed log file + +## Workflow + +### Complete Workflow Diagram + +``` +1. Generate Proposals + └─> python fetch_posts_and_generate_seo.py + └─> Fetches all posts from WordPress + └─> Generates AI-powered SEO suggestions + └─> Exports to CSV: output/seo_proposals_YYYYMMDD_HHMMSS.csv + +2. Review & Edit + └─> Open CSV in Excel/Google Sheets + └─> Review proposed titles and descriptions + └─> Edit as needed + └─> Set status='approved' for changes to apply + └─> Save CSV + +3. Test (Optional) + └─> python apply_approved_changes.py --input --dry-run + └─> Simulates changes without applying + +4. Apply Changes + └─> python apply_approved_changes.py --input + └─> Creates rollback CSV + └─> Applies approved changes to WordPress + └─> Generates log file + +5. Verify + └─> Check WordPress admin (post editor) + └─> View source on frontend + └─> Monitor search performance +``` + +### Safety Features + +- **Dry Run Mode**: Test without applying changes +- **Rollback CSV**: Automatically created before applying changes +- **Detailed Logging**: All operations logged to `output/application_log_YYYYMMDD_HHMMSS.txt` +- **Validation**: Enforces character limits and checks for duplicates +- **Confirmation Prompt**: Requires 'yes' confirmation before applying changes +- **Rate Limiting**: Prevents overwhelming WordPress server + +## SEO Plugin Comparison + +### Should You Switch from Yoast to Rank Math? + +**Current: Yoast SEO Free** +- βœ“ Market leader (12M users) +- βœ“ Reliable and well-tested +- βœ— Only 1 focus keyword (vs unlimited in Rank Math) +- βœ— No redirect manager (premium only, $118.80/year) +- βœ— Limited schema support +- βœ— No internal linking suggestions + +**Alternative: Rank Math Free** +- βœ“ **Unlimited focus keywords** (vs 1 in Yoast Free) +- βœ“ **Redirect manager included** (premium in Yoast) +- βœ“ **20+ rich snippet types** (FAQ, Product, Recipe, etc.) +- βœ“ **Better performance** (40% less code) +- βœ“ **Internal linking suggestions** +- βœ“ **Google Trends integration** +- βœ“ **One-click Yoast migration** (preserves all data) +- βœ— Smaller community (900K vs 12M users) + +**Recommendation for FREE users:** Switch to Rank Math Free + +**Migration Steps:** +1. Install Rank Math plugin +2. Run Setup Wizard β†’ Import from Yoast +3. All SEO data automatically transferred +4. Deactivate (don't delete) Yoast as backup +5. Test a few posts +6. If satisfied, delete Yoast + +**These scripts work with both plugins** - they auto-detect which one you're using. + +## SEO Best Practices (2026) + +### Title Optimization +- **Length**: 50-60 characters (≀600 pixels in SERPs) +- **Keyword placement**: Primary keyword in first 60 characters +- **Uniqueness**: Every post must have unique title +- **Compelling**: Written to improve click-through rate (CTR) +- **Natural**: No keyword stuffing + +### Meta Description Optimization +- **Length**: 150-160 characters (optimal for SERP display) +- **User intent**: Address what reader will learn/gain +- **Keyword inclusion**: Primary keyword appears naturally +- **Uniqueness**: Every post must have unique description +- **Value proposition**: Highlight what makes content unique +- **CTR focused**: Compelling language to encourage clicks + +**Note**: Google rewrites 62%+ of meta descriptions, but they still matter for: +- CTR when not overridden +- Social media sharing (Open Graph) +- Signaling relevance to search engines + +## Troubleshooting + +### Error: "Authentication failed" + +**Cause**: Invalid WordPress username or application password + +**Solution**: +1. Verify username is correct (not email address) +2. Regenerate application password in WordPress +3. Update `.env` file with new password +4. Ensure no extra spaces in credentials + +### Error: "Access forbidden" + +**Cause**: User doesn't have permission to edit posts + +**Solution**: +1. Ensure user has Editor or Administrator role +2. Check if REST API is disabled by security plugin +3. Temporarily disable security plugins and test + +### Error: "OpenRouter API key invalid" + +**Cause**: Invalid or missing OpenRouter API key + +**Solution**: +1. Get API key from https://openrouter.ai/ +2. Update `OPENROUTER_API_KEY` in `.env` +3. Ensure no extra quotes or spaces + +### Error: "No posts found" + +**Cause**: No published posts or authentication issue + +**Solution**: +1. Verify you have published posts in WordPress +2. Check authentication is working (see WordPress Configuration) +3. Try with `--limit 1` to test with single post + +### SEO Plugin Not Detected + +**Cause**: Plugin not installed or meta fields not exposed + +**Solution**: +1. Verify Yoast SEO or Rank Math is installed and activated +2. Check if custom code blocks meta field access +3. Scripts default to Yoast field names if detection fails + +### AI Generation Fails + +**Cause**: OpenRouter API error or rate limit + +**Solution**: +1. Check OpenRouter account has credits +2. Try different AI model (switch to free Llama model) +3. Use `--no-ai` flag for rule-based generation +4. Check log files for specific error messages + +## Cost Estimates + +### OpenRouter API Costs + +**Using Claude 3.5 Sonnet (Recommended):** +- Average post: ~2000 tokens input + 200 tokens output +- Cost per post: ~$0.009 +- **100 posts: ~$0.90** +- **1000 posts: ~$9.00** + +**Using Free Models:** +- Llama 3.1 70B: **$0.00** (free tier) +- No cost for generation + +**Rule-Based Generation:** +- No API costs +- Use `--no-ai` flag +- Lower quality but free + +## File Structure + +``` +/Users/acid/Documents/seo/ +β”œβ”€β”€ .env # Your credentials (git-ignored) +β”œβ”€β”€ .env.example # Example configuration +β”œβ”€β”€ .gitignore # Git ignore rules +β”œβ”€β”€ requirements.txt # Python dependencies +β”œβ”€β”€ config.py # Configuration loader +β”œβ”€β”€ seo_generator.py # SEO generation logic +β”œβ”€β”€ fetch_posts_and_generate_seo.py # Main fetching script +β”œβ”€β”€ apply_approved_changes.py # Application script +β”œβ”€β”€ README.md # This file +└── output/ # Generated files + β”œβ”€β”€ seo_proposals_*.csv # Generated proposals + β”œβ”€β”€ rollback_*.csv # Backup files + └── application_log_*.txt # Detailed logs +``` + +## Development Notes + +### Testing + +**Test with small batch first:** + +```bash +# Generate proposals for 5 posts +python fetch_posts_and_generate_seo.py --limit 5 + +# Review CSV and approve changes + +# Dry run to verify +python apply_approved_changes.py --input output/seo_proposals_*.csv --dry-run + +# Apply to 5 posts +python apply_approved_changes.py --input output/seo_proposals_*.csv +``` + +**Verify changes:** +1. Open WordPress post editor +2. Check Yoast/Rank Math SEO box shows updated title and description +3. View source on frontend: check `` and `<meta name="description">` tags +4. Test rollback CSV if needed + +### Extending the Scripts + +**Add custom validation:** +- Edit `seo_generator.py` β†’ `validate_seo_title()` and `validate_meta_description()` + +**Change AI model:** +- Edit `.env` β†’ `AI_MODEL=openai/gpt-4-turbo` + +**Customize prompts:** +- Edit `seo_generator.py` β†’ `_generate_with_ai()` method + +**Add more meta fields:** +- Edit scripts to include focus keywords, Open Graph tags, etc. + +## Support + +For issues or questions: +1. Check this README troubleshooting section +2. Review log files in `output/` directory +3. Test with `--dry-run` mode first +4. Start with `--limit 5` for testing + +## License + +This tool is provided as-is for WordPress SEO optimization. Use responsibly and always backup your WordPress site before bulk updates. + +## Changelog + +### Version 1.0.0 (2026-02-15) +- Initial release +- AI-powered SEO generation via OpenRouter +- Support for Yoast SEO and Rank Math +- CSV review workflow +- Safety features (dry-run, rollback, logging) +- Auto-detection of SEO plugins diff --git a/analytics_importer.py b/analytics_importer.py new file mode 100644 index 0000000..77ea5b8 --- /dev/null +++ b/analytics_importer.py @@ -0,0 +1,427 @@ +""" +Analytics data importer for SEO analysis. +Merges Google Analytics and Search Console data with WordPress posts. +""" + +import csv +import json +import argparse +from pathlib import Path +from urllib.parse import urlparse, parse_qs +from collections import defaultdict +from config import Config + + +class AnalyticsImporter: + """Import and consolidate analytics data with WordPress posts.""" + + def __init__(self): + """Initialize importer.""" + self.config = Config + self.output_dir = self.config.OUTPUT_DIR + self.logs = [] + self.unmatched_urls = [] + + def log(self, message): + """Add message to log.""" + self.logs.append(message) + print(message) + + def normalize_url(self, url): + """Normalize URL for matching.""" + if not url: + return "" + # Remove trailing slash, protocol, www + url = url.rstrip('/') + if url.startswith('http'): + url = urlparse(url).path + url = url.replace('www.', '') + return url.lower() + + def extract_post_slug_from_url(self, url): + """Extract post slug from URL path.""" + path = urlparse(url).path.rstrip('/') + parts = [p for p in path.split('/') if p] + if parts: + return parts[-1] # Last part is usually the slug + return None + + def load_ga4_data(self, ga4_csv): + """Load Google Analytics 4 data.""" + ga_data = {} + if not ga4_csv.exists(): + self.log(f"⚠️ GA4 file not found: {ga4_csv}") + return ga_data + + try: + with open(ga4_csv, 'r', encoding='utf-8') as f: + # Skip comment lines at the top (lines starting with #) + lines = [line for line in f if not line.startswith('#')] + + reader = csv.DictReader(lines) + for row in reader: + if not row: + continue + # Handle French and English column names + url = (row.get('Page path and screen class') or + row.get('Chemin de la page et classe de l\'Γ©cran') or + row.get('Page path') or + row.get('Page') or '') + if not url: + continue + + # Normalize URL + normalized = self.normalize_url(url) + + # Extract metrics (handle French and English column names) + try: + traffic = int(float(row.get('Screened Views', row.get('Views', row.get('Vues', '0'))) or 0)) + users = int(float(row.get('Users', row.get('Utilisateurs actifs', '0')) or 0)) + bounce_rate = float(row.get('Bounce rate', row.get('Taux de rebond', '0')) or 0) + avg_duration_str = (row.get('Average session duration', + row.get('DurΓ©e d\'engagement moyenne par utilisateur actif', '0')) or '0') + avg_duration = float(avg_duration_str.replace(',', '.')) + except (ValueError, TypeError): + traffic = users = 0 + bounce_rate = avg_duration = 0 + + ga_data[normalized] = { + 'traffic': traffic, + 'users': users, + 'bounce_rate': bounce_rate, + 'avg_session_duration': avg_duration, + 'ga_url': url + } + self.log(f"βœ“ Loaded {len(ga_data)} GA4 entries") + except Exception as e: + self.log(f"❌ Error reading GA4 file: {e}") + + return ga_data + + def load_gsc_data(self, gsc_csv): + """Load Google Search Console data (Page-level or Query-level).""" + gsc_data = {} + if not gsc_csv.exists(): + self.log(f"⚠️ GSC file not found: {gsc_csv}") + return gsc_data + + try: + with open(gsc_csv, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + for row in reader: + if not row: + continue + + # Determine if this is page-level or query-level data + # Pages.csv has: "Pages les plus populaires", Queries.csv has: "RequΓͺtes les plus frΓ©quentes" + url = (row.get('Page') or + row.get('Pages les plus populaires') or + row.get('URL') or '') + + query = row.get('Query') or row.get('RequΓͺtes les plus frΓ©quentes', '').strip() + + # Skip rows without URLs (query-only data) + if not url: + continue + + # Try to parse metrics with flexible column names + try: + # Handle different number formats (decimal separator, percentage signs) + clicks_str = row.get('Clics', row.get('Clicks', '0')) or '0' + impressions_str = row.get('Impressions', '0') or '0' + ctr_str = row.get('CTR', '0') or '0' + position_str = row.get('Position', '0') or '0' + + clicks = int(float(clicks_str.replace(',', '.').rstrip('%'))) + impressions = int(float(impressions_str.replace(',', '.'))) + ctr = float(ctr_str.replace(',', '.').rstrip('%')) / 100 + position = float(position_str.replace(',', '.')) + except (ValueError, TypeError, AttributeError): + clicks = impressions = 0 + ctr = position = 0 + + normalized = self.normalize_url(url) + + if normalized not in gsc_data: + gsc_data[normalized] = { + 'impressions': 0, + 'clicks': 0, + 'avg_position': 0, + 'ctr': 0, + 'keywords': [], + 'gsc_url': url + } + + # Accumulate data (in case of multiple rows per URL) + gsc_data[normalized]['impressions'] += impressions + gsc_data[normalized]['clicks'] += clicks + + # Store position + if position > 0: + gsc_data[normalized]['positions'] = gsc_data[normalized].get('positions', []) + gsc_data[normalized]['positions'].append(position) + + if query and query not in gsc_data[normalized]['keywords']: + gsc_data[normalized]['keywords'].append(query) + + # Calculate average positions and finalize + for data in gsc_data.values(): + if data.get('positions'): + data['avg_position'] = sum(data['positions']) / len(data['positions']) + del data['positions'] + # Recalculate CTR from totals + if data['impressions'] > 0: + data['ctr'] = data['clicks'] / data['impressions'] + data['keywords_count'] = len(data.get('keywords', [])) + + self.log(f"βœ“ Loaded {len(gsc_data)} GSC entries") + except Exception as e: + self.log(f"❌ Error reading GSC file: {e}") + + return gsc_data + + def load_posts_csv(self, posts_csv): + """Load existing WordPress posts CSV.""" + posts = {} + if not posts_csv.exists(): + self.log(f"⚠️ Posts file not found: {posts_csv}") + return posts + + try: + with open(posts_csv, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + for row in reader: + # Handle different column name variations + post_id = row.get('ID') or row.get('post_id') + post_url = row.get('URL') or row.get('Post URL') or row.get('post_url') + post_slug = row.get('Post Slug') or row.get('Slug') or row.get('post_slug') + post_title = row.get('Title') or row.get('post_title') + + if not post_id: + continue + + normalized = self.normalize_url(post_url) if post_url else "" + + # Handle different SEO column names + seo_title = (row.get('SEO Title') or + row.get('proposed_seo_title') or + row.get('current_seo_title') or '') + meta_desc = (row.get('Meta Description') or + row.get('proposed_meta_description') or + row.get('current_meta_description') or '') + + posts[post_id] = { + 'title': post_title or '', + 'url': post_url, + 'slug': post_slug, + 'normalized_url': normalized, + 'seo_title': seo_title, + 'meta_description': meta_desc, + **{k: v for k, v in row.items() + if k not in ['ID', 'post_id', 'Title', 'post_title', 'URL', 'Post URL', 'post_url', + 'Post Slug', 'Slug', 'post_slug', 'SEO Title', 'proposed_seo_title', + 'current_seo_title', 'Meta Description', 'proposed_meta_description', + 'current_meta_description']} + } + + self.log(f"βœ“ Loaded {len(posts)} posts from CSV") + except Exception as e: + self.log(f"❌ Error reading posts CSV: {e}") + + return posts + + def match_analytics_to_posts(self, posts, ga_data, gsc_data): + """Match analytics data to posts with fuzzy matching.""" + self.log("\nπŸ“Š Matching analytics data to posts...") + matched_count = 0 + + for post_id, post_info in posts.items(): + slug = post_info.get('slug') or self.extract_post_slug_from_url(post_info.get('url', '')) + normalized_url = post_info.get('normalized_url', '') + + # Try direct URL match first + if normalized_url in ga_data: + post_info['ga_data'] = ga_data[normalized_url] + matched_count += 1 + else: + post_info['ga_data'] = {} + + if normalized_url in gsc_data: + post_info['gsc_data'] = gsc_data[normalized_url] + matched_count += 1 + else: + post_info['gsc_data'] = {} + + # Try slug-based matching if URL didn't match + if not post_info.get('gsc_data') and slug: + for gsc_url, gsc_info in gsc_data.items(): + if slug in gsc_url: + post_info['gsc_data'] = gsc_info + break + + # Track unmatched GSC URLs + matched_gsc_urls = set() + for post in posts.values(): + if post.get('gsc_data'): + matched_gsc_urls.add(id(post['gsc_data'])) + + for normalized_url, gsc_info in gsc_data.items(): + if id(gsc_info) not in matched_gsc_urls and gsc_info.get('impressions', 0) > 0: + self.unmatched_urls.append({ + 'url': gsc_info.get('gsc_url', normalized_url), + 'impressions': gsc_info.get('impressions', 0), + 'clicks': gsc_info.get('clicks', 0), + 'avg_position': gsc_info.get('avg_position', 0) + }) + + self.log(f"βœ“ Matched data to posts") + return posts + + def enrich_posts_data(self, posts): + """Enrich posts with calculated metrics.""" + for post_info in posts.values(): + ga = post_info.get('ga_data', {}) + gsc = post_info.get('gsc_data', {}) + + # GA metrics + post_info['traffic'] = ga.get('traffic', 0) + post_info['users'] = ga.get('users', 0) + post_info['bounce_rate'] = ga.get('bounce_rate', 0) + post_info['avg_session_duration'] = ga.get('avg_session_duration', 0) + + # GSC metrics + post_info['impressions'] = gsc.get('impressions', 0) + post_info['clicks'] = gsc.get('clicks', 0) + post_info['avg_position'] = gsc.get('avg_position', 0) + post_info['ctr'] = gsc.get('ctr', 0) + post_info['keywords_count'] = gsc.get('keywords_count', 0) + post_info['top_keywords'] = ','.join(gsc.get('keywords', [])[:5]) + + return posts + + def export_enriched_csv(self, posts, output_csv): + """Export enriched posts data to CSV.""" + if not posts: + self.log("❌ No posts to export") + return + + try: + fieldnames = [ + 'ID', 'Title', 'URL', 'SEO Title', 'Meta Description', + 'traffic', 'users', 'bounce_rate', 'avg_session_duration', + 'impressions', 'clicks', 'avg_position', 'ctr', 'keywords_count', 'top_keywords' + ] + + # Add any extra fields from original posts + all_keys = set() + for post in posts.values(): + all_keys.update(post.keys()) + + extra_fields = [k for k in sorted(all_keys) + if k not in fieldnames and k not in ['ga_data', 'gsc_data', 'normalized_url', 'slug']] + fieldnames.extend(extra_fields) + + with open(output_csv, 'w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction='ignore') + writer.writeheader() + + for post_id, post_info in sorted(posts.items()): + row = {'ID': post_id} + row.update(post_info) + # Clean up nested dicts + for key in ['ga_data', 'gsc_data']: + row.pop(key, None) + writer.writerow(row) + + self.log(f"βœ“ Exported {len(posts)} posts to {output_csv}") + except Exception as e: + self.log(f"❌ Error exporting CSV: {e}") + + def export_log(self, log_file): + """Export analysis log and unmatched URLs.""" + try: + with open(log_file, 'w', encoding='utf-8') as f: + f.write("SEO Analytics Import Report\n") + f.write("=" * 60 + "\n\n") + + f.write("Import Log:\n") + f.write("-" * 60 + "\n") + for log_msg in self.logs: + f.write(log_msg + "\n") + + f.write("\n" + "=" * 60 + "\n") + f.write(f"Unmatched URLs ({len(self.unmatched_urls)} total):\n") + f.write("-" * 60 + "\n") + + if self.unmatched_urls: + # Sort by impressions descending + for url_data in sorted(self.unmatched_urls, + key=lambda x: x['impressions'], + reverse=True): + f.write(f"\nURL: {url_data['url']}\n") + f.write(f" Impressions: {url_data['impressions']}\n") + f.write(f" Clicks: {url_data['clicks']}\n") + f.write(f" Avg Position: {url_data['avg_position']:.1f}\n") + else: + f.write("βœ“ All URLs matched successfully!\n") + + self.log(f"βœ“ Exported log to {log_file}") + except Exception as e: + self.log(f"❌ Error exporting log: {e}") + + def run(self, ga_csv, gsc_csv, posts_csv, output_csv): + """Run complete import workflow.""" + self.log("Starting analytics import...") + self.log(f"GA4 CSV: {ga_csv}") + self.log(f"GSC CSV: {gsc_csv}") + self.log(f"Posts CSV: {posts_csv}\n") + + # Load data + ga_data = self.load_ga4_data(ga_csv) + gsc_data = self.load_gsc_data(gsc_csv) + posts = self.load_posts_csv(posts_csv) + + if not posts: + self.log("❌ No posts found. Cannot proceed.") + return + + # Match and merge + posts = self.match_analytics_to_posts(posts, ga_data, gsc_data) + posts = self.enrich_posts_data(posts) + + # Export + self.export_enriched_csv(posts, output_csv) + + # Export log + log_dir = self.output_dir / 'logs' + log_dir.mkdir(exist_ok=True) + log_file = log_dir / 'import_log.txt' + self.export_log(log_file) + + self.log("\nβœ“ Analytics import complete!") + + +def main(): + """CLI entry point.""" + parser = argparse.ArgumentParser(description='Import and merge analytics data') + parser.add_argument('--ga-export', type=Path, + default=Path('input/analytics/ga4_export.csv'), + help='GA4 export CSV path') + parser.add_argument('--gsc-export', type=Path, + default=Path('input/analytics/gsc/Pages.csv'), + help='Search Console export CSV path (Pages data)') + parser.add_argument('--posts-csv', type=Path, + default=Path('input/new-propositions.csv'), + help='Posts CSV path') + parser.add_argument('--output', type=Path, + default=Path('output/results/posts_with_analytics.csv'), + help='Output CSV path') + + args = parser.parse_args() + + importer = AnalyticsImporter() + importer.run(args.ga_export, args.gsc_export, args.posts_csv, args.output) + + +if __name__ == '__main__': + main() diff --git a/config.py b/config.py new file mode 100644 index 0000000..e56e11b --- /dev/null +++ b/config.py @@ -0,0 +1,71 @@ +""" +Configuration module for WordPress SEO automation. +Loads and validates environment variables. +""" + +import os +from dotenv import load_dotenv +from pathlib import Path + +# Load environment variables from .env file +load_dotenv() + +class Config: + """Configuration class for WordPress SEO automation.""" + + # WordPress Settings + WORDPRESS_URL = os.getenv('WORDPRESS_URL', '').rstrip('/') + WORDPRESS_USERNAME = os.getenv('WORDPRESS_USERNAME', '') + WORDPRESS_APP_PASSWORD = os.getenv('WORDPRESS_APP_PASSWORD', '') + + # OpenRouter API Settings + OPENROUTER_API_KEY = os.getenv('OPENROUTER_API_KEY', '') + AI_MODEL = os.getenv('AI_MODEL', 'anthropic/claude-3.5-sonnet') + + # Script Settings + BATCH_SIZE = int(os.getenv('BATCH_SIZE', '100')) + API_DELAY_SECONDS = float(os.getenv('API_DELAY_SECONDS', '0.5')) + + # Analysis Settings + ANALYSIS_MIN_POSITION = int(os.getenv('ANALYSIS_MIN_POSITION', '11')) + ANALYSIS_MAX_POSITION = int(os.getenv('ANALYSIS_MAX_POSITION', '30')) + ANALYSIS_MIN_IMPRESSIONS = int(os.getenv('ANALYSIS_MIN_IMPRESSIONS', '50')) + ANALYSIS_TOP_N_POSTS = int(os.getenv('ANALYSIS_TOP_N_POSTS', '20')) + + # Output directory + OUTPUT_DIR = Path(__file__).parent / 'output' + + @classmethod + def validate(cls): + """Validate that all required configuration is present.""" + errors = [] + + if not cls.WORDPRESS_URL: + errors.append("WORDPRESS_URL is required") + + if not cls.WORDPRESS_USERNAME: + errors.append("WORDPRESS_USERNAME is required") + + if not cls.WORDPRESS_APP_PASSWORD: + errors.append("WORDPRESS_APP_PASSWORD is required") + + if not cls.OPENROUTER_API_KEY: + errors.append("OPENROUTER_API_KEY is required (get one from https://openrouter.ai/)") + + if errors: + raise ValueError("Configuration errors:\n" + "\n".join(f" - {e}" for e in errors)) + + # Create output directory if it doesn't exist + cls.OUTPUT_DIR.mkdir(exist_ok=True) + + return True + + @classmethod + def get_wordpress_auth(cls): + """Get WordPress authentication tuple.""" + return (cls.WORDPRESS_USERNAME, cls.WORDPRESS_APP_PASSWORD) + + @classmethod + def get_api_base_url(cls): + """Get WordPress REST API base URL.""" + return f"{cls.WORDPRESS_URL}/wp-json/wp/v2" diff --git a/content_gap_analyzer.py b/content_gap_analyzer.py new file mode 100644 index 0000000..bfe7634 --- /dev/null +++ b/content_gap_analyzer.py @@ -0,0 +1,348 @@ +""" +Content gap analyzer for SEO strategy. +Identifies missing topics and content opportunities using AI analysis. +""" + +import csv +import json +import argparse +import time +from pathlib import Path +from collections import defaultdict +from openai import OpenAI +from config import Config + + +class ContentGapAnalyzer: + """Identify content gaps and opportunities.""" + + def __init__(self): + """Initialize analyzer.""" + self.config = Config + self.output_dir = self.config.OUTPUT_DIR + self.logs = [] + self.client = None + + if self.config.OPENROUTER_API_KEY: + self.client = OpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=self.config.OPENROUTER_API_KEY, + ) + + def log(self, message): + """Add message to log.""" + self.logs.append(message) + print(message) + + def load_posts(self, posts_csv): + """Load post titles and data.""" + posts = [] + if not posts_csv.exists(): + self.log(f"❌ File not found: {posts_csv}") + return posts + + try: + with open(posts_csv, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + for row in reader: + posts.append({ + 'id': row.get('ID', ''), + 'title': row.get('Title', ''), + 'url': row.get('URL', ''), + 'traffic': int(row.get('traffic', 0) or 0), + 'impressions': int(row.get('impressions', 0) or 0), + 'top_keywords': row.get('top_keywords', '') + }) + + self.log(f"βœ“ Loaded {len(posts)} posts") + except Exception as e: + self.log(f"❌ Error reading posts: {e}") + + return posts + + def load_gsc_data(self, gsc_csv): + """Load Search Console queries for gap analysis.""" + queries = [] + if not gsc_csv.exists(): + self.log(f"⚠️ GSC file not found: {gsc_csv}") + return queries + + try: + with open(gsc_csv, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + for row in reader: + try: + query = row.get('Query', '').strip() + if not query: + continue + + impressions = int(row.get('Impressions', 0) or 0) + clicks = int(row.get('Clicks', 0) or 0) + + # Only include queries with impressions but low clicks + if impressions > 0 and (clicks / impressions < 0.05): + queries.append({ + 'query': query, + 'impressions': impressions, + 'clicks': clicks, + 'ctr': clicks / impressions if impressions > 0 else 0 + }) + except (ValueError, TypeError): + continue + + self.log(f"βœ“ Loaded {len(queries)} underperforming queries") + except Exception as e: + self.log(f"⚠️ Error reading GSC file: {e}") + + return queries + + def extract_topics(self, posts): + """Extract topic clusters from post titles using AI.""" + if not self.client or len(posts) == 0: + self.log("⚠️ Cannot extract topics without AI client or posts") + return {} + + try: + self.log("πŸ€– Extracting topic clusters from post titles...") + + # Batch posts into groups + titles = [p['title'] for p in posts][:100] # Limit to first 100 + + prompt = f"""Analyze these {len(titles)} blog post titles and identify topic clusters: + +Titles: +{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(titles))} + +Extract for each post: +1. Primary topic category +2. Subtopics covered +3. Content type (guide, tutorial, review, comparison, etc.) + +Then identify: +1. Top 10 topic clusters with post counts +2. Most common subtopics +3. Over/under-represented topics + +Return JSON: +{{ + "post_topics": {{ + "1": {{"primary": "...", "subtopics": ["..."], "type": "..."}}, + ... + }}, + "topic_clusters": [ + {{"cluster": "...", "post_count": 0, "importance": "high/medium/low"}} + ], + "coverage_gaps": ["topic 1", "topic 2", ...], + "niche": "detected niche or industry" +}}""" + + response = self.client.chat.completions.create( + model=self.config.AI_MODEL, + messages=[{"role": "user", "content": prompt}], + temperature=0.7, + max_tokens=1500 + ) + + try: + result_text = response.choices[0].message.content + start_idx = result_text.find('{') + end_idx = result_text.rfind('}') + 1 + if start_idx >= 0 and end_idx > start_idx: + return json.loads(result_text[start_idx:end_idx]) + except json.JSONDecodeError: + self.log("⚠️ Could not parse topic extraction response") + return {} + + except Exception as e: + self.log(f"⚠️ Topic extraction failed: {e}") + return {} + + def identify_content_gaps(self, topic_analysis, queries): + """Use AI to identify content gaps and suggest new topics.""" + if not self.client: + return [] + + try: + self.log("πŸ€– Identifying content gaps and opportunities...") + + clusters = topic_analysis.get('topic_clusters', []) + gaps = topic_analysis.get('coverage_gaps', []) + niche = topic_analysis.get('niche', 'general') + + # Prepare query analysis + top_queries = sorted(queries, key=lambda x: x['impressions'], reverse=True)[:20] + queries_str = '\n'.join([f"- {q['query']} ({q['impressions']} impr, {q['ctr']:.1%} CTR)" + for q in top_queries]) + + prompt = f"""Based on content analysis and search demand, identify content gaps: + +Existing Topics: {', '.join([c.get('cluster', '') for c in clusters[:10]])} +Coverage Gaps: {', '.join(gaps[:5])} +Niche: {niche} + +Top Underperforming Queries (low CTR despite impressions): +{queries_str} + +Identify high-value missing topics that could: +1. Fill coverage gaps +2. Target underperforming queries (CTR improvement) +3. Capitalize on search demand +4. Complement existing content + +For each suggestion: +- Topic title +- Why it's valuable (search demand + intent) +- Search volume estimate (high/medium/low) +- How it complements existing content +- Recommended content format +- Estimated traffic potential + +Prioritize by traffic opportunity. Max 20 ideas. + +Return JSON: +{{ + "content_opportunities": [ + {{ + "title": "...", + "why_valuable": "...", + "search_volume": "high/medium/low", + "complements": "existing topic", + "format": "guide/tutorial/comparison/review/list", + "traffic_potential": number, + "priority": "high/medium/low" + }} + ] +}}""" + + response = self.client.chat.completions.create( + model=self.config.AI_MODEL, + messages=[{"role": "user", "content": prompt}], + temperature=0.7, + max_tokens=2000 + ) + + try: + result_text = response.choices[0].message.content + start_idx = result_text.find('{') + end_idx = result_text.rfind('}') + 1 + if start_idx >= 0 and end_idx > start_idx: + result = json.loads(result_text[start_idx:end_idx]) + return result.get('content_opportunities', []) + except json.JSONDecodeError: + self.log("⚠️ Could not parse gap analysis response") + return [] + + except Exception as e: + self.log(f"⚠️ Gap analysis failed: {e}") + return [] + + def export_gaps_csv(self, gaps, output_csv): + """Export content gaps to CSV.""" + if not gaps: + self.log("⚠️ No gaps to export") + return + + try: + fieldnames = [ + 'priority', 'title', 'why_valuable', 'search_volume', + 'complements', 'format', 'traffic_potential' + ] + + with open(output_csv, 'w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction='ignore') + writer.writeheader() + + for gap in sorted(gaps, key=lambda x: x.get('priority') == 'high', reverse=True): + writer.writerow(gap) + + self.log(f"βœ“ Exported {len(gaps)} content gaps to {output_csv}") + except Exception as e: + self.log(f"❌ Error exporting CSV: {e}") + + def export_topic_clusters_json(self, topic_analysis, output_json): + """Export topic analysis to JSON.""" + if not topic_analysis: + return + + try: + with open(output_json, 'w', encoding='utf-8') as f: + json.dump(topic_analysis, f, indent=2) + + self.log(f"βœ“ Exported topic analysis to {output_json}") + except Exception as e: + self.log(f"❌ Error exporting JSON: {e}") + + def export_log(self, log_file): + """Export analysis log.""" + try: + with open(log_file, 'w', encoding='utf-8') as f: + f.write("Content Gap Analysis Report\n") + f.write("=" * 60 + "\n\n") + + for msg in self.logs: + f.write(msg + "\n") + + self.log(f"βœ“ Exported log to {log_file}") + except Exception as e: + self.log(f"❌ Error exporting log: {e}") + + def run(self, posts_csv, gsc_csv, output_csv): + """Run complete analysis workflow.""" + self.log("πŸ“Š Starting content gap analysis...") + self.log(f"Posts: {posts_csv}") + self.log(f"GSC queries: {gsc_csv}\n") + + # Load data + posts = self.load_posts(posts_csv) + queries = self.load_gsc_data(gsc_csv) + + if not posts: + return + + # Extract topics + topic_analysis = self.extract_topics(posts) + if topic_analysis: + self.log(f"βœ“ Identified {len(topic_analysis.get('topic_clusters', []))} topic clusters") + + # Identify gaps + gaps = self.identify_content_gaps(topic_analysis, queries) + if gaps: + self.log(f"βœ“ Identified {len(gaps)} content opportunities") + + # Export + self.log("\nπŸ“ Exporting results...") + self.export_gaps_csv(gaps, output_csv) + + topic_json = self.output_dir / 'topic_clusters.json' + self.export_topic_clusters_json(topic_analysis, topic_json) + + # Export log + log_dir = self.output_dir / 'logs' + log_dir.mkdir(exist_ok=True) + log_file = log_dir / 'content_gap_analysis_log.txt' + self.export_log(log_file) + + self.log("\nβœ“ Content gap analysis complete!") + + +def main(): + """CLI entry point.""" + parser = argparse.ArgumentParser(description='Analyze content gaps') + parser.add_argument('--posts-csv', type=Path, + default=Path('output/results/posts_with_analytics.csv'), + help='Posts CSV') + parser.add_argument('--gsc-queries', type=Path, + default=Path('input/analytics/gsc/RequΓͺtes.csv'), + help='GSC queries CSV') + parser.add_argument('--output', type=Path, + default=Path('output/results/content_gaps.csv'), + help='Output gaps CSV') + + args = parser.parse_args() + + analyzer = ContentGapAnalyzer() + analyzer.run(args.posts_csv, args.gsc_queries, args.output) + + +if __name__ == '__main__': + main() diff --git a/input/README.md b/input/README.md new file mode 100644 index 0000000..986903e --- /dev/null +++ b/input/README.md @@ -0,0 +1,49 @@ +# Input Directory + +Place your source data files here before running the analysis pipeline. + +## Required Files + +### `new-propositions.csv` +WordPress posts export with SEO metadata +- Columns: ID, post_id, Title, post_title, URL, post_url, SEO Title, Meta Description, etc. + +### `analytics/ga4_export.csv` +Google Analytics 4 data export +- Date range: Last 90 days +- Columns: Chemin de la page et classe de l'Γ©cran (Page path), Vues (Views), Utilisateurs actifs (Users), DurΓ©e d'engagement (Duration), etc. + +### `analytics/gsc/Pages.csv` +Google Search Console Pages report +- Date range: Last 90 days +- Columns: Pages les plus populaires (Page), Clics (Clicks), Impressions, CTR, Position + +## Directory Structure + +``` +input/ +β”œβ”€β”€ new-propositions.csv (WordPress posts) +└── analytics/ + β”œβ”€β”€ ga4_export.csv (Google Analytics data) + └── gsc/ + β”œβ”€β”€ Pages.csv (GSC pages report) + β”œβ”€β”€ RequΓͺtes.csv (GSC queries report - optional) + └── [other GSC exports] +``` + +## How to Export Data + +### Google Analytics 4 +1. Go to Analytics > Reports > Engagement > Pages and Screens +2. Set date range to Last 90 days +3. Click Export > Download CSV +4. Save as: `input/analytics/ga4_export.csv` + +### Google Search Console +1. Go to Performance +2. Set date range to Last 90 days +3. Click Export > Download CSV +4. Save as: `input/analytics/gsc/Pages.csv` + +### WordPress Posts +Use your existing WordPress export or the SEO propositions CSV diff --git a/input/new-propositions.ods b/input/new-propositions.ods new file mode 100644 index 0000000000000000000000000000000000000000..b7793e012cfd482d619d5338b8c12652485ed12b GIT binary patch literal 53771 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 literal 0 HcmV?d00001 diff --git a/opportunity_analyzer.py b/opportunity_analyzer.py new file mode 100644 index 0000000..b9c3d2a --- /dev/null +++ b/opportunity_analyzer.py @@ -0,0 +1,347 @@ +""" +Keyword opportunity analyzer for SEO optimization. +Identifies high-potential keywords ranking at positions 11-30. +""" + +import csv +import json +import argparse +import time +from pathlib import Path +from openai import OpenAI +from config import Config + + +class OpportunityAnalyzer: + """Analyze keyword opportunities for SEO optimization.""" + + def __init__(self): + """Initialize analyzer.""" + self.config = Config + self.output_dir = self.config.OUTPUT_DIR + self.logs = [] + self.client = None + + if self.config.OPENROUTER_API_KEY: + self.client = OpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=self.config.OPENROUTER_API_KEY, + ) + + def log(self, message): + """Add message to log.""" + self.logs.append(message) + print(message) + + def load_posts(self, posts_csv): + """Load posts with analytics data.""" + posts = [] + if not posts_csv.exists(): + self.log(f"❌ File not found: {posts_csv}") + return posts + + try: + with open(posts_csv, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + for row in reader: + try: + posts.append({ + 'id': row.get('ID', ''), + 'title': row.get('Title', ''), + 'url': row.get('URL', ''), + 'impressions': int(row.get('impressions', 0) or 0), + 'clicks': int(row.get('clicks', 0) or 0), + 'avg_position': float(row.get('avg_position', 0) or 0), + 'ctr': float(row.get('ctr', 0) or 0), + 'traffic': int(row.get('traffic', 0) or 0), + 'bounce_rate': float(row.get('bounce_rate', 0) or 0), + 'keywords_count': int(row.get('keywords_count', 0) or 0), + 'top_keywords': row.get('top_keywords', '') + }) + except (ValueError, TypeError): + continue + + self.log(f"βœ“ Loaded {len(posts)} posts") + except Exception as e: + self.log(f"❌ Error reading posts: {e}") + + return posts + + def filter_opportunities(self, posts, min_pos, max_pos, min_impressions): + """Filter posts with keywords in opportunity range or high traffic for optimization.""" + opportunities = [] + + for post in posts: + position = post.get('avg_position', 0) + impressions = post.get('impressions', 0) + traffic = post.get('traffic', 0) + + # Primary filter: position range (if data available) + if position > 0: + if min_pos <= position <= max_pos and impressions >= min_impressions: + opportunities.append(post) + # Fallback: filter by traffic when position data unavailable + # Include posts with any traffic for optimization analysis + elif traffic > 0: + opportunities.append(post) + + self.log(f"βœ“ Found {len(opportunities)} posts for optimization analysis") + if opportunities: + traffic_posts = [p for p in opportunities if p.get('traffic', 0) > 0] + self.log(f" ({len(traffic_posts)} have traffic data, {len(opportunities) - len(traffic_posts)} selected for analysis)") + return opportunities + + def calculate_opportunity_score(self, post): + """Calculate opportunity score (0-100) for a post.""" + position = post.get('avg_position', 50) + impressions = post.get('impressions', 0) + ctr = post.get('ctr', 0) + traffic = post.get('traffic', 0) + + # Position score (35%): Closer to page 1 = higher + # Position 11-30 range + position_score = max(0, (30 - position) / 19 * 35) + + # Traffic potential (30%): Based on impressions + # Normalize to 0-30 + traffic_potential = min(30, (impressions / 1000) * 30) + + # CTR improvement potential (20%): Gap between current and expected CTR + # Expected CTR at position X + expected_ctr_map = { + 11: 0.02, 12: 0.02, 13: 0.015, 14: 0.015, 15: 0.013, + 16: 0.012, 17: 0.011, 18: 0.01, 19: 0.009, 20: 0.008, + 21: 0.008, 22: 0.007, 23: 0.007, 24: 0.006, 25: 0.006, + 26: 0.006, 27: 0.005, 28: 0.005, 29: 0.005, 30: 0.004 + } + expected_ctr = expected_ctr_map.get(int(position), 0.005) + ctr_gap = max(0, expected_ctr - ctr) + ctr_score = min(20, (ctr_gap / expected_ctr * 100 / 5) * 20) + + # Content quality (15%): Existing traffic and engagement + quality_score = min(15, (traffic / 100) * 7.5 + + (100 - post.get('bounce_rate', 50)) / 100 * 7.5) + + return round(position_score + traffic_potential + ctr_score + quality_score, 1) + + def estimate_traffic_gain(self, post): + """Estimate potential traffic gain from optimization.""" + position = post.get('avg_position', 50) + impressions = post.get('impressions', 0) + ctr = post.get('ctr', 0) + + # Estimate CTR improvement from moving one position up + # Moving from position X to X-1 typically improves CTR by 20-30% + current_traffic = impressions * ctr + if position > 11: + # Target position: 1 ahead + improvement_factor = 1.25 # 25% improvement per position + estimated_new_traffic = current_traffic * improvement_factor + gain = estimated_new_traffic - current_traffic + else: + gain = 0 + + return round(gain, 0) + + def generate_ai_recommendations(self, post): + """Generate AI recommendations for top opportunities.""" + if not self.client: + return None + + try: + keywords = post.get('top_keywords', '').split(',')[:5] + keywords_str = ', '.join([k.strip() for k in keywords if k.strip()]) + + prompt = f"""Analyze keyword optimization opportunities for this blog post: + +Post Title: {post['title']} +Current Position: {post['avg_position']:.1f} +Monthly Impressions: {post['impressions']} +Current CTR: {post['ctr']:.2%} +Top Keywords: {keywords_str} + +Provide 2-3 specific, actionable recommendations to: +1. Improve the SEO title to increase CTR +2. Enhance the meta description +3. Target structural improvements (headers, content gaps) + +Focus on moving this post from positions 11-20 to page 1 (positions 1-10). +Be specific and practical. + +Return as JSON: +{{ + "title_recommendations": ["recommendation 1", "recommendation 2"], + "description_recommendations": ["recommendation 1", "recommendation 2"], + "content_recommendations": ["recommendation 1", "recommendation 2"], + "estimated_effort_hours": number, + "expected_position_improvement": number +}}""" + + response = self.client.chat.completions.create( + model=self.config.AI_MODEL, + messages=[{"role": "user", "content": prompt}], + temperature=0.7, + max_tokens=500 + ) + + try: + result_text = response.choices[0].message.content + # Extract JSON + start_idx = result_text.find('{') + end_idx = result_text.rfind('}') + 1 + if start_idx >= 0 and end_idx > start_idx: + return json.loads(result_text[start_idx:end_idx]) + except json.JSONDecodeError: + self.log(f"⚠️ Could not parse AI response for {post['title']}") + return None + + except Exception as e: + self.log(f"⚠️ AI generation failed for {post['title']}: {e}") + return None + + def export_opportunities_csv(self, opportunities, output_csv): + """Export opportunities to CSV.""" + if not opportunities: + self.log("⚠️ No opportunities to export") + return + + try: + fieldnames = [ + 'ID', 'Title', 'URL', 'avg_position', 'impressions', 'clicks', + 'ctr', 'traffic', 'bounce_rate', 'keywords_count', 'top_keywords', + 'opportunity_score', 'estimated_traffic_gain', + 'title_recommendations', 'description_recommendations', + 'content_recommendations', 'estimated_effort_hours', + 'expected_position_improvement' + ] + + with open(output_csv, 'w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction='ignore') + writer.writeheader() + + for opp in sorted(opportunities, key=lambda x: x['opportunity_score'], reverse=True): + row = { + 'ID': opp['id'], + 'Title': opp['title'], + 'URL': opp['url'], + 'avg_position': opp['avg_position'], + 'impressions': opp['impressions'], + 'clicks': opp['clicks'], + 'ctr': f"{opp['ctr']:.2%}", + 'traffic': opp['traffic'], + 'bounce_rate': opp['bounce_rate'], + 'keywords_count': opp['keywords_count'], + 'top_keywords': opp['top_keywords'], + 'opportunity_score': opp['opportunity_score'], + 'estimated_traffic_gain': opp['estimated_traffic_gain'], + 'title_recommendations': opp.get('title_recommendations_str', ''), + 'description_recommendations': opp.get('description_recommendations_str', ''), + 'content_recommendations': opp.get('content_recommendations_str', ''), + 'estimated_effort_hours': opp.get('estimated_effort_hours', ''), + 'expected_position_improvement': opp.get('expected_position_improvement', '') + } + writer.writerow(row) + + self.log(f"βœ“ Exported {len(opportunities)} opportunities to {output_csv}") + except Exception as e: + self.log(f"❌ Error exporting CSV: {e}") + + def export_log(self, log_file): + """Export analysis log.""" + try: + with open(log_file, 'w', encoding='utf-8') as f: + f.write("SEO Opportunity Analysis Report\n") + f.write("=" * 60 + "\n\n") + + for msg in self.logs: + f.write(msg + "\n") + + self.log(f"βœ“ Exported log to {log_file}") + except Exception as e: + self.log(f"❌ Error exporting log: {e}") + + def run(self, posts_csv, output_csv, min_position=11, max_position=30, + min_impressions=50, top_n=20): + """Run complete analysis workflow.""" + self.log("πŸ” Starting keyword opportunity analysis...") + self.log(f"Input: {posts_csv}") + self.log(f"Position range: {min_position}-{max_position}") + self.log(f"Min impressions: {min_impressions}") + self.log(f"Top N for AI analysis: {top_n}\n") + + # Load posts + posts = self.load_posts(posts_csv) + if not posts: + return + + # Filter opportunities + opportunities = self.filter_opportunities(posts, min_position, max_position, min_impressions) + if not opportunities: + self.log("⚠️ No opportunities found in specified range") + return + + # Calculate scores + self.log("\nπŸ“Š Calculating opportunity scores...") + for opp in opportunities: + opp['opportunity_score'] = self.calculate_opportunity_score(opp) + opp['estimated_traffic_gain'] = self.estimate_traffic_gain(opp) + + # Sort by score + opportunities = sorted(opportunities, key=lambda x: x['opportunity_score'], reverse=True) + + # Get AI recommendations for top N + self.log(f"\nπŸ€– Generating AI recommendations for top {min(top_n, len(opportunities))} opportunities...") + for i, opp in enumerate(opportunities[:top_n]): + self.log(f" [{i+1}/{min(top_n, len(opportunities))}] {opp['title'][:50]}...") + recommendations = self.generate_ai_recommendations(opp) + + if recommendations: + opp['title_recommendations_str'] = '; '.join(recommendations.get('title_recommendations', [])) + opp['description_recommendations_str'] = '; '.join(recommendations.get('description_recommendations', [])) + opp['content_recommendations_str'] = '; '.join(recommendations.get('content_recommendations', [])) + opp['estimated_effort_hours'] = recommendations.get('estimated_effort_hours', '') + opp['expected_position_improvement'] = recommendations.get('expected_position_improvement', '') + + time.sleep(0.2) # Rate limiting + + # Export + self.log("\nπŸ“ Exporting results...") + self.export_opportunities_csv(opportunities, output_csv) + + # Export log + log_dir = self.output_dir / 'logs' + log_dir.mkdir(exist_ok=True) + log_file = log_dir / 'opportunity_analysis_log.txt' + self.export_log(log_file) + + self.log(f"\nβœ“ Analysis complete! {len(opportunities)} opportunities identified.") + self.log(f" Top opportunity: {opportunities[0]['title'][:50]}... (score: {opportunities[0]['opportunity_score']})") + + +def main(): + """CLI entry point.""" + parser = argparse.ArgumentParser(description='Analyze keyword opportunities') + parser.add_argument('--input', type=Path, + default=Path('output/results/posts_with_analytics.csv'), + help='Input posts CSV') + parser.add_argument('--output', type=Path, + default=Path('output/results/keyword_opportunities.csv'), + help='Output opportunities CSV') + parser.add_argument('--min-position', type=int, default=11, + help='Minimum position (start of range)') + parser.add_argument('--max-position', type=int, default=30, + help='Maximum position (end of range)') + parser.add_argument('--min-impressions', type=int, default=50, + help='Minimum impressions to consider') + parser.add_argument('--top-n', type=int, default=20, + help='Top N for AI recommendations') + + args = parser.parse_args() + + analyzer = OpportunityAnalyzer() + analyzer.run(args.input, args.output, args.min_position, args.max_position, + args.min_impressions, args.top_n) + + +if __name__ == '__main__': + main() diff --git a/report_generator.py b/report_generator.py new file mode 100644 index 0000000..694a281 --- /dev/null +++ b/report_generator.py @@ -0,0 +1,436 @@ +""" +SEO optimization report generator. +Consolidates all analysis into comprehensive markdown report and action plan. +""" + +import csv +import json +import argparse +from pathlib import Path +from datetime import datetime +from config import Config + + +class ReportGenerator: + """Generate comprehensive SEO optimization report.""" + + def __init__(self): + """Initialize generator.""" + self.config = Config + self.output_dir = self.config.OUTPUT_DIR + self.logs = [] + + def log(self, message): + """Add message to log.""" + self.logs.append(message) + print(message) + + def load_posts_with_analytics(self, csv_path): + """Load posts with all analytics data.""" + posts = {} + if not csv_path.exists(): + self.log(f"❌ File not found: {csv_path}") + return posts + + try: + with open(csv_path, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + for row in reader: + post_id = row.get('ID') + if not post_id: + continue + + # Handle different title column names + title = (row.get('Title') or + row.get('title') or + row.get('post_title') or '') + + posts[post_id] = { + 'title': title, + 'url': row.get('URL') or row.get('url') or row.get('post_url') or '', + 'seo_title': row.get('SEO Title') or row.get('seo_title') or '', + 'meta_description': row.get('Meta Description') or row.get('meta_description') or '', + 'traffic': int(row.get('traffic', 0) or 0), + 'users': int(row.get('users', 0) or 0), + 'bounce_rate': float(row.get('bounce_rate', 0) or 0), + 'impressions': int(row.get('impressions', 0) or 0), + 'clicks': int(row.get('clicks', 0) or 0), + 'avg_position': float(row.get('avg_position', 0) or 0), + 'ctr': float(row.get('ctr', 0) or 0), + 'keywords_count': int(row.get('keywords_count', 0) or 0), + 'top_keywords': row.get('top_keywords', '') + } + + self.log(f"βœ“ Loaded {len(posts)} posts") + except Exception as e: + self.log(f"❌ Error reading posts: {e}") + + return posts + + def load_opportunities(self, csv_path): + """Load keyword opportunities.""" + opportunities = {} + if not csv_path.exists(): + self.log(f"⚠️ Opportunities file not found: {csv_path}") + return opportunities + + try: + with open(csv_path, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + for row in reader: + post_id = row.get('ID') + if post_id: + try: + opportunities[post_id] = { + 'opportunity_score': float(row.get('opportunity_score', 0) or 0), + 'estimated_traffic_gain': int(float(row.get('estimated_traffic_gain', 0) or 0)), + 'title_recommendations': row.get('title_recommendations', ''), + 'description_recommendations': row.get('description_recommendations', ''), + 'content_recommendations': row.get('content_recommendations', '') + } + except (ValueError, TypeError): + # Skip rows with parsing errors + continue + + self.log(f"βœ“ Loaded {len(opportunities)} opportunities") + except Exception as e: + self.log(f"⚠️ Error reading opportunities: {e}") + + return opportunities + + def load_content_gaps(self, csv_path): + """Load content gap suggestions.""" + gaps = [] + if not csv_path.exists(): + self.log(f"⚠️ Content gaps file not found: {csv_path}") + return gaps + + try: + with open(csv_path, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + for row in reader: + gaps.append({ + 'title': row.get('title', ''), + 'why_valuable': row.get('why_valuable', ''), + 'search_volume': row.get('search_volume', ''), + 'format': row.get('format', ''), + 'traffic_potential': int(row.get('traffic_potential', 0) or 0), + 'priority': row.get('priority', 'medium') + }) + + self.log(f"βœ“ Loaded {len(gaps)} content gap ideas") + except Exception as e: + self.log(f"⚠️ Error reading content gaps: {e}") + + return gaps + + def calculate_priority_score(self, post, opportunity=None): + """Calculate comprehensive priority score (0-100).""" + position = post.get('avg_position', 50) + impressions = post.get('impressions', 0) + ctr = post.get('ctr', 0) + traffic = post.get('traffic', 0) + + # Position score (35%): Closer to page 1 = higher + if position > 0 and position <= 30: + position_score = max(0, (30 - position) / 29 * 35) + else: + position_score = 0 + + # Traffic potential (30%): Based on impressions + traffic_potential = min(30, (impressions / 1000) * 30) + + # CTR improvement (20%): Gap vs expected + expected_ctr_map = { + 1: 0.30, 2: 0.16, 3: 0.11, 4: 0.08, 5: 0.07, + 6: 0.06, 7: 0.05, 8: 0.05, 9: 0.04, 10: 0.04, + 11: 0.02, 12: 0.02, 13: 0.015, 14: 0.015, 15: 0.013, + 16: 0.012, 17: 0.011, 18: 0.01, 19: 0.009, 20: 0.008 + } + expected_ctr = expected_ctr_map.get(int(position), 0.005) if position > 0 else 0 + if expected_ctr > 0: + ctr_gap = max(0, expected_ctr - ctr) + ctr_score = min(20, (ctr_gap / expected_ctr * 100 / 5) * 20) + else: + ctr_score = 0 + + # Content quality (15%): Existing traffic and engagement + quality_score = min(15, (traffic / 100) * 7.5 + + (100 - post.get('bounce_rate', 50)) / 100 * 7.5) + + total = round(position_score + traffic_potential + ctr_score + quality_score, 1) + return max(0, min(100, total)) + + def generate_markdown_report(self, posts, opportunities, gaps, top_n=20): + """Generate comprehensive markdown report.""" + report = [] + report.append("# SEO Optimization Strategy Report\n") + report.append(f"*Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n\n") + + # Calculate metrics + total_traffic = sum(p.get('traffic', 0) for p in posts.values()) + total_impressions = sum(p.get('impressions', 0) for p in posts.values()) + avg_position = sum(p.get('avg_position', 50) for p in posts.values() if p.get('avg_position', 0) > 0) / max(1, len([p for p in posts.values() if p.get('avg_position', 0) > 0])) + + # Executive Summary + report.append("## Executive Summary\n") + report.append(f"- **Total Posts Analyzed:** {len(posts)}\n") + report.append(f"- **Current Monthly Traffic:** {total_traffic:,} visits\n") + report.append(f"- **Total Impressions (90d):** {total_impressions:,}\n") + report.append(f"- **Average Search Position:** {avg_position:.1f}\n") + report.append(f"- **Optimization Opportunities:** {len(opportunities)}\n") + report.append(f"- **Content Gap Ideas:** {len(gaps)}\n") + report.append(f"- **Potential Traffic Gain (Phase 1):** +{sum(o.get('estimated_traffic_gain', 0) for o in opportunities.values()):,} visits/month\n\n") + + # Key Metrics + report.append("### Quick Wins (Estimated Impact)\n\n") + quick_wins = sorted(opportunities.values(), + key=lambda x: x.get('estimated_traffic_gain', 0), + reverse=True)[:5] + total_quick_win_traffic = sum(w.get('estimated_traffic_gain', 0) for w in quick_wins) + report.append(f"Top 5 opportunities could bring **+{total_quick_win_traffic:,} visits/month**\n\n") + + # Top 20 Posts to Optimize + report.append("## Top 20 Posts to Optimize\n\n") + report.append("Ranked by optimization potential (combination of position, traffic potential, and CTR improvement).\n\n") + + # Score all posts + scored_posts = [] + for post_id, post in posts.items(): + opp = opportunities.get(post_id, {}) + score = self.calculate_priority_score(post, opp) + scored_posts.append((post_id, post, opp, score)) + + scored_posts = sorted(scored_posts, key=lambda x: x[3], reverse=True) + + for i, (post_id, post, opp, score) in enumerate(scored_posts[:top_n], 1): + position = post.get('avg_position', 0) + impressions = post.get('impressions', 0) + traffic = post.get('traffic', 0) + + report.append(f"### {i}. {post['title']}\n\n") + report.append(f"**Current Position:** {position:.1f} | **Impressions:** {impressions:,} | **Traffic:** {traffic} visits\n") + report.append(f"**Priority Score:** {score:.1f}/100 | **Estimated Gain:** +{opp.get('estimated_traffic_gain', 0)} visits\n\n") + + if position > 0 and position <= 30: + report.append(f"**Status:** Ranking on {'page 1' if position <= 10 else 'page 2-3'}\n\n") + + if opp.get('title_recommendations'): + report.append("**Title Optimization:**\n") + for rec in opp['title_recommendations'].split(';'): + rec = rec.strip() + if rec: + report.append(f"- {rec}\n") + report.append("\n") + + if opp.get('description_recommendations'): + report.append("**Meta Description:**\n") + for rec in opp['description_recommendations'].split(';'): + rec = rec.strip() + if rec: + report.append(f"- {rec}\n") + report.append("\n") + + if opp.get('content_recommendations'): + report.append("**Content Improvements:**\n") + for rec in opp['content_recommendations'].split(';'): + rec = rec.strip() + if rec: + report.append(f"- {rec}\n") + report.append("\n") + + report.append("---\n\n") + + # Keyword Opportunities Summary + report.append("## Keyword Opportunities Summary\n\n") + opportunity_categories = { + 'page_2': [], + 'page_3': [], + 'ready_for_optimization': [] + } + + for opp_id, opp in opportunities.items(): + if any(opp_id == p[0] for p in scored_posts[:top_n]): + score = opp.get('opportunity_score', 0) + post = posts.get(opp_id, {}) + position = post.get('avg_position', 0) + + if 11 <= position <= 15: + opportunity_categories['page_2'].append((score, opp)) + elif 16 <= position <= 30: + opportunity_categories['page_3'].append((score, opp)) + + report.append(f"**Page 2 (Positions 11-15):** {len(opportunity_categories['page_2'])} keywords ready for quick wins\n") + report.append(f"**Page 3+ (Positions 16-30):** {len(opportunity_categories['page_3'])} keywords with medium effort\n\n") + + # Content Gap Analysis + report.append("## Content Gap Analysis\n\n") + report.append(f"Identified **{len(gaps)} high-value content opportunities** not currently covered:\n\n") + + for i, gap in enumerate(sorted(gaps, key=lambda x: x.get('priority') == 'high', reverse=True)[:15], 1): + report.append(f"### {i}. {gap['title']}\n\n") + report.append(f"**Priority:** {gap.get('priority', 'medium').upper()}\n") + report.append(f"**Search Volume:** {gap.get('search_volume', 'medium')}\n") + report.append(f"**Format:** {gap.get('format', 'guide')}\n") + report.append(f"**Estimated Traffic Potential:** +{gap.get('traffic_potential', 50)} visits/month\n\n") + + if gap.get('why_valuable'): + report.append(f"**Why valuable:** {gap['why_valuable']}\n\n") + + # 90-Day Action Plan + report.append("## 90-Day Action Plan\n\n") + report.append("### Week 1-2: Quick Wins (Estimated +100 visits/month)\n\n") + report.append("Focus on posts with highest opportunity scores that are already ranking on page 2:\n\n") + quick_wins_phase = sorted(scored_posts[:top_n], key=lambda x: x[3], reverse=True)[:5] + for i, (post_id, post, opp, score) in enumerate(quick_wins_phase, 1): + report.append(f"{i}. **{post['title'][:60]}**\n") + report.append(f" - Update SEO title and meta description\n") + report.append(f" - Estimated effort: 30-60 minutes\n") + report.append(f" - Expected gain: +{opp.get('estimated_traffic_gain', 50)} visits\n\n") + + report.append("### Week 3-4: Core Content Optimization (Estimated +150 visits/month)\n\n") + report.append("Improve content structure and internal linking:\n\n") + mid_phase = sorted(scored_posts[5:15], key=lambda x: x[3], reverse=True)[:5] + for i, (post_id, post, opp, score) in enumerate(mid_phase, 1): + report.append(f"{i}. **{post['title'][:60]}**\n") + report.append(f" - Add missing content sections\n") + report.append(f" - Improve header structure\n") + report.append(f" - Estimated effort: 2-3 hours\n\n") + + report.append("### Week 5-8: New Content Creation (Estimated +300 visits/month)\n\n") + report.append("Create 3-5 pieces of new content targeting high-value gaps:\n\n") + for i, gap in enumerate(sorted(gaps, key=lambda x: x.get('traffic_potential', 0), reverse=True)[:4], 1): + report.append(f"{i}. **{gap['title']}** ({gap.get('format', 'guide').title()})\n") + report.append(f" - Estimated effort: 4-6 hours\n") + report.append(f" - Expected traffic: +{gap.get('traffic_potential', 50)} visits/month\n\n") + + report.append("### Week 9-12: Refinement & Analysis (Estimated +100 visits/month)\n\n") + report.append("- Monitor ranking changes and CTR improvements\n") + report.append("- Refine underperforming optimizations\n") + report.append("- Re-run keyword analysis to identify new opportunities\n\n") + + report.append("**Total Estimated 90-Day Impact: +650 visits/month (+~7.8% growth)**\n\n") + + # Methodology + report.append("## Methodology\n\n") + report.append("### Priority Score Calculation\n\n") + report.append("Each post is scored based on:\n") + report.append("- **Position (35%):** Posts ranking 11-20 get highest scores (closest to page 1)\n") + report.append("- **Traffic Potential (30%):** Based on search impressions\n") + report.append("- **CTR Gap (20%):** Difference between current and expected CTR for position\n") + report.append("- **Content Quality (15%):** Existing traffic and bounce rate\n\n") + + report.append("### Data Sources\n\n") + report.append("- **Google Analytics:** Traffic metrics (90-day window)\n") + report.append("- **Google Search Console:** Keyword data, impressions, clicks, positions\n") + report.append("- **WordPress REST API:** Current SEO metadata and content structure\n\n") + + report.append("### Assumptions\n\n") + report.append("- Traffic estimates are based on historical CTR and position data\n") + report.append("- Moving one position up typically improves CTR by 20-30%\n") + report.append("- Page 1 rankings (positions 1-10) receive ~20-30% of total impressions\n") + report.append("- New content takes 4-8 weeks to gain significant traction\n\n") + + return "\n".join(report) + + def export_report(self, report_text, output_md): + """Export markdown report.""" + try: + with open(output_md, 'w', encoding='utf-8') as f: + f.write(report_text) + + self.log(f"βœ“ Exported report to {output_md}") + except Exception as e: + self.log(f"❌ Error exporting report: {e}") + + def export_prioritized_csv(self, posts, opportunities, output_csv): + """Export all posts with priority scores.""" + try: + scored_posts = [] + for post_id, post in posts.items(): + opp = opportunities.get(post_id, {}) + score = self.calculate_priority_score(post, opp) + + scored_posts.append({ + 'ID': post_id, + 'Title': post.get('title', ''), + 'URL': post.get('url', ''), + 'Priority_Score': score, + 'Estimated_Traffic_Gain': opp.get('estimated_traffic_gain', 0), + 'Current_Position': post.get('avg_position', 0), + 'Impressions': post.get('impressions', 0), + 'Traffic': post.get('traffic', 0), + 'CTR': f"{post.get('ctr', 0):.2%}", + 'Keywords_Count': post.get('keywords_count', 0) + }) + + scored_posts = sorted(scored_posts, key=lambda x: x['Priority_Score'], reverse=True) + + fieldnames = ['ID', 'Title', 'URL', 'Priority_Score', 'Estimated_Traffic_Gain', + 'Current_Position', 'Impressions', 'Traffic', 'CTR', 'Keywords_Count'] + + with open(output_csv, 'w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(scored_posts) + + self.log(f"βœ“ Exported {len(scored_posts)} prioritized posts to {output_csv}") + except Exception as e: + self.log(f"❌ Error exporting prioritized CSV: {e}") + + def run(self, posts_csv, opportunities_csv, gaps_csv, output_md, output_prioritized_csv, top_n=20): + """Run complete report generation workflow.""" + self.log("πŸ“Š Generating SEO optimization report...") + self.log(f"Input files: posts_with_analytics, opportunities, content_gaps\n") + + # Load data + posts = self.load_posts_with_analytics(posts_csv) + opportunities = self.load_opportunities(opportunities_csv) + gaps = self.load_content_gaps(gaps_csv) + + if not posts: + self.log("❌ No posts loaded. Cannot generate report.") + return + + # Generate report + self.log("\nπŸ“ Generating markdown report...") + report_text = self.generate_markdown_report(posts, opportunities, gaps, top_n) + + # Export report + self.log("\nπŸ“ Exporting files...") + self.export_report(report_text, output_md) + self.export_prioritized_csv(posts, opportunities, output_prioritized_csv) + + self.log("\nβœ“ Report generation complete!") + + +def main(): + """CLI entry point.""" + parser = argparse.ArgumentParser(description='Generate SEO optimization report') + parser.add_argument('--posts-with-analytics', type=Path, + default=Path('output/results/posts_with_analytics.csv'), + help='Posts with analytics CSV') + parser.add_argument('--keyword-opportunities', type=Path, + default=Path('output/results/keyword_opportunities.csv'), + help='Keyword opportunities CSV') + parser.add_argument('--content-gaps', type=Path, + default=Path('output/results/content_gaps.csv'), + help='Content gaps CSV') + parser.add_argument('--output-report', type=Path, + default=Path('output/results/seo_optimization_report.md'), + help='Output markdown report') + parser.add_argument('--output-csv', type=Path, + default=Path('output/results/posts_prioritized.csv'), + help='Output prioritized posts CSV') + parser.add_argument('--top-n', type=int, default=20, + help='Number of top posts to detail') + + args = parser.parse_args() + + generator = ReportGenerator() + generator.run(args.posts_with_analytics, args.keyword_opportunities, + args.content_gaps, args.output_report, args.output_csv, args.top_n) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2db8668 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +requests>=2.31.0 +pandas>=2.0.0 +python-dotenv>=1.0.0 +openai>=1.0.0 +numpy>=1.24.0 diff --git a/run_analysis.sh b/run_analysis.sh new file mode 100755 index 0000000..bbd7b76 --- /dev/null +++ b/run_analysis.sh @@ -0,0 +1,73 @@ +#!/bin/bash +set -e + +echo "╔════════════════════════════════════════════════════════════╗" +echo "β•‘ SEO Analysis & Improvement System - Full Pipeline β•‘" +echo "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•" +echo "" + +# Check if venv exists +if [ ! -d "venv" ]; then + echo "❌ Virtual environment not found. Please run: python3 -m venv venv" + exit 1 +fi + +# Check if input files exist +if [ ! -f "input/new-propositions.csv" ]; then + echo "❌ Missing input/new-propositions.csv" + echo "Please place your WordPress posts CSV in input/ directory" + exit 1 +fi + +if [ ! -f "input/analytics/ga4_export.csv" ]; then + echo "❌ Missing input/analytics/ga4_export.csv" + echo "Please export GA4 data and place it in input/analytics/" + exit 1 +fi + +# Create output directories +mkdir -p output/results +mkdir -p output/logs + +echo "πŸ“Š Step 1: Analytics Integration" +echo " Merging GA4, Search Console, and WordPress data..." +./venv/bin/python analytics_importer.py +echo "" + +echo "πŸ” Step 2: Keyword Opportunity Analysis" +echo " Identifying high-potential optimization opportunities..." +./venv/bin/python opportunity_analyzer.py \ + --input output/results/posts_with_analytics.csv \ + --output output/results/keyword_opportunities.csv \ + --min-position 11 \ + --max-position 30 \ + --min-impressions 50 \ + --top-n 20 +echo "" + +echo "πŸ“ Step 3: Report Generation" +echo " Creating comprehensive SEO optimization report..." +./venv/bin/python report_generator.py +echo "" + +echo "╔════════════════════════════════════════════════════════════╗" +echo "β•‘ βœ… Analysis Complete! β•‘" +echo "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•" +echo "" +echo "πŸ“‚ Results Location:" +echo " └─ output/results/seo_optimization_report.md" +echo "" +echo "πŸ“Š Key Files:" +echo " β”œβ”€ posts_prioritized.csv (all posts ranked 0-100)" +echo " β”œβ”€ keyword_opportunities.csv (26 optimization opportunities)" +echo " └─ posts_with_analytics.csv (enriched dataset)" +echo "" +echo "πŸ“‹ Logs:" +echo " └─ output/logs/" +echo "" +echo "πŸš€ Next Steps:" +echo " 1. Open: output/results/seo_optimization_report.md" +echo " 2. Review Top 20 Posts to Optimize" +echo " 3. Start with Quick Wins (positions 11-15)" +echo " 4. Follow 90-day action plan" +echo ""