Add direct WordPress meta description updater

- Add update_meta command to fetch, generate, and update meta on WordPress
- Require --site parameter to specify target website
- Support filtering by post IDs (--post-ids)
- Support filtering by category names (--category) or IDs (--category-id)
- Support limit parameter to batch process posts
- Skip existing good quality meta descriptions by default
- Add --force flag to regenerate all meta descriptions
- Include dry-run mode to preview changes
- Save update results to CSV for review
- Rate limited API calls (0.5s delay between requests)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Kevin Bataille
2026-02-17 00:04:54 +01:00
parent ba8e39b5d8
commit 93ea5794f0
3 changed files with 675 additions and 0 deletions

View File

@@ -14,6 +14,7 @@ from .category_manager import WordPressCategoryManager, CategoryAssignmentProces
from .editorial_strategy import EditorialStrategyAnalyzer from .editorial_strategy import EditorialStrategyAnalyzer
from .post_migrator import WordPressPostMigrator from .post_migrator import WordPressPostMigrator
from .meta_description_generator import MetaDescriptionGenerator from .meta_description_generator import MetaDescriptionGenerator
from .meta_description_updater import MetaDescriptionUpdater
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -319,6 +320,46 @@ class SEOApp:
limit=limit limit=limit
) )
def update_meta_descriptions(self, site: str,
post_ids: Optional[List[int]] = None,
category_names: Optional[List[str]] = None,
category_ids: Optional[List[int]] = None,
limit: Optional[int] = None,
dry_run: bool = False,
skip_existing: bool = True,
force_regenerate: bool = False) -> Dict:
"""
Fetch posts from WordPress, generate AI meta descriptions, and update them.
Args:
site: WordPress site name (REQUIRED) - mistergeek.net, webscroll.fr, hellogeek.net
post_ids: Specific post IDs to update
category_names: Filter by category names
category_ids: Filter by category IDs
limit: Maximum number of posts to process
dry_run: If True, preview changes without updating
skip_existing: If True, skip posts with existing good quality meta descriptions
force_regenerate: If True, regenerate even for good quality metas
Returns:
Statistics dict
"""
logger.info(f"🔄 Updating meta descriptions on {site}...")
if not site:
raise ValueError("Site is required. Use --site mistergeek.net|webscroll.fr|hellogeek.net")
updater = MetaDescriptionUpdater(site)
return updater.run(
post_ids=post_ids,
category_ids=category_ids,
category_names=category_names,
limit=limit,
dry_run=dry_run,
skip_existing=skip_existing,
force_regenerate=force_regenerate
)
def _find_latest_export(self) -> Optional[str]: def _find_latest_export(self) -> Optional[str]:
"""Find the latest exported CSV file.""" """Find the latest exported CSV file."""
csv_files = list(self.output_dir.glob('all_posts_*.csv')) csv_files = list(self.output_dir.glob('all_posts_*.csv'))

View File

