Initial commit
This commit is contained in:
407
skills/google-ads-scripts/scripts/validators.py
Normal file
407
skills/google-ads-scripts/scripts/validators.py
Normal file
@@ -0,0 +1,407 @@
|
||||
#!/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}")
|
||||
Reference in New Issue
Block a user