157 lines
5.3 KiB
Python
157 lines
5.3 KiB
Python
"""
|
|
Meta Conversions API (CAPI) 클라이언트
|
|
|
|
Meta 픽셀과 병행하여 서버 사이드 전환 이벤트를 전송합니다.
|
|
브라우저 픽셀(fbq)과 동일한 event_id를 사용하여 Meta가 중복 이벤트를
|
|
제거(deduplication)할 수 있도록 합니다.
|
|
|
|
전송 규칙 (Meta 요구사항):
|
|
- external_id 등 개인 식별 정보: SHA-256 해시 후 전송
|
|
- fbc/fbp 쿠키, client_ip_address, client_user_agent: 원문 그대로 전송
|
|
- event_time: 유닉스 타임스탬프 (7일 이내)
|
|
- action_source: "website" 고정
|
|
|
|
참고: https://developers.facebook.com/docs/marketing-api/conversions-api
|
|
"""
|
|
|
|
import hashlib
|
|
import time
|
|
|
|
import httpx
|
|
|
|
from app.utils.logger import get_logger
|
|
from config import meta_conversion_settings
|
|
|
|
logger = get_logger("meta_capi")
|
|
|
|
# Meta Graph API 버전 및 요청 타임아웃
|
|
GRAPH_API_VERSION = "v21.0"
|
|
REQUEST_TIMEOUT = 10.0
|
|
|
|
|
|
def sha256_hash(value: str) -> str:
|
|
"""개인 식별 정보를 Meta 매칭 규격에 맞게 SHA-256 해시합니다.
|
|
|
|
Meta는 소문자 변환 + 공백 제거 후 해싱을 요구합니다.
|
|
|
|
Args:
|
|
value: 해시할 원문 문자열 (예: user_uuid, 이메일)
|
|
|
|
Returns:
|
|
str: SHA-256 해시 (16진수 소문자)
|
|
"""
|
|
normalized = value.strip().lower()
|
|
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
|
|
|
|
async def send_capi_event(
|
|
event_name: str,
|
|
event_id: str,
|
|
external_id: str,
|
|
client_ip: str | None = None,
|
|
client_user_agent: str | None = None,
|
|
fbc: str | None = None,
|
|
fbp: str | None = None,
|
|
event_source_url: str | None = None,
|
|
custom_data: dict | None = None,
|
|
test_event_code: str | None = None,
|
|
) -> bool:
|
|
"""Meta Conversions API로 서버 이벤트를 전송합니다.
|
|
|
|
전송 실패는 로깅만 하고 예외를 전파하지 않습니다 (전환 추적 실패가
|
|
서비스 기능에 영향을 주지 않도록 fire-and-forget 처리).
|
|
|
|
Args:
|
|
event_name: 이벤트 이름 (브라우저 fbq와 철자/대소문자 일치 필수)
|
|
event_id: 중복제거용 이벤트 ID (브라우저 fbq의 eventID와 동일해야 함)
|
|
external_id: 사용자 식별자 원문 (user_uuid). 내부에서 SHA-256 해시됨
|
|
client_ip: 클라이언트 IP 주소 (원문)
|
|
client_user_agent: 클라이언트 User-Agent (원문)
|
|
fbc: Meta 클릭 ID 쿠키(_fbc) 원문
|
|
fbp: Meta 브라우저 ID 쿠키(_fbp) 원문
|
|
event_source_url: 이벤트가 발생한 페이지 URL
|
|
custom_data: 추가 데이터 (예: Purchase의 value/currency)
|
|
test_event_code: 이벤트 관리자 "테스트 이벤트" 검증용 코드.
|
|
미지정 시 FACEBOOK_TEST_EVENT_CODE 환경변수 값을 사용 (운영 시 빈 값 유지)
|
|
|
|
Returns:
|
|
bool: 전송 성공 여부
|
|
"""
|
|
pixel_id = meta_conversion_settings.FACEBOOK_PIXEL_ID
|
|
access_token = meta_conversion_settings.FACEBOOK_ACCESS_TOKEN
|
|
|
|
if not pixel_id or not access_token:
|
|
logger.warning(
|
|
"[MetaCAPI] SKIP - FACEBOOK_PIXEL_ID/FACEBOOK_ACCESS_TOKEN 미설정 "
|
|
f"(event_name: {event_name}, event_id: {event_id})"
|
|
)
|
|
return False
|
|
|
|
user_data: dict = {
|
|
"external_id": [sha256_hash(external_id)],
|
|
}
|
|
if client_ip:
|
|
user_data["client_ip_address"] = client_ip
|
|
if client_user_agent:
|
|
user_data["client_user_agent"] = client_user_agent
|
|
if fbc:
|
|
user_data["fbc"] = fbc
|
|
if fbp:
|
|
user_data["fbp"] = fbp
|
|
|
|
event: dict = {
|
|
"event_name": event_name,
|
|
"event_time": int(time.time()),
|
|
"event_id": event_id,
|
|
"action_source": "website",
|
|
"user_data": user_data,
|
|
}
|
|
if event_source_url:
|
|
event["event_source_url"] = event_source_url
|
|
if custom_data:
|
|
event["custom_data"] = custom_data
|
|
|
|
payload: dict = {"data": [event]}
|
|
effective_test_code = test_event_code or meta_conversion_settings.FACEBOOK_TEST_EVENT_CODE
|
|
if effective_test_code:
|
|
payload["test_event_code"] = effective_test_code
|
|
logger.info(f"[MetaCAPI] TEST MODE - test_event_code: {effective_test_code}")
|
|
|
|
url = f"https://graph.facebook.com/{GRAPH_API_VERSION}/{pixel_id}/events"
|
|
|
|
try:
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.post(
|
|
url,
|
|
json=payload,
|
|
params={"access_token": access_token},
|
|
timeout=REQUEST_TIMEOUT,
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
logger.info(
|
|
f"[MetaCAPI] SUCCESS - event_name: {event_name}, "
|
|
f"event_id: {event_id}, response: {response.json()}"
|
|
)
|
|
return True
|
|
|
|
logger.error(
|
|
f"[MetaCAPI] FAILED - event_name: {event_name}, event_id: {event_id}, "
|
|
f"status: {response.status_code}, body: {response.text}"
|
|
)
|
|
return False
|
|
|
|
except httpx.HTTPError as e:
|
|
logger.error(
|
|
f"[MetaCAPI] HTTP ERROR - event_name: {event_name}, "
|
|
f"event_id: {event_id}, error: {e}"
|
|
)
|
|
return False
|
|
except Exception as e:
|
|
logger.error(
|
|
f"[MetaCAPI] UNEXPECTED ERROR - event_name: {event_name}, "
|
|
f"event_id: {event_id}, error: {e}",
|
|
exc_info=True,
|
|
)
|
|
return False
|