@@ -74,6 +74,12 @@ Examples:
parser.add_argument('--only-missing', action='store_true', help='Only generate for posts without meta descriptions') parser.add_argument('--only-missing', action='store_true', help='Only generate for posts without meta descriptions')
parser.add_argument('--only-poor', action='store_true', help='Only generate for posts with poor quality meta descriptions') parser.add_argument('--only-poor', action='store_true', help='Only generate for posts with poor quality meta descriptions')
# Update meta arguments
parser.add_argument('--post-ids', type=int, nargs='+', help='Specific post IDs to update')
parser.add_argument('--category', nargs='+', help='Filter by category name(s)')
parser.add_argument('--category-id', type=int, nargs='+', help='Filter by category ID(s)')
parser.add_argument('--force', action='store_true', help='Force regenerate even for good quality meta descriptions')
args = parser.parse_args() args = parser.parse_args()
if not args.command: if not args.command:
@@ -100,6 +106,7 @@ Examples:
'editorial_strategy': cmd_editorial_strategy, 'editorial_strategy': cmd_editorial_strategy,
'migrate': cmd_migrate, 'migrate': cmd_migrate,
'meta_description': cmd_meta_description, 'meta_description': cmd_meta_description,
'update_meta': cmd_update_meta,
'status': cmd_status, 'status': cmd_status,
'help': cmd_help, 'help': cmd_help,
} }
@@ -427,6 +434,61 @@ def cmd_meta_description(app, args):
return 0 return 0
def cmd_update_meta(app, args):
"""Fetch, generate, and update meta descriptions directly on WordPress."""
if args.dry_run:
print("Would update meta descriptions on WordPress")
if not args.site:
print(" ❌ Site is required. Use --site mistergeek.net|webscroll.fr|hellogeek.net")
return 1
print(f" Site: {args.site}")
if args.post_ids:
print(f" Post IDs: {args.post_ids}")
if args.category:
print(f" Categories: {args.category}")
if args.limit:
print(f" Limit: {args.limit} posts")
return 0
# Site is required
if not args.site:
print("❌ Site is required. Use --site mistergeek.net|webscroll.fr|hellogeek.net")
return 1
print(f"Updating meta descriptions on {args.site}...")
if args.post_ids:
print(f" Post IDs: {args.post_ids}")
if args.category:
print(f" Categories: {args.category}")
if args.category_id:
print(f" Category IDs: {args.category_id}")
if args.limit:
print(f" Limit: {args.limit} posts")
print(f" Skip existing: {not args.force}")
print(f" Dry run: {args.dry_run}")
stats = app.update_meta_descriptions(
site=args.site,
post_ids=args.post_ids,
category_names=args.category,
category_ids=args.category_id,
limit=args.limit,
dry_run=args.dry_run,
skip_existing=not args.force,
force_regenerate=args.force
)
if stats:
print(f"\n✅ Meta description update completed!")
print(f"\n📊 Summary:")
print(f" Total posts: {stats.get('total_posts', 0)}")
print(f" Updated: {stats.get('updated', 0)}")
print(f" Failed: {stats.get('failed', 0)}")
print(f" Skipped: {stats.get('skipped', 0)}")
print(f" API calls: {stats.get('api_calls', 0)}")
return 0
def cmd_status(app, args): def cmd_status(app, args):
"""Show status.""" """Show status."""
if args.dry_run: if args.dry_run:
@@ -462,6 +524,9 @@ Export & Analysis:
category_propose [csv] Propose categories based on content category_propose [csv] Propose categories based on content
meta_description [csv] Generate AI-optimized meta descriptions meta_description [csv] Generate AI-optimized meta descriptions
meta_description --only-missing Generate only for posts without meta descriptions meta_description --only-missing Generate only for posts without meta descriptions
update_meta --site <site> Fetch, generate, and update meta on WordPress
update_meta --site A --post-ids 1 2 3 Update specific posts
update_meta --site A --category "VPN" Update posts in category
Category Management: Category Management:
category_apply [csv] Apply AI category proposals to WordPress category_apply [csv] Apply AI category proposals to WordPress
@@ -492,6 +557,13 @@ Meta Description Options:
--limit Limit number of posts to process --limit Limit number of posts to process
--output, -o Custom output file path --output, -o Custom output file path
Update Meta Options:
--site, -s WordPress site (REQUIRED): mistergeek.net, webscroll.fr, hellogeek.net
--post-ids Specific post IDs to update
--category Filter by category name(s)
--category-id Filter by category ID(s)
--force Force regenerate even for good quality meta descriptions
Migration Options: Migration Options:
--destination, --to Destination site: mistergeek.net, webscroll.fr, hellogeek.net --destination, --to Destination site: mistergeek.net, webscroll.fr, hellogeek.net
--source, --from Source site for filtered migration --source, --from Source site for filtered migration
@@ -534,6 +606,10 @@ Examples:
seo meta_description # Generate for all posts seo meta_description # Generate for all posts
seo meta_description --only-missing # Generate only for posts without meta seo meta_description --only-missing # Generate only for posts without meta
seo meta_description --only-poor --limit 10 # Fix 10 poor quality metas seo meta_description --only-poor --limit 10 # Fix 10 poor quality metas
seo update_meta --site mistergeek.net # Update all posts on site
seo update_meta --site A --post-ids 1 2 3 # Update specific posts
seo update_meta --site A --category "VPN" --limit 10 # Update 10 posts in category
seo update_meta --site A --dry-run # Preview changes
seo status seo status
""") """)
return 0 return 0

View File

