"""Facebook audit 페이지(KR·EN)를 수집 데이터로 구성. 수치 지표(최근 게시일·게시 빈도·참여율)는 **수집 시점에** 결정적으로 산출해 DB에 박는다 (transform_for_storage). 콘텐츠 주제(top_content_type)는 캡션 본문 이해가 필요해 LLM이 채운다 (리포트 프롬프트 지시).""" import json from datetime import datetime, timezone from common.utils import parse_ts from integrations.llm.llm_service import LLMService from integrations.llm.prompt import facebook_diagnosis_prompt from integrations.llm.schemas.report import FacebookAudit, DiagnosisItem def _humanize_age(days: int) -> str: days = max(days, 0) if days == 0: return "오늘" if days < 7: return f"{days}일 전" if days < 30: return f"{days // 7}주 전" if days < 365: return f"{days // 30}개월 전" return f"{days // 365}년 전" def _frequency_label(avg_gap_days: float) -> str: """게시물 사이 평균 간격(일) → 빈도 라벨.""" if avg_gap_days <= 1.5: return "거의 매일" if avg_gap_days <= 10: return f"주 {7 / avg_gap_days:.1f}회" if avg_gap_days <= 45: return f"월 {30 / avg_gap_days:.1f}회" return "비정기 (분기 이상 간격)" def _engagement_text(posts: list[dict]) -> str: """게시물당 좋아요/반응/공유/조회를 min~max 범위로. 전부 0인 지표는 제외. 댓글은 posts actor가 안 줘서 '댓글 거의 없음' 고정 부가 (FB 페이지는 댓글 희박이 일반적).""" def _rng(vals: list[int], label: str, unit: str) -> str | None: lo, hi = min(vals), max(vals) if hi == 0: return None return f"{label} {lo}{unit}" if lo == hi else f"{label} {lo}~{hi}{unit}" parts = [ _rng([p.get("likes", 0) for p in posts], "좋아요", "개"), _rng([p.get("reactions", 0) for p in posts], "반응", "개"), _rng([p.get("shares", 0) for p in posts], "공유", "개"), ] vid_views = [p.get("views", 0) for p in posts if p.get("isVideo")] if vid_views: parts.append(_rng(vid_views, "영상 조회", "회")) parts = [x for x in parts if x] if not parts: return "게시물당 참여 거의 없음" return "게시물당 " + " · ".join(parts) + " · 댓글 거의 없음" def transform_for_storage(fb: dict | None) -> dict | None: """apify 원본 → DB에 저장할 최종 형태. - 수치 지표(recent_post_age·post_frequency·engagement)를 그 자리에서 계산해 박음. - 게시물은 캡션·타입만 남김 (raw 숫자/timestamp는 어차피 재계산 안 하므로 버림). 수집 시점에 한 번 계산 → 리포트 생성 때는 그대로 갖다 박기만 함.""" if not isinstance(fb, dict): return fb posts = fb.get("latestPosts") or [] out = {k: v for k, v in fb.items() if k != "latestPosts"} if posts: dts = sorted((d for d in (parse_ts(p.get("timestamp")) for p in posts) if d), reverse=True) if dts: out["recent_post_age"] = _humanize_age((datetime.now(timezone.utc) - dts[0]).days) if len(dts) > 1: avg_gap = ((dts[0] - dts[-1]).days or 1) / (len(dts) - 1) out["post_frequency"] = _frequency_label(avg_gap) out["engagement"] = _engagement_text(posts) out["latestPosts"] = [ {"caption": (p.get("text") or "")[:160], "type": "video" if p.get("isVideo") else "image"} for p in posts ] else: out["latestPosts"] = [] return out def _logo_data(channel_logos: dict, channel: str) -> dict: """channelLogos(비전 결과)에서 해당 채널 가져온다.""" for c in (channel_logos or {}).get("channel_logos", []): if c.get("channel") == channel: return c print("channel_logos NOT FOUND : ", channel_logos) return { "is_official" : False, "logo_description" : "로고 찾지 못함" } def _page_patch(item: dict, channel_logos) -> dict: p: dict = {"page_name": "", "followers": 0, "category": "", "reviews": 0, "following": 0, "recent_post_age": "", "post_frequency": "", "engagement": ""} fb = item["raw_data"] language = item.get("language") if item.get("language") else "KR" label = "페이스북 " + language channel = "Facebook" if language != "KR": channel = channel + " " + language logo_data = _logo_data(channel_logos, channel) if fb.get("pageUrl"): p["url"] = p["link"] = fb["pageUrl"] if fb.get("pageName"): p["page_name"] = fb["pageName"] if fb.get("followers"): p["followers"] = fb["followers"] if fb.get("intro"): p["bio"] = fb["intro"] if fb.get("categories"): p["category"] = ", ".join(fb["categories"]) if fb.get("website"): p["linked_domain"] = fb["website"] if fb.get("reviews") is not None: p["reviews"] = fb["reviews"] if fb.get("following") is not None: p["following"] = fb["following"] for key in ("recent_post_age", "post_frequency", "engagement"): if fb.get(key): p[key] = fb[key] if p: p["language"] = item["language"] p["label"] = label p["logo"] = "일치 (공식 로고)" if logo_data["is_official"] else "불일치 (비공식 변형)" p["logo_description"] = logo_data["logo_description"] return p async def build_facebook_audit(facebook: list[dict], brand_patch: list[dict], channel_logos) -> dict: pages = [_page_patch(item, channel_logos) for item in facebook] diagnosis_result = await LLMService(provider="perplexity").generate( facebook_diagnosis_prompt, {"pages": json.dumps(pages, ensure_ascii=False)}, ) return FacebookAudit.model_validate({ "pages": pages, "diagnosis": [DiagnosisItem.model_validate(item).model_dump() for item in diagnosis_result.diagnosis], "brand_inconsistencies": brand_patch, }).model_dump()