Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:32:43 +08:00
commit e959028259
8 changed files with 2925 additions and 0 deletions

View 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}")