# 키워드를 받아 네이버 실시간 조회 + 캐싱 후 분석 결과를 반환하는 FastAPI 서버 import os import time from collections import defaultdict from fastapi import FastAPI, HTTPException, Request, Header from fastapi.middleware.cors import CORSMiddleware import cache from naver_collector import fetch_keywords from analyzer import build_overview from clusterer import cluster_keywords, filter_by_relevance from intent_classifier import classify_clusters from search_path import build_search_path from autocomplete import build_autocomplete_path from datalab import demographics import supabase_store app = FastAPI(title="IntentFinder API") # ── 배포 보호 설정 (전부 env 기반 · 미설정 시 로컬 개발용 기본값) ── # ALLOWED_ORIGINS: 허용 출처(쉼표구분). 배포 시 대시보드 도메인으로 제한. 기본 "*"(로컬). # DT_API_KEY: 설정되면 X-DT-Key 헤더가 일치해야 호출 허용(과금 남용 방지). 미설정 시 무인증(로컬). # RATE_MAX_PER_MIN: IP당 분당 최대 호출 수. ALLOWED_ORIGINS = [o.strip() for o in os.getenv("ALLOWED_ORIGINS", "*").split(",") if o.strip()] or ["*"] DT_API_KEY = os.getenv("DT_API_KEY", "") RATE_MAX_PER_MIN = int(os.getenv("RATE_MAX_PER_MIN", "20")) app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, allow_methods=["*"], allow_headers=["*"], ) cache.init() _hits: dict[str, list[float]] = defaultdict(list) def _check_rate(ip: str): now = time.time() recent = [t for t in _hits[ip] if now - t < 60] if len(recent) >= RATE_MAX_PER_MIN: raise HTTPException(status_code=429, detail="요청이 많습니다. 잠시 후 다시 시도하세요.") recent.append(now) _hits[ip] = recent @app.get("/health") def health(): return {"ok": True} @app.get("/analyze") def analyze(keyword: str, request: Request, x_dt_key: str | None = Header(default=None)): # 공유 시크릿 검증(설정된 경우) + IP 레이트리밋 if DT_API_KEY and x_dt_key != DT_API_KEY: raise HTTPException(status_code=401, detail="인증되지 않은 요청입니다.") _check_rate(request.client.host if request.client else "unknown") # 키워드 분석: 캐시 히트면 즉시, 아니면 네이버 호출 후 캐싱 keyword = keyword.strip() if not keyword: raise HTTPException(status_code=400, detail="키워드가 비어 있습니다.") hit = cache.get(keyword) if hit: return {**hit, "cached": True} try: df_raw = fetch_keywords(keyword) except Exception as e: raise HTTPException(status_code=502, detail=f"네이버 API 호출 실패: {e}") # 시드 무관 범용어(계산기 등) 제거 후 집계·군집화 df, emb = filter_by_relevance(df_raw, keyword) result = build_overview(keyword, df) result["raw_keyword_count"] = int(len(df_raw)) # 필터 전 원본 수 result["filtered_out"] = int(len(df_raw) - len(df)) # 무관어 제거 수 clusters = cluster_keywords(df, embeddings=emb) result["clusters"] = clusters result["cluster_count"] = len(clusters) # 그룹 수 카드 result["clustered_keyword_count"] = sum(c["keyword_count"] for c in clusters) # 검색 경로: 네이버 자동완성(실제 동시검색) 우선, 빈약하면 임베딩 추정으로 폴백 vol_lookup = dict(zip( df["keyword"], (df["search_volume_pc"].fillna(0) + df["search_volume_mobile"].fillna(0)).astype(int), )) try: sp = build_autocomplete_path(keyword, vol_lookup) sp["source"] = "autocomplete" if len(sp["nodes"]) < 5: # 자동완성이 빈약하면 임베딩 경로로 대체 sp = build_search_path(keyword, df, emb) sp["source"] = "embedding" except Exception: sp = build_search_path(keyword, df, emb) sp["source"] = "embedding" result["search_path"] = sp # 클러스터 단위 의도 분류 (도넛 차트). Claude 호출 실패해도 나머지는 반환 try: intent = classify_clusters(clusters) result["intent_breakdown"] = intent["intent_breakdown"] result["marketing_suggestion"] = intent["marketing_suggestion"] result["intent_usage"] = intent["usage"] except Exception as e: result["intent_breakdown"] = {} result["intent_error"] = str(e) # 성별×연령 추정 비중 (데이터랩). 키 없거나 실패해도 나머지는 반환 try: result["demographics"] = demographics(keyword) except Exception as e: result["demographics"] = None result["demographics_error"] = str(e) # 통합 대시보드(Supabase)에 적재 — 자격증명 없거나 실패해도 응답엔 영향 없음 if supabase_store.enabled(): try: supabase_store.persist(result) result["supabase_persisted"] = True except Exception as e: result["supabase_error"] = str(e) cache.put(keyword, result) return {**result, "cached": False}