582 lines
20 KiB
Python
582 lines
20 KiB
Python
"""
|
|
Metadata optimization module for App Store Optimization.
|
|
Optimizes titles, descriptions, and keyword fields with platform-specific character limit validation.
|
|
"""
|
|
|
|
from typing import Dict, List, Any, Optional, Tuple
|
|
import re
|
|
|
|
|
|
class MetadataOptimizer:
|
|
"""Optimizes app store metadata for maximum discoverability and conversion."""
|
|
|
|
# Platform-specific character limits
|
|
CHAR_LIMITS = {
|
|
'apple': {
|
|
'title': 30,
|
|
'subtitle': 30,
|
|
'promotional_text': 170,
|
|
'description': 4000,
|
|
'keywords': 100,
|
|
'whats_new': 4000
|
|
},
|
|
'google': {
|
|
'title': 50,
|
|
'short_description': 80,
|
|
'full_description': 4000
|
|
}
|
|
}
|
|
|
|
def __init__(self, platform: str = 'apple'):
|
|
"""
|
|
Initialize metadata optimizer.
|
|
|
|
Args:
|
|
platform: 'apple' or 'google'
|
|
"""
|
|
if platform not in ['apple', 'google']:
|
|
raise ValueError("Platform must be 'apple' or 'google'")
|
|
|
|
self.platform = platform
|
|
self.limits = self.CHAR_LIMITS[platform]
|
|
|
|
def optimize_title(
|
|
self,
|
|
app_name: str,
|
|
target_keywords: List[str],
|
|
include_brand: bool = True
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Optimize app title with keyword integration.
|
|
|
|
Args:
|
|
app_name: Your app's brand name
|
|
target_keywords: List of keywords to potentially include
|
|
include_brand: Whether to include brand name
|
|
|
|
Returns:
|
|
Optimized title options with analysis
|
|
"""
|
|
max_length = self.limits['title']
|
|
|
|
title_options = []
|
|
|
|
# Option 1: Brand name only
|
|
if include_brand:
|
|
option1 = app_name[:max_length]
|
|
title_options.append({
|
|
'title': option1,
|
|
'length': len(option1),
|
|
'remaining_chars': max_length - len(option1),
|
|
'keywords_included': [],
|
|
'strategy': 'brand_only',
|
|
'pros': ['Maximum brand recognition', 'Clean and simple'],
|
|
'cons': ['No keyword targeting', 'Lower discoverability']
|
|
})
|
|
|
|
# Option 2: Brand + Primary Keyword
|
|
if target_keywords:
|
|
primary_keyword = target_keywords[0]
|
|
option2 = self._build_title_with_keywords(
|
|
app_name,
|
|
[primary_keyword],
|
|
max_length
|
|
)
|
|
if option2:
|
|
title_options.append({
|
|
'title': option2,
|
|
'length': len(option2),
|
|
'remaining_chars': max_length - len(option2),
|
|
'keywords_included': [primary_keyword],
|
|
'strategy': 'brand_plus_primary',
|
|
'pros': ['Targets main keyword', 'Maintains brand identity'],
|
|
'cons': ['Limited keyword coverage']
|
|
})
|
|
|
|
# Option 3: Brand + Multiple Keywords (if space allows)
|
|
if len(target_keywords) > 1:
|
|
option3 = self._build_title_with_keywords(
|
|
app_name,
|
|
target_keywords[:2],
|
|
max_length
|
|
)
|
|
if option3:
|
|
title_options.append({
|
|
'title': option3,
|
|
'length': len(option3),
|
|
'remaining_chars': max_length - len(option3),
|
|
'keywords_included': target_keywords[:2],
|
|
'strategy': 'brand_plus_multiple',
|
|
'pros': ['Multiple keyword targets', 'Better discoverability'],
|
|
'cons': ['May feel cluttered', 'Less brand focus']
|
|
})
|
|
|
|
# Option 4: Keyword-first approach (for new apps)
|
|
if target_keywords and not include_brand:
|
|
option4 = " ".join(target_keywords[:2])[:max_length]
|
|
title_options.append({
|
|
'title': option4,
|
|
'length': len(option4),
|
|
'remaining_chars': max_length - len(option4),
|
|
'keywords_included': target_keywords[:2],
|
|
'strategy': 'keyword_first',
|
|
'pros': ['Maximum SEO benefit', 'Clear functionality'],
|
|
'cons': ['No brand recognition', 'Generic appearance']
|
|
})
|
|
|
|
return {
|
|
'platform': self.platform,
|
|
'max_length': max_length,
|
|
'options': title_options,
|
|
'recommendation': self._recommend_title_option(title_options)
|
|
}
|
|
|
|
def optimize_description(
|
|
self,
|
|
app_info: Dict[str, Any],
|
|
target_keywords: List[str],
|
|
description_type: str = 'full'
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Optimize app description with keyword integration and conversion focus.
|
|
|
|
Args:
|
|
app_info: Dict with 'name', 'key_features', 'unique_value', 'target_audience'
|
|
target_keywords: List of keywords to integrate naturally
|
|
description_type: 'full', 'short' (Google), 'subtitle' (Apple)
|
|
|
|
Returns:
|
|
Optimized description with analysis
|
|
"""
|
|
if description_type == 'short' and self.platform == 'google':
|
|
return self._optimize_short_description(app_info, target_keywords)
|
|
elif description_type == 'subtitle' and self.platform == 'apple':
|
|
return self._optimize_subtitle(app_info, target_keywords)
|
|
else:
|
|
return self._optimize_full_description(app_info, target_keywords)
|
|
|
|
def optimize_keyword_field(
|
|
self,
|
|
target_keywords: List[str],
|
|
app_title: str = "",
|
|
app_description: str = ""
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Optimize Apple's 100-character keyword field.
|
|
|
|
Rules:
|
|
- No spaces between commas
|
|
- No plural forms if singular exists
|
|
- No duplicates
|
|
- Keywords in title/subtitle are already indexed
|
|
|
|
Args:
|
|
target_keywords: List of target keywords
|
|
app_title: Current app title (to avoid duplication)
|
|
app_description: Current description (to check coverage)
|
|
|
|
Returns:
|
|
Optimized keyword field (comma-separated, no spaces)
|
|
"""
|
|
if self.platform != 'apple':
|
|
return {'error': 'Keyword field optimization only applies to Apple App Store'}
|
|
|
|
max_length = self.limits['keywords']
|
|
|
|
# Extract words already in title (these don't need to be in keyword field)
|
|
title_words = set(app_title.lower().split()) if app_title else set()
|
|
|
|
# Process keywords
|
|
processed_keywords = []
|
|
for keyword in target_keywords:
|
|
keyword_lower = keyword.lower().strip()
|
|
|
|
# Skip if already in title
|
|
if keyword_lower in title_words:
|
|
continue
|
|
|
|
# Remove duplicates and process
|
|
words = keyword_lower.split()
|
|
for word in words:
|
|
if word not in processed_keywords and word not in title_words:
|
|
processed_keywords.append(word)
|
|
|
|
# Remove plurals if singular exists
|
|
deduplicated = self._remove_plural_duplicates(processed_keywords)
|
|
|
|
# Build keyword field within 100 character limit
|
|
keyword_field = self._build_keyword_field(deduplicated, max_length)
|
|
|
|
# Calculate keyword density in description
|
|
density = self._calculate_coverage(target_keywords, app_description)
|
|
|
|
return {
|
|
'keyword_field': keyword_field,
|
|
'length': len(keyword_field),
|
|
'remaining_chars': max_length - len(keyword_field),
|
|
'keywords_included': keyword_field.split(','),
|
|
'keywords_count': len(keyword_field.split(',')),
|
|
'keywords_excluded': [kw for kw in target_keywords if kw.lower() not in keyword_field],
|
|
'description_coverage': density,
|
|
'optimization_tips': [
|
|
'Keywords in title are auto-indexed - no need to repeat',
|
|
'Use singular forms only (Apple indexes plurals automatically)',
|
|
'No spaces between commas to maximize character usage',
|
|
'Update keyword field with each app update to test variations'
|
|
]
|
|
}
|
|
|
|
def validate_character_limits(
|
|
self,
|
|
metadata: Dict[str, str]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Validate all metadata fields against platform character limits.
|
|
|
|
Args:
|
|
metadata: Dictionary of field_name: value
|
|
|
|
Returns:
|
|
Validation report with errors and warnings
|
|
"""
|
|
validation_results = {
|
|
'is_valid': True,
|
|
'errors': [],
|
|
'warnings': [],
|
|
'field_status': {}
|
|
}
|
|
|
|
for field_name, value in metadata.items():
|
|
if field_name not in self.limits:
|
|
validation_results['warnings'].append(
|
|
f"Unknown field '{field_name}' for {self.platform} platform"
|
|
)
|
|
continue
|
|
|
|
max_length = self.limits[field_name]
|
|
actual_length = len(value)
|
|
remaining = max_length - actual_length
|
|
|
|
field_status = {
|
|
'value': value,
|
|
'length': actual_length,
|
|
'limit': max_length,
|
|
'remaining': remaining,
|
|
'is_valid': actual_length <= max_length,
|
|
'usage_percentage': round((actual_length / max_length) * 100, 1)
|
|
}
|
|
|
|
validation_results['field_status'][field_name] = field_status
|
|
|
|
if actual_length > max_length:
|
|
validation_results['is_valid'] = False
|
|
validation_results['errors'].append(
|
|
f"'{field_name}' exceeds limit: {actual_length}/{max_length} chars"
|
|
)
|
|
elif remaining > max_length * 0.2: # More than 20% unused
|
|
validation_results['warnings'].append(
|
|
f"'{field_name}' under-utilizes space: {remaining} chars remaining"
|
|
)
|
|
|
|
return validation_results
|
|
|
|
def calculate_keyword_density(
|
|
self,
|
|
text: str,
|
|
target_keywords: List[str]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Calculate keyword density in text.
|
|
|
|
Args:
|
|
text: Text to analyze
|
|
target_keywords: Keywords to check
|
|
|
|
Returns:
|
|
Density analysis
|
|
"""
|
|
text_lower = text.lower()
|
|
total_words = len(text_lower.split())
|
|
|
|
keyword_densities = {}
|
|
for keyword in target_keywords:
|
|
keyword_lower = keyword.lower()
|
|
count = text_lower.count(keyword_lower)
|
|
density = (count / total_words * 100) if total_words > 0 else 0
|
|
|
|
keyword_densities[keyword] = {
|
|
'occurrences': count,
|
|
'density_percentage': round(density, 2),
|
|
'status': self._assess_density(density)
|
|
}
|
|
|
|
# Overall assessment
|
|
total_keyword_occurrences = sum(kw['occurrences'] for kw in keyword_densities.values())
|
|
overall_density = (total_keyword_occurrences / total_words * 100) if total_words > 0 else 0
|
|
|
|
return {
|
|
'total_words': total_words,
|
|
'keyword_densities': keyword_densities,
|
|
'overall_keyword_density': round(overall_density, 2),
|
|
'assessment': self._assess_overall_density(overall_density),
|
|
'recommendations': self._generate_density_recommendations(keyword_densities)
|
|
}
|
|
|
|
def _build_title_with_keywords(
|
|
self,
|
|
app_name: str,
|
|
keywords: List[str],
|
|
max_length: int
|
|
) -> Optional[str]:
|
|
"""Build title combining app name and keywords within limit."""
|
|
separators = [' - ', ': ', ' | ']
|
|
|
|
for sep in separators:
|
|
for kw in keywords:
|
|
title = f"{app_name}{sep}{kw}"
|
|
if len(title) <= max_length:
|
|
return title
|
|
|
|
return None
|
|
|
|
def _optimize_short_description(
|
|
self,
|
|
app_info: Dict[str, Any],
|
|
target_keywords: List[str]
|
|
) -> Dict[str, Any]:
|
|
"""Optimize Google Play short description (80 chars)."""
|
|
max_length = self.limits['short_description']
|
|
|
|
# Focus on unique value proposition with primary keyword
|
|
unique_value = app_info.get('unique_value', '')
|
|
primary_keyword = target_keywords[0] if target_keywords else ''
|
|
|
|
# Template: [Primary Keyword] - [Unique Value]
|
|
short_desc = f"{primary_keyword.title()} - {unique_value}"[:max_length]
|
|
|
|
return {
|
|
'short_description': short_desc,
|
|
'length': len(short_desc),
|
|
'remaining_chars': max_length - len(short_desc),
|
|
'keywords_included': [primary_keyword] if primary_keyword in short_desc.lower() else [],
|
|
'strategy': 'keyword_value_proposition'
|
|
}
|
|
|
|
def _optimize_subtitle(
|
|
self,
|
|
app_info: Dict[str, Any],
|
|
target_keywords: List[str]
|
|
) -> Dict[str, Any]:
|
|
"""Optimize Apple App Store subtitle (30 chars)."""
|
|
max_length = self.limits['subtitle']
|
|
|
|
# Very concise - primary keyword or key feature
|
|
primary_keyword = target_keywords[0] if target_keywords else ''
|
|
key_feature = app_info.get('key_features', [''])[0] if app_info.get('key_features') else ''
|
|
|
|
options = [
|
|
primary_keyword[:max_length],
|
|
key_feature[:max_length],
|
|
f"{primary_keyword} App"[:max_length]
|
|
]
|
|
|
|
return {
|
|
'subtitle_options': [opt for opt in options if opt],
|
|
'max_length': max_length,
|
|
'recommendation': options[0] if options else ''
|
|
}
|
|
|
|
def _optimize_full_description(
|
|
self,
|
|
app_info: Dict[str, Any],
|
|
target_keywords: List[str]
|
|
) -> Dict[str, Any]:
|
|
"""Optimize full app description (4000 chars for both platforms)."""
|
|
max_length = self.limits.get('description', self.limits.get('full_description', 4000))
|
|
|
|
# Structure: Hook → Features → Benefits → Social Proof → CTA
|
|
sections = []
|
|
|
|
# Hook (with primary keyword)
|
|
primary_keyword = target_keywords[0] if target_keywords else ''
|
|
unique_value = app_info.get('unique_value', '')
|
|
hook = f"{unique_value} {primary_keyword.title()} that helps you achieve more.\n\n"
|
|
sections.append(hook)
|
|
|
|
# Features (with keywords naturally integrated)
|
|
features = app_info.get('key_features', [])
|
|
if features:
|
|
sections.append("KEY FEATURES:\n")
|
|
for i, feature in enumerate(features[:5], 1):
|
|
# Integrate keywords naturally
|
|
feature_text = f"• {feature}"
|
|
if i <= len(target_keywords):
|
|
keyword = target_keywords[i-1]
|
|
if keyword.lower() not in feature.lower():
|
|
feature_text = f"• {feature} with {keyword}"
|
|
sections.append(f"{feature_text}\n")
|
|
sections.append("\n")
|
|
|
|
# Benefits
|
|
target_audience = app_info.get('target_audience', 'users')
|
|
sections.append(f"PERFECT FOR:\n{target_audience}\n\n")
|
|
|
|
# Social proof placeholder
|
|
sections.append("WHY USERS LOVE US:\n")
|
|
sections.append("Join thousands of satisfied users who have transformed their workflow.\n\n")
|
|
|
|
# CTA
|
|
sections.append("Download now and start experiencing the difference!")
|
|
|
|
# Combine and validate length
|
|
full_description = "".join(sections)
|
|
if len(full_description) > max_length:
|
|
full_description = full_description[:max_length-3] + "..."
|
|
|
|
# Calculate keyword density
|
|
density = self.calculate_keyword_density(full_description, target_keywords)
|
|
|
|
return {
|
|
'full_description': full_description,
|
|
'length': len(full_description),
|
|
'remaining_chars': max_length - len(full_description),
|
|
'keyword_analysis': density,
|
|
'structure': {
|
|
'has_hook': True,
|
|
'has_features': len(features) > 0,
|
|
'has_benefits': True,
|
|
'has_cta': True
|
|
}
|
|
}
|
|
|
|
def _remove_plural_duplicates(self, keywords: List[str]) -> List[str]:
|
|
"""Remove plural forms if singular exists."""
|
|
deduplicated = []
|
|
singular_set = set()
|
|
|
|
for keyword in keywords:
|
|
if keyword.endswith('s') and len(keyword) > 1:
|
|
singular = keyword[:-1]
|
|
if singular not in singular_set:
|
|
deduplicated.append(singular)
|
|
singular_set.add(singular)
|
|
else:
|
|
if keyword not in singular_set:
|
|
deduplicated.append(keyword)
|
|
singular_set.add(keyword)
|
|
|
|
return deduplicated
|
|
|
|
def _build_keyword_field(self, keywords: List[str], max_length: int) -> str:
|
|
"""Build comma-separated keyword field within character limit."""
|
|
keyword_field = ""
|
|
|
|
for keyword in keywords:
|
|
test_field = f"{keyword_field},{keyword}" if keyword_field else keyword
|
|
if len(test_field) <= max_length:
|
|
keyword_field = test_field
|
|
else:
|
|
break
|
|
|
|
return keyword_field
|
|
|
|
def _calculate_coverage(self, keywords: List[str], text: str) -> Dict[str, int]:
|
|
"""Calculate how many keywords are covered in text."""
|
|
text_lower = text.lower()
|
|
coverage = {}
|
|
|
|
for keyword in keywords:
|
|
coverage[keyword] = text_lower.count(keyword.lower())
|
|
|
|
return coverage
|
|
|
|
def _assess_density(self, density: float) -> str:
|
|
"""Assess individual keyword density."""
|
|
if density < 0.5:
|
|
return "too_low"
|
|
elif density <= 2.5:
|
|
return "optimal"
|
|
else:
|
|
return "too_high"
|
|
|
|
def _assess_overall_density(self, density: float) -> str:
|
|
"""Assess overall keyword density."""
|
|
if density < 2:
|
|
return "Under-optimized: Consider adding more keyword variations"
|
|
elif density <= 5:
|
|
return "Optimal: Good keyword integration without stuffing"
|
|
elif density <= 8:
|
|
return "High: Approaching keyword stuffing - reduce keyword usage"
|
|
else:
|
|
return "Too High: Keyword stuffing detected - rewrite for natural flow"
|
|
|
|
def _generate_density_recommendations(
|
|
self,
|
|
keyword_densities: Dict[str, Dict[str, Any]]
|
|
) -> List[str]:
|
|
"""Generate recommendations based on keyword density analysis."""
|
|
recommendations = []
|
|
|
|
for keyword, data in keyword_densities.items():
|
|
if data['status'] == 'too_low':
|
|
recommendations.append(
|
|
f"Increase usage of '{keyword}' - currently only {data['occurrences']} times"
|
|
)
|
|
elif data['status'] == 'too_high':
|
|
recommendations.append(
|
|
f"Reduce usage of '{keyword}' - appears {data['occurrences']} times (keyword stuffing risk)"
|
|
)
|
|
|
|
if not recommendations:
|
|
recommendations.append("Keyword density is well-balanced")
|
|
|
|
return recommendations
|
|
|
|
def _recommend_title_option(self, options: List[Dict[str, Any]]) -> str:
|
|
"""Recommend best title option based on strategy."""
|
|
if not options:
|
|
return "No valid options available"
|
|
|
|
# Prefer brand_plus_primary for established apps
|
|
for option in options:
|
|
if option['strategy'] == 'brand_plus_primary':
|
|
return f"Recommended: '{option['title']}' (Balance of brand and SEO)"
|
|
|
|
# Fallback to first option
|
|
return f"Recommended: '{options[0]['title']}' ({options[0]['strategy']})"
|
|
|
|
|
|
def optimize_app_metadata(
|
|
platform: str,
|
|
app_info: Dict[str, Any],
|
|
target_keywords: List[str]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Convenience function to optimize all metadata fields.
|
|
|
|
Args:
|
|
platform: 'apple' or 'google'
|
|
app_info: App information dictionary
|
|
target_keywords: Target keywords list
|
|
|
|
Returns:
|
|
Complete metadata optimization package
|
|
"""
|
|
optimizer = MetadataOptimizer(platform)
|
|
|
|
return {
|
|
'platform': platform,
|
|
'title': optimizer.optimize_title(
|
|
app_info['name'],
|
|
target_keywords
|
|
),
|
|
'description': optimizer.optimize_description(
|
|
app_info,
|
|
target_keywords,
|
|
'full'
|
|
),
|
|
'keyword_field': optimizer.optimize_keyword_field(
|
|
target_keywords
|
|
) if platform == 'apple' else None
|
|
}
|