408 lines
11 KiB
Python
408 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Google Ads Script Validators
|
|
|
|
Validation utilities for Google Ads campaigns, bids, budgets, and keywords.
|
|
Use these validators before applying changes to ensure data integrity.
|
|
|
|
Based on Google Ads API specifications and best practices.
|
|
"""
|
|
|
|
from typing import Dict, List, Tuple, Optional, Any
|
|
from dataclasses import dataclass
|
|
import re
|
|
|
|
|
|
@dataclass
|
|
class ValidationResult:
|
|
"""Result of a validation check"""
|
|
is_valid: bool
|
|
errors: List[str]
|
|
warnings: List[str] = None
|
|
|
|
def __post_init__(self):
|
|
if self.warnings is None:
|
|
self.warnings = []
|
|
|
|
|
|
class GoogleAdsValidators:
|
|
"""Comprehensive validation for Google Ads Script operations"""
|
|
|
|
# Constants
|
|
MIN_BUDGET = 0.01 # Minimum $0.01
|
|
MAX_BUDGET = 999999.99 # Practical maximum
|
|
MIN_BID = 0.01 # Minimum $0.01 in currency
|
|
MIN_BID_MICROS = 10000 # Minimum 0.01 in micros
|
|
MICROS_MULTIPLIER = 1000000
|
|
|
|
@staticmethod
|
|
def is_campaign_name_valid(name: str) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate campaign name.
|
|
|
|
Rules:
|
|
- Must be string
|
|
- Length 1-255 characters
|
|
|
|
Returns:
|
|
(is_valid, error_message)
|
|
"""
|
|
if not name or not isinstance(name, str):
|
|
return False, "Campaign name must be a non-empty string"
|
|
|
|
if len(name) < 1 or len(name) > 255:
|
|
return False, f"Campaign name must be 1-255 characters (got {len(name)})"
|
|
|
|
return True, None
|
|
|
|
@classmethod
|
|
def is_budget_valid(cls, amount: float) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate budget amount in local currency.
|
|
|
|
Args:
|
|
amount: Budget in local currency (e.g., 50.00 for $50)
|
|
|
|
Returns:
|
|
(is_valid, error_message)
|
|
"""
|
|
if not isinstance(amount, (int, float)):
|
|
return False, "Budget must be a number"
|
|
|
|
if amount < cls.MIN_BUDGET:
|
|
return False, f"Budget must be at least {cls.MIN_BUDGET}"
|
|
|
|
if amount > cls.MAX_BUDGET:
|
|
return False, f"Budget exceeds maximum of {cls.MAX_BUDGET}"
|
|
|
|
return True, None
|
|
|
|
@classmethod
|
|
def is_bid_valid(cls, bid: float) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate bid amount in local currency.
|
|
|
|
Args:
|
|
bid: Bid in local currency (e.g., 1.50 for $1.50)
|
|
|
|
Returns:
|
|
(is_valid, error_message)
|
|
"""
|
|
if not isinstance(bid, (int, float)):
|
|
return False, "Bid must be a number"
|
|
|
|
if bid < cls.MIN_BID:
|
|
return False, f"Bid must be at least {cls.MIN_BID}"
|
|
|
|
return True, None
|
|
|
|
@staticmethod
|
|
def is_quality_score_valid(score: Any) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate quality score.
|
|
|
|
Args:
|
|
score: Quality score (1-10 or None)
|
|
|
|
Returns:
|
|
(is_valid, error_message)
|
|
"""
|
|
if score is None:
|
|
return True, None # Quality score can be null
|
|
|
|
if not isinstance(score, int):
|
|
return False, "Quality score must be an integer"
|
|
|
|
if score < 1 or score > 10:
|
|
return False, "Quality score must be 1-10"
|
|
|
|
return True, None
|
|
|
|
@classmethod
|
|
def is_cpc_bid_micros_valid(cls, bid_micros: int, max_bid: float = 10000) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate CPC bid in micros.
|
|
|
|
Args:
|
|
bid_micros: Bid in micros (e.g., 50000 for $0.05)
|
|
max_bid: Maximum allowed bid in currency
|
|
|
|
Returns:
|
|
(is_valid, error_message)
|
|
"""
|
|
if not isinstance(bid_micros, int):
|
|
return False, "Bid (micros) must be an integer"
|
|
|
|
if bid_micros < cls.MIN_BID_MICROS:
|
|
return False, f"Bid must be at least {cls.MIN_BID_MICROS} micros ($0.01)"
|
|
|
|
max_bid_micros = int(max_bid * cls.MICROS_MULTIPLIER)
|
|
if bid_micros > max_bid_micros:
|
|
return False, f"Bid exceeds maximum of {max_bid_micros} micros (${max_bid})"
|
|
|
|
return True, None
|
|
|
|
@staticmethod
|
|
def is_campaign_status_valid(status: str) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate campaign status.
|
|
|
|
Args:
|
|
status: Campaign status
|
|
|
|
Returns:
|
|
(is_valid, error_message)
|
|
"""
|
|
valid_statuses = ['ENABLED', 'PAUSED', 'REMOVED']
|
|
|
|
if status not in valid_statuses:
|
|
return False, f"Status must be one of {valid_statuses}"
|
|
|
|
return True, None
|
|
|
|
@staticmethod
|
|
def is_campaign_type_valid(campaign_type: str) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate campaign type.
|
|
|
|
Args:
|
|
campaign_type: Campaign type
|
|
|
|
Returns:
|
|
(is_valid, error_message)
|
|
"""
|
|
valid_types = ['SEARCH', 'DISPLAY', 'SHOPPING', 'VIDEO', 'PERFORMANCE_MAX']
|
|
|
|
if campaign_type not in valid_types:
|
|
return False, f"Campaign type must be one of {valid_types}"
|
|
|
|
return True, None
|
|
|
|
@staticmethod
|
|
def is_keyword_text_valid(text: str) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate keyword text.
|
|
|
|
Args:
|
|
text: Keyword text
|
|
|
|
Returns:
|
|
(is_valid, error_message)
|
|
"""
|
|
if not text or not isinstance(text, str):
|
|
return False, "Keyword text must be a non-empty string"
|
|
|
|
if len(text) < 1 or len(text) > 80:
|
|
return False, f"Keyword text must be 1-80 characters (got {len(text)})"
|
|
|
|
return True, None
|
|
|
|
@staticmethod
|
|
def is_match_type_valid(match_type: str) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate keyword match type.
|
|
|
|
Args:
|
|
match_type: Match type
|
|
|
|
Returns:
|
|
(is_valid, error_message)
|
|
"""
|
|
valid_types = ['BROAD', 'PHRASE', 'EXACT']
|
|
|
|
if match_type not in valid_types:
|
|
return False, f"Match type must be one of {valid_types}"
|
|
|
|
return True, None
|
|
|
|
@classmethod
|
|
def validate_campaign_update(cls, updates: Dict[str, Any]) -> ValidationResult:
|
|
"""
|
|
Validate campaign update payload.
|
|
|
|
Args:
|
|
updates: Dictionary with campaign update fields
|
|
|
|
Returns:
|
|
ValidationResult with is_valid and errors
|
|
"""
|
|
errors = []
|
|
warnings = []
|
|
|
|
# Validate name
|
|
if 'name' in updates:
|
|
is_valid, error = cls.is_campaign_name_valid(updates['name'])
|
|
if not is_valid:
|
|
errors.append(error)
|
|
|
|
# Validate budget
|
|
if 'budget' in updates:
|
|
is_valid, error = cls.is_budget_valid(updates['budget'])
|
|
if not is_valid:
|
|
errors.append(error)
|
|
|
|
# Validate status
|
|
if 'status' in updates:
|
|
is_valid, error = cls.is_campaign_status_valid(updates['status'])
|
|
if not is_valid:
|
|
errors.append(error)
|
|
|
|
# Validate type
|
|
if 'type' in updates:
|
|
is_valid, error = cls.is_campaign_type_valid(updates['type'])
|
|
if not is_valid:
|
|
errors.append(error)
|
|
|
|
# Validate dates
|
|
if 'start_date' in updates and 'end_date' in updates:
|
|
try:
|
|
from datetime import datetime
|
|
start = datetime.fromisoformat(updates['start_date'])
|
|
end = datetime.fromisoformat(updates['end_date'])
|
|
if end <= start:
|
|
errors.append("End date must be after start date")
|
|
except ValueError:
|
|
errors.append("Invalid date format (use YYYY-MM-DD)")
|
|
|
|
return ValidationResult(
|
|
is_valid=len(errors) == 0,
|
|
errors=errors,
|
|
warnings=warnings
|
|
)
|
|
|
|
@classmethod
|
|
def validate_keyword_update(cls, updates: Dict[str, Any]) -> ValidationResult:
|
|
"""
|
|
Validate keyword update payload.
|
|
|
|
Args:
|
|
updates: Dictionary with keyword update fields
|
|
|
|
Returns:
|
|
ValidationResult with is_valid and errors
|
|
"""
|
|
errors = []
|
|
warnings = []
|
|
|
|
# Validate keyword text
|
|
if 'text' in updates:
|
|
is_valid, error = cls.is_keyword_text_valid(updates['text'])
|
|
if not is_valid:
|
|
errors.append(error)
|
|
|
|
# Validate match type
|
|
if 'match_type' in updates:
|
|
is_valid, error = cls.is_match_type_valid(updates['match_type'])
|
|
if not is_valid:
|
|
errors.append(error)
|
|
|
|
# Validate bid (if in currency)
|
|
if 'max_cpc' in updates:
|
|
is_valid, error = cls.is_bid_valid(updates['max_cpc'])
|
|
if not is_valid:
|
|
errors.append(error)
|
|
|
|
# Validate bid (if in micros)
|
|
if 'max_cpc_micros' in updates:
|
|
is_valid, error = cls.is_cpc_bid_micros_valid(updates['max_cpc_micros'])
|
|
if not is_valid:
|
|
errors.append(error)
|
|
|
|
# Validate status
|
|
if 'status' in updates:
|
|
is_valid, error = cls.is_campaign_status_valid(updates['status'])
|
|
if not is_valid:
|
|
errors.append(error)
|
|
|
|
return ValidationResult(
|
|
is_valid=len(errors) == 0,
|
|
errors=errors,
|
|
warnings=warnings
|
|
)
|
|
|
|
@classmethod
|
|
def validate_quota_remaining(cls, current_cost: int, daily_limit: int, used: int) -> Dict[str, Any]:
|
|
"""
|
|
Track API quota consumption.
|
|
|
|
Args:
|
|
current_cost: Cost of current operation
|
|
daily_limit: Daily quota limit
|
|
used: Amount of quota already used
|
|
|
|
Returns:
|
|
Dictionary with quota status
|
|
"""
|
|
remaining = daily_limit - used
|
|
percent_used = (used / daily_limit) * 100 if daily_limit > 0 else 100
|
|
|
|
return {
|
|
'remaining': remaining,
|
|
'percent_used': percent_used,
|
|
'is_critical': percent_used > 90,
|
|
'should_throttle': percent_used > 85,
|
|
'can_proceed': (used + current_cost) <= daily_limit
|
|
}
|
|
|
|
|
|
def currency_to_micros(amount: float) -> int:
|
|
"""
|
|
Convert currency to micros.
|
|
|
|
Args:
|
|
amount: Amount in local currency (e.g., 5.00 for $5)
|
|
|
|
Returns:
|
|
Amount in micros (e.g., 5000000)
|
|
"""
|
|
return int(amount * GoogleAdsValidators.MICROS_MULTIPLIER)
|
|
|
|
|
|
def micros_to_currency(micros: int) -> float:
|
|
"""
|
|
Convert micros to currency.
|
|
|
|
Args:
|
|
micros: Amount in micros (e.g., 5000000)
|
|
|
|
Returns:
|
|
Amount in local currency (e.g., 5.00)
|
|
"""
|
|
return micros / GoogleAdsValidators.MICROS_MULTIPLIER
|
|
|
|
|
|
# Example usage
|
|
if __name__ == "__main__":
|
|
validators = GoogleAdsValidators()
|
|
|
|
# Test campaign validation
|
|
campaign_data = {
|
|
'name': 'Q4 Sale Campaign',
|
|
'budget': 5000,
|
|
'status': 'ENABLED',
|
|
'type': 'SEARCH'
|
|
}
|
|
|
|
result = validators.validate_campaign_update(campaign_data)
|
|
print(f"Campaign validation: {'✅ Valid' if result.is_valid else '❌ Invalid'}")
|
|
if result.errors:
|
|
print(f"Errors: {result.errors}")
|
|
|
|
# Test currency conversion
|
|
bid_currency = 1.50
|
|
bid_micros = currency_to_micros(bid_currency)
|
|
print(f"\n${bid_currency} = {bid_micros} micros")
|
|
print(f"{bid_micros} micros = ${micros_to_currency(bid_micros)}")
|
|
|
|
# Test keyword validation
|
|
keyword_data = {
|
|
'text': 'running shoes',
|
|
'match_type': 'PHRASE',
|
|
'max_cpc': 2.50
|
|
}
|
|
|
|
result = validators.validate_keyword_update(keyword_data)
|
|
print(f"\nKeyword validation: {'✅ Valid' if result.is_valid else '❌ Invalid'}")
|
|
if result.errors:
|
|
print(f"Errors: {result.errors}")
|