Media Engine Control Panel — 6탭 웹 대시보드 [백엔드] FastAPI (dashboard/backend/) - server.py: 포트 8080, CORS, React SPA 서빙 - api_overview.py: KPI 카드 + 파이프라인 상태 + 활동 로그 - api_content.py: 칸반 보드 + 승인/거부 + 수동 트리거 - api_analytics.py: 방문자 추이 + 플랫폼/코너별 성과 - api_novels.py: 소설 목록/생성/에피소드 관리 - api_settings.py: engine.json CRUD - api_connections.py: AI 서비스 연결 관리 + 키 저장 - api_tools.py: 기능별 AI 도구 선택 - api_cost.py: 구독 현황 + API 사용량 추적 - api_logs.py: 시스템 로그 필터/검색 [프론트엔드] React + Vite + Tailwind + Recharts (dashboard/frontend/) - Overview: KPI 카드 + 파이프라인 + 코너별 바차트 + 활동 로그 - Content: 4열 칸반 보드 + 상세 모달 + 승인/거부 - Analytics: LineChart 방문자 추이 + 플랫폼별 성과 - Novel: 소설 목록 + 에피소드 테이블 + 새 소설 생성 폼 - Settings: 5개 서브탭 (AI연결/도구선택/배포채널/품질/비용관리) - Logs: 필터/검색 시스템 로그 뷰어 [디자인] CNN 다크+골드 테마 - 배경 #0a0a0d + 액센트 #c8a84e - 모바일 반응형 (Tailscale 외부 접속 대응) [실행] - dashboard/start.bat 더블클릭 → http://localhost:8080 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
132 lines
4.0 KiB
Python
132 lines
4.0 KiB
Python
"""
|
|
dashboard/backend/api_analytics.py
|
|
Analytics 탭 API — 방문자 통계, KPI, 코너별 성과
|
|
"""
|
|
import json
|
|
from datetime import date, timedelta
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Query
|
|
|
|
BASE_DIR = Path(__file__).parent.parent.parent
|
|
DATA_DIR = BASE_DIR / "data"
|
|
ANALYTICS_DIR = DATA_DIR / "analytics"
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _load_all_analytics() -> list:
|
|
"""analytics/*.json 전체 로드"""
|
|
records = []
|
|
if not ANALYTICS_DIR.exists():
|
|
return records
|
|
|
|
for f in sorted(ANALYTICS_DIR.glob("*.json")):
|
|
try:
|
|
data = json.loads(f.read_text(encoding="utf-8"))
|
|
if isinstance(data, list):
|
|
records.extend(data)
|
|
elif isinstance(data, dict):
|
|
records.append(data)
|
|
except Exception:
|
|
pass
|
|
return records
|
|
|
|
|
|
def _aggregate_kpi(records: list) -> dict:
|
|
total_visitors = sum(r.get("visitors", 0) for r in records)
|
|
total_pageviews = sum(r.get("pageviews", 0) for r in records)
|
|
avg_duration = 0
|
|
avg_ctr = 0.0
|
|
|
|
durations = [r.get("avg_duration_sec", 0) for r in records if r.get("avg_duration_sec")]
|
|
if durations:
|
|
avg_duration = int(sum(durations) / len(durations))
|
|
|
|
ctrs = [r.get("ctr", 0.0) for r in records if r.get("ctr")]
|
|
if ctrs:
|
|
avg_ctr = round(sum(ctrs) / len(ctrs), 2)
|
|
|
|
return {
|
|
"visitors": total_visitors,
|
|
"pageviews": total_pageviews,
|
|
"avg_duration_sec": avg_duration,
|
|
"ctr": avg_ctr,
|
|
}
|
|
|
|
|
|
def _aggregate_corners(records: list) -> list:
|
|
corner_map: dict = {}
|
|
for r in records:
|
|
corner = r.get("corner", "기타")
|
|
if corner not in corner_map:
|
|
corner_map[corner] = {"visitors": 0, "pageviews": 0, "posts": 0}
|
|
corner_map[corner]["visitors"] += r.get("visitors", 0)
|
|
corner_map[corner]["pageviews"] += r.get("pageviews", 0)
|
|
corner_map[corner]["posts"] += r.get("post_count", 1)
|
|
|
|
result = []
|
|
for name, data in corner_map.items():
|
|
result.append({"corner": name, **data})
|
|
result.sort(key=lambda x: x["visitors"], reverse=True)
|
|
return result
|
|
|
|
|
|
def _top_posts(records: list, limit: int = 5) -> list:
|
|
posts = []
|
|
for r in records:
|
|
if "title" in r and "visitors" in r:
|
|
posts.append({
|
|
"title": r["title"],
|
|
"visitors": r["visitors"],
|
|
"corner": r.get("corner", ""),
|
|
"published_at": r.get("date", ""),
|
|
})
|
|
posts.sort(key=lambda x: x["visitors"], reverse=True)
|
|
return posts[:limit]
|
|
|
|
|
|
def _platform_performance(records: list) -> list:
|
|
platform_map: dict = {}
|
|
for r in records:
|
|
platform = r.get("platform", "blogger")
|
|
if platform not in platform_map:
|
|
platform_map[platform] = {"visitors": 0, "posts": 0}
|
|
platform_map[platform]["visitors"] += r.get("visitors", 0)
|
|
platform_map[platform]["posts"] += 1
|
|
|
|
return [{"platform": k, **v} for k, v in platform_map.items()]
|
|
|
|
|
|
@router.get("/analytics")
|
|
async def get_analytics():
|
|
records = _load_all_analytics()
|
|
return {
|
|
"kpi": _aggregate_kpi(records),
|
|
"corners": _aggregate_corners(records),
|
|
"top_posts": _top_posts(records),
|
|
"platforms": _platform_performance(records),
|
|
"total_records": len(records),
|
|
}
|
|
|
|
|
|
@router.get("/analytics/chart")
|
|
async def get_analytics_chart(days: int = Query(default=7, ge=1, le=365)):
|
|
"""days일간 방문자 시계열 데이터"""
|
|
records = _load_all_analytics()
|
|
|
|
today = date.today()
|
|
date_range = [(today - timedelta(days=i)).isoformat() for i in range(days - 1, -1, -1)]
|
|
|
|
# 날짜별 집계
|
|
daily: dict = {d: {"date": d, "visitors": 0, "pageviews": 0} for d in date_range}
|
|
|
|
for r in records:
|
|
d = r.get("date", "")[:10]
|
|
if d in daily:
|
|
daily[d]["visitors"] += r.get("visitors", 0)
|
|
daily[d]["pageviews"] += r.get("pageviews", 0)
|
|
|
|
return {"chart": list(daily.values()), "days": days}
|