import json import math import os import sys import time REQUIRED_FIELDS = [ "campaign", "spend", "impressions", "clicks", "conversions", "revenue", ] def is_number(value): return ( isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) ) def load_json(file_path): try: with open(file_path, "r", encoding="utf-8") as file: return json.load(file) except FileNotFoundError: raise ValueError(f"입력 파일을 찾을 수 없습니다: {file_path}") except json.JSONDecodeError as error: raise ValueError( f"JSON 형식이 올바르지 않습니다: {error.msg} (line {error.lineno})" ) except OSError as error: raise ValueError(f"입력 파일을 읽을 수 없습니다: {error}") def validate_goals(goals): errors = [] if not isinstance(goals, dict): return ["goals는 딕셔너리여야 합니다."] target_roas = goals.get("target_roas") target_cpa = goals.get("target_cpa") if not is_number(target_roas) or target_roas < 0: errors.append("target_roas는 0 이상의 숫자여야 합니다.") if not is_number(target_cpa) or target_cpa <= 0: errors.append("target_cpa는 0보다 큰 숫자여야 합니다.") return errors def validate_campaign(campaign, seen_names): errors = [] if not isinstance(campaign, dict): return ["캠페인 데이터는 딕셔너리여야 합니다."] missing_fields = [ field for field in REQUIRED_FIELDS if campaign.get(field) is None ] if missing_fields: errors.append(f"필수 항목 누락: {', '.join(missing_fields)}") name = campaign.get("campaign") if not isinstance(name, str) or not name.strip(): errors.append("campaign은 비어 있지 않은 문자열이어야 합니다.") else: normalized_name = name.strip().lower() if normalized_name in seen_names: errors.append(f"중복 캠페인명: {name.strip()}") else: seen_names.add(normalized_name) for field in ["spend", "revenue"]: value = campaign.get(field) if not is_number(value) or value < 0: errors.append(f"{field}는 0 이상의 숫자여야 합니다.") for field in ["impressions", "clicks", "conversions"]: value = campaign.get(field) if not isinstance(value, int) or isinstance(value, bool) or value < 0: errors.append(f"{field}는 0 이상의 정수여야 합니다.") impressions = campaign.get("impressions") clicks = campaign.get("clicks") conversions = campaign.get("conversions") if ( isinstance(impressions, int) and not isinstance(impressions, bool) and isinstance(clicks, int) and not isinstance(clicks, bool) and clicks > impressions ): errors.append("clicks는 impressions보다 클 수 없습니다.") if ( isinstance(clicks, int) and not isinstance(clicks, bool) and isinstance(conversions, int) and not isinstance(conversions, bool) and conversions > clicks ): errors.append("conversions는 clicks보다 클 수 없습니다.") return errors def divide_or_none(numerator, denominator, multiplier=1): if denominator == 0: return None return numerator / denominator * multiplier def calculate_metrics(campaign): return { "ctr": divide_or_none( campaign["clicks"], campaign["impressions"], 100 ), "cvr": divide_or_none( campaign["conversions"], campaign["clicks"], 100 ), "cpc": divide_or_none(campaign["spend"], campaign["clicks"]), "cpa": divide_or_none(campaign["spend"], campaign["conversions"]), "roas": divide_or_none(campaign["revenue"], campaign["spend"], 100), } def evaluate_performance(metrics, goals): if metrics["roas"] is None or metrics["cpa"] is None: return "판정 보류" checks = [ metrics["roas"] >= goals["target_roas"], metrics["cpa"] <= goals["target_cpa"], ] if all(checks): return "목표 달성" if any(checks): return "부분 달성" return "개선 필요" def round_metrics(metrics): rounded = {} for name, value in metrics.items(): rounded[name] = None if value is None else round(value, 2) return rounded def diagnose_campaigns(campaigns, goals): results = [] valid_campaigns = [] seen_names = set() for number, campaign in enumerate(campaigns, start=1): errors = validate_campaign(campaign, seen_names) campaign_name = campaign.get("campaign") if isinstance(campaign, dict) else None if errors: results.append( { "number": number, "campaign": campaign_name or f"캠페인 {number}", "status": "입력 오류", "errors": errors, } ) continue metrics = calculate_metrics(campaign) results.append( { "number": number, "campaign": campaign["campaign"].strip(), "status": evaluate_performance(metrics, goals), "metrics": round_metrics(metrics), } ) valid_campaigns.append(campaign) return results, valid_campaigns def calculate_summary(campaigns, goals): if not campaigns: return None totals = { "spend": sum(campaign["spend"] for campaign in campaigns), "impressions": sum(campaign["impressions"] for campaign in campaigns), "clicks": sum(campaign["clicks"] for campaign in campaigns), "conversions": sum(campaign["conversions"] for campaign in campaigns), "revenue": sum(campaign["revenue"] for campaign in campaigns), } metrics = calculate_metrics(totals) return { "valid_campaigns": len(campaigns), "totals": totals, "metrics": round_metrics(metrics), "status": evaluate_performance(metrics, goals), } def format_percent(value): return "계산 불가" if value is None else f"{value:,.2f}%" def format_currency(value): return "계산 불가" if value is None else f"{value:,.0f}원" def print_report(results, summary, goals): print("\n광고 캠페인 성과 진단기") print("=" * 32) print(f"목표 ROAS: {goals['target_roas']:,.2f}%") print(f"목표 CPA: {goals['target_cpa']:,.0f}원") for result in results: print(f"\n[{result['status']}] {result['campaign']}") if result["status"] == "입력 오류": for error in result["errors"]: print(f"- {error}") continue metrics = result["metrics"] print(f"CTR: {format_percent(metrics['ctr'])}") print(f"CVR: {format_percent(metrics['cvr'])}") print(f"CPC: {format_currency(metrics['cpc'])}") print(f"CPA: {format_currency(metrics['cpa'])}") print(f"ROAS: {format_percent(metrics['roas'])}") print("\n전체 요약") print("-" * 32) if summary is None: print("정상적으로 계산할 수 있는 캠페인이 없습니다.") return totals = summary["totals"] metrics = summary["metrics"] print(f"정상 캠페인: {summary['valid_campaigns']}개") print(f"총 광고비: {format_currency(totals['spend'])}") print(f"총 매출: {format_currency(totals['revenue'])}") print(f"통합 CPA: {format_currency(metrics['cpa'])}") print(f"통합 ROAS: {format_percent(metrics['roas'])}") print(f"종합 판정: {summary['status']}") def save_report(file_path, goals, results, summary): report = { "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"), "goals": goals, "results": results, "summary": summary, } try: with open(file_path, "x", encoding="utf-8") as file: json.dump(report, file, ensure_ascii=False, indent=2) except FileExistsError: raise ValueError( f"결과 파일이 이미 존재합니다: {file_path} " "(다른 결과 파일명을 지정해 주세요.)" ) except OSError as error: raise ValueError(f"결과 파일을 저장할 수 없습니다: {error}") def make_default_output_path(input_path): directory = os.path.dirname(input_path) file_name = os.path.splitext(os.path.basename(input_path))[0] return os.path.join(directory, f"{file_name}_report.json") def main(): if len(sys.argv) < 2 or len(sys.argv) > 3: print( "사용법: python campaign_diagnostic.py " "입력파일.json [결과파일.json]" ) return 1 input_path = sys.argv[1] output_path = ( sys.argv[2] if len(sys.argv) == 3 else make_default_output_path(input_path) ) try: data = load_json(input_path) if not isinstance(data, dict): raise ValueError("JSON 최상위 데이터는 딕셔너리여야 합니다.") goals = data.get("goals") goal_errors = validate_goals(goals) if goal_errors: raise ValueError(" ".join(goal_errors)) campaigns = data.get("campaigns") if not isinstance(campaigns, list) or not campaigns: raise ValueError("campaigns는 한 개 이상의 캠페인을 담은 리스트여야 합니다.") results, valid_campaigns = diagnose_campaigns(campaigns, goals) summary = calculate_summary(valid_campaigns, goals) print_report(results, summary, goals) save_report(output_path, goals, results, summary) print(f"\n진단 결과 저장 완료: {output_path}") return 0 except ValueError as error: print(f"오류: {error}") return 1 if __name__ == "__main__": sys.exit(main())