File size: 5,106 Bytes
21bdc64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# ํ‚ค์›Œ๋“œ๋ฅผ ๋ฐ›์•„ ๋„ค์ด๋ฒ„ ์‹ค์‹œ๊ฐ„ ์กฐํšŒ + ์บ์‹ฑ ํ›„ ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” 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}