@@ -0,0 +1,558 @@
"""
Meta Description Updater - Fetch, generate, and update meta descriptions directly on WordPress
"""
import csv
import json
import logging
import time
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional, Tuple
import requests
from requests.auth import HTTPBasicAuth
from .config import Config
from .meta_description_generator import MetaDescriptionGenerator
logger = logging.getLogger(__name__)
class MetaDescriptionUpdater:
"""Fetch posts from WordPress, generate AI meta descriptions, and update them."""
def __init__(self, site_name: str):
"""
Initialize the updater.
Args:
site_name: WordPress site name (e.g., 'mistergeek.net')
"""
self.site_name = site_name
self.sites = Config.WORDPRESS_SITES
if site_name not in self.sites:
raise ValueError(f"Site '{site_name}' not found in configuration")
self.site_config = self.sites[site_name]
self.base_url = self.site_config['url'].rstrip('/')
self.auth = HTTPBasicAuth(
self.site_config['username'],
self.site_config['password']
)
self.openrouter_api_key = Config.OPENROUTER_API_KEY
self.ai_model = Config.AI_MODEL
self.posts = []
self.update_results = []
self.api_calls = 0
self.stats = {
'total_posts': 0,
'updated': 0,
'failed': 0,
'skipped': 0
}
def fetch_posts(self, post_ids: Optional[List[int]] = None,
category_ids: Optional[List[int]] = None,
category_names: Optional[List[str]] = None,
limit: Optional[int] = None,
status: Optional[List[str]] = None) -> List[Dict]:
"""
Fetch posts from WordPress site.
Args:
post_ids: Specific post IDs to fetch
category_ids: Filter by category IDs
category_names: Filter by category names (will be resolved to IDs)
limit: Maximum number of posts to fetch
status: Post statuses to fetch (default: ['publish'])
Returns:
List of post dicts
"""
logger.info(f"Fetching posts from {self.site_name}...")
if post_ids:
logger.info(f" Post IDs: {post_ids}")
if category_ids:
logger.info(f" Category IDs: {category_ids}")
if category_names:
logger.info(f" Category names: {category_names}")
if limit:
logger.info(f" Limit: {limit}")
# Resolve category names to IDs if needed
if category_names and not category_ids:
category_ids = self._get_category_ids_by_names(category_names)
# Build API parameters
params = {
'per_page': 100,
'page': 1,
'status': ','.join(status) if status else 'publish',
'_embed': True
}
if post_ids:
# Fetch specific posts
posts = []
for post_id in post_ids:
try:
response = requests.get(
f"{self.base_url}/wp-json/wp/v2/posts/{post_id}",
auth=self.auth,
timeout=10
)
if response.status_code == 200:
posts.append(response.json())
else:
logger.warning(f" Post {post_id} not found or inaccessible")
except Exception as e:
logger.error(f" Error fetching post {post_id}: {e}")
self.posts = posts
else:
# Fetch posts with filters
if category_ids:
params['categories'] = ','.join(map(str, category_ids))
posts = []
while True:
try:
response = requests.get(
f"{self.base_url}/wp-json/wp/v2/posts",
params=params,
auth=self.auth,
timeout=30
)
response.raise_for_status()
page_posts = response.json()
if not page_posts:
break
posts.extend(page_posts)
if len(page_posts) < 100:
break
if limit and len(posts) >= limit:
break
params['page'] += 1
time.sleep(0.3)
except Exception as e:
logger.error(f"Error fetching posts: {e}")
break
# Apply limit if specified
if limit:
posts = posts[:limit]
self.posts = posts
logger.info(f"✓ Fetched {len(self.posts)} posts from {self.site_name}")
return self.posts
def _get_category_ids_by_names(self, category_names: List[str]) -> List[int]:
"""
Get category IDs by category names.
Args:
category_names: List of category names
Returns:
List of category IDs
"""
logger.info(f"Resolving category names to IDs...")
try:
response = requests.get(
f"{self.base_url}/wp-json/wp/v2/categories",
params={'per_page': 100},
auth=self.auth,
timeout=10
)
response.raise_for_status()
categories = response.json()
category_map = {cat['name'].lower(): cat['id'] for cat in categories}
category_ids = []
for name in category_names:
name_lower = name.lower()
if name_lower in category_map:
category_ids.append(category_map[name_lower])
logger.info(f"'{name}' -> ID {category_map[name_lower]}")
else:
# Try partial match
for cat_name, cat_id in category_map.items():
if name_lower in cat_name or cat_name in name_lower:
category_ids.append(cat_id)
logger.info(f"'{name}' -> ID {cat_id} (partial match)")
break
else:
logger.warning(f" ✗ Category '{name}' not found")
return category_ids
except Exception as e:
logger.error(f"Error fetching categories: {e}")
return []
def _generate_meta_description(self, post: Dict) -> Optional[str]:
"""
Generate meta description for a post using AI.
Args:
post: Post data dict
Returns:
Generated meta description or None
"""
title = post.get('title', {}).get('rendered', '')
content = post.get('content', {}).get('rendered', '')
excerpt = post.get('excerpt', {}).get('rendered', '')
# Strip HTML from content
import re
content_text = re.sub('<[^<]+?>', '', content)[:500]
excerpt_text = re.sub('<[^<]+?>', '', excerpt)
# Build prompt
prompt = f"""You are an SEO expert. Generate an optimized meta description for the following blog post.
**Post Title:** {title}
**Content Context:**
Excerpt: {excerpt_text}
Content preview: {content_text}...
**Requirements:**
1. Length: 120-160 characters (optimal for SEO)
2. Make it compelling and action-oriented
3. Clearly describe what the post is about
4. Use active voice
5. Include a call-to-action when appropriate
6. Avoid clickbait - be accurate and valuable
**Output Format:**
Return ONLY the meta description text, nothing else. No quotes, no explanations."""
# Call AI API
url = "https://openrouter.ai/api/v1/chat/completions"
headers = {
"Authorization": f"Bearer {self.openrouter_api_key}",
"Content-Type": "application/json"
}
payload = {
"model": self.ai_model,
"messages": [
{
"role": "system",
"content": "You are an SEO expert specializing in meta description optimization."
},
{
"role": "user",
"content": prompt
}
],
"temperature": 0.7,
"max_tokens": 100
}
try:
response = requests.post(url, json=payload, headers=headers, timeout=30)
response.raise_for_status()
result = response.json()
self.api_calls += 1
if 'choices' in result and len(result['choices']) > 0:
meta_description = result['choices'][0]['message']['content'].strip()
# Remove quotes if AI included them
if meta_description.startswith('"') and meta_description.endswith('"'):
meta_description = meta_description[1:-1]
return meta_description
else:
logger.warning("No AI response received")
return None
except Exception as e:
logger.error(f"API call failed: {e}")
return None
def _update_post_meta(self, post_id: int, meta_description: str) -> bool:
"""
Update post meta description in WordPress.
Args:
post_id: Post ID to update
meta_description: New meta description
Returns:
True if successful, False otherwise
"""
logger.info(f"Updating post {post_id}...")
# Determine which SEO plugin meta key to use
# Try RankMath first, then Yoast
meta_fields = {
'rank_math_description': meta_description
}
try:
# First, get current post meta to preserve other fields
response = requests.get(
f"{self.base_url}/wp-json/wp/v2/posts/{post_id}",
auth=self.auth,
timeout=10
)
if response.status_code != 200:
logger.error(f" Could not fetch post {post_id}")
return False
current_post = response.json()
current_meta = current_post.get('meta', {})
# Update with new meta description
updated_meta = {**current_meta, **meta_fields}
# Update post
update_response = requests.post(
f"{self.base_url}/wp-json/wp/v2/posts/{post_id}",
json={'meta': updated_meta},
auth=self.auth,
timeout=10
)
if update_response.status_code == 200:
logger.info(f" ✓ Updated post {post_id}")
return True
else:
logger.error(f" ✗ Failed to update post {post_id}: {update_response.status_code}")
logger.error(f" Response: {update_response.text}")
return False
except Exception as e:
logger.error(f" ✗ Error updating post {post_id}: {e}")
return False
def _validate_meta_description(self, meta: str) -> Dict:
"""Validate meta description quality."""
length = len(meta)
validation = {
'length': length,
'is_optimal': 120 <= length <= 160,
'too_short': length < 120,
'too_long': length > 160,
'score': 0
}
if validation['is_optimal']:
validation['score'] = 100
elif validation['too_short']:
validation['score'] = max(0, 50 - (120 - length))
else:
validation['score'] = max(0, 50 - (length - 160))
# Bonus for ending with period
if meta.endswith('.'):
validation['score'] = min(100, validation['score'] + 5)
# Bonus for CTA words
cta_words = ['learn', 'discover', 'find', 'explore', 'read', 'get', 'see', 'try', 'start']
if any(word in meta.lower() for word in cta_words):
validation['score'] = min(100, validation['score'] + 5)
return validation
def update_posts(self, dry_run: bool = False,
skip_existing: bool = False,
force_regenerate: bool = False) -> Dict:
"""
Generate and update meta descriptions for fetched posts.
Args:
dry_run: If True, preview changes without updating
skip_existing: If True, skip posts that already have meta descriptions
force_regenerate: If True, regenerate even for posts with good meta descriptions
Returns:
Statistics dict
"""
logger.info("\n" + "="*70)
logger.info("META DESCRIPTION UPDATE")
logger.info("="*70)
logger.info(f"Site: {self.site_name}")
logger.info(f"Posts to process: {len(self.posts)}")
logger.info(f"Dry run: {dry_run}")
logger.info(f"Skip existing: {skip_existing}")
logger.info(f"Force regenerate: {force_regenerate}")
logger.info("="*70)
self.stats['total_posts'] = len(self.posts)
for i, post in enumerate(self.posts, 1):
post_id = post.get('id')
title = post.get('title', {}).get('rendered', '')[:50]
logger.info(f"\n[{i}/{len(self.posts)}] Processing post {post_id}: {title}...")
# Check current meta description
meta_dict = post.get('meta', {})
current_meta = (
meta_dict.get('rank_math_description', '') or
meta_dict.get('_yoast_wpseo_metadesc', '') or
''
)
# Skip if has existing meta and skip_existing is True
if current_meta and skip_existing and not force_regenerate:
logger.info(f" Skipping: Already has meta description")
self.stats['skipped'] += 1
continue
# Validate existing meta (if any)
if current_meta and not force_regenerate:
validation = self._validate_meta_description(current_meta)
if validation['score'] >= 80:
logger.info(f" Skipping: Existing meta is good quality (score: {validation['score']})")
self.stats['skipped'] += 1
continue
# Generate new meta description
logger.info(f" Generating meta description...")
generated_meta = self._generate_meta_description(post)
if not generated_meta:
logger.error(f" ✗ Failed to generate meta description")
self.stats['failed'] += 1
continue
# Validate generated meta
validation = self._validate_meta_description(generated_meta)
logger.info(f" Generated: {generated_meta[:80]}...")
logger.info(f" Length: {validation['length']} chars, Score: {validation['score']}")
# Update post
if dry_run:
logger.info(f" [DRY RUN] Would update post {post_id}")
self.update_results.append({
'post_id': post_id,
'title': title,
'current_meta': current_meta,
'generated_meta': generated_meta,
'status': 'dry_run',
'validation_score': validation['score']
})
else:
success = self._update_post_meta(post_id, generated_meta)
if success:
logger.info(f" ✓ Successfully updated post {post_id}")
self.stats['updated'] += 1
self.update_results.append({
'post_id': post_id,
'title': title,
'current_meta': current_meta,
'generated_meta': generated_meta,
'status': 'updated',
'validation_score': validation['score']
})
else:
self.stats['failed'] += 1
self.update_results.append({
'post_id': post_id,
'title': title,
'status': 'failed',
'validation_score': validation['score']
})
# Rate limiting
time.sleep(0.5)
# Save results
self._save_results()
# Print summary
logger.info("\n" + "="*70)
logger.info("UPDATE SUMMARY")
logger.info("="*70)
logger.info(f"Total posts: {self.stats['total_posts']}")
logger.info(f"Updated: {self.stats['updated']}")
logger.info(f"Failed: {self.stats['failed']}")
logger.info(f"Skipped: {self.stats['skipped']}")
logger.info(f"API calls: {self.api_calls}")
logger.info("="*70)
return self.stats
def _save_results(self):
"""Save update results to CSV."""
if not self.update_results:
return
output_dir = Path(__file__).parent.parent.parent / 'output'
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
output_file = output_dir / f'meta_update_{self.site_name}_{timestamp}.csv'
fieldnames = [
'post_id', 'title', 'current_meta', 'generated_meta',
'status', 'validation_score'
]
with open(output_file, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(self.update_results)
logger.info(f"\n✓ Results saved to: {output_file}")
def run(self, post_ids: Optional[List[int]] = None,
category_ids: Optional[List[int]] = None,
category_names: Optional[List[str]] = None,
limit: Optional[int] = None,
dry_run: bool = False,
skip_existing: bool = False,
force_regenerate: bool = False) -> Dict:
"""
Run complete meta description update process.
Args:
post_ids: Specific post IDs to update
category_ids: Filter by category IDs
category_names: Filter by category names
limit: Maximum number of posts to process
dry_run: If True, preview changes without updating
skip_existing: If True, skip posts with existing meta descriptions
force_regenerate: If True, regenerate even for good quality metas
Returns:
Statistics dict
"""
# Fetch posts
self.fetch_posts(
post_ids=post_ids,
category_ids=category_ids,
category_names=category_names,
limit=limit
)
if not self.posts:
logger.warning("No posts found matching criteria")
return self.stats
# Update posts
return self.update_posts(
dry_run=dry_run,
skip_existing=skip_existing,
force_regenerate=force_regenerate
)