Upload 15 files
Browse files- api_utils.py +253 -0
- app.py +722 -555
- category_analysis.py +1029 -0
- export_utils.py +510 -0
- keyword_analysis.py +1687 -0
- keyword_analysis_report.css +422 -0
- keyword_diversity_fix.py +918 -0
- keyword_processor.py +388 -0
- keyword_search.py +194 -0
- product_search.py +430 -0
- requirements.txt +9 -5
- style.css +1 -6
- text_utils.py +227 -0
- trend_analysis.py +323 -0
- trend_analysis_v2.py +1128 -0
api_utils.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API 관련 유틸리티 함수 모음 (환경변수 버전)
|
| 3 |
+
- API 키 관리 (환경변수에서 로드)
|
| 4 |
+
- 시그니처 생성
|
| 5 |
+
- API 헤더 생성
|
| 6 |
+
- Gemini API 키 랜덤 로테이션 추가
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
import hmac
|
| 12 |
+
import hashlib
|
| 13 |
+
import base64
|
| 14 |
+
import requests
|
| 15 |
+
import threading
|
| 16 |
+
import random
|
| 17 |
+
import google.generativeai as genai
|
| 18 |
+
import logging
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
# 환경변수에서 API 설정 로드
|
| 23 |
+
def get_api_configs():
|
| 24 |
+
# 환경변수 'API_CONFIGS'에서 전체 설정을 가져옴
|
| 25 |
+
api_configs_str = os.getenv('API_CONFIGS', '')
|
| 26 |
+
|
| 27 |
+
if not api_configs_str:
|
| 28 |
+
logger.error("API_CONFIGS 환경변수가 설정되지 않았습니다.")
|
| 29 |
+
return [], [], [], []
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
# 환경변수 값을 exec로 실행하여 설정 로드
|
| 33 |
+
local_vars = {}
|
| 34 |
+
exec(api_configs_str, {}, local_vars)
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
local_vars.get('NAVER_API_CONFIGS', []),
|
| 38 |
+
local_vars.get('NAVER_SHOPPING_CONFIGS', []),
|
| 39 |
+
local_vars.get('NAVER_DATALAB_CONFIGS', []),
|
| 40 |
+
local_vars.get('GEMINI_API_CONFIGS', [])
|
| 41 |
+
)
|
| 42 |
+
except Exception as e:
|
| 43 |
+
logger.error(f"환경변수 파싱 오류: {e}")
|
| 44 |
+
return [], [], [], []
|
| 45 |
+
|
| 46 |
+
# API 설정 로드
|
| 47 |
+
NAVER_API_CONFIGS, NAVER_SHOPPING_CONFIGS, NAVER_DATALAB_CONFIGS, GEMINI_API_CONFIGS = get_api_configs()
|
| 48 |
+
|
| 49 |
+
# 순차 사용을 위한 인덱스와 락
|
| 50 |
+
current_api_index = 0
|
| 51 |
+
current_shopping_api_index = 0
|
| 52 |
+
current_datalab_api_index = 0
|
| 53 |
+
current_gemini_api_index = 0
|
| 54 |
+
api_lock = threading.Lock()
|
| 55 |
+
shopping_lock = threading.Lock()
|
| 56 |
+
datalab_lock = threading.Lock()
|
| 57 |
+
gemini_lock = threading.Lock()
|
| 58 |
+
|
| 59 |
+
# Gemini 모델 캐시
|
| 60 |
+
_gemini_models = {}
|
| 61 |
+
|
| 62 |
+
# API 설정 초기화 함수 추가
|
| 63 |
+
def initialize_api_configs():
|
| 64 |
+
"""API 설정을 초기화하고 랜덤하게 정렬"""
|
| 65 |
+
global NAVER_API_CONFIGS, NAVER_SHOPPING_CONFIGS, NAVER_DATALAB_CONFIGS, GEMINI_API_CONFIGS
|
| 66 |
+
|
| 67 |
+
# API 설정을 다시 로드
|
| 68 |
+
NAVER_API_CONFIGS, NAVER_SHOPPING_CONFIGS, NAVER_DATALAB_CONFIGS, GEMINI_API_CONFIGS = get_api_configs()
|
| 69 |
+
|
| 70 |
+
# API 설정을 랜덤하게 섞기
|
| 71 |
+
if NAVER_API_CONFIGS:
|
| 72 |
+
random.shuffle(NAVER_API_CONFIGS)
|
| 73 |
+
if NAVER_SHOPPING_CONFIGS:
|
| 74 |
+
random.shuffle(NAVER_SHOPPING_CONFIGS)
|
| 75 |
+
if NAVER_DATALAB_CONFIGS:
|
| 76 |
+
random.shuffle(NAVER_DATALAB_CONFIGS)
|
| 77 |
+
if GEMINI_API_CONFIGS:
|
| 78 |
+
random.shuffle(GEMINI_API_CONFIGS)
|
| 79 |
+
|
| 80 |
+
print(f"API 설정 초기화 완료:")
|
| 81 |
+
print(f" - 검색광고 API: {len(NAVER_API_CONFIGS)}개")
|
| 82 |
+
print(f" - 쇼핑 API: {len(NAVER_SHOPPING_CONFIGS)}개")
|
| 83 |
+
print(f" - 데이터랩 API: {len(NAVER_DATALAB_CONFIGS)}개")
|
| 84 |
+
print(f" - Gemini API: {len(GEMINI_API_CONFIGS)}개")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def generate_signature(timestamp, method, uri, secret_key):
|
| 88 |
+
"""시그니처 생성 함수"""
|
| 89 |
+
message = f"{timestamp}.{method}.{uri}"
|
| 90 |
+
digest = hmac.new(secret_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).digest()
|
| 91 |
+
return base64.b64encode(digest).decode()
|
| 92 |
+
|
| 93 |
+
def get_header(method, uri, api_key, secret_key, customer_id):
|
| 94 |
+
"""API 헤더 생성 함수"""
|
| 95 |
+
timestamp = str(round(time.time() * 1000))
|
| 96 |
+
signature = generate_signature(timestamp, method, uri, secret_key)
|
| 97 |
+
return {
|
| 98 |
+
"Content-Type": "application/json; charset=UTF-8",
|
| 99 |
+
"X-Timestamp": timestamp,
|
| 100 |
+
"X-API-KEY": api_key,
|
| 101 |
+
"X-Customer": str(customer_id),
|
| 102 |
+
"X-Signature": signature
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
def get_next_api_config():
|
| 106 |
+
"""순차적으로 다음 API 설정을 반환 (스레드 안전)"""
|
| 107 |
+
global current_api_index
|
| 108 |
+
|
| 109 |
+
if not NAVER_API_CONFIGS:
|
| 110 |
+
logger.error("네이버 검색광고 API 설정이 없습니다.")
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
with api_lock:
|
| 114 |
+
config = NAVER_API_CONFIGS[current_api_index]
|
| 115 |
+
current_api_index = (current_api_index + 1) % len(NAVER_API_CONFIGS)
|
| 116 |
+
return config
|
| 117 |
+
|
| 118 |
+
def get_next_shopping_api_config():
|
| 119 |
+
"""순차적으로 다음 쇼핑 API 설정을 반환 (오류 키 건너뛰기 추가)"""
|
| 120 |
+
global current_shopping_api_index
|
| 121 |
+
|
| 122 |
+
if not NAVER_SHOPPING_CONFIGS:
|
| 123 |
+
logger.error("네이버 쇼핑 API 설정이 없습니다.")
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
with shopping_lock:
|
| 127 |
+
# 최대 전체 키 수만큼 시도 (무한 루프 방지)
|
| 128 |
+
for _ in range(len(NAVER_SHOPPING_CONFIGS)):
|
| 129 |
+
config = NAVER_SHOPPING_CONFIGS[current_shopping_api_index]
|
| 130 |
+
current_shopping_api_index = (current_shopping_api_index + 1) % len(NAVER_SHOPPING_CONFIGS)
|
| 131 |
+
|
| 132 |
+
# 기본값 체크
|
| 133 |
+
if config["CLIENT_ID"] and not config["CLIENT_ID"].startswith("YOUR_"):
|
| 134 |
+
return config
|
| 135 |
+
|
| 136 |
+
# 모든 키가 기본값인 경우 첫 번째 키 반환
|
| 137 |
+
return NAVER_SHOPPING_CONFIGS[0] if NAVER_SHOPPING_CONFIGS else None
|
| 138 |
+
|
| 139 |
+
def get_next_datalab_api_config():
|
| 140 |
+
"""순차적으로 다음 데이터랩 API 설정을 반환 (스레드 안전)"""
|
| 141 |
+
global current_datalab_api_index
|
| 142 |
+
|
| 143 |
+
if not NAVER_DATALAB_CONFIGS:
|
| 144 |
+
logger.error("네이버 데이터랩 API 설정이 없습니다.")
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
with datalab_lock:
|
| 148 |
+
# API 키가 설정되지 않았으면 None 반환
|
| 149 |
+
if not NAVER_DATALAB_CONFIGS[0]["CLIENT_ID"] or NAVER_DATALAB_CONFIGS[0]["CLIENT_ID"].startswith("YOUR_"):
|
| 150 |
+
return None
|
| 151 |
+
|
| 152 |
+
config = NAVER_DATALAB_CONFIGS[current_datalab_api_index]
|
| 153 |
+
current_datalab_api_index = (current_datalab_api_index + 1) % len(NAVER_DATALAB_CONFIGS)
|
| 154 |
+
return config
|
| 155 |
+
|
| 156 |
+
def get_next_gemini_api_key():
|
| 157 |
+
"""순차적으로 다음 Gemini API 키를 반환 (스레드 안전)"""
|
| 158 |
+
global current_gemini_api_index
|
| 159 |
+
|
| 160 |
+
if not GEMINI_API_CONFIGS:
|
| 161 |
+
logger.warning("사용 가능한 Gemini API 키가 없습니다.")
|
| 162 |
+
return None
|
| 163 |
+
|
| 164 |
+
with gemini_lock:
|
| 165 |
+
# 최대 전체 키 수만큼 시도 (무한 루프 방지)
|
| 166 |
+
for _ in range(len(GEMINI_API_CONFIGS)):
|
| 167 |
+
api_key = GEMINI_API_CONFIGS[current_gemini_api_index]
|
| 168 |
+
current_gemini_api_index = (current_gemini_api_index + 1) % len(GEMINI_API_CONFIGS)
|
| 169 |
+
|
| 170 |
+
# 기본값이 아닌 키만 반환
|
| 171 |
+
if api_key and not api_key.startswith("YOUR_") and api_key.strip():
|
| 172 |
+
return api_key
|
| 173 |
+
|
| 174 |
+
# 모든 키가 기본값인 경우 None 반환
|
| 175 |
+
logger.warning("사용 가능한 Gemini API 키가 없습니다.")
|
| 176 |
+
return None
|
| 177 |
+
|
| 178 |
+
def get_gemini_model():
|
| 179 |
+
"""캐시된 Gemini 모델을 반환하거나 새로 생성"""
|
| 180 |
+
api_key = get_next_gemini_api_key()
|
| 181 |
+
|
| 182 |
+
if not api_key:
|
| 183 |
+
logger.error("Gemini API 키를 가져올 수 없습니다.")
|
| 184 |
+
return None
|
| 185 |
+
|
| 186 |
+
# 캐시에서 모델 확인
|
| 187 |
+
if api_key in _gemini_models:
|
| 188 |
+
return _gemini_models[api_key]
|
| 189 |
+
|
| 190 |
+
try:
|
| 191 |
+
# 새 모델 생성
|
| 192 |
+
genai.configure(api_key=api_key)
|
| 193 |
+
model = genai.GenerativeModel("gemini-2.0-flash-exp")
|
| 194 |
+
|
| 195 |
+
# 캐시에 저장
|
| 196 |
+
_gemini_models[api_key] = model
|
| 197 |
+
|
| 198 |
+
logger.info(f"Gemini 모델 생성 성공: {api_key[:8]}***{api_key[-4:]}")
|
| 199 |
+
return model
|
| 200 |
+
|
| 201 |
+
except Exception as e:
|
| 202 |
+
logger.error(f"Gemini 모델 생성 실패 ({api_key[:8]}***): {e}")
|
| 203 |
+
return None
|
| 204 |
+
|
| 205 |
+
def validate_api_config(api_config):
|
| 206 |
+
"""API 설정 유효성 검사"""
|
| 207 |
+
if not api_config:
|
| 208 |
+
return False, "API 설정이 없습니다."
|
| 209 |
+
|
| 210 |
+
API_KEY = api_config.get("API_KEY", "")
|
| 211 |
+
SECRET_KEY = api_config.get("SECRET_KEY", "")
|
| 212 |
+
CUSTOMER_ID_STR = api_config.get("CUSTOMER_ID", "")
|
| 213 |
+
|
| 214 |
+
if not all([API_KEY, SECRET_KEY, CUSTOMER_ID_STR]):
|
| 215 |
+
return False, "API 키가 설정되지 않았습니다."
|
| 216 |
+
|
| 217 |
+
if CUSTOMER_ID_STR.startswith("YOUR_") or API_KEY.startswith("YOUR_"):
|
| 218 |
+
return False, "API 키가 플레이스홀더입니다."
|
| 219 |
+
|
| 220 |
+
try:
|
| 221 |
+
CUSTOMER_ID = int(CUSTOMER_ID_STR)
|
| 222 |
+
except ValueError:
|
| 223 |
+
return False, f"CUSTOMER_ID 변환 오류: '{CUSTOMER_ID_STR}'는 유효한 숫자가 아닙니다."
|
| 224 |
+
|
| 225 |
+
return True, "유효한 API 설정입니다."
|
| 226 |
+
|
| 227 |
+
def validate_datalab_config(datalab_config):
|
| 228 |
+
"""데이터랩 API 설정 유효성 검사"""
|
| 229 |
+
if not datalab_config:
|
| 230 |
+
return False, "데이터랩 API 설정이 없습니다."
|
| 231 |
+
|
| 232 |
+
CLIENT_ID = datalab_config.get("CLIENT_ID", "")
|
| 233 |
+
CLIENT_SECRET = datalab_config.get("CLIENT_SECRET", "")
|
| 234 |
+
|
| 235 |
+
if not all([CLIENT_ID, CLIENT_SECRET]):
|
| 236 |
+
return False, "데이터랩 API 키가 설정되지 않았습니다."
|
| 237 |
+
|
| 238 |
+
if CLIENT_ID.startswith("YOUR_") or CLIENT_SECRET.startswith("YOUR_"):
|
| 239 |
+
return False, "데이터랩 API 키가 플레이스홀더입니다."
|
| 240 |
+
|
| 241 |
+
return True, "유효한 데이터랩 API 설정입니다."
|
| 242 |
+
|
| 243 |
+
def validate_gemini_config():
|
| 244 |
+
"""Gemini API 설정 유효성 검사"""
|
| 245 |
+
valid_keys = 0
|
| 246 |
+
for api_key in GEMINI_API_CONFIGS:
|
| 247 |
+
if api_key and not api_key.startswith("YOUR_") and api_key.strip():
|
| 248 |
+
valid_keys += 1
|
| 249 |
+
|
| 250 |
+
if valid_keys == 0:
|
| 251 |
+
return False, "사용 가능한 Gemini API 키가 없습니다."
|
| 252 |
+
|
| 253 |
+
return True, f"{valid_keys}개의 유효한 Gemini API 키가 설정되어 있습니다."
|
app.py
CHANGED
|
@@ -1,46 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import gradio as gr
|
| 2 |
import pandas as pd
|
| 3 |
import os
|
| 4 |
import logging
|
| 5 |
-
|
| 6 |
-
import
|
|
|
|
| 7 |
import time
|
| 8 |
-
import tempfile
|
| 9 |
-
import zipfile
|
| 10 |
import re
|
| 11 |
-
import
|
|
|
|
|
|
|
| 12 |
|
| 13 |
# 로깅 설정
|
| 14 |
-
logging.basicConfig(level=logging.
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
-
#
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
# ===== API
|
| 24 |
-
def
|
| 25 |
-
"""
|
| 26 |
try:
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
# 환경변수에서 API 엔드포인트 가져오기
|
| 30 |
-
api_endpoint = os.getenv('API_ENDPOINT')
|
| 31 |
-
|
| 32 |
-
if not api_endpoint:
|
| 33 |
-
logger.error("API_ENDPOINT 환경변수가 설정되지 않았습니다.")
|
| 34 |
-
raise ValueError("API_ENDPOINT 환경변수가 설정되지 않았습니다.")
|
| 35 |
-
|
| 36 |
-
client = Client(api_endpoint)
|
| 37 |
-
logger.info("원격 API 클라이언트 초기화 성공")
|
| 38 |
-
return client
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
except Exception as e:
|
| 41 |
-
logger.error(f"
|
| 42 |
return None
|
| 43 |
|
|
|
|
|
|
|
|
|
|
| 44 |
# ===== 한국시간 관련 함수 =====
|
| 45 |
def get_korean_time():
|
| 46 |
"""한국시간 반환"""
|
|
@@ -61,108 +77,427 @@ def format_korean_datetime(dt=None, format_type="filename"):
|
|
| 61 |
else:
|
| 62 |
return dt.strftime("%y%m%d_%H%M")
|
| 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 |
else:
|
| 95 |
-
logger.info("
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
-
if not
|
| 119 |
-
logger.warning("⚠️ Export 데이터가 없거나 딕셔너리가 아님 - 기본 구조 생성")
|
| 120 |
return {
|
| 121 |
-
"
|
| 122 |
-
"
|
| 123 |
-
"
|
| 124 |
-
"
|
| 125 |
-
"related_keywords_df": None, # 더미 데이터 대신 None
|
| 126 |
-
"analysis_completed": True
|
| 127 |
}
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
"main_keyword": "메인키워드",
|
| 133 |
-
"analysis_html": "<div>분석 완료</div>",
|
| 134 |
-
"analysis_completed": True
|
| 135 |
-
}
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
export_data[key] = default_value
|
| 140 |
-
logger.info(f"🔧 {key} 키 복구: {default_value}")
|
| 141 |
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
export_data[df_key] = None
|
| 160 |
-
elif not hasattr(df_data, 'shape'):
|
| 161 |
-
logger.warning(f"⚠️ {df_key}가 DataFrame이 아님 - None으로 설정")
|
| 162 |
-
export_data[df_key] = None
|
| 163 |
-
|
| 164 |
-
logger.info("✅ Export 데이터 유효성 검사 및 복구 완료 (더미 데이터 없음)")
|
| 165 |
-
return export_data
|
| 166 |
|
| 167 |
# ===== 파일 출력 함수들 =====
|
| 168 |
def create_timestamp_filename(analysis_keyword):
|
|
@@ -173,16 +508,8 @@ def create_timestamp_filename(analysis_keyword):
|
|
| 173 |
return f"{safe_keyword}_{timestamp}_분석결과"
|
| 174 |
|
| 175 |
def export_to_excel(main_keyword, main_keywords_df, analysis_keyword, related_keywords_df, filename_base):
|
| 176 |
-
"""엑셀 파일로 출력
|
| 177 |
try:
|
| 178 |
-
# 실제 데��터가 있는지 확인
|
| 179 |
-
has_main_data = main_keywords_df is not None and not main_keywords_df.empty
|
| 180 |
-
has_related_data = related_keywords_df is not None and not related_keywords_df.empty
|
| 181 |
-
|
| 182 |
-
if not has_main_data and not has_related_data:
|
| 183 |
-
logger.info("📋 생성할 데이터가 없어 엑셀 파일 생성 건너뜀")
|
| 184 |
-
return None
|
| 185 |
-
|
| 186 |
excel_filename = f"{filename_base}.xlsx"
|
| 187 |
excel_path = os.path.join(tempfile.gettempdir(), excel_filename)
|
| 188 |
|
|
@@ -214,8 +541,8 @@ def export_to_excel(main_keyword, main_keywords_df, analysis_keyword, related_ke
|
|
| 214 |
'border': 1
|
| 215 |
})
|
| 216 |
|
| 217 |
-
# 첫 번째 시트: 메인키워드 조합키워드
|
| 218 |
-
if
|
| 219 |
main_keywords_df.to_excel(writer, sheet_name=f'{main_keyword}_조합키워드', index=False)
|
| 220 |
worksheet1 = writer.sheets[f'{main_keyword}_조합키워드']
|
| 221 |
|
|
@@ -226,7 +553,7 @@ def export_to_excel(main_keyword, main_keywords_df, analysis_keyword, related_ke
|
|
| 226 |
# 데이터 스타일 적용
|
| 227 |
for row_num in range(1, len(main_keywords_df) + 1):
|
| 228 |
for col_num, value in enumerate(main_keywords_df.iloc[row_num-1]):
|
| 229 |
-
if
|
| 230 |
worksheet1.write(row_num, col_num, value, number_format)
|
| 231 |
else:
|
| 232 |
worksheet1.write(row_num, col_num, value, data_format)
|
|
@@ -238,11 +565,9 @@ def export_to_excel(main_keyword, main_keywords_df, analysis_keyword, related_ke
|
|
| 238 |
len(str(col))
|
| 239 |
)
|
| 240 |
worksheet1.set_column(i, i, min(max_len + 2, 50))
|
| 241 |
-
|
| 242 |
-
logger.info(f"✅ 메인키워드 시트 생성: {main_keywords_df.shape}")
|
| 243 |
|
| 244 |
-
# 두 번째 시트: 분석키워드 연관검색어
|
| 245 |
-
if
|
| 246 |
related_keywords_df.to_excel(writer, sheet_name=f'{analysis_keyword}_연관검색어', index=False)
|
| 247 |
worksheet2 = writer.sheets[f'{analysis_keyword}_연관검색어']
|
| 248 |
|
|
@@ -253,7 +578,7 @@ def export_to_excel(main_keyword, main_keywords_df, analysis_keyword, related_ke
|
|
| 253 |
# 데이터 스타일 적용
|
| 254 |
for row_num in range(1, len(related_keywords_df) + 1):
|
| 255 |
for col_num, value in enumerate(related_keywords_df.iloc[row_num-1]):
|
| 256 |
-
if
|
| 257 |
worksheet2.write(row_num, col_num, value, number_format)
|
| 258 |
else:
|
| 259 |
worksheet2.write(row_num, col_num, value, data_format)
|
|
@@ -265,8 +590,6 @@ def export_to_excel(main_keyword, main_keywords_df, analysis_keyword, related_ke
|
|
| 265 |
len(str(col))
|
| 266 |
)
|
| 267 |
worksheet2.set_column(i, i, min(max_len + 2, 50))
|
| 268 |
-
|
| 269 |
-
logger.info(f"✅ 연관검색어 시트 생성: {related_keywords_df.shape}")
|
| 270 |
|
| 271 |
logger.info(f"엑셀 파일 생성 완료: {excel_path}")
|
| 272 |
return excel_path
|
|
@@ -396,7 +719,7 @@ def export_to_html(analysis_html, filename_base):
|
|
| 396 |
<div class="container">
|
| 397 |
<div class="header">
|
| 398 |
<h1><i class="fas fa-chart-line"></i> 키워드 심충분석 결과</h1>
|
| 399 |
-
<p>AI 상품 소싱 분석 시스템
|
| 400 |
</div>
|
| 401 |
<div class="content">
|
| 402 |
{analysis_html}
|
|
@@ -425,71 +748,48 @@ def create_zip_file(excel_path, html_path, filename_base):
|
|
| 425 |
zip_filename = f"{filename_base}.zip"
|
| 426 |
zip_path = os.path.join(tempfile.gettempdir(), zip_filename)
|
| 427 |
|
| 428 |
-
files_added = 0
|
| 429 |
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
| 430 |
if excel_path and os.path.exists(excel_path):
|
| 431 |
zipf.write(excel_path, f"{filename_base}.xlsx")
|
| 432 |
logger.info(f"엑셀 파일 압축 추가: {filename_base}.xlsx")
|
| 433 |
-
files_added += 1
|
| 434 |
|
| 435 |
if html_path and os.path.exists(html_path):
|
| 436 |
zipf.write(html_path, f"{filename_base}.html")
|
| 437 |
logger.info(f"HTML 파일 압축 추가: {filename_base}.html")
|
| 438 |
-
files_added += 1
|
| 439 |
|
| 440 |
-
|
| 441 |
-
logger.warning("압축할 파일이 없음")
|
| 442 |
-
return None
|
| 443 |
-
|
| 444 |
-
logger.info(f"압축 파일 생성 완료: {zip_path} ({files_added}개 파일)")
|
| 445 |
return zip_path
|
| 446 |
|
| 447 |
except Exception as e:
|
| 448 |
logger.error(f"압축 파일 생성 오류: {e}")
|
| 449 |
return None
|
| 450 |
|
| 451 |
-
def
|
| 452 |
-
"""
|
| 453 |
try:
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
export_data = validate_and_repair_export_data(export_data)
|
| 458 |
|
| 459 |
-
analysis_keyword = export_data.get("analysis_keyword", "
|
| 460 |
-
analysis_html = export_data.get("analysis_html", "
|
| 461 |
-
main_keyword = export_data.get("main_keyword",
|
| 462 |
main_keywords_df = export_data.get("main_keywords_df")
|
| 463 |
related_keywords_df = export_data.get("related_keywords_df")
|
| 464 |
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
logger.info(f" - related_keywords_df: {related_keywords_df.shape if related_keywords_df is not None else 'None'}")
|
| 471 |
|
| 472 |
# 파일명 생성 (한국시간 적용)
|
| 473 |
filename_base = create_timestamp_filename(analysis_keyword)
|
| 474 |
-
logger.info(f"
|
| 475 |
-
|
| 476 |
-
# HTML 파일은 분석 결과가 있으면 생성
|
| 477 |
-
html_path = None
|
| 478 |
-
if analysis_html and len(str(analysis_html).strip()) > 20: # 의미있는 HTML인지 확인
|
| 479 |
-
logger.info("🌐 HTML 파일 생성 시작...")
|
| 480 |
-
html_path = export_to_html(analysis_html, filename_base)
|
| 481 |
-
if html_path:
|
| 482 |
-
logger.info(f"✅ HTML 파일 생성 성공: {html_path}")
|
| 483 |
-
else:
|
| 484 |
-
logger.error("❌ HTML 파일 생성 실패")
|
| 485 |
-
else:
|
| 486 |
-
logger.info("📄 분석 HTML이 없어 HTML 파일 생성 건너뜀")
|
| 487 |
|
| 488 |
-
# 엑셀 파일 생성
|
| 489 |
excel_path = None
|
| 490 |
-
if
|
| 491 |
-
(related_keywords_df is not None and not related_keywords_df.empty):
|
| 492 |
-
logger.info("📊 엑셀 파일 생성 시작...")
|
| 493 |
excel_path = export_to_excel(
|
| 494 |
main_keyword,
|
| 495 |
main_keywords_df,
|
|
@@ -497,340 +797,53 @@ def export_analysis_results_enhanced(export_data):
|
|
| 497 |
related_keywords_df,
|
| 498 |
filename_base
|
| 499 |
)
|
| 500 |
-
if excel_path:
|
| 501 |
-
logger.info(f"✅ 엑셀 파일 생성 성공: {excel_path}")
|
| 502 |
-
else:
|
| 503 |
-
logger.warning("⚠️ 엑셀 파일 생성 실패")
|
| 504 |
-
else:
|
| 505 |
-
logger.info("📊 실제 DataFrame 데이터가 없어 엑셀 파일 생성 생략")
|
| 506 |
|
| 507 |
-
#
|
| 508 |
-
|
| 509 |
-
logger.warning("⚠️ 생성된 파일이 없음")
|
| 510 |
-
return None, "⚠️ 생성할 수 있는 데이터가 없습니다. 분석을 먼저 완료해주세요."
|
| 511 |
|
| 512 |
# 압축 파일 생성
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
if excel_path:
|
| 520 |
-
file_types.append("엑셀")
|
| 521 |
-
|
| 522 |
-
file_list = " + ".join(file_types)
|
| 523 |
-
logger.info(f"✅ 압축 파일 생성 성공: {zip_path} ({file_list})")
|
| 524 |
-
return zip_path, f"✅ 분석 결과가 성공적으로 출력되었습니다!\n파일명: {filename_base}.zip\n포함 파일: {file_list}\n\n💡 더미 데이터 제거 버전 - 실제 분석 데이터만 포함됩니다."
|
| 525 |
else:
|
| 526 |
-
|
| 527 |
-
return None, "압축 파일 생성에 실패했습니다."
|
| 528 |
|
| 529 |
except Exception as e:
|
| 530 |
-
logger.error(f"
|
| 531 |
-
import traceback
|
| 532 |
-
logger.error(f"스택 트레이스:\n{traceback.format_exc()}")
|
| 533 |
return None, f"출력 중 오류가 발생했습니다: {str(e)}"
|
| 534 |
|
| 535 |
-
# ===== 로딩 애니메이션 =====
|
| 536 |
-
def create_loading_animation():
|
| 537 |
-
"""로딩 애니메이션 HTML"""
|
| 538 |
-
return """
|
| 539 |
-
<div style="display: flex; flex-direction: column; align-items: center; padding: 40px; background: white; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);">
|
| 540 |
-
<div style="width: 60px; height: 60px; border: 4px solid #f3f3f3; border-top: 4px solid #FB7F0D; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 20px;"></div>
|
| 541 |
-
<h3 style="color: #FB7F0D; margin: 10px 0; font-size: 18px;">분석 중입니다...</h3>
|
| 542 |
-
<p style="color: #666; margin: 5px 0; text-align: center;">원격 서버에서 데이터를 수집하고 AI가 분석하고 있습니다.<br>잠시만 기다려주세요.</p>
|
| 543 |
-
<div style="width: 200px; height: 4px; background: #f0f0f0; border-radius: 2px; margin-top: 15px; overflow: hidden;">
|
| 544 |
-
<div style="width: 100%; height: 100%; background: linear-gradient(90deg, #FB7F0D, #ff9a8b); border-radius: 2px; animation: progress 2s ease-in-out infinite;"></div>
|
| 545 |
-
</div>
|
| 546 |
-
</div>
|
| 547 |
-
|
| 548 |
-
<style>
|
| 549 |
-
@keyframes spin {
|
| 550 |
-
0% { transform: rotate(0deg); }
|
| 551 |
-
100% { transform: rotate(360deg); }
|
| 552 |
-
}
|
| 553 |
-
|
| 554 |
-
@keyframes progress {
|
| 555 |
-
0% { transform: translateX(-100%); }
|
| 556 |
-
100% { transform: translateX(100%); }
|
| 557 |
-
}
|
| 558 |
-
</style>
|
| 559 |
-
"""
|
| 560 |
-
|
| 561 |
-
# ===== 에러 처리 함수 =====
|
| 562 |
-
def generate_error_response(error_message):
|
| 563 |
-
"""에러 응답 생성"""
|
| 564 |
-
return f'''
|
| 565 |
-
<div style="color: red; padding: 30px; text-align: center; width: 100%;
|
| 566 |
-
background-color: #f8d7da; border-radius: 12px; border: 1px solid #f5c6cb;">
|
| 567 |
-
<h3 style="margin-bottom: 15px;">❌ 연결 오류</h3>
|
| 568 |
-
<p style="margin-bottom: 20px;">{error_message}</p>
|
| 569 |
-
<div style="background: white; padding: 15px; border-radius: 8px; color: #333;">
|
| 570 |
-
<h4>해결 방법:</h4>
|
| 571 |
-
<ul style="text-align: left; padding-left: 20px;">
|
| 572 |
-
<li>네트워크 연결을 확인해주세요</li>
|
| 573 |
-
<li>원격 서버 상태를 확인해주세요</li>
|
| 574 |
-
<li>잠시 후 다시 시도해주세요</li>
|
| 575 |
-
<li>문제가 지속되면 관리자에게 문의하세요</li>
|
| 576 |
-
</ul>
|
| 577 |
-
</div>
|
| 578 |
-
</div>
|
| 579 |
-
'''
|
| 580 |
-
|
| 581 |
-
# ===== 원격 API 호출 함수들 =====
|
| 582 |
-
def call_collect_data_api(keyword):
|
| 583 |
-
"""1단계: 상품 데이터 수집 API 호출"""
|
| 584 |
-
try:
|
| 585 |
-
client = get_api_client()
|
| 586 |
-
if not client:
|
| 587 |
-
return generate_error_response("API 클라이언트를 초기화할 수 없습니다."), {}
|
| 588 |
-
|
| 589 |
-
logger.info("원격 API 호출: 상품 데이터 수집")
|
| 590 |
-
result = client.predict(
|
| 591 |
-
keyword=keyword,
|
| 592 |
-
api_name="/on_collect_data"
|
| 593 |
-
)
|
| 594 |
-
|
| 595 |
-
logger.info(f"데이터 수집 API 결과 타입: {type(result)}")
|
| 596 |
-
|
| 597 |
-
# 결과가 튜플인 경우 첫 번째 요소는 HTML, 두 번째는 세션 데이터
|
| 598 |
-
if isinstance(result, tuple) and len(result) == 2:
|
| 599 |
-
html_result, session_data = result
|
| 600 |
-
|
| 601 |
-
# 세션 데이터가 제대로 있는지 확인
|
| 602 |
-
if isinstance(session_data, dict):
|
| 603 |
-
logger.info(f"데이터 수집 세션 데이터 수신: {list(session_data.keys()) if session_data else '빈 딕셔너리'}")
|
| 604 |
-
return html_result, session_data
|
| 605 |
-
else:
|
| 606 |
-
logger.warning("세션 데이터가 딕셔너리가 아닙니다.")
|
| 607 |
-
return html_result, {}
|
| 608 |
-
else:
|
| 609 |
-
logger.warning("예상과 다른 데이터 수집 결과 형태")
|
| 610 |
-
return str(result), {"keywords_collected": True}
|
| 611 |
-
|
| 612 |
-
except Exception as e:
|
| 613 |
-
logger.error(f"상품 데이터 수집 API 호출 오류: {e}")
|
| 614 |
-
return generate_error_response(f"원격 서버 연결 실패: {str(e)}"), {}
|
| 615 |
-
|
| 616 |
-
def call_analyze_keyword_api_enhanced(analysis_keyword, base_keyword, keywords_data):
|
| 617 |
-
"""3단계: 강화된 키워드 심충분석 API 호출 (더미 데이터 제거)"""
|
| 618 |
-
try:
|
| 619 |
-
client = get_api_client()
|
| 620 |
-
if not client:
|
| 621 |
-
return generate_error_response("API 클라이언트를 초기화할 수 없습니다."), {}
|
| 622 |
-
|
| 623 |
-
logger.info("=== 🚀 강화된 키워드 심충분석 API 호출 (더미 데이터 제거) ===")
|
| 624 |
-
logger.info(f"파라미터 - analysis_keyword: '{analysis_keyword}'")
|
| 625 |
-
logger.info(f"파라미터 - base_keyword: '{base_keyword}'")
|
| 626 |
-
logger.info(f"파라미터 - keywords_data 타입: {type(keywords_data)}")
|
| 627 |
-
|
| 628 |
-
# 원격 API 호출
|
| 629 |
-
result = client.predict(
|
| 630 |
-
analysis_keyword,
|
| 631 |
-
base_keyword,
|
| 632 |
-
keywords_data,
|
| 633 |
-
api_name="/on_analyze_keyword"
|
| 634 |
-
)
|
| 635 |
-
|
| 636 |
-
logger.info(f"📡 원격 API 응답 수신:")
|
| 637 |
-
logger.info(f" - 응답 타입: {type(result)}")
|
| 638 |
-
logger.info(f" - 응답 길이: {len(result) if hasattr(result, '__len__') else 'N/A'}")
|
| 639 |
-
|
| 640 |
-
# 응답 처리 및 Export 데이터 구조 생성
|
| 641 |
-
if isinstance(result, tuple) and len(result) == 2:
|
| 642 |
-
html_result, remote_export_data = result
|
| 643 |
-
|
| 644 |
-
logger.info(f"📊 원격 export 데이터:")
|
| 645 |
-
logger.info(f" - 타입: {type(remote_export_data)}")
|
| 646 |
-
logger.info(f" - 키들: {list(remote_export_data.keys()) if isinstance(remote_export_data, dict) else 'None'}")
|
| 647 |
-
|
| 648 |
-
# HTML 결과가 있으면 Export 데이터 구조 생성 (더미 데이터 없이)
|
| 649 |
-
if html_result:
|
| 650 |
-
logger.info("🔧 Export 데이터 구조 생성 시작 (더미 데이터 제거)")
|
| 651 |
-
enhanced_export_data = create_export_data_from_html(
|
| 652 |
-
analysis_keyword=analysis_keyword,
|
| 653 |
-
main_keyword=base_keyword,
|
| 654 |
-
analysis_html=html_result,
|
| 655 |
-
step1_data=keywords_data
|
| 656 |
-
)
|
| 657 |
-
|
| 658 |
-
# 원격에서 온 실제 데이터가 있으면 병합
|
| 659 |
-
if isinstance(remote_export_data, dict) and remote_export_data:
|
| 660 |
-
logger.info("🔗 원격 실제 데이터와 로컬 데이터 병합")
|
| 661 |
-
for key, value in remote_export_data.items():
|
| 662 |
-
if value is not None and key in ["main_keywords_df", "related_keywords_df"]:
|
| 663 |
-
# DataFrame 데이터만 검증하여 병합
|
| 664 |
-
if isinstance(value, dict) and value: # 빈 딕셔너리가 아닌 경우만
|
| 665 |
-
enhanced_export_data[key] = value
|
| 666 |
-
logger.info(f" - {key} 원격 실제 데이터로 업데이트")
|
| 667 |
-
elif hasattr(value, 'shape') and not value.empty: # DataFrame이고 비어있지 않은 경우
|
| 668 |
-
enhanced_export_data[key] = value
|
| 669 |
-
logger.info(f" - {key} 원격 DataFrame 데이터로 업데이트")
|
| 670 |
-
elif value is not None and key not in ["main_keywords_df", "related_keywords_df"]:
|
| 671 |
-
enhanced_export_data[key] = value
|
| 672 |
-
logger.info(f" - {key} 원격 데이터로 업데이트")
|
| 673 |
-
|
| 674 |
-
logger.info(f"✅ 최종 Export 데이터 구조 (더미 데이터 없음):")
|
| 675 |
-
logger.info(f" - 키 개수: {len(enhanced_export_data)}")
|
| 676 |
-
logger.info(f" - 키 목록: {list(enhanced_export_data.keys())}")
|
| 677 |
-
|
| 678 |
-
return html_result, enhanced_export_data
|
| 679 |
-
else:
|
| 680 |
-
logger.warning("⚠️ HTML 결과가 비어있음")
|
| 681 |
-
return str(result), {}
|
| 682 |
-
else:
|
| 683 |
-
logger.warning("⚠️ 예상과 다른 API 응답 형태")
|
| 684 |
-
# HTML만 반환된 경우도 처리
|
| 685 |
-
if isinstance(result, str) and len(result) > 100: # HTML일 가능성이 높음
|
| 686 |
-
logger.info("📄 HTML 문자열로 추정되는 응답 - Export 데이터 생성 (더미 데이터 없이)")
|
| 687 |
-
enhanced_export_data = create_export_data_from_html(
|
| 688 |
-
analysis_keyword=analysis_keyword,
|
| 689 |
-
main_keyword=base_keyword,
|
| 690 |
-
analysis_html=result,
|
| 691 |
-
step1_data=keywords_data
|
| 692 |
-
)
|
| 693 |
-
return result, enhanced_export_data
|
| 694 |
-
else:
|
| 695 |
-
return str(result), {}
|
| 696 |
-
|
| 697 |
-
except Exception as e:
|
| 698 |
-
logger.error(f"❌ 키워드 심충분석 API 호출 오류: {e}")
|
| 699 |
-
import traceback
|
| 700 |
-
logger.error(f"��택 트레이스:\n{traceback.format_exc()}")
|
| 701 |
-
return generate_error_response(f"원격 서버 연결 실패: {str(e)}"), {}
|
| 702 |
-
|
| 703 |
# ===== 그라디오 인터페이스 =====
|
| 704 |
def create_interface():
|
| 705 |
-
# CSS
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
--
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
--table-hover-bg: #f0f0f0;
|
| 721 |
-
--shadow: 0 8px 30px rgba(251, 127, 13, 0.08);
|
| 722 |
-
--shadow-light: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 723 |
-
--border-radius: 18px;
|
| 724 |
-
}
|
| 725 |
-
@media (prefers-color-scheme: dark) {
|
| 726 |
-
:root {
|
| 727 |
-
--background-color: #1a1a1a;
|
| 728 |
-
--card-bg: #2d2d2d;
|
| 729 |
-
--input-bg: #2d2d2d;
|
| 730 |
-
--text-color: #e5e5e5;
|
| 731 |
-
--text-secondary: #a1a1aa;
|
| 732 |
-
--border-color: #404040;
|
| 733 |
-
--border-light: #525252;
|
| 734 |
-
--table-even-bg: #333333;
|
| 735 |
-
--table-hover-bg: #404040;
|
| 736 |
-
--shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
|
| 737 |
-
--shadow-light: 0 2px 4px rgba(0, 0, 0, 0.2);
|
| 738 |
}
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
padding: 0;
|
| 747 |
-
transition: background-color 0.3s ease, color 0.3s ease;
|
| 748 |
-
}
|
| 749 |
-
.gradio-container {
|
| 750 |
-
width: 100%;
|
| 751 |
-
margin: 0 auto;
|
| 752 |
-
padding: 20px;
|
| 753 |
-
background-color: var(--background-color) !important;
|
| 754 |
-
}
|
| 755 |
-
.custom-frame {
|
| 756 |
-
background-color: var(--card-bg) !important;
|
| 757 |
-
border: 1px solid var(--border-light) !important;
|
| 758 |
-
border-radius: var(--border-radius);
|
| 759 |
-
padding: 20px;
|
| 760 |
-
margin: 10px 0;
|
| 761 |
-
box-shadow: var(--shadow) !important;
|
| 762 |
-
color: var(--text-color) !important;
|
| 763 |
-
}
|
| 764 |
-
.custom-button {
|
| 765 |
-
border-radius: 30px !important;
|
| 766 |
-
background: var(--primary-color) !important;
|
| 767 |
-
color: white !important;
|
| 768 |
-
font-size: 18px !important;
|
| 769 |
-
padding: 10px 20px !important;
|
| 770 |
-
border: none;
|
| 771 |
-
box-shadow: 0 4px 8px rgba(251, 127, 13, 0.25);
|
| 772 |
-
transition: transform 0.3s ease;
|
| 773 |
-
height: 45px !important;
|
| 774 |
-
width: 100% !important;
|
| 775 |
-
}
|
| 776 |
-
.custom-button:hover {
|
| 777 |
-
transform: translateY(-2px);
|
| 778 |
-
box-shadow: 0 6px 12px rgba(251, 127, 13, 0.3);
|
| 779 |
-
}
|
| 780 |
-
.export-button {
|
| 781 |
-
background: linear-gradient(135deg, #28a745, #20c997) !important;
|
| 782 |
-
color: white !important;
|
| 783 |
-
border-radius: 25px !important;
|
| 784 |
-
height: 50px !important;
|
| 785 |
-
font-size: 17px !important;
|
| 786 |
-
font-weight: bold !important;
|
| 787 |
-
width: 100% !important;
|
| 788 |
-
margin-top: 20px !important;
|
| 789 |
-
}
|
| 790 |
-
.section-title {
|
| 791 |
-
display: flex;
|
| 792 |
-
align-items: center;
|
| 793 |
-
font-size: 20px;
|
| 794 |
-
font-weight: 700;
|
| 795 |
-
color: var(--text-color) !important;
|
| 796 |
-
margin-bottom: 10px;
|
| 797 |
-
padding-bottom: 5px;
|
| 798 |
-
border-bottom: 2px solid var(--primary-color);
|
| 799 |
-
font-family: 'Pretendard', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 800 |
-
}
|
| 801 |
-
.section-title img, .section-title i {
|
| 802 |
-
margin-right: 10px;
|
| 803 |
-
font-size: 20px;
|
| 804 |
-
color: var(--primary-color);
|
| 805 |
-
}
|
| 806 |
-
.gr-input, .gr-text-input, .gr-sample-inputs,
|
| 807 |
-
input[type="text"], input[type="number"], textarea, select {
|
| 808 |
-
border-radius: var(--border-radius) !important;
|
| 809 |
-
border: 1px solid var(--border-color) !important;
|
| 810 |
-
padding: 12px !important;
|
| 811 |
-
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05) !important;
|
| 812 |
-
transition: all 0.3s ease !important;
|
| 813 |
-
background-color: var(--input-bg) !important;
|
| 814 |
-
color: var(--text-color) !important;
|
| 815 |
-
}
|
| 816 |
-
.gr-input:focus, .gr-text-input:focus,
|
| 817 |
-
input[type="text"]:focus, textarea:focus, select:focus {
|
| 818 |
-
border-color: var(--primary-color) !important;
|
| 819 |
-
outline: none !important;
|
| 820 |
-
box-shadow: 0 0 0 2px rgba(251, 127, 13, 0.2) !important;
|
| 821 |
-
}
|
| 822 |
-
.fade-in {
|
| 823 |
-
animation: fadeIn 0.5s ease-out;
|
| 824 |
-
}
|
| 825 |
-
@keyframes fadeIn {
|
| 826 |
-
from { opacity: 0; transform: translateY(10px); }
|
| 827 |
-
to { opacity: 1; transform: translateY(0); }
|
| 828 |
-
}
|
| 829 |
-
"""
|
| 830 |
|
| 831 |
with gr.Blocks(
|
| 832 |
css=custom_css,
|
| 833 |
-
title="🛒 AI 상품 소싱 분석기
|
| 834 |
theme=gr.themes.Default(primary_hue="orange", secondary_hue="orange")
|
| 835 |
) as interface:
|
| 836 |
|
|
@@ -840,11 +853,11 @@ def create_interface():
|
|
| 840 |
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css">
|
| 841 |
""")
|
| 842 |
|
| 843 |
-
# 세션별 상태 변수
|
| 844 |
keywords_data_state = gr.State()
|
| 845 |
export_data_state = gr.State({})
|
| 846 |
|
| 847 |
-
# ===
|
| 848 |
with gr.Column(elem_classes="custom-frame fade-in"):
|
| 849 |
gr.HTML('<div class="section-title"><i class="fas fa-search"></i> 1단계: 메인 키워드 입력</div>')
|
| 850 |
|
|
@@ -857,10 +870,12 @@ def create_interface():
|
|
| 857 |
|
| 858 |
collect_data_btn = gr.Button("1단계: 상품 데이터 수집하기", elem_classes="custom-button", size="lg")
|
| 859 |
|
|
|
|
| 860 |
with gr.Column(elem_classes="custom-frame fade-in"):
|
| 861 |
gr.HTML('<div class="section-title"><i class="fas fa-database"></i> 2단계: 수집된 키워드 목록</div>')
|
| 862 |
keywords_result = gr.HTML()
|
| 863 |
|
|
|
|
| 864 |
with gr.Column(elem_classes="custom-frame fade-in"):
|
| 865 |
gr.HTML('<div class="section-title"><i class="fas fa-bullseye"></i> 3단계: 분석할 키워드 선택</div>')
|
| 866 |
|
|
@@ -873,21 +888,14 @@ def create_interface():
|
|
| 873 |
|
| 874 |
analyze_keyword_btn = gr.Button("키워드 심충분석 하기", elem_classes="custom-button", size="lg")
|
| 875 |
|
|
|
|
| 876 |
with gr.Column(elem_classes="custom-frame fade-in"):
|
| 877 |
gr.HTML('<div class="section-title"><i class="fas fa-chart-line"></i> 키워드 심충분석</div>')
|
| 878 |
analysis_result = gr.HTML(label="키워드 심충분석")
|
| 879 |
|
|
|
|
| 880 |
with gr.Column(elem_classes="custom-frame fade-in"):
|
| 881 |
gr.HTML('<div class="section-title"><i class="fas fa-download"></i> 분석 결과 출력</div>')
|
| 882 |
-
|
| 883 |
-
gr.HTML("""
|
| 884 |
-
<div style="background: #e3f2fd; border-left: 4px solid #2196f3; padding: 15px; margin: 10px 0; border-radius: 5px;">
|
| 885 |
-
<h4 style="margin: 0 0 10px 0; color: #1976d2;"><i class="fas fa-info-circle"></i> 실제 데이터 출력 버전</h4>
|
| 886 |
-
<p style="margin: 0; color: #1976d2; font-size: 14px;">
|
| 887 |
-
• 분석된 데이터를 파일로 출력됩니다<br>
|
| 888 |
-
</p>
|
| 889 |
-
</div>
|
| 890 |
-
""")
|
| 891 |
|
| 892 |
export_btn = gr.Button("📊 분석결과 출력하기", elem_classes="export-button", size="lg")
|
| 893 |
export_result = gr.HTML()
|
|
@@ -901,10 +909,30 @@ def create_interface():
|
|
| 901 |
# 로딩 상태 표시
|
| 902 |
yield (create_loading_animation(), None)
|
| 903 |
|
| 904 |
-
|
| 905 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 906 |
|
| 907 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 908 |
|
| 909 |
def on_analyze_keyword(analysis_keyword, base_keyword, keywords_data):
|
| 910 |
if not analysis_keyword.strip():
|
|
@@ -913,30 +941,78 @@ def create_interface():
|
|
| 913 |
# 로딩 상태 표시
|
| 914 |
yield create_loading_animation(), {}
|
| 915 |
|
| 916 |
-
#
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
)
|
|
|
|
| 920 |
|
| 921 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 922 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 923 |
def on_export_results(export_data):
|
| 924 |
-
"""
|
| 925 |
try:
|
| 926 |
-
|
| 927 |
-
if isinstance(export_data, dict):
|
| 928 |
-
logger.info(f"📋 export_data 키들: {list(export_data.keys())}")
|
| 929 |
-
|
| 930 |
-
# 강화된 출력 함수 호출 (더미 데이터 제거)
|
| 931 |
-
zip_path, message = export_analysis_results_enhanced(export_data)
|
| 932 |
|
| 933 |
if zip_path:
|
|
|
|
| 934 |
success_html = f"""
|
| 935 |
<div style="background: #d4edda; border: 1px solid #c3e6cb; padding: 20px; border-radius: 8px; margin: 10px 0;">
|
| 936 |
<h4 style="color: #155724; margin: 0 0 15px 0;"><i class="fas fa-check-circle"></i> 출력 완료!</h4>
|
| 937 |
<p style="color: #155724; margin: 0; line-height: 1.6;">
|
| 938 |
{message}<br>
|
| 939 |
-
<strong
|
|
|
|
|
|
|
| 940 |
<br>
|
| 941 |
<i class="fas fa-download"></i> 아래 다운로드 버튼을 클릭하여 파일을 저장하세요.<br>
|
| 942 |
<small style="color: #666;">⏰ 한국시간 기준으로 파일명이 생성됩니다.</small>
|
|
@@ -945,40 +1021,21 @@ def create_interface():
|
|
| 945 |
"""
|
| 946 |
return success_html, gr.update(value=zip_path, visible=True)
|
| 947 |
else:
|
|
|
|
| 948 |
error_html = f"""
|
| 949 |
<div style="background: #f8d7da; border: 1px solid #f5c6cb; padding: 20px; border-radius: 8px; margin: 10px 0;">
|
| 950 |
<h4 style="color: #721c24; margin: 0 0 10px 0;"><i class="fas fa-exclamation-triangle"></i> 출력 실패</h4>
|
| 951 |
<p style="color: #721c24; margin: 0;">{message}</p>
|
| 952 |
-
<div style="margin-top: 15px; padding: 15px; background: white; border-radius: 5px;">
|
| 953 |
-
<h5 style="color: #721c24; margin: 0 0 10px 0;">🔍 디버깅 정보:</h5>
|
| 954 |
-
<ul style="color: #721c24; margin: 0; padding-left: 20px;">
|
| 955 |
-
<li>Export 데이터 타입: {type(export_data)}</li>
|
| 956 |
-
<li>Export 데이터 유효성: {'유효' if export_data else '무효'}</li>
|
| 957 |
-
<li>키워드 심충분석 상태: {'완료' if export_data.get('analysis_completed') else '미완료'}</li>
|
| 958 |
-
</ul>
|
| 959 |
-
</div>
|
| 960 |
</div>
|
| 961 |
"""
|
| 962 |
-
logger.error("❌ 강화된 출력 실패")
|
| 963 |
return error_html, gr.update(visible=False)
|
| 964 |
|
| 965 |
except Exception as e:
|
| 966 |
-
logger.error(f"
|
| 967 |
-
import traceback
|
| 968 |
-
logger.error(f"스택 트레이스:\n{traceback.format_exc()}")
|
| 969 |
-
|
| 970 |
error_html = f"""
|
| 971 |
<div style="background: #f8d7da; border: 1px solid #f5c6cb; padding: 20px; border-radius: 8px; margin: 10px 0;">
|
| 972 |
<h4 style="color: #721c24; margin: 0 0 10px 0;"><i class="fas fa-exclamation-triangle"></i> 시스템 오류</h4>
|
| 973 |
-
<p style="color: #721c24; margin: 0;"
|
| 974 |
-
<code style="display: block; margin: 10px 0; padding: 10px; background: #f8f9fa; border-radius: 3px; color: #721c24;">
|
| 975 |
-
{type(e).__name__}: {str(e)}
|
| 976 |
-
</code>
|
| 977 |
-
<div style="margin-top: 15px; padding: 10px; background: #fff3cd; border-radius: 5px;">
|
| 978 |
-
<p style="margin: 0; color: #856404; font-size: 14px;">
|
| 979 |
-
💡 실제 분석 결과가 있어야만 파일이 생성됩니다.
|
| 980 |
-
</p>
|
| 981 |
-
</div>
|
| 982 |
</div>
|
| 983 |
"""
|
| 984 |
return error_html, gr.update(visible=False)
|
|
@@ -987,26 +1044,80 @@ def create_interface():
|
|
| 987 |
collect_data_btn.click(
|
| 988 |
fn=on_collect_data,
|
| 989 |
inputs=[keyword_input],
|
| 990 |
-
outputs=[keywords_result, keywords_data_state]
|
| 991 |
-
api_name="on_collect_data"
|
| 992 |
)
|
| 993 |
|
| 994 |
analyze_keyword_btn.click(
|
| 995 |
fn=on_analyze_keyword,
|
| 996 |
-
inputs=[analysis_keyword_input, keyword_input, keywords_data_state],
|
| 997 |
-
outputs=[analysis_result, export_data_state]
|
| 998 |
-
api_name="on_analyze_keyword"
|
| 999 |
)
|
| 1000 |
|
| 1001 |
export_btn.click(
|
| 1002 |
fn=on_export_results,
|
| 1003 |
inputs=[export_data_state],
|
| 1004 |
-
outputs=[export_result, download_file]
|
| 1005 |
-
api_name="on_export_results"
|
| 1006 |
)
|
| 1007 |
|
| 1008 |
return interface
|
| 1009 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1010 |
# ===== 메인 실행 =====
|
| 1011 |
if __name__ == "__main__":
|
| 1012 |
# pytz 모듈 설치 확인
|
|
@@ -1014,8 +1125,64 @@ if __name__ == "__main__":
|
|
| 1014 |
import pytz
|
| 1015 |
logger.info("✅ pytz 모듈 로드 성공 - 한국시간 지원")
|
| 1016 |
except ImportError:
|
|
|
|
| 1017 |
logger.info("시스템 시간을 사용합니다.")
|
| 1018 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1019 |
# 앱 실행
|
| 1020 |
app = create_interface()
|
| 1021 |
app.launch(server_name="0.0.0.0", server_port=7860, share=True)
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
AI 상품 소싱 분석 시스템 v2.9 - 출력 기능 추가 + 멀티사용자 안전
|
| 4 |
+
- 연관검색어 엑셀 출력
|
| 5 |
+
- 키워드 심충분석 HTML 출력
|
| 6 |
+
- 압축파일로 결과 다운로드
|
| 7 |
+
- Gemini API 키 통합 관리
|
| 8 |
+
- 한국시간 적용
|
| 9 |
+
- 멀티 사용자 안전: gr.State 사용으로 세션별 데이터 관리
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
import gradio as gr
|
| 13 |
import pandas as pd
|
| 14 |
import os
|
| 15 |
import logging
|
| 16 |
+
import google.generativeai as genai
|
| 17 |
+
from datetime import datetime, timedelta
|
| 18 |
+
import pytz # 한국시간 적용을 위한 추가
|
| 19 |
import time
|
|
|
|
|
|
|
| 20 |
import re
|
| 21 |
+
from collections import Counter
|
| 22 |
+
import zipfile
|
| 23 |
+
import tempfile
|
| 24 |
|
| 25 |
# 로깅 설정
|
| 26 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 27 |
logger = logging.getLogger(__name__)
|
| 28 |
|
| 29 |
+
# 모듈 임포트
|
| 30 |
+
import api_utils
|
| 31 |
+
import text_utils
|
| 32 |
+
import keyword_search
|
| 33 |
+
import product_search
|
| 34 |
+
import keyword_processor
|
| 35 |
+
import export_utils
|
| 36 |
+
import keyword_analysis
|
| 37 |
+
import trend_analysis_v2
|
| 38 |
|
| 39 |
+
# ===== Gemini API 설정 =====
|
| 40 |
+
def setup_gemini_model():
|
| 41 |
+
"""Gemini 모델 초기화 - api_utils에서 관리"""
|
| 42 |
try:
|
| 43 |
+
# api_utils에서 Gemini 모델 가져오기
|
| 44 |
+
model = api_utils.get_gemini_model()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
+
if model:
|
| 47 |
+
logger.info("Gemini 모델 초기화 성공 (api_utils 통합 관리)")
|
| 48 |
+
return model
|
| 49 |
+
else:
|
| 50 |
+
logger.warning("Gemini API 키가 설정되지 않았습니다.")
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
except Exception as e:
|
| 54 |
+
logger.error(f"Gemini 모델 초기화 실패: {e}")
|
| 55 |
return None
|
| 56 |
|
| 57 |
+
# Gemini 모델 초기화
|
| 58 |
+
gemini_model = setup_gemini_model()
|
| 59 |
+
|
| 60 |
# ===== 한국시간 관련 함수 =====
|
| 61 |
def get_korean_time():
|
| 62 |
"""한국시간 반환"""
|
|
|
|
| 77 |
else:
|
| 78 |
return dt.strftime("%y%m%d_%H%M")
|
| 79 |
|
| 80 |
+
# ===== 출력 전용 상태 변수 제거 (멀티 사용자 안전을 위해 gr.State 사용) =====
|
| 81 |
+
# export_state 전역 변수 제거 - 멀티 사용자 환경에서 데이터 혼합 문제 해결
|
| 82 |
+
|
| 83 |
+
# ===== 연관검색어 분석 기능 =====
|
| 84 |
+
def analyze_related_keywords(keyword):
|
| 85 |
+
"""연관검색어 분석 - 네이버 상품 40개를 기반으로 복합키워드 추출"""
|
| 86 |
+
logger.info(f"연관검색어 분석 시작: '{keyword}'")
|
| 87 |
+
|
| 88 |
+
try:
|
| 89 |
+
# 1단계: 네이버 상품 40개 추출
|
| 90 |
+
api_keyword = keyword.replace(" ", "")
|
| 91 |
+
products_data = []
|
| 92 |
+
|
| 93 |
+
# 40개 상품을 가져오기 위해 여러 페이지 호출
|
| 94 |
+
for page in range(1, 5): # 4페이지 * 10개 = 40개
|
| 95 |
+
result = product_search.fetch_products_by_keyword(api_keyword, page=page, display=10)
|
| 96 |
+
if result["status"] == "success" and result["products"]:
|
| 97 |
+
products_data.extend(result["products"])
|
| 98 |
+
else:
|
| 99 |
+
break
|
| 100 |
+
time.sleep(0.3) # API 레이트 리밋 방지
|
| 101 |
+
|
| 102 |
+
if not products_data:
|
| 103 |
+
return {
|
| 104 |
+
"status": "error",
|
| 105 |
+
"message": f"'{keyword}' 키워드로 상품을 찾을 수 없습니다.",
|
| 106 |
+
"keywords_df": pd.DataFrame()
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
# 실제 가져온 상품 수 제한
|
| 110 |
+
products_data = products_data[:40]
|
| 111 |
+
logger.info(f"상품 추출 완료: {len(products_data)}개")
|
| 112 |
+
|
| 113 |
+
# 2단계: 상품명에서 키워드 추출 (스페이스바로 분류)
|
| 114 |
+
all_words = []
|
| 115 |
+
for product in products_data:
|
| 116 |
+
title = product.get("상품명", "")
|
| 117 |
+
# 공백과 쉼표로 분리
|
| 118 |
+
words = re.split(r'[,\s]+', title)
|
| 119 |
+
all_words.extend([word.strip() for word in words if word.strip() and len(word.strip()) >= 1])
|
| 120 |
+
|
| 121 |
+
# 중복 제거
|
| 122 |
+
unique_words = list(set(all_words))
|
| 123 |
+
logger.info(f"추출된 단어 수: {len(unique_words)}개")
|
| 124 |
+
|
| 125 |
+
# 3단계: 분석할 키워드를 앞뒤로 붙여서 복합키워드 생성
|
| 126 |
+
compound_keywords = []
|
| 127 |
+
main_keyword = keyword.strip()
|
| 128 |
+
|
| 129 |
+
for word in unique_words:
|
| 130 |
+
if word != main_keyword and len(word) >= 2: # 단일 글자 제외
|
| 131 |
+
# 앞에 붙이기
|
| 132 |
+
front_compound = f"{word} {main_keyword}"
|
| 133 |
+
compound_keywords.append(front_compound)
|
| 134 |
+
|
| 135 |
+
# 뒤에 붙이기
|
| 136 |
+
back_compound = f"{main_keyword} {word}"
|
| 137 |
+
compound_keywords.append(back_compound)
|
| 138 |
+
|
| 139 |
+
# 중복 제거
|
| 140 |
+
compound_keywords = list(set(compound_keywords))
|
| 141 |
+
logger.info(f"생성된 복합키워드 수: {len(compound_keywords)}개")
|
| 142 |
+
|
| 143 |
+
# 4단계: 검색량 추출
|
| 144 |
+
api_keywords = [kw.replace(" ", "") for kw in compound_keywords]
|
| 145 |
+
search_volumes = keyword_search.fetch_all_search_volumes(api_keywords)
|
| 146 |
+
|
| 147 |
+
# 5단계: 앞뒤 키워드 중 높은 것 선택, 낮은 것 제거
|
| 148 |
+
keyword_pairs = {} # {base_word: {"front": front_kw, "back": back_kw, "front_vol": vol, "back_vol": vol}}
|
| 149 |
+
|
| 150 |
+
for word in unique_words:
|
| 151 |
+
if word != main_keyword and len(word) >= 2:
|
| 152 |
+
front_kw = f"{word} {main_keyword}"
|
| 153 |
+
back_kw = f"{main_keyword} {word}"
|
| 154 |
+
|
| 155 |
+
front_api = front_kw.replace(" ", "")
|
| 156 |
+
back_api = back_kw.replace(" ", "")
|
| 157 |
+
|
| 158 |
+
front_vol = search_volumes.get(front_api, {}).get("총검색량", 0)
|
| 159 |
+
back_vol = search_volumes.get(back_api, {}).get("총검색량", 0)
|
| 160 |
+
|
| 161 |
+
keyword_pairs[word] = {
|
| 162 |
+
"front": front_kw,
|
| 163 |
+
"back": back_kw,
|
| 164 |
+
"front_vol": front_vol,
|
| 165 |
+
"back_vol": back_vol
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
# 6단계: 높은 검색량의 키워드만 선택
|
| 169 |
+
final_keywords = []
|
| 170 |
+
for word, data in keyword_pairs.items():
|
| 171 |
+
if data["front_vol"] > data["back_vol"]:
|
| 172 |
+
selected_kw = data["front"]
|
| 173 |
+
selected_vol = data["front_vol"]
|
| 174 |
+
selected_api = selected_kw.replace(" ", "")
|
| 175 |
+
elif data["back_vol"] > data["front_vol"]:
|
| 176 |
+
selected_kw = data["back"]
|
| 177 |
+
selected_vol = data["back_vol"]
|
| 178 |
+
selected_api = selected_kw.replace(" ", "")
|
| 179 |
+
elif data["front_vol"] == data["back_vol"] and data["front_vol"] > 0:
|
| 180 |
+
# 같은 검색량이면 자연스러운 순서 선택 (일반적으로 뒤에 붙이는 것이 자연스러움)
|
| 181 |
+
selected_kw = data["back"]
|
| 182 |
+
selected_vol = data["back_vol"]
|
| 183 |
+
selected_api = selected_kw.replace(" ", "")
|
| 184 |
+
else:
|
| 185 |
+
# 둘 다 0이면 제외
|
| 186 |
+
continue
|
| 187 |
+
|
| 188 |
+
if selected_vol > 0: # 검색량이 있는 것만 포함
|
| 189 |
+
vol_data = search_volumes.get(selected_api, {})
|
| 190 |
+
final_keywords.append({
|
| 191 |
+
"연관 키워드": selected_kw,
|
| 192 |
+
"PC검색량": vol_data.get("PC검색량", 0),
|
| 193 |
+
"모바일검색량": vol_data.get("모바일검색량", 0),
|
| 194 |
+
"총검색량": selected_vol,
|
| 195 |
+
"검색량구간": text_utils.get_search_volume_range(selected_vol)
|
| 196 |
+
})
|
| 197 |
+
|
| 198 |
+
# 검색량 기준으로 내림차순 정렬
|
| 199 |
+
final_keywords = sorted(final_keywords, key=lambda x: x["총검색량"], reverse=True)
|
| 200 |
+
|
| 201 |
+
# DataFrame 생성
|
| 202 |
+
df_keywords = pd.DataFrame(final_keywords)
|
| 203 |
+
|
| 204 |
+
logger.info(f"연관검색어 분석 완료: {len(final_keywords)}개 키워드")
|
| 205 |
+
|
| 206 |
+
return {
|
| 207 |
+
"status": "success",
|
| 208 |
+
"message": f"'{keyword}' 연관검색어 {len(final_keywords)}개를 찾았습니다.",
|
| 209 |
+
"keywords_df": df_keywords,
|
| 210 |
+
"total_products": len(products_data)
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
except Exception as e:
|
| 214 |
+
logger.error(f"연관검색어 분석 오류: {e}")
|
| 215 |
+
return {
|
| 216 |
+
"status": "error",
|
| 217 |
+
"message": f"연관검색어 분석 중 오류가 발생했습니다: {str(e)}",
|
| 218 |
+
"keywords_df": pd.DataFrame()
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
# ===== 로딩 애니메이션 =====
|
| 222 |
+
def create_loading_animation():
|
| 223 |
+
"""로딩 애니메이션 HTML"""
|
| 224 |
+
return """
|
| 225 |
+
<div style="display: flex; flex-direction: column; align-items: center; padding: 40px; background: white; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);">
|
| 226 |
+
<div style="width: 60px; height: 60px; border: 4px solid #f3f3f3; border-top: 4px solid #FB7F0D; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 20px;"></div>
|
| 227 |
+
<h3 style="color: #FB7F0D; margin: 10px 0; font-size: 18px;">분석 중입니다...</h3>
|
| 228 |
+
<p style="color: #666; margin: 5px 0; text-align: center;">네이버 데이터를 수집하고 AI가 분석하고 있습니다.<br>잠시만 기다려주세요.</p>
|
| 229 |
+
<div style="width: 200px; height: 4px; background: #f0f0f0; border-radius: 2px; margin-top: 15px; overflow: hidden;">
|
| 230 |
+
<div style="width: 100%; height: 100%; background: linear-gradient(90deg, #FB7F0D, #ff9a8b); border-radius: 2px; animation: progress 2s ease-in-out infinite;"></div>
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
|
| 234 |
+
<style>
|
| 235 |
+
@keyframes spin {
|
| 236 |
+
0% { transform: rotate(0deg); }
|
| 237 |
+
100% { transform: rotate(360deg); }
|
| 238 |
}
|
| 239 |
|
| 240 |
+
@keyframes progress {
|
| 241 |
+
0% { transform: translateX(-100%); }
|
| 242 |
+
100% { transform: translateX(100%); }
|
| 243 |
+
}
|
| 244 |
+
</style>
|
| 245 |
+
"""
|
| 246 |
+
|
| 247 |
+
# ===== 에러 처리 함수 =====
|
| 248 |
+
def generate_error_response(error_message):
|
| 249 |
+
"""에러 응답 생성"""
|
| 250 |
+
return f'''
|
| 251 |
+
<div style="color: red; padding: 30px; text-align: center; width: 100%;
|
| 252 |
+
background-color: #f8d7da; border-radius: 12px; border: 1px solid #f5c6cb;">
|
| 253 |
+
<h3 style="margin-bottom: 15px;">❌ 분석 오류</h3>
|
| 254 |
+
<p style="margin-bottom: 20px;">{error_message}</p>
|
| 255 |
+
<div style="background: white; padding: 15px; border-radius: 8px; color: #333;">
|
| 256 |
+
<h4>해결 방법:</h4>
|
| 257 |
+
<ul style="text-align: left; padding-left: 20px;">
|
| 258 |
+
<li>키워드 철자를 확인해주세요</li>
|
| 259 |
+
<li>더 간단한 키워드를 사용해보세요</li>
|
| 260 |
+
<li>네트워크 연결을 확인해주세요</li>
|
| 261 |
+
<li>잠시 후 다시 시도해주세요</li>
|
| 262 |
+
</ul>
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
'''
|
| 266 |
+
|
| 267 |
+
# ===== 메인 키워드 분석 함수 =====
|
| 268 |
+
def safe_keyword_analysis(analysis_keyword, base_keyword, keywords_data):
|
| 269 |
+
"""에러 방지를 위한 안전한 키워드 분석 - 세션별 데이터 반환"""
|
| 270 |
+
|
| 271 |
+
# 입력값 검증
|
| 272 |
+
if not analysis_keyword or not analysis_keyword.strip():
|
| 273 |
+
return generate_error_response("분석할 키워드를 입력해주세요."), {}
|
| 274 |
+
|
| 275 |
+
analysis_keyword = analysis_keyword.strip()
|
| 276 |
+
|
| 277 |
+
try:
|
| 278 |
+
# 검색량 조회 - 에러 방지
|
| 279 |
+
api_keyword = keyword_analysis.normalize_keyword_for_api(analysis_keyword)
|
| 280 |
+
search_volumes = keyword_search.fetch_all_search_volumes([api_keyword])
|
| 281 |
+
volume_data = search_volumes.get(api_keyword, {"PC검색량": 0, "모바일검색량": 0, "총검색량": 0})
|
| 282 |
+
|
| 283 |
+
# 검색량이 0이거나 키워드가 존재하지 않는 경우 처리
|
| 284 |
+
if volume_data['총검색량'] == 0:
|
| 285 |
+
logger.warning(f"'{analysis_keyword}' 키워드의 검색량이 0이거나 존재하지 않습니다.")
|
| 286 |
+
error_result = f"""
|
| 287 |
+
<div style="padding: 30px; text-align: center; background: #fff3cd; border-radius: 12px; border: 1px solid #ffeaa7;">
|
| 288 |
+
<h3 style="color: #856404; margin-bottom: 15px;">⚠️ 키워드 분석 불가</h3>
|
| 289 |
+
<p style="color: #856404; margin-bottom: 10px;"><strong>'{analysis_keyword}'</strong> 키워드는 검색량이 없거나 올바르지 않은 키워드입니다.</p>
|
| 290 |
+
<div style="background: white; padding: 15px; border-radius: 8px; margin-top: 15px;">
|
| 291 |
+
<h4 style="color: #333; margin-bottom: 10px;">💡 권장사항</h4>
|
| 292 |
+
<ul style="text-align: left; color: #666; padding-left: 20px;">
|
| 293 |
+
<li>키워드 철자를 확인해주세요</li>
|
| 294 |
+
<li>더 일반적인 키워드를 사용해보세요</li>
|
| 295 |
+
<li>2단계에서 제안한 키워드 목록을 참고해주세요</li>
|
| 296 |
+
<li>키워드를 띄어쓰기로 구분해보세요 (예: '여성 슬리퍼')</li>
|
| 297 |
+
</ul>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
"""
|
| 301 |
+
return error_result, {}
|
| 302 |
+
|
| 303 |
+
logger.info(f"'{analysis_keyword}' 현재 검색량: {volume_data['총검색량']:,}")
|
| 304 |
+
|
| 305 |
+
# 트렌드 분석 시도
|
| 306 |
+
monthly_data_1year = {}
|
| 307 |
+
monthly_data_3year = {}
|
| 308 |
+
trend_available = False
|
| 309 |
+
|
| 310 |
+
try:
|
| 311 |
+
# 데이터랩 API 키 확인
|
| 312 |
+
datalab_config = api_utils.get_next_datalab_api_config()
|
| 313 |
+
if datalab_config and not datalab_config["CLIENT_ID"].startswith("YOUR_"):
|
| 314 |
+
logger.info("데이터랩 API 키가 설정되어 있어 1년, 3년 트렌드 분석을 시도합니다.")
|
| 315 |
+
|
| 316 |
+
# 최적화된 API 함수 사용
|
| 317 |
+
# 1년 트렌드 데이터
|
| 318 |
+
trend_data_1year = trend_analysis_v2.get_naver_trend_data_v5([analysis_keyword], "1year", max_retries=3)
|
| 319 |
+
if trend_data_1year:
|
| 320 |
+
current_volumes = {api_keyword: volume_data}
|
| 321 |
+
monthly_data_1year = trend_analysis_v2.calculate_monthly_volumes_v7([analysis_keyword], current_volumes, trend_data_1year, "1year")
|
| 322 |
+
|
| 323 |
+
# 3년 트렌드 데이터
|
| 324 |
+
trend_data_3year = trend_analysis_v2.get_naver_trend_data_v5([analysis_keyword], "3year", max_retries=3)
|
| 325 |
+
if trend_data_3year:
|
| 326 |
+
current_volumes = {api_keyword: volume_data}
|
| 327 |
+
monthly_data_3year = trend_analysis_v2.calculate_monthly_volumes_v7([analysis_keyword], current_volumes, trend_data_3year, "3year")
|
| 328 |
+
|
| 329 |
+
# 3년 데이터가 없는 경우 1년 데이터로 확장
|
| 330 |
+
if not monthly_data_3year and monthly_data_1year:
|
| 331 |
+
logger.info("3년 데이터가 없어 1년 데이터를 기반으로 3년 차트 생성")
|
| 332 |
+
keyword = analysis_keyword
|
| 333 |
+
if keyword in monthly_data_1year:
|
| 334 |
+
data_1y = monthly_data_1year[keyword]
|
| 335 |
+
|
| 336 |
+
# 3년 분량의 날짜 생성 (24개월 추가)
|
| 337 |
+
extended_dates = []
|
| 338 |
+
extended_volumes = []
|
| 339 |
+
|
| 340 |
+
# 기존 1년 데이터 이전에 24개월 추가 (모두 0으로)
|
| 341 |
+
start_date = datetime.strptime(data_1y["dates"][0], "%Y-%m-%d")
|
| 342 |
+
for i in range(24, 0, -1):
|
| 343 |
+
prev_date = start_date - timedelta(days=30 * i)
|
| 344 |
+
extended_dates.append(prev_date.strftime("%Y-%m-%d"))
|
| 345 |
+
extended_volumes.append(0)
|
| 346 |
+
|
| 347 |
+
# 기존 1년 데이터 추가 (예상 데이터 제외)
|
| 348 |
+
actual_count = data_1y.get("actual_count", len(data_1y["dates"]))
|
| 349 |
+
extended_dates.extend(data_1y["dates"][:actual_count])
|
| 350 |
+
extended_volumes.extend(data_1y["monthly_volumes"][:actual_count])
|
| 351 |
+
|
| 352 |
+
monthly_data_3year = {
|
| 353 |
+
keyword: {
|
| 354 |
+
"monthly_volumes": extended_volumes,
|
| 355 |
+
"dates": extended_dates,
|
| 356 |
+
"current_volume": data_1y["current_volume"],
|
| 357 |
+
"growth_rate": trend_analysis_v2.calculate_3year_growth_rate_improved(extended_volumes),
|
| 358 |
+
"volume_per_percent": data_1y["volume_per_percent"],
|
| 359 |
+
"current_ratio": data_1y["current_ratio"],
|
| 360 |
+
"actual_count": len(extended_volumes),
|
| 361 |
+
"predicted_count": 0
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
if monthly_data_1year or monthly_data_3year:
|
| 366 |
+
trend_available = True
|
| 367 |
+
logger.info("트렌드 분석 성공")
|
| 368 |
+
else:
|
| 369 |
+
logger.info("트렌드 데이터 처리 실패")
|
| 370 |
else:
|
| 371 |
+
logger.info("데이터랩 API 키가 설정되지 않음")
|
| 372 |
+
except Exception as e:
|
| 373 |
+
logger.info(f"트렌드 분석 건너뜀: {str(e)[:100]}")
|
| 374 |
+
|
| 375 |
+
# 키워드 데이터 준비
|
| 376 |
+
step2_keywords_df = keywords_data.get("keywords_df") if keywords_data else None
|
| 377 |
+
filtered_keywords_df = step2_keywords_df # 단순히 원본 데이터 사용
|
| 378 |
+
target_categories = [] # 빈 리스트
|
| 379 |
+
|
| 380 |
+
# === 📈 검색량 트렌드 분석 섹션 ===
|
| 381 |
+
if trend_available and (monthly_data_1year or monthly_data_3year):
|
| 382 |
+
try:
|
| 383 |
+
trend_chart = trend_analysis_v2.create_trend_chart_v7(monthly_data_1year, monthly_data_3year)
|
| 384 |
+
except Exception as e:
|
| 385 |
+
logger.warning(f"트렌드 차트 생성 실패, 기본 차트 사용: {e}")
|
| 386 |
+
trend_chart = trend_analysis_v2.create_enhanced_current_chart(volume_data, analysis_keyword)
|
| 387 |
+
else:
|
| 388 |
+
trend_chart = trend_analysis_v2.create_enhanced_current_chart(volume_data, analysis_keyword)
|
| 389 |
+
|
| 390 |
+
# 트렌드 섹션
|
| 391 |
+
trend_section = f"""
|
| 392 |
+
<div style="width: 100%; margin: 30px auto; font-family: 'Pretendard', sans-serif;">
|
| 393 |
+
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 15px; border-radius: 10px 10px 0 0; color: white; text-align: center;">
|
| 394 |
+
<h3 style="margin: 0; font-size: 18px; color: white;">📈 검색량 트렌드 분석</h3>
|
| 395 |
+
</div>
|
| 396 |
+
<div style="background: white; padding: 20px; border-radius: 0 0 10px 10px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
|
| 397 |
+
{trend_chart}
|
| 398 |
+
</div>
|
| 399 |
+
</div>
|
| 400 |
+
"""
|
| 401 |
+
|
| 402 |
+
# === 🎯 키워드 분석 섹션 (AI 분석) ===
|
| 403 |
+
# api_utils에서 Gemini 모델 가져오기
|
| 404 |
+
current_gemini_model = api_utils.get_gemini_model()
|
| 405 |
+
|
| 406 |
+
keyword_analysis_html = keyword_analysis.analyze_keyword_for_sourcing(
|
| 407 |
+
analysis_keyword, volume_data, monthly_data_1year, monthly_data_3year,
|
| 408 |
+
filtered_keywords_df, target_categories, current_gemini_model
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
keyword_analysis_section = f"""
|
| 412 |
+
<div style="width: 100%; margin: 30px auto; font-family: 'Pretendard', sans-serif;">
|
| 413 |
+
<div style="background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); padding: 15px; border-radius: 10px 10px 0 0; color: white; text-align: center;">
|
| 414 |
+
<h3 style="margin: 0; font-size: 18px; color: white;">🎯 키워드 분석</h3>
|
| 415 |
+
</div>
|
| 416 |
+
<div style="background: white; padding: 20px; border-radius: 0 0 10px 10px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); overflow: hidden;">
|
| 417 |
+
{keyword_analysis_html}
|
| 418 |
+
</div>
|
| 419 |
+
</div>
|
| 420 |
+
"""
|
| 421 |
+
|
| 422 |
+
# 경고 섹션 (필요한 경우)
|
| 423 |
+
warning_section = ""
|
| 424 |
+
if not trend_available:
|
| 425 |
+
warning_section = f"""
|
| 426 |
+
<div style="width: 100%; margin: 20px auto; padding: 15px; background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 8px; font-family: 'Pretendard', sans-serif;">
|
| 427 |
+
<div style="display: flex; align-items: center;">
|
| 428 |
+
<span style="font-size: 20px; margin-right: 10px;">⚠️</span>
|
| 429 |
+
<div>
|
| 430 |
+
<strong style="color: #856404;">일부 기능 제한</strong>
|
| 431 |
+
<div style="font-size: 14px; color: #856404; margin-top: 5px;">
|
| 432 |
+
트렌드 분석에 제한이 있습니다. 현재 검색량 분석과 AI 추천은 정상 제공됩니다.<br>
|
| 433 |
+
<small>완전한 월 데이터 기준으로 분석하기 위해 최신 완료된 월까지만 표시됩니다.</small>
|
| 434 |
+
</div>
|
| 435 |
+
</div>
|
| 436 |
+
</div>
|
| 437 |
+
</div>
|
| 438 |
+
"""
|
| 439 |
+
|
| 440 |
+
# 최종 결과 조합
|
| 441 |
+
final_result = warning_section + trend_section + keyword_analysis_section
|
| 442 |
+
|
| 443 |
+
# 세션별 출력용 상태 데이터 반환 (멀티 사용자 안전)
|
| 444 |
+
session_export_data = {
|
| 445 |
+
"main_keyword": base_keyword,
|
| 446 |
+
"analysis_keyword": analysis_keyword,
|
| 447 |
+
"main_keywords_df": keywords_data.get("keywords_df") if keywords_data else None,
|
| 448 |
+
"related_keywords_df": None, # 여기서는 연관검색어 분석하지 않음
|
| 449 |
+
"analysis_html": final_result
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
return final_result, session_export_data
|
| 453 |
+
|
| 454 |
+
except Exception as e:
|
| 455 |
+
logger.error(f"키워드 분석 중 전체 오류: {e}")
|
| 456 |
+
error_result = generate_error_response(f"키워드 분석 중 오류가 발생했습니다: {str(e)}")
|
| 457 |
+
return error_result, {}
|
| 458 |
|
| 459 |
+
# ===== 2단계: 상품 데이터 기반 키워드 추출 =====
|
| 460 |
+
def extract_keywords_from_products(keyword):
|
| 461 |
+
"""네이버 쇼핑에서 실제 상품 데이터를 수집하고 모든 키워드 표시"""
|
| 462 |
+
logger.info(f"상품 키워드 추출 시작: 키워드='{keyword}'")
|
| 463 |
+
|
| 464 |
+
api_keyword = keyword_analysis.normalize_keyword_for_api(keyword)
|
| 465 |
+
search_results = product_search.fetch_naver_shopping_data(
|
| 466 |
+
keyword, korean_only=True, apply_main_keyword=True, exclude_zero_volume=True
|
| 467 |
+
)
|
| 468 |
|
| 469 |
+
if not search_results.get("product_list"):
|
|
|
|
| 470 |
return {
|
| 471 |
+
"status": "error",
|
| 472 |
+
"message": "상품 데이터를 가져올 수 없습니다.",
|
| 473 |
+
"products": [],
|
| 474 |
+
"keywords": []
|
|
|
|
|
|
|
| 475 |
}
|
| 476 |
|
| 477 |
+
processed_results = keyword_processor.process_search_results(
|
| 478 |
+
search_results, keyword, exclude_zero_volume=True
|
| 479 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
|
| 481 |
+
df_keywords = processed_results["keywords_df"]
|
| 482 |
+
df_products = processed_results["products_df"]
|
|
|
|
|
|
|
| 483 |
|
| 484 |
+
if df_keywords.empty:
|
| 485 |
+
return {
|
| 486 |
+
"status": "error",
|
| 487 |
+
"message": "추출된 키워드가 없습니다.",
|
| 488 |
+
"products": [],
|
| 489 |
+
"keywords": []
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
logger.info(f"키워드 추출 완료: 총 {len(df_keywords)}개 키워드")
|
| 493 |
+
|
| 494 |
+
return {
|
| 495 |
+
"status": "success",
|
| 496 |
+
"message": "키워드 추출 완료",
|
| 497 |
+
"products": df_products,
|
| 498 |
+
"keywords_df": df_keywords,
|
| 499 |
+
"categories": processed_results["categories"]
|
| 500 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
|
| 502 |
# ===== 파일 출력 함수들 =====
|
| 503 |
def create_timestamp_filename(analysis_keyword):
|
|
|
|
| 508 |
return f"{safe_keyword}_{timestamp}_분석결과"
|
| 509 |
|
| 510 |
def export_to_excel(main_keyword, main_keywords_df, analysis_keyword, related_keywords_df, filename_base):
|
| 511 |
+
"""엑셀 파일로 출력"""
|
| 512 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
excel_filename = f"{filename_base}.xlsx"
|
| 514 |
excel_path = os.path.join(tempfile.gettempdir(), excel_filename)
|
| 515 |
|
|
|
|
| 541 |
'border': 1
|
| 542 |
})
|
| 543 |
|
| 544 |
+
# 첫 번째 시트: 메인키워드 조합키워드
|
| 545 |
+
if main_keywords_df is not None and not main_keywords_df.empty:
|
| 546 |
main_keywords_df.to_excel(writer, sheet_name=f'{main_keyword}_조합키워드', index=False)
|
| 547 |
worksheet1 = writer.sheets[f'{main_keyword}_조합키워드']
|
| 548 |
|
|
|
|
| 553 |
# 데이터 스타일 적용
|
| 554 |
for row_num in range(1, len(main_keywords_df) + 1):
|
| 555 |
for col_num, value in enumerate(main_keywords_df.iloc[row_num-1]):
|
| 556 |
+
if col_num in [1, 2, 3]: # PC검색량, 모바일검색량, 총검색량 컬럼
|
| 557 |
worksheet1.write(row_num, col_num, value, number_format)
|
| 558 |
else:
|
| 559 |
worksheet1.write(row_num, col_num, value, data_format)
|
|
|
|
| 565 |
len(str(col))
|
| 566 |
)
|
| 567 |
worksheet1.set_column(i, i, min(max_len + 2, 50))
|
|
|
|
|
|
|
| 568 |
|
| 569 |
+
# 두 번째 시트: 분석키워드 연관검색어
|
| 570 |
+
if related_keywords_df is not None and not related_keywords_df.empty:
|
| 571 |
related_keywords_df.to_excel(writer, sheet_name=f'{analysis_keyword}_연관검색어', index=False)
|
| 572 |
worksheet2 = writer.sheets[f'{analysis_keyword}_연관검색어']
|
| 573 |
|
|
|
|
| 578 |
# 데이터 스타일 적용
|
| 579 |
for row_num in range(1, len(related_keywords_df) + 1):
|
| 580 |
for col_num, value in enumerate(related_keywords_df.iloc[row_num-1]):
|
| 581 |
+
if col_num in [1, 2, 3]: # PC검색량, 모바일검색량, 총검색량 컬럼
|
| 582 |
worksheet2.write(row_num, col_num, value, number_format)
|
| 583 |
else:
|
| 584 |
worksheet2.write(row_num, col_num, value, data_format)
|
|
|
|
| 590 |
len(str(col))
|
| 591 |
)
|
| 592 |
worksheet2.set_column(i, i, min(max_len + 2, 50))
|
|
|
|
|
|
|
| 593 |
|
| 594 |
logger.info(f"엑셀 파일 생성 완료: {excel_path}")
|
| 595 |
return excel_path
|
|
|
|
| 719 |
<div class="container">
|
| 720 |
<div class="header">
|
| 721 |
<h1><i class="fas fa-chart-line"></i> 키워드 심충분석 결과</h1>
|
| 722 |
+
<p>AI 상품 소싱 분석 시스템 v2.9</p>
|
| 723 |
</div>
|
| 724 |
<div class="content">
|
| 725 |
{analysis_html}
|
|
|
|
| 748 |
zip_filename = f"{filename_base}.zip"
|
| 749 |
zip_path = os.path.join(tempfile.gettempdir(), zip_filename)
|
| 750 |
|
|
|
|
| 751 |
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
| 752 |
if excel_path and os.path.exists(excel_path):
|
| 753 |
zipf.write(excel_path, f"{filename_base}.xlsx")
|
| 754 |
logger.info(f"엑셀 파일 압축 추가: {filename_base}.xlsx")
|
|
|
|
| 755 |
|
| 756 |
if html_path and os.path.exists(html_path):
|
| 757 |
zipf.write(html_path, f"{filename_base}.html")
|
| 758 |
logger.info(f"HTML 파일 압축 추가: {filename_base}.html")
|
|
|
|
| 759 |
|
| 760 |
+
logger.info(f"압축 파일 생성 완료: {zip_path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 761 |
return zip_path
|
| 762 |
|
| 763 |
except Exception as e:
|
| 764 |
logger.error(f"압축 파일 생성 오류: {e}")
|
| 765 |
return None
|
| 766 |
|
| 767 |
+
def export_analysis_results(export_data):
|
| 768 |
+
"""분석 결과 출력 메인 함수 - 세션별 데이터 처리"""
|
| 769 |
try:
|
| 770 |
+
# 출력할 데이터 확인
|
| 771 |
+
if not export_data or not isinstance(export_data, dict):
|
| 772 |
+
return None, "분석 데이터가 없습니다. 먼저 키워드 심충분석을 실행해주세요."
|
|
|
|
| 773 |
|
| 774 |
+
analysis_keyword = export_data.get("analysis_keyword", "")
|
| 775 |
+
analysis_html = export_data.get("analysis_html", "")
|
| 776 |
+
main_keyword = export_data.get("main_keyword", "")
|
| 777 |
main_keywords_df = export_data.get("main_keywords_df")
|
| 778 |
related_keywords_df = export_data.get("related_keywords_df")
|
| 779 |
|
| 780 |
+
if not analysis_keyword:
|
| 781 |
+
return None, "분석할 키워드가 설정되지 않았습니다. 먼저 키워드 분석을 실행해주세요."
|
| 782 |
+
|
| 783 |
+
if not analysis_html:
|
| 784 |
+
return None, "분석 결과가 없습니다. 먼저 키워드 심충분석을 실행해주세요."
|
|
|
|
| 785 |
|
| 786 |
# 파일명 생성 (한국시간 적용)
|
| 787 |
filename_base = create_timestamp_filename(analysis_keyword)
|
| 788 |
+
logger.info(f"출력 파일명: {filename_base}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 789 |
|
| 790 |
+
# 엑셀 파일 생성
|
| 791 |
excel_path = None
|
| 792 |
+
if main_keywords_df is not None or related_keywords_df is not None:
|
|
|
|
|
|
|
| 793 |
excel_path = export_to_excel(
|
| 794 |
main_keyword,
|
| 795 |
main_keywords_df,
|
|
|
|
| 797 |
related_keywords_df,
|
| 798 |
filename_base
|
| 799 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
|
| 801 |
+
# HTML 파일 생성
|
| 802 |
+
html_path = export_to_html(analysis_html, filename_base)
|
|
|
|
|
|
|
| 803 |
|
| 804 |
# 압축 파일 생성
|
| 805 |
+
if excel_path or html_path:
|
| 806 |
+
zip_path = create_zip_file(excel_path, html_path, filename_base)
|
| 807 |
+
if zip_path:
|
| 808 |
+
return zip_path, f"✅ 분석 결과가 성공적으로 출력되었습니다!\n파일명: {filename_base}.zip"
|
| 809 |
+
else:
|
| 810 |
+
return None, "압축 파일 생성에 실패했습니다."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 811 |
else:
|
| 812 |
+
return None, "출력할 파일이 없습니다."
|
|
|
|
| 813 |
|
| 814 |
except Exception as e:
|
| 815 |
+
logger.error(f"분석 결과 출력 오류: {e}")
|
|
|
|
|
|
|
| 816 |
return None, f"출력 중 오류가 발생했습니다: {str(e)}"
|
| 817 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 818 |
# ===== 그라디오 인터페이스 =====
|
| 819 |
def create_interface():
|
| 820 |
+
# CSS 파일 로드
|
| 821 |
+
try:
|
| 822 |
+
with open('style.css', 'r', encoding='utf-8') as f:
|
| 823 |
+
custom_css = f.read()
|
| 824 |
+
|
| 825 |
+
with open('keyword_analysis_report.css', 'r', encoding='utf-8') as f:
|
| 826 |
+
keyword_css = f.read()
|
| 827 |
+
custom_css += "\n" + keyword_css
|
| 828 |
+
except:
|
| 829 |
+
custom_css = """
|
| 830 |
+
:root { --primary-color: #FB7F0D; --secondary-color: #ff9a8b; }
|
| 831 |
+
.custom-button {
|
| 832 |
+
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)) !important;
|
| 833 |
+
color: white !important; border-radius: 30px !important; height: 45px !important;
|
| 834 |
+
font-size: 16px !important; font-weight: bold !important; width: 100% !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 835 |
}
|
| 836 |
+
.export-button {
|
| 837 |
+
background: linear-gradient(135deg, #28a745, #20c997) !important;
|
| 838 |
+
color: white !important; border-radius: 25px !important; height: 50px !important;
|
| 839 |
+
font-size: 17px !important; font-weight: bold !important; width: 100% !important;
|
| 840 |
+
margin-top: 20px !important;
|
| 841 |
+
}
|
| 842 |
+
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 843 |
|
| 844 |
with gr.Blocks(
|
| 845 |
css=custom_css,
|
| 846 |
+
title="🛒 AI 상품 소싱 분석기 v2.9",
|
| 847 |
theme=gr.themes.Default(primary_hue="orange", secondary_hue="orange")
|
| 848 |
) as interface:
|
| 849 |
|
|
|
|
| 853 |
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css">
|
| 854 |
""")
|
| 855 |
|
| 856 |
+
# 세션별 상태 변수 (멀티 사용자 안전)
|
| 857 |
keywords_data_state = gr.State()
|
| 858 |
export_data_state = gr.State({})
|
| 859 |
|
| 860 |
+
# === 1단계: 메인 키워드 입력 ===
|
| 861 |
with gr.Column(elem_classes="custom-frame fade-in"):
|
| 862 |
gr.HTML('<div class="section-title"><i class="fas fa-search"></i> 1단계: 메인 키워드 입력</div>')
|
| 863 |
|
|
|
|
| 870 |
|
| 871 |
collect_data_btn = gr.Button("1단계: 상품 데이터 수집하기", elem_classes="custom-button", size="lg")
|
| 872 |
|
| 873 |
+
# === 2단계: 수집된 키워드 목록 ===
|
| 874 |
with gr.Column(elem_classes="custom-frame fade-in"):
|
| 875 |
gr.HTML('<div class="section-title"><i class="fas fa-database"></i> 2단계: 수집된 키워드 목록</div>')
|
| 876 |
keywords_result = gr.HTML()
|
| 877 |
|
| 878 |
+
# === 3단계: 분석할 키워드 선택 ===
|
| 879 |
with gr.Column(elem_classes="custom-frame fade-in"):
|
| 880 |
gr.HTML('<div class="section-title"><i class="fas fa-bullseye"></i> 3단계: 분석할 키워드 선택</div>')
|
| 881 |
|
|
|
|
| 888 |
|
| 889 |
analyze_keyword_btn = gr.Button("키워드 심충분석 하기", elem_classes="custom-button", size="lg")
|
| 890 |
|
| 891 |
+
# === 키워드 심충분석 ===
|
| 892 |
with gr.Column(elem_classes="custom-frame fade-in"):
|
| 893 |
gr.HTML('<div class="section-title"><i class="fas fa-chart-line"></i> 키워드 심충분석</div>')
|
| 894 |
analysis_result = gr.HTML(label="키워드 심충분석")
|
| 895 |
|
| 896 |
+
# === 결과 출력 섹션 ===
|
| 897 |
with gr.Column(elem_classes="custom-frame fade-in"):
|
| 898 |
gr.HTML('<div class="section-title"><i class="fas fa-download"></i> 분석 결과 출력</div>')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 899 |
|
| 900 |
export_btn = gr.Button("📊 분석결과 출력하기", elem_classes="export-button", size="lg")
|
| 901 |
export_result = gr.HTML()
|
|
|
|
| 909 |
# 로딩 상태 표시
|
| 910 |
yield (create_loading_animation(), None)
|
| 911 |
|
| 912 |
+
result = extract_keywords_from_products(keyword)
|
| 913 |
+
|
| 914 |
+
if result["status"] == "error":
|
| 915 |
+
yield (f"<div style='color: red; padding: 20px; text-align: center; width: 100%;'>{result['message']}</div>", None)
|
| 916 |
+
return
|
| 917 |
+
|
| 918 |
+
keywords_df = result["keywords_df"]
|
| 919 |
+
html_table = export_utils.create_table_without_checkboxes(keywords_df)
|
| 920 |
|
| 921 |
+
success_html = f"""
|
| 922 |
+
<div style="width: 100%; background: #d4edda; border: 1px solid #c3e6cb; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
| 923 |
+
<h4 style="color: #155724; margin: 0 0 10px 0;">✅ 네이버 데이터 수집 완료!</h4>
|
| 924 |
+
<p style="margin: 0; color: #155724;">
|
| 925 |
+
• 실제 상품 {len(result['products'])}개 분석<br>
|
| 926 |
+
• 추출된 키워드: <strong>{len(keywords_df)}개</strong><br>
|
| 927 |
+
• 아래 목록에서 원하는 키워드를 선택하여 분석하세요
|
| 928 |
+
</p>
|
| 929 |
+
</div>
|
| 930 |
+
|
| 931 |
+
<h5 style="margin: 20px 0 10px 0; color: #495057;">📊 전체 키워드 목록</h5>
|
| 932 |
+
{html_table}
|
| 933 |
+
"""
|
| 934 |
+
|
| 935 |
+
yield (success_html, result)
|
| 936 |
|
| 937 |
def on_analyze_keyword(analysis_keyword, base_keyword, keywords_data):
|
| 938 |
if not analysis_keyword.strip():
|
|
|
|
| 941 |
# 로딩 상태 표시
|
| 942 |
yield create_loading_animation(), {}
|
| 943 |
|
| 944 |
+
# 연관검색어 분석 먼저 실행
|
| 945 |
+
related_result = analyze_related_keywords(analysis_keyword)
|
| 946 |
+
|
| 947 |
+
# 실제 키워드 분석 실행 (세션별 데이터 반환)
|
| 948 |
+
keyword_result, session_export_data = safe_keyword_analysis(analysis_keyword, base_keyword, keywords_data)
|
| 949 |
|
| 950 |
+
# 연관검색어 분석 결과를 세션 데이터에 추가
|
| 951 |
+
if related_result["status"] == "success" and not related_result["keywords_df"].empty:
|
| 952 |
+
session_export_data["related_keywords_df"] = related_result["keywords_df"]
|
| 953 |
+
|
| 954 |
+
# 연관검색어 분석 결과 HTML 생성
|
| 955 |
+
if related_result["status"] == "success" and not related_result["keywords_df"].empty:
|
| 956 |
+
df_keywords = related_result["keywords_df"]
|
| 957 |
+
related_table = export_utils.create_table_without_checkboxes(df_keywords)
|
| 958 |
|
| 959 |
+
related_html = f"""
|
| 960 |
+
<div style="width: 100%; margin: 30px auto; font-family: 'Pretendard', sans-serif;">
|
| 961 |
+
<div style="background: linear-gradient(135deg, #17a2b8 0%, #20c997 100%); padding: 15px; border-radius: 10px 10px 0 0; color: white; text-align: center;">
|
| 962 |
+
<h3 style="margin: 0; font-size: 18px; color: white;">🔗 연관검색어 분석</h3>
|
| 963 |
+
</div>
|
| 964 |
+
<div style="background: white; padding: 20px; border-radius: 0 0 10px 10px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
|
| 965 |
+
<div style="background: #e8f5e8; border: 1px solid #c3e6cb; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
| 966 |
+
<h4 style="color: #155724; margin: 0 0 10px 0;">🔗 연관검색어 분석 완료!</h4>
|
| 967 |
+
<p style="margin: 0; color: #155724;">
|
| 968 |
+
• 분석 기준 상품: <strong>{related_result['total_products']}개</strong><br>
|
| 969 |
+
• 발견된 연관검색어: <strong>{len(df_keywords)}개</strong><br>
|
| 970 |
+
• 메인 키워드와 결합된 복합키워드만 표시됩니다
|
| 971 |
+
</p>
|
| 972 |
+
</div>
|
| 973 |
+
{related_table}
|
| 974 |
+
</div>
|
| 975 |
+
</div>
|
| 976 |
+
"""
|
| 977 |
+
|
| 978 |
+
# 세션 데이터의 analysis_html을 업데이트
|
| 979 |
+
session_export_data["analysis_html"] = related_html + session_export_data["analysis_html"]
|
| 980 |
+
else:
|
| 981 |
+
related_html = f"""
|
| 982 |
+
<div style="width: 100%; margin: 30px auto; font-family: 'Pretendard', sans-serif;">
|
| 983 |
+
<div style="background: linear-gradient(135deg, #17a2b8 0%, #20c997 100%); padding: 15px; border-radius: 10px 10px 0 0; color: white; text-align: center;">
|
| 984 |
+
<h3 style="margin: 0; font-size: 18px; color: white;">🔗 연관검색어 분석</h3>
|
| 985 |
+
</div>
|
| 986 |
+
<div style="background: white; padding: 20px; border-radius: 0 0 10px 10px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
|
| 987 |
+
<div style="color: orange; padding: 20px; text-align: center; background: #fff3cd; border-radius: 8px;">
|
| 988 |
+
'{analysis_keyword}' 키워드의 연관검색어를 찾을 수 없습니다.
|
| 989 |
+
</div>
|
| 990 |
+
</div>
|
| 991 |
+
</div>
|
| 992 |
+
"""
|
| 993 |
+
|
| 994 |
+
# 세션 데이터의 analysis_html을 업데이트
|
| 995 |
+
session_export_data["analysis_html"] = related_html + session_export_data["analysis_html"]
|
| 996 |
+
|
| 997 |
+
# 최종 결과 조합
|
| 998 |
+
final_result = related_html + keyword_result
|
| 999 |
+
yield final_result, session_export_data
|
| 1000 |
+
|
| 1001 |
def on_export_results(export_data):
|
| 1002 |
+
"""분석 결과 출력 핸들러 - 세션별 데이터 처리"""
|
| 1003 |
try:
|
| 1004 |
+
zip_path, message = export_analysis_results(export_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1005 |
|
| 1006 |
if zip_path:
|
| 1007 |
+
# 성공 메시지와 함께 다운로드 파일 제공
|
| 1008 |
success_html = f"""
|
| 1009 |
<div style="background: #d4edda; border: 1px solid #c3e6cb; padding: 20px; border-radius: 8px; margin: 10px 0;">
|
| 1010 |
<h4 style="color: #155724; margin: 0 0 15px 0;"><i class="fas fa-check-circle"></i> 출력 완료!</h4>
|
| 1011 |
<p style="color: #155724; margin: 0; line-height: 1.6;">
|
| 1012 |
{message}<br>
|
| 1013 |
+
<strong>포함 파일:</strong><br>
|
| 1014 |
+
• 📊 엑셀 파일: 메인키워드 조합키워드 + 연관검색어 데이터<br>
|
| 1015 |
+
• 🌐 HTML 파일: 키워드 심충분석 결과 (그래프 포함)<br>
|
| 1016 |
<br>
|
| 1017 |
<i class="fas fa-download"></i> 아래 다운로드 버튼을 클릭하여 파일을 저장하세요.<br>
|
| 1018 |
<small style="color: #666;">⏰ 한국시간 기준으로 파일명이 생성됩니다.</small>
|
|
|
|
| 1021 |
"""
|
| 1022 |
return success_html, gr.update(value=zip_path, visible=True)
|
| 1023 |
else:
|
| 1024 |
+
# 실패 메시지
|
| 1025 |
error_html = f"""
|
| 1026 |
<div style="background: #f8d7da; border: 1px solid #f5c6cb; padding: 20px; border-radius: 8px; margin: 10px 0;">
|
| 1027 |
<h4 style="color: #721c24; margin: 0 0 10px 0;"><i class="fas fa-exclamation-triangle"></i> 출력 실패</h4>
|
| 1028 |
<p style="color: #721c24; margin: 0;">{message}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1029 |
</div>
|
| 1030 |
"""
|
|
|
|
| 1031 |
return error_html, gr.update(visible=False)
|
| 1032 |
|
| 1033 |
except Exception as e:
|
| 1034 |
+
logger.error(f"출력 핸들러 오류: {e}")
|
|
|
|
|
|
|
|
|
|
| 1035 |
error_html = f"""
|
| 1036 |
<div style="background: #f8d7da; border: 1px solid #f5c6cb; padding: 20px; border-radius: 8px; margin: 10px 0;">
|
| 1037 |
<h4 style="color: #721c24; margin: 0 0 10px 0;"><i class="fas fa-exclamation-triangle"></i> 시스템 오류</h4>
|
| 1038 |
+
<p style="color: #721c24; margin: 0;">출력 중 시스템 오류가 발생했습니다: {str(e)}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1039 |
</div>
|
| 1040 |
"""
|
| 1041 |
return error_html, gr.update(visible=False)
|
|
|
|
| 1044 |
collect_data_btn.click(
|
| 1045 |
fn=on_collect_data,
|
| 1046 |
inputs=[keyword_input],
|
| 1047 |
+
outputs=[keywords_result, keywords_data_state]
|
|
|
|
| 1048 |
)
|
| 1049 |
|
| 1050 |
analyze_keyword_btn.click(
|
| 1051 |
fn=on_analyze_keyword,
|
| 1052 |
+
inputs=[analysis_keyword_input, keyword_input, keywords_data_state],
|
| 1053 |
+
outputs=[analysis_result, export_data_state]
|
|
|
|
| 1054 |
)
|
| 1055 |
|
| 1056 |
export_btn.click(
|
| 1057 |
fn=on_export_results,
|
| 1058 |
inputs=[export_data_state],
|
| 1059 |
+
outputs=[export_result, download_file]
|
|
|
|
| 1060 |
)
|
| 1061 |
|
| 1062 |
return interface
|
| 1063 |
|
| 1064 |
+
# ===== API 설정 확인 함수 =====
|
| 1065 |
+
def check_datalab_api_config():
|
| 1066 |
+
"""네이버 데이터랩 API 설정 확인"""
|
| 1067 |
+
logger.info("=== 네이버 데이터랩 API 설정 확인 ===")
|
| 1068 |
+
|
| 1069 |
+
datalab_config = api_utils.get_next_datalab_api_config()
|
| 1070 |
+
|
| 1071 |
+
if not datalab_config:
|
| 1072 |
+
logger.warning("❌ 데이터랩 API 키가 설정되지 않았습니다.")
|
| 1073 |
+
logger.info("트렌드 분석 기능이 비활성화됩니다.")
|
| 1074 |
+
return False
|
| 1075 |
+
|
| 1076 |
+
client_id = datalab_config["CLIENT_ID"]
|
| 1077 |
+
client_secret = datalab_config["CLIENT_SECRET"]
|
| 1078 |
+
|
| 1079 |
+
logger.info(f"총 {len(api_utils.NAVER_DATALAB_CONFIGS)}개의 데이터랩 API 설정 사용 중")
|
| 1080 |
+
logger.info(f"현재 선택된 API:")
|
| 1081 |
+
logger.info(f" CLIENT_ID: {client_id[:8]}***{client_id[-4:] if len(client_id) > 12 else '***'}")
|
| 1082 |
+
logger.info(f" CLIENT_SECRET: {client_secret[:4]}***{client_secret[-2:] if len(client_secret) > 6 else '***'}")
|
| 1083 |
+
|
| 1084 |
+
# 기본값 체크
|
| 1085 |
+
if client_id.startswith("YOUR_"):
|
| 1086 |
+
logger.error("❌ CLIENT_ID가 기본값으로 설정되어 있습니다!")
|
| 1087 |
+
return False
|
| 1088 |
+
|
| 1089 |
+
if client_secret.startswith("YOUR_"):
|
| 1090 |
+
logger.error("❌ CLIENT_SECRET이 기본값으로 설정되어 있습니다!")
|
| 1091 |
+
return False
|
| 1092 |
+
|
| 1093 |
+
# 길이 체크
|
| 1094 |
+
if len(client_id) < 10:
|
| 1095 |
+
logger.warning("⚠️ CLIENT_ID가 짧습니다. 올바른 키인지 확인해주세요.")
|
| 1096 |
+
|
| 1097 |
+
if len(client_secret) < 5:
|
| 1098 |
+
logger.warning("⚠️ CLIENT_SECRET이 짧습니다. 올바른 키인지 확인해주세요.")
|
| 1099 |
+
|
| 1100 |
+
logger.info("✅ 데이터랩 API 키 형식 검증 완료")
|
| 1101 |
+
return True
|
| 1102 |
+
|
| 1103 |
+
def check_gemini_api_config():
|
| 1104 |
+
"""Gemini API 설정 확인"""
|
| 1105 |
+
logger.info("=== Gemini API 설정 확인 ===")
|
| 1106 |
+
|
| 1107 |
+
is_valid, message = api_utils.validate_gemini_config()
|
| 1108 |
+
|
| 1109 |
+
if is_valid:
|
| 1110 |
+
logger.info(f"✅ {message}")
|
| 1111 |
+
# 첫 번째 사용 가능한 키 테스트
|
| 1112 |
+
test_key = api_utils.get_next_gemini_api_key()
|
| 1113 |
+
if test_key:
|
| 1114 |
+
logger.info(f"현재 사용 중인 Gemini API 키: {test_key[:8]}***{test_key[-4:]}")
|
| 1115 |
+
return True
|
| 1116 |
+
else:
|
| 1117 |
+
logger.warning(f"❌ {message}")
|
| 1118 |
+
logger.info("AI 분석 기능이 제한될 수 있습니다.")
|
| 1119 |
+
return False
|
| 1120 |
+
|
| 1121 |
# ===== 메인 실행 =====
|
| 1122 |
if __name__ == "__main__":
|
| 1123 |
# pytz 모듈 설치 확인
|
|
|
|
| 1125 |
import pytz
|
| 1126 |
logger.info("✅ pytz 모듈 로드 성공 - 한국시간 지원")
|
| 1127 |
except ImportError:
|
| 1128 |
+
logger.warning("⚠️ pytz 모듈이 설치되지 않음 - pip install pytz 실행 필요")
|
| 1129 |
logger.info("시스템 시간을 사용합니다.")
|
| 1130 |
|
| 1131 |
+
# API 설정 초기화
|
| 1132 |
+
api_utils.initialize_api_configs()
|
| 1133 |
+
logger.info("===== 상품 소싱 분석 시스템 v2.9 (출력기능 추가 + 한국시간 + 멀티사용자 안전) 시작 =====")
|
| 1134 |
+
|
| 1135 |
+
# 네이버 데이터랩 API 설정 확인
|
| 1136 |
+
datalab_available = check_datalab_api_config()
|
| 1137 |
+
|
| 1138 |
+
# Gemini API 설정 확인
|
| 1139 |
+
gemini_available = check_gemini_api_config()
|
| 1140 |
+
|
| 1141 |
+
# 필요한 패키지 안내
|
| 1142 |
+
print("📦 필요한 패키지:")
|
| 1143 |
+
print(" pip install gradio google-generativeai pandas requests xlsxwriter markdown plotly pytz")
|
| 1144 |
+
print()
|
| 1145 |
+
|
| 1146 |
+
# API 키 설정 안내
|
| 1147 |
+
if not gemini_available:
|
| 1148 |
+
print("⚠️ GEMINI_API_KEY 또는 GOOGLE_API_KEY 환경변수를 설정하세요.")
|
| 1149 |
+
print(" export GEMINI_API_KEY='your-api-key'")
|
| 1150 |
+
print(" 또는")
|
| 1151 |
+
print(" export GOOGLE_API_KEY='your-api-key'")
|
| 1152 |
+
print()
|
| 1153 |
+
|
| 1154 |
+
if not datalab_available:
|
| 1155 |
+
print("⚠️ 네이버 데이터랩 API 트렌드 분석을 위해서는:")
|
| 1156 |
+
print(" 1. 네이버 개발자센터(https://developers.naver.com)에서 애플리케이션 등록")
|
| 1157 |
+
print(" 2. '데이터랩(검색어 트렌드)' API 추가")
|
| 1158 |
+
print(" 3. 발급받은 CLIENT_ID와 CLIENT_SECRET을 api_utils.py의 NAVER_DATALAB_CONFIGS에 설정")
|
| 1159 |
+
print(" 4. 현재는 현재 검색량 정보만 표시됩니다.")
|
| 1160 |
+
print()
|
| 1161 |
+
else:
|
| 1162 |
+
print("✅ 데이터랩 API 설정 완료 - 1년, 3년 트렌드 분석이 가능합니다!")
|
| 1163 |
+
print()
|
| 1164 |
+
|
| 1165 |
+
if gemini_available:
|
| 1166 |
+
print("✅ Gemini API 설정 완료 - AI 분석이 가능합니다!")
|
| 1167 |
+
print()
|
| 1168 |
+
|
| 1169 |
+
print("🛡️ v2.9 멀티사용자 안전 개선사항:")
|
| 1170 |
+
print(" • 전역 변수 export_state 완전 제거")
|
| 1171 |
+
print(" ��� gr.State({}) 사용으로 각 사용자별 세션 데이터 완전 분리")
|
| 1172 |
+
print(" • safe_keyword_analysis() 함수에서 세션별 데이터 반환")
|
| 1173 |
+
print(" • export_analysis_results() 함수에서 세션별 데이터 처리")
|
| 1174 |
+
print(" • 이벤트 핸들러에서 export_data_state 세션 상태 관리")
|
| 1175 |
+
print(" • 허깅페이스 스페이스 등 멀티사용자 환경에서 안전한 동시 사용 보장")
|
| 1176 |
+
print()
|
| 1177 |
+
|
| 1178 |
+
print("🚀 기존 v2.9 기능:")
|
| 1179 |
+
print(" • 연관검색어 엑셀 출력")
|
| 1180 |
+
print(" • 키워드 심충분석 HTML 출력")
|
| 1181 |
+
print(" • 압축파일로 결과 다운로드")
|
| 1182 |
+
print(" • Gemini API 키 통합 관리")
|
| 1183 |
+
print(" • 한국시간 적용")
|
| 1184 |
+
print()
|
| 1185 |
+
|
| 1186 |
# 앱 실행
|
| 1187 |
app = create_interface()
|
| 1188 |
app.launch(server_name="0.0.0.0", server_port=7860, share=True)
|
category_analysis.py
ADDED
|
@@ -0,0 +1,1029 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
카테고리 분석 모듈 - 상품의 카테고리 분석 기능 제공 (개선버전)
|
| 3 |
+
- 1년/3년 트렌드 모두 분석
|
| 4 |
+
- 너비 100% 적용
|
| 5 |
+
- 3년 기준 성장률 계산
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import time
|
| 10 |
+
import re
|
| 11 |
+
import random
|
| 12 |
+
from collections import Counter, defaultdict
|
| 13 |
+
import text_utils
|
| 14 |
+
import product_search
|
| 15 |
+
import keyword_search
|
| 16 |
+
import logging
|
| 17 |
+
|
| 18 |
+
# 로깅 설정
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
logger.setLevel(logging.INFO)
|
| 21 |
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 22 |
+
handler = logging.StreamHandler()
|
| 23 |
+
handler.setFormatter(formatter)
|
| 24 |
+
logger.addHandler(handler)
|
| 25 |
+
|
| 26 |
+
# 마지막 키워드 분석 결과를 저장할 전역 변수
|
| 27 |
+
_last_keyword_results = []
|
| 28 |
+
|
| 29 |
+
def get_last_keyword_results():
|
| 30 |
+
"""마지막으로 분석된 키워드 결과 반환"""
|
| 31 |
+
global _last_keyword_results
|
| 32 |
+
return _last_keyword_results
|
| 33 |
+
|
| 34 |
+
def exponential_backoff_sleep(retry_count, base_delay=0.3, max_delay=5.0):
|
| 35 |
+
"""지수 백오프 방식의 대기 시간 계산"""
|
| 36 |
+
delay = min(base_delay * (2 ** retry_count), max_delay)
|
| 37 |
+
# 약간의 랜덤성 추가 (지터)
|
| 38 |
+
jitter = random.uniform(0, 0.5) * delay
|
| 39 |
+
time.sleep(delay + jitter)
|
| 40 |
+
|
| 41 |
+
def analyze_product_categories(main_keyword, product_name, category_filter=None):
|
| 42 |
+
"""
|
| 43 |
+
메인 키워드와 상품명으로 카테고리 분석을 수행
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
main_keyword (str): 메인 검색 키워드
|
| 47 |
+
product_name (str): 분석할 상품명
|
| 48 |
+
category_filter (str, optional): 카테고리 필터
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
dict: 분석 결과
|
| 52 |
+
"""
|
| 53 |
+
logger.info(f"카테고리 분석 시작: 메인 키워드={main_keyword}, 상품명={product_name}")
|
| 54 |
+
|
| 55 |
+
# 1단계: 메인 키워드로 100개 상품 가져오기 (10개씩 10페이지)
|
| 56 |
+
all_products = []
|
| 57 |
+
for page in range(1, 11):
|
| 58 |
+
result = product_search.fetch_products_by_keyword(main_keyword, page=page, display=10)
|
| 59 |
+
if result["products"]:
|
| 60 |
+
all_products.extend(result["products"])
|
| 61 |
+
exponential_backoff_sleep(0) # API 레이트 리밋 방지
|
| 62 |
+
|
| 63 |
+
if not all_products:
|
| 64 |
+
return {
|
| 65 |
+
"status": "error",
|
| 66 |
+
"message": "상품을 가져오지 못했습니다.",
|
| 67 |
+
"main_keyword": main_keyword,
|
| 68 |
+
"product_name": product_name,
|
| 69 |
+
"total_count": 0,
|
| 70 |
+
"products": [],
|
| 71 |
+
"categories": [],
|
| 72 |
+
"analysis": None
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
# 2단계: 상품명에서 키워드 추출 (개선: 더 정확한 키워드 추출)
|
| 76 |
+
product_keywords = []
|
| 77 |
+
|
| 78 |
+
# 공백과 쉼표를 기준으로 자연스럽게 분리
|
| 79 |
+
words = re.split(r'[,\s]+', product_name)
|
| 80 |
+
for word in words:
|
| 81 |
+
word = word.strip()
|
| 82 |
+
if word and len(word) >= 2: # 최소 2글자 이상인 단어만
|
| 83 |
+
# 중복 제거
|
| 84 |
+
if word not in product_keywords:
|
| 85 |
+
product_keywords.append(word)
|
| 86 |
+
|
| 87 |
+
logger.info(f"상품명에서 추출한 키워드: {product_keywords}")
|
| 88 |
+
|
| 89 |
+
# 3단계: 상품 카테고리 분석
|
| 90 |
+
category_counter = Counter()
|
| 91 |
+
products_by_category = defaultdict(list)
|
| 92 |
+
|
| 93 |
+
for product in all_products:
|
| 94 |
+
category = product["카테고리"]
|
| 95 |
+
category_counter[category] += 1
|
| 96 |
+
products_by_category[category].append(product)
|
| 97 |
+
|
| 98 |
+
# 카테고리 필터 적용
|
| 99 |
+
if category_filter and category_filter != "전체 보기":
|
| 100 |
+
# 카테고리에서 괄호 부분 제거
|
| 101 |
+
category_filter_clean = category_filter.split(" (")[0] if " (" in category_filter else category_filter
|
| 102 |
+
|
| 103 |
+
filtered_categories = {}
|
| 104 |
+
for cat, count in category_counter.items():
|
| 105 |
+
# 상품 카테고리에서도 괄호 있으면 제거
|
| 106 |
+
cat_clean = cat
|
| 107 |
+
if " (" in cat_clean:
|
| 108 |
+
cat_clean = cat_clean.split(" (")[0]
|
| 109 |
+
|
| 110 |
+
if category_filter_clean.lower() in cat_clean.lower():
|
| 111 |
+
filtered_categories[cat] = count
|
| 112 |
+
|
| 113 |
+
category_counter = Counter(filtered_categories)
|
| 114 |
+
|
| 115 |
+
# 4단계: 키워드 검색량 조회
|
| 116 |
+
all_keywords = [main_keyword] + product_keywords
|
| 117 |
+
search_volumes = keyword_search.fetch_all_search_volumes(all_keywords)
|
| 118 |
+
|
| 119 |
+
# 5단계: 카테고리별 매칭 상태 분석
|
| 120 |
+
category_matching = []
|
| 121 |
+
|
| 122 |
+
# 정렬된 카테고리 목록 (출현 빈도순)
|
| 123 |
+
sorted_categories = [cat for cat, _ in category_counter.most_common()]
|
| 124 |
+
|
| 125 |
+
for category in sorted_categories:
|
| 126 |
+
products_in_category = products_by_category[category]
|
| 127 |
+
count = len(products_in_category)
|
| 128 |
+
|
| 129 |
+
# 이 카테고리에 속한 상품들 중 10개만 가져옴
|
| 130 |
+
sample_products = products_in_category[:100]
|
| 131 |
+
|
| 132 |
+
category_matching.append({
|
| 133 |
+
"카테고리": category,
|
| 134 |
+
"상품수": count,
|
| 135 |
+
"매칭상품": sample_products
|
| 136 |
+
})
|
| 137 |
+
|
| 138 |
+
# 6단계: 검색량 정보 추가 및 결과 정리
|
| 139 |
+
keyword_info = []
|
| 140 |
+
for kw in all_keywords:
|
| 141 |
+
volume = search_volumes.get(kw, {"PC검색량": 0, "모바일검색량": 0, "총검색량": 0})
|
| 142 |
+
keyword_info.append({
|
| 143 |
+
"키워드": kw,
|
| 144 |
+
"PC검색량": volume.get("PC검색량", 0),
|
| 145 |
+
"모바일검색량": volume.get("모바일검색량", 0),
|
| 146 |
+
"총검색량": volume.get("총검색량", 0),
|
| 147 |
+
"검색량구간": text_utils.get_search_volume_range(volume.get("총검색량", 0))
|
| 148 |
+
})
|
| 149 |
+
|
| 150 |
+
# 결과 반환
|
| 151 |
+
return {
|
| 152 |
+
"status": "success",
|
| 153 |
+
"message": "분석이 완료되었습니다.",
|
| 154 |
+
"main_keyword": main_keyword,
|
| 155 |
+
"product_name": product_name,
|
| 156 |
+
"total_count": len(all_products),
|
| 157 |
+
"products": all_products,
|
| 158 |
+
"categories": sorted_categories,
|
| 159 |
+
"category_counter": dict(category_counter),
|
| 160 |
+
"category_matching": category_matching,
|
| 161 |
+
"keyword_info": keyword_info
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
def analyze_keywords_by_category(keywords, selected_category, df_all=None):
|
| 165 |
+
"""
|
| 166 |
+
입력된 키워드 목록과 선택된 카테고리로 분석을 수행하는 함수
|
| 167 |
+
"""
|
| 168 |
+
import re
|
| 169 |
+
|
| 170 |
+
if not keywords or not selected_category:
|
| 171 |
+
return "키워드와 카테고리를 모두 선택해주세요."
|
| 172 |
+
|
| 173 |
+
# 카테고리에서 카운트 정보 제거 (예: "패션의류 (10)" -> "패션의류")
|
| 174 |
+
selected_category_clean = selected_category
|
| 175 |
+
is_overall_view = False # '전체 보기'인지 여부 플래그
|
| 176 |
+
if " (" in selected_category and selected_category != "전체 보기":
|
| 177 |
+
selected_category_clean = selected_category.split(" (")[0]
|
| 178 |
+
elif selected_category == "전체 보기":
|
| 179 |
+
selected_category_clean = "" # 전체 카테고리 분석용
|
| 180 |
+
is_overall_view = True
|
| 181 |
+
|
| 182 |
+
# 키워드 리스트 처리 (최대 20개)
|
| 183 |
+
if isinstance(keywords, str):
|
| 184 |
+
# 쉼표나 엔터로 분리
|
| 185 |
+
keywords_list = [k.strip() for k in re.split(r'[,\n]+', keywords) if k.strip()]
|
| 186 |
+
# 20개로 제한
|
| 187 |
+
keywords_list = keywords_list[:20]
|
| 188 |
+
else:
|
| 189 |
+
keywords_list = keywords[:20]
|
| 190 |
+
|
| 191 |
+
if not keywords_list:
|
| 192 |
+
return "분석할 키워드가 없습니다."
|
| 193 |
+
|
| 194 |
+
logger.info(f"카테고리 분석 시작: {len(keywords_list)}개 키워드, 선택 카테고리: '{selected_category_clean if not is_overall_view else '전체 보기'}'")
|
| 195 |
+
|
| 196 |
+
# 개선된 HTML 결과 - 너비 100% 적용
|
| 197 |
+
result_html = f'''
|
| 198 |
+
<style>
|
| 199 |
+
.result-container {{
|
| 200 |
+
width: 100%;
|
| 201 |
+
margin-top: 20px;
|
| 202 |
+
padding: 15px;
|
| 203 |
+
border-radius: 8px;
|
| 204 |
+
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
| 205 |
+
background-color: #f9f9f9;
|
| 206 |
+
}}
|
| 207 |
+
|
| 208 |
+
.result-header {{
|
| 209 |
+
font-size: 18px;
|
| 210 |
+
font-weight: bold;
|
| 211 |
+
margin-bottom: 15px;
|
| 212 |
+
color: #009879;
|
| 213 |
+
border-bottom: 2px solid #009879;
|
| 214 |
+
padding-bottom: 5px;
|
| 215 |
+
}}
|
| 216 |
+
|
| 217 |
+
.keyword-tags {{
|
| 218 |
+
display: flex;
|
| 219 |
+
flex-wrap: wrap;
|
| 220 |
+
gap: 10px;
|
| 221 |
+
margin-bottom: 20px;
|
| 222 |
+
width: 100%;
|
| 223 |
+
}}
|
| 224 |
+
|
| 225 |
+
.keyword-tag {{
|
| 226 |
+
display: inline-block;
|
| 227 |
+
background-color: #009879;
|
| 228 |
+
color: white;
|
| 229 |
+
padding: 8px 15px;
|
| 230 |
+
border-radius: 20px;
|
| 231 |
+
font-size: 14px;
|
| 232 |
+
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
| 233 |
+
transition: transform 0.2s;
|
| 234 |
+
}}
|
| 235 |
+
|
| 236 |
+
.keyword-tag:hover {{
|
| 237 |
+
transform: translateY(-2px);
|
| 238 |
+
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
| 239 |
+
}}
|
| 240 |
+
|
| 241 |
+
.category-container {{
|
| 242 |
+
width: 100%;
|
| 243 |
+
margin-bottom: 20px;
|
| 244 |
+
padding: 10px 15px;
|
| 245 |
+
background-color: #f0f8ff;
|
| 246 |
+
border-left: 4px solid #2c7fb8;
|
| 247 |
+
border-radius: 4px;
|
| 248 |
+
}}
|
| 249 |
+
|
| 250 |
+
.category-title {{
|
| 251 |
+
font-weight: bold;
|
| 252 |
+
margin-bottom: 5px;
|
| 253 |
+
color: #2c7fb8;
|
| 254 |
+
}}
|
| 255 |
+
|
| 256 |
+
.category-path {{
|
| 257 |
+
font-size: 16px;
|
| 258 |
+
color: #333;
|
| 259 |
+
word-break: break-word;
|
| 260 |
+
}}
|
| 261 |
+
|
| 262 |
+
.analysis-result {{
|
| 263 |
+
width: 100%;
|
| 264 |
+
margin-top: 30px;
|
| 265 |
+
border: 1px solid #ddd;
|
| 266 |
+
border-radius: 5px;
|
| 267 |
+
padding: 15px;
|
| 268 |
+
background-color: #ffffff;
|
| 269 |
+
}}
|
| 270 |
+
|
| 271 |
+
.result-header-analysis {{
|
| 272 |
+
font-weight: bold;
|
| 273 |
+
margin-bottom: 15px;
|
| 274 |
+
color: #009879;
|
| 275 |
+
font-size: 16px;
|
| 276 |
+
}}
|
| 277 |
+
|
| 278 |
+
.match-item {{
|
| 279 |
+
width: 100%;
|
| 280 |
+
margin: 12px 0;
|
| 281 |
+
padding: 8px 12px;
|
| 282 |
+
border-bottom: 1px solid #eee;
|
| 283 |
+
transition: background-color 0.2s;
|
| 284 |
+
display: grid;
|
| 285 |
+
grid-template-columns: 3fr 1fr 4fr;
|
| 286 |
+
gap: 10px;
|
| 287 |
+
}}
|
| 288 |
+
|
| 289 |
+
.match-item:hover {{
|
| 290 |
+
background-color: #f5f5f5;
|
| 291 |
+
}}
|
| 292 |
+
|
| 293 |
+
.match-keyword {{
|
| 294 |
+
font-weight: bold;
|
| 295 |
+
color: #2c7fb8;
|
| 296 |
+
font-size: 15px;
|
| 297 |
+
}}
|
| 298 |
+
|
| 299 |
+
.match-count {{
|
| 300 |
+
display: inline-block;
|
| 301 |
+
background-color: #009879;
|
| 302 |
+
color: white;
|
| 303 |
+
padding: 3px 10px;
|
| 304 |
+
border-radius: 15px;
|
| 305 |
+
font-size: 13px;
|
| 306 |
+
margin-left: 10px;
|
| 307 |
+
}}
|
| 308 |
+
|
| 309 |
+
.match-status {{
|
| 310 |
+
text-align: center;
|
| 311 |
+
font-weight: bold;
|
| 312 |
+
color: #009879;
|
| 313 |
+
}}
|
| 314 |
+
|
| 315 |
+
.match-categories {{
|
| 316 |
+
color: #555;
|
| 317 |
+
font-size: 14px;
|
| 318 |
+
line-height: 1.4;
|
| 319 |
+
}}
|
| 320 |
+
|
| 321 |
+
.match-header {{
|
| 322 |
+
width: 100%;
|
| 323 |
+
display: grid;
|
| 324 |
+
grid-template-columns: 3fr 1fr 4fr;
|
| 325 |
+
gap: 10px;
|
| 326 |
+
padding: 10px 12px;
|
| 327 |
+
background-color: #e7f7f3;
|
| 328 |
+
border-radius: 5px 5px 0 0;
|
| 329 |
+
font-weight: bold;
|
| 330 |
+
margin-bottom: 10px;
|
| 331 |
+
}}
|
| 332 |
+
</style>
|
| 333 |
+
|
| 334 |
+
<div class="result-container">
|
| 335 |
+
<div class="result-header">분석 결과 요약</div>
|
| 336 |
+
|
| 337 |
+
<div class="keyword-tags">
|
| 338 |
+
<div class="category-title">분석 키워드 ({len(keywords_list)}개)</div>
|
| 339 |
+
<div class="keyword-tags">
|
| 340 |
+
{''.join([f'<span class="keyword-tag">{k}</span>' for k in keywords_list])}
|
| 341 |
+
</div>
|
| 342 |
+
</div>
|
| 343 |
+
|
| 344 |
+
<div class="category-container">
|
| 345 |
+
<div class="category-title">선택된 카테고리</div>
|
| 346 |
+
<div class="category-path">{selected_category}</div>
|
| 347 |
+
</div>
|
| 348 |
+
|
| 349 |
+
<div class="analysis-result">
|
| 350 |
+
<div class="result-header-analysis">카테고리 일치 분석 결과</div>
|
| 351 |
+
|
| 352 |
+
<div class="match-header">
|
| 353 |
+
<div>키워드</div>
|
| 354 |
+
<div>분석 키워드</div>
|
| 355 |
+
<div>매칭된 카테고리</div>
|
| 356 |
+
</div>
|
| 357 |
+
'''
|
| 358 |
+
|
| 359 |
+
# 키워드 배치 처리 준비 (5개씩 묶음)
|
| 360 |
+
batch_size = 5
|
| 361 |
+
batches = []
|
| 362 |
+
for i in range(0, len(keywords_list), batch_size):
|
| 363 |
+
batches.append(keywords_list[i:i + batch_size])
|
| 364 |
+
|
| 365 |
+
logger.info(f"총 {len(batches)}개 배치로 {len(keywords_list)}개 키워드 처리")
|
| 366 |
+
|
| 367 |
+
# 각 배치 처리
|
| 368 |
+
match_results = {} # 각 키워드별 매칭 정보 저장 (match_count, total_count)
|
| 369 |
+
batch_categories_info = {} # 각 키워드별로 추출된 카테고리 목록 및 비율 저장
|
| 370 |
+
|
| 371 |
+
for batch_idx, batch in enumerate(batches):
|
| 372 |
+
logger.info(f"배치 {batch_idx+1}/{len(batches)} 처리 중...")
|
| 373 |
+
|
| 374 |
+
# 각 키워드 처리
|
| 375 |
+
for keyword in batch:
|
| 376 |
+
max_retries = 3
|
| 377 |
+
retry_count = 0
|
| 378 |
+
api_keyword = keyword.replace(" ", "") # 공백 제거
|
| 379 |
+
|
| 380 |
+
current_keyword_match_count = 0
|
| 381 |
+
current_keyword_total_products = 0
|
| 382 |
+
current_keyword_categories_found = [] # 비율과 함께 저장될 카테고리 문자열 리스트
|
| 383 |
+
|
| 384 |
+
while retry_count < max_retries:
|
| 385 |
+
try:
|
| 386 |
+
# 네이버 API 호출
|
| 387 |
+
products = product_search.fetch_naver_shopping_data_for_analysis(api_keyword, count=100) # 상위 10개 상품
|
| 388 |
+
|
| 389 |
+
if products:
|
| 390 |
+
current_keyword_total_products = len(products)
|
| 391 |
+
categories_counter_for_keyword = Counter() # 현재 키워드의 상품들 카테고리 분포
|
| 392 |
+
|
| 393 |
+
for product in products:
|
| 394 |
+
product_category_full = product.get("category", "") or product.get("카테고리", "")
|
| 395 |
+
if product_category_full:
|
| 396 |
+
categories_counter_for_keyword[product_category_full] += 1
|
| 397 |
+
|
| 398 |
+
# 실제 매칭 여부 카운트 (is_overall_view가 아닐 때만)
|
| 399 |
+
if not is_overall_view and selected_category_clean:
|
| 400 |
+
product_category_for_match = product_category_full
|
| 401 |
+
if " (" in product_category_for_match: # 상품 카테고리 이름에서 count 제거
|
| 402 |
+
product_category_for_match = product_category_for_match.split(" (")[0]
|
| 403 |
+
|
| 404 |
+
sel_lower = selected_category_clean.lower()
|
| 405 |
+
prod_lower = product_category_for_match.lower()
|
| 406 |
+
|
| 407 |
+
if sel_lower in prod_lower or prod_lower in sel_lower:
|
| 408 |
+
current_keyword_match_count += 1
|
| 409 |
+
|
| 410 |
+
if is_overall_view: # '전체 보기'일 경우, 모든 상품이 매칭된 것으로 간주
|
| 411 |
+
current_keyword_match_count = current_keyword_total_products
|
| 412 |
+
|
| 413 |
+
# 카테고리별 비율 계산 및 저장
|
| 414 |
+
category_percentages = []
|
| 415 |
+
for cat, count in categories_counter_for_keyword.most_common(): # 빈도 높은 순으로
|
| 416 |
+
percentage = (count / current_keyword_total_products) * 100 if current_keyword_total_products > 0 else 0
|
| 417 |
+
category_percentages.append((cat, percentage))
|
| 418 |
+
|
| 419 |
+
# category_percentages.sort(key=lambda x: x[1], reverse=True) # 이미 most_common으로 정렬됨
|
| 420 |
+
|
| 421 |
+
for cat, percentage in category_percentages:
|
| 422 |
+
current_keyword_categories_found.append(f"{cat} ({percentage:.0f}%)")
|
| 423 |
+
|
| 424 |
+
logger.info(f" - '{keyword}' 처리 완료: {current_keyword_match_count}/{current_keyword_total_products} 일치")
|
| 425 |
+
break # 성공했으므로 재시도 루프 종료
|
| 426 |
+
else:
|
| 427 |
+
logger.warning(f" - '{keyword}' API 결과 없음 (시도 {retry_count+1}/{max_retries})")
|
| 428 |
+
retry_count += 1
|
| 429 |
+
exponential_backoff_sleep(retry_count)
|
| 430 |
+
|
| 431 |
+
except Exception as e:
|
| 432 |
+
logger.error(f" - '{keyword}' 처리 중 오류: {e} (시도 {retry_count+1}/{max_retries})")
|
| 433 |
+
retry_count += 1
|
| 434 |
+
exponential_backoff_sleep(retry_count)
|
| 435 |
+
|
| 436 |
+
# 결과 저장
|
| 437 |
+
if retry_count >= max_retries and current_keyword_total_products == 0: # 최종 실패
|
| 438 |
+
match_results[keyword] = {
|
| 439 |
+
"match_count": 0,
|
| 440 |
+
"total_count": 0,
|
| 441 |
+
"error": True
|
| 442 |
+
}
|
| 443 |
+
batch_categories_info[keyword] = ["오류 발생"]
|
| 444 |
+
logger.error(f" - '{keyword}' 최대 재시도 후 실패")
|
| 445 |
+
else:
|
| 446 |
+
match_results[keyword] = {
|
| 447 |
+
"match_count": current_keyword_match_count,
|
| 448 |
+
"total_count": current_keyword_total_products,
|
| 449 |
+
"error": False
|
| 450 |
+
}
|
| 451 |
+
batch_categories_info[keyword] = current_keyword_categories_found if current_keyword_categories_found else ["카테고리 정보 없음"]
|
| 452 |
+
|
| 453 |
+
# API 레이트 리밋 방지 - 지수 백오프 사용
|
| 454 |
+
exponential_backoff_sleep(0)
|
| 455 |
+
|
| 456 |
+
logger.info(f"전체 {len(keywords_list)}개 키워드 중 {len(match_results)}개 처리 완료")
|
| 457 |
+
|
| 458 |
+
# 결과를 HTML로 변환
|
| 459 |
+
for keyword in keywords_list:
|
| 460 |
+
result = match_results.get(keyword, {"match_count": 0, "total_count": 0, "error": True})
|
| 461 |
+
|
| 462 |
+
# 수정된 부분: keyword_status 결정 로직 변경
|
| 463 |
+
# 선택된 카테고리와 하나라도 매칭되면 "O", 아니면 "X"
|
| 464 |
+
# "전체 보기" 선택 시에는 항상 "O" (모든 상품이 매칭된 것으로 간주했으므로)
|
| 465 |
+
if result.get("error"):
|
| 466 |
+
keyword_status = "오류" # 에러 발생 시
|
| 467 |
+
status_color = "red"
|
| 468 |
+
elif is_overall_view: # '전체 보기'의 경우
|
| 469 |
+
keyword_status = "O"
|
| 470 |
+
status_color = "#009879" # Green
|
| 471 |
+
else: # 특정 카테고리 선택 시
|
| 472 |
+
keyword_status = "O" if result["match_count"] > 0 else "X"
|
| 473 |
+
status_color = "#009879" if keyword_status == "O" else "red"
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
# 매칭된 카테고리 정보
|
| 477 |
+
categories_html_list = batch_categories_info.get(keyword, ["정보 없음"])
|
| 478 |
+
categories_html = "<br>".join(categories_html_list)
|
| 479 |
+
|
| 480 |
+
if result.get("error", False):
|
| 481 |
+
result_html += f'''
|
| 482 |
+
<div class="match-item">
|
| 483 |
+
<div class="match-keyword">{keyword}</div>
|
| 484 |
+
<div class="match-status" style="color:{status_color}; font-weight:bold;">{keyword_status}</div>
|
| 485 |
+
<div class="match-categories">분석 중 오류 발생</div>
|
| 486 |
+
</div>
|
| 487 |
+
'''
|
| 488 |
+
else:
|
| 489 |
+
match_count_display = result["match_count"]
|
| 490 |
+
total_count_display = result["total_count"]
|
| 491 |
+
|
| 492 |
+
result_html += f'''
|
| 493 |
+
<div class="match-item">
|
| 494 |
+
<div class="match-keyword">{keyword}<span class="match-count">{match_count_display}/{total_count_display}</span></div>
|
| 495 |
+
<div class="match-status" style="color:{status_color}; font-weight:bold;">{keyword_status}</div>
|
| 496 |
+
<div class="match-categories">{categories_html}</div>
|
| 497 |
+
</div>
|
| 498 |
+
'''
|
| 499 |
+
|
| 500 |
+
result_html += '</div></div></div>' # .analysis-result, .result-container 닫기
|
| 501 |
+
|
| 502 |
+
return result_html
|
| 503 |
+
|
| 504 |
+
def analyze_product_terms(product_name, main_keyword=""):
|
| 505 |
+
"""
|
| 506 |
+
상품명에서 추출한 키워드들을 분석하여 카테고리 항목 제공 (1년, 3년 트렌드 모두 분석)
|
| 507 |
+
|
| 508 |
+
Args:
|
| 509 |
+
product_name (str): 분석할 상품명
|
| 510 |
+
main_keyword (str): 메인 키워드 (optional)
|
| 511 |
+
|
| 512 |
+
Returns:
|
| 513 |
+
tuple: (HTML 형식의 결과 테이블, 키워드 분석 결과 리스트, 트렌드 분석 결과)
|
| 514 |
+
"""
|
| 515 |
+
global _last_keyword_results # 함수 시작 부분에 global 선언
|
| 516 |
+
|
| 517 |
+
# 전처리: 상품명 앞뒤 공백 제거 및 유효성 확인
|
| 518 |
+
product_name = product_name.strip() if product_name else ""
|
| 519 |
+
if not product_name:
|
| 520 |
+
return "상품명이 비어있습니다. 유효한 상품명을 입력해주세요.", [], None
|
| 521 |
+
|
| 522 |
+
# 디버깅용 로그
|
| 523 |
+
logger.info(f"분석 시작 - 상품명: '{product_name}', 메인 키워드: '{main_keyword}'")
|
| 524 |
+
|
| 525 |
+
# 상품명에서 키워드를 더 자연스럽게 분리 (공백과 쉼표 기준으로 분리)
|
| 526 |
+
# 수정된 부분: 정규표현식 패턴 조정 및 예외처리 추가
|
| 527 |
+
try:
|
| 528 |
+
words = []
|
| 529 |
+
# 먼저 쉼표로 분리
|
| 530 |
+
comma_parts = product_name.split(',')
|
| 531 |
+
|
| 532 |
+
for part in comma_parts:
|
| 533 |
+
# 각 부분을 공백으로 분리
|
| 534 |
+
space_parts = part.split()
|
| 535 |
+
words.extend([word.strip() for word in space_parts if word.strip()])
|
| 536 |
+
|
| 537 |
+
# 중복 제거 및 1글자 이상 키워드만 유지
|
| 538 |
+
words = list(set([word for word in words if len(word) >= 1]))
|
| 539 |
+
|
| 540 |
+
logger.info(f"상품명에서 추출한 원본 키워드 (총 {len(words)}개): {words}")
|
| 541 |
+
|
| 542 |
+
# 키워드가 하나도 추출되지 않았다면 원본 상품명 사용
|
| 543 |
+
if not words:
|
| 544 |
+
words = [product_name]
|
| 545 |
+
logger.warning(f"키워드 추출 실패, 원본 상품명 사용: '{product_name}'")
|
| 546 |
+
except Exception as e:
|
| 547 |
+
logger.error(f"키워드 추출 중 오류 발생: {e}")
|
| 548 |
+
# 오류 발생 시 원본 상품명을 그대로 사용
|
| 549 |
+
words = [product_name]
|
| 550 |
+
|
| 551 |
+
# 메인 키워드 처리
|
| 552 |
+
if not main_keyword:
|
| 553 |
+
# 메인 키워드가 없는 경우, 오징어 관련 키워드 찾기 (기존 로직)
|
| 554 |
+
for word in words:
|
| 555 |
+
if "오징어" in word:
|
| 556 |
+
main_keyword = "오징어"
|
| 557 |
+
break
|
| 558 |
+
|
| 559 |
+
# 원본 키워드 목록 저장
|
| 560 |
+
keywords = []
|
| 561 |
+
for word in words:
|
| 562 |
+
# 숫자, 영문 등을 포함한 모든 단어 허용
|
| 563 |
+
if word and word != main_keyword:
|
| 564 |
+
# 메인 키워드가 있고, 단어에 메인 키워드가 없으면 조합
|
| 565 |
+
if main_keyword and main_keyword not in word:
|
| 566 |
+
# 조합 키워드 생성 (자연스러운 형태로)
|
| 567 |
+
combined = f"{word} {main_keyword}"
|
| 568 |
+
if combined not in keywords:
|
| 569 |
+
keywords.append(combined)
|
| 570 |
+
# 원래 키워드도 따로 추가 (개선: 단일 키워드도 유지)
|
| 571 |
+
if word not in keywords:
|
| 572 |
+
keywords.append(word)
|
| 573 |
+
|
| 574 |
+
# 메인 키워드도 단독으로 추가
|
| 575 |
+
if main_keyword and main_keyword not in keywords:
|
| 576 |
+
keywords.append(main_keyword)
|
| 577 |
+
|
| 578 |
+
if not keywords:
|
| 579 |
+
return "상품명에서 키워드를 추출할 수 없습니다.", [], None
|
| 580 |
+
|
| 581 |
+
logger.info(f"분석할 최종 키워드 목록 (총 {len(keywords)}개): {keywords}")
|
| 582 |
+
|
| 583 |
+
# 추출된 키워드를 배치로 나누기 (배치당 5개씩)
|
| 584 |
+
batch_size = 5
|
| 585 |
+
keyword_batches = []
|
| 586 |
+
for i in range(0, len(keywords), batch_size):
|
| 587 |
+
keyword_batches.append(keywords[i:i + batch_size])
|
| 588 |
+
|
| 589 |
+
logger.info(f"총 {len(keyword_batches)}개 배치로 {len(keywords)}개 키워드 처리")
|
| 590 |
+
|
| 591 |
+
# 키워드 분석 결과 저장
|
| 592 |
+
keyword_results = []
|
| 593 |
+
|
| 594 |
+
# 각 배치 처리
|
| 595 |
+
for batch_idx, batch in enumerate(keyword_batches):
|
| 596 |
+
logger.info(f"배치 {batch_idx+1}/{len(keyword_batches)} 처리 중...")
|
| 597 |
+
|
| 598 |
+
# 상품 검색 배치 처리
|
| 599 |
+
batch_products = {}
|
| 600 |
+
for keyword in batch:
|
| 601 |
+
# API 호출용 키워드 (공백 제거)
|
| 602 |
+
api_keyword = keyword.replace(" ", "")
|
| 603 |
+
|
| 604 |
+
# 최대 3번 재시도
|
| 605 |
+
max_retries = 3
|
| 606 |
+
retry_count = 0
|
| 607 |
+
|
| 608 |
+
while retry_count < max_retries:
|
| 609 |
+
try:
|
| 610 |
+
# 키워드로 상품 검색
|
| 611 |
+
products = product_search.fetch_naver_shopping_data_for_analysis(api_keyword, count=100)
|
| 612 |
+
|
| 613 |
+
if products:
|
| 614 |
+
batch_products[keyword] = products
|
| 615 |
+
logger.info(f" - '{keyword}' 상품 검색 성공: {len(products)}개")
|
| 616 |
+
break # 성공했으므로 루프 종료
|
| 617 |
+
else:
|
| 618 |
+
logger.warning(f" - '{keyword}' 상품 없음 (시도 {retry_count+1}/{max_retries})")
|
| 619 |
+
retry_count += 1
|
| 620 |
+
exponential_backoff_sleep(retry_count)
|
| 621 |
+
except Exception as e:
|
| 622 |
+
logger.error(f" - '{keyword}' 상품 검색 중 오류: {e} (시도 {retry_count+1}/{max_retries})")
|
| 623 |
+
retry_count += 1
|
| 624 |
+
exponential_backoff_sleep(retry_count)
|
| 625 |
+
|
| 626 |
+
# 최대 재시도 후에도 실패한 경우 로그 기록
|
| 627 |
+
if retry_count >= max_retries and keyword not in batch_products:
|
| 628 |
+
logger.error(f" - '{keyword}' 최대 재시도 후 실패")
|
| 629 |
+
|
| 630 |
+
# 검색량 조회 배치 처리
|
| 631 |
+
api_keywords = [kw.replace(" ", "") for kw in batch]
|
| 632 |
+
volumes = keyword_search.fetch_all_search_volumes(api_keywords)
|
| 633 |
+
|
| 634 |
+
# 각 키워드 처리
|
| 635 |
+
for keyword in batch:
|
| 636 |
+
if keyword in batch_products:
|
| 637 |
+
products = batch_products[keyword]
|
| 638 |
+
|
| 639 |
+
# 개선: 카테고리 항목과 함께 카테고리별 점유율 계산
|
| 640 |
+
category_counter = Counter()
|
| 641 |
+
for product in products:
|
| 642 |
+
category = product.get("category", "") or product.get("카테고리", "")
|
| 643 |
+
if category:
|
| 644 |
+
category_counter[category] += 1
|
| 645 |
+
|
| 646 |
+
# 카테고리와 점유율 계산
|
| 647 |
+
total_products = len(products)
|
| 648 |
+
categories_with_percentage = []
|
| 649 |
+
for category, count in category_counter.most_common():
|
| 650 |
+
percentage = (count / total_products) * 100 if total_products > 0 else 0
|
| 651 |
+
categories_with_percentage.append(f"{category}({percentage:.0f}%)")
|
| 652 |
+
|
| 653 |
+
# 검색량 조회 (API 호출용 키워드 사용)
|
| 654 |
+
api_keyword = keyword.replace(" ", "")
|
| 655 |
+
volume_data = volumes.get(api_keyword, {"PC검색량": 0, "모바일검색량": 0, "총검색량": 0})
|
| 656 |
+
|
| 657 |
+
# 결과 저장 (카테고리 항목과 카운트 정보 포함)
|
| 658 |
+
keyword_results.append({
|
| 659 |
+
"키워드": keyword, # UI 표시용 키워드 (공백 포함)
|
| 660 |
+
"PC검색량": volume_data.get("PC검색량", 0),
|
| 661 |
+
"모바일검색량": volume_data.get("모바일검색량", 0),
|
| 662 |
+
"총검색량": volume_data.get("총검색량", 0),
|
| 663 |
+
"검색량구간": text_utils.get_search_volume_range(volume_data.get("총검색량", 0)),
|
| 664 |
+
"카테고리항목": "\n".join(categories_with_percentage) if categories_with_percentage else "-",
|
| 665 |
+
"카테고리정보": dict(category_counter) # 원본 카테고리 카운터 저장 (요약용)
|
| 666 |
+
})
|
| 667 |
+
|
| 668 |
+
logger.info(f" - '{keyword}' 분석 완료: 카테고리 항목 {len(category_counter)}개, 검색량 {volume_data.get('총검색량', 0)}")
|
| 669 |
+
|
| 670 |
+
# 최종 결과 요약 출력
|
| 671 |
+
logger.info(f"키워드 분석 완료: 총 {len(keywords)}개 중 {len(keyword_results)}개 성공")
|
| 672 |
+
|
| 673 |
+
# 결과를 검색량 기준으로 내림차순 정렬 (높은 것이 먼저 나오도록)
|
| 674 |
+
keyword_results = sorted(keyword_results, key=lambda x: x["총검색량"], reverse=True)
|
| 675 |
+
|
| 676 |
+
# 추천 카테고리 계산
|
| 677 |
+
recommended_categories = Counter()
|
| 678 |
+
for result in keyword_results:
|
| 679 |
+
for category, count in result.get("카테고리정보", {}).items():
|
| 680 |
+
recommended_categories[category] += count
|
| 681 |
+
|
| 682 |
+
# 추천 카테고리 상위 3개 선택
|
| 683 |
+
top_categories = recommended_categories.most_common(3)
|
| 684 |
+
|
| 685 |
+
# 총 상품 수 계산
|
| 686 |
+
total_products_count = sum(recommended_categories.values())
|
| 687 |
+
|
| 688 |
+
# 카테고리별 점유율 계산
|
| 689 |
+
top_categories_with_percentage = []
|
| 690 |
+
for category, count in top_categories:
|
| 691 |
+
percentage = (count / total_products_count) * 100 if total_products_count > 0 else 0
|
| 692 |
+
top_categories_with_percentage.append({
|
| 693 |
+
"카테고리": category,
|
| 694 |
+
"개수": count,
|
| 695 |
+
"점유율": f"{percentage:.0f}%"
|
| 696 |
+
})
|
| 697 |
+
|
| 698 |
+
# 1년, 3년 트렌드 분석 실행
|
| 699 |
+
trend_results = {"1year": None, "3year": None}
|
| 700 |
+
trend_html = ""
|
| 701 |
+
|
| 702 |
+
if keyword_results:
|
| 703 |
+
try:
|
| 704 |
+
# 트렌드 분석 모듈 import
|
| 705 |
+
import trend_analysis
|
| 706 |
+
|
| 707 |
+
# 상위 5개 키워드로 1년, 3년 트렌드 분석
|
| 708 |
+
top_keywords = [result["키워드"] for result in keyword_results[:5]]
|
| 709 |
+
logger.info(f"1년, 3년 트렌드 분석 시작: {top_keywords}")
|
| 710 |
+
|
| 711 |
+
# 1년 트렌드 분석
|
| 712 |
+
trend_result_1year = trend_analysis.get_trend_data(top_keywords, "1year")
|
| 713 |
+
if trend_result_1year["status"] == "success":
|
| 714 |
+
trend_results["1year"] = trend_result_1year
|
| 715 |
+
|
| 716 |
+
# 3년 트렌드 분석
|
| 717 |
+
trend_result_3year = trend_analysis.get_trend_data(top_keywords, "3year")
|
| 718 |
+
if trend_result_3year["status"] == "success":
|
| 719 |
+
trend_results["3year"] = trend_result_3year
|
| 720 |
+
|
| 721 |
+
# 트렌드 분석 HTML 생성 (1년, 3년 모두)
|
| 722 |
+
if trend_results["1year"] or trend_results["3year"]:
|
| 723 |
+
trend_html = f'''
|
| 724 |
+
<div class="trend-analysis-section" style="width: 100%; margin-top: 30px;">
|
| 725 |
+
<div class="section-title">🔍 검색량 트렌드 분석</div>
|
| 726 |
+
'''
|
| 727 |
+
|
| 728 |
+
for period, result in trend_results.items():
|
| 729 |
+
if result and result["status"] == "success":
|
| 730 |
+
period_text = "최근 1년" if period == "1year" else "최근 3년"
|
| 731 |
+
|
| 732 |
+
# 트렌드 인사이트 추출
|
| 733 |
+
insights = trend_analysis.analyze_trend_insights(result["trend_data"])
|
| 734 |
+
|
| 735 |
+
trend_html += f'''
|
| 736 |
+
<div class="trend-period-section" style="width: 100%; background-color: #f0f8ff; padding: 15px; border-radius: 8px; margin-bottom: 20px;">
|
| 737 |
+
<div class="insights-title" style="font-weight: bold; margin-bottom: 15px; color: #2c7fb8;">📊 {period_text} 주요 인사이트</div>
|
| 738 |
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px; width: 100%;">
|
| 739 |
+
'''
|
| 740 |
+
|
| 741 |
+
for keyword, insight in insights.items():
|
| 742 |
+
growth_icon = "📈" if insight['growth_rate'] > 0 else "📉" if insight['growth_rate'] < 0 else "📊"
|
| 743 |
+
growth_color = "#28a745" if insight['growth_rate'] > 0 else "#dc3545" if insight['growth_rate'] < 0 else "#6c757d"
|
| 744 |
+
|
| 745 |
+
trend_html += f'''
|
| 746 |
+
<div class="insight-item" style="background: white; padding: 12px; border-radius: 6px; border-left: 4px solid {growth_color};">
|
| 747 |
+
<div style="font-weight: bold; color: #2c7fb8; margin-bottom: 8px;">{keyword} {growth_icon}</div>
|
| 748 |
+
<div style="font-size: 13px; line-height: 1.4;">
|
| 749 |
+
<div>🏆 최고점: <strong>{insight['max_volume']:,}</strong> ({insight['max_period']})</div>
|
| 750 |
+
<div>📊 전체 평균: <strong>{insight['total_avg']:,}</strong></div>
|
| 751 |
+
<div style="color: {growth_color};">📈 성장률: <strong>{insight['growth_rate']:+.1f}%</strong></div>
|
| 752 |
+
</div>
|
| 753 |
+
</div>
|
| 754 |
+
'''
|
| 755 |
+
|
| 756 |
+
trend_html += '''
|
| 757 |
+
</div>
|
| 758 |
+
|
| 759 |
+
<div class="trend-graph-container" style="width: 100%; background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-top: 15px;">
|
| 760 |
+
'''
|
| 761 |
+
trend_html += result["graph_html"]
|
| 762 |
+
trend_html += '''
|
| 763 |
+
</div>
|
| 764 |
+
</div>
|
| 765 |
+
'''
|
| 766 |
+
|
| 767 |
+
trend_html += '''
|
| 768 |
+
</div>
|
| 769 |
+
'''
|
| 770 |
+
|
| 771 |
+
logger.info(f"1년, 3년 트렌드 분석 완료")
|
| 772 |
+
else:
|
| 773 |
+
logger.warning(f"트렌드 분석 실패")
|
| 774 |
+
trend_html = '''
|
| 775 |
+
<div class="trend-analysis-section" style="width: 100%; margin-top: 30px;">
|
| 776 |
+
<div class="section-title">🔍 검색량 트렌드 분석</div>
|
| 777 |
+
<div style="width: 100%; background-color: #fff3cd; padding: 15px; border-radius: 8px; color: #856404;">
|
| 778 |
+
⚠️ 트렌드 분석을 실행할 수 없습니다. 나중에 다시 시도해주세요.
|
| 779 |
+
</div>
|
| 780 |
+
</div>
|
| 781 |
+
'''
|
| 782 |
+
except Exception as e:
|
| 783 |
+
logger.error(f"트렌드 분석 중 오류 발생: {e}")
|
| 784 |
+
trend_html = '''
|
| 785 |
+
<div class="trend-analysis-section" style="width: 100%; margin-top: 30px;">
|
| 786 |
+
<div class="section-title">🔍 검색량 트렌드 분석</div>
|
| 787 |
+
<div style="width: 100%; background-color: #f8d7da; padding: 15px; border-radius: 8px; color: #721c24;">
|
| 788 |
+
❌ 트렌드 분석 중 오류가 발생했습니다.
|
| 789 |
+
</div>
|
| 790 |
+
</div>
|
| 791 |
+
'''
|
| 792 |
+
|
| 793 |
+
# 결과를 HTML 테이블로 변환 - 너비 100% 적용
|
| 794 |
+
html = f'''
|
| 795 |
+
<style>
|
| 796 |
+
.product-analysis-table {{
|
| 797 |
+
width: 100%;
|
| 798 |
+
border-collapse: collapse;
|
| 799 |
+
margin: 25px 0;
|
| 800 |
+
font-size: 14px;
|
| 801 |
+
box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
|
| 802 |
+
border-radius: 5px;
|
| 803 |
+
overflow: hidden;
|
| 804 |
+
}}
|
| 805 |
+
|
| 806 |
+
.product-analysis-table thead tr {{
|
| 807 |
+
background-color: #009879;
|
| 808 |
+
color: white;
|
| 809 |
+
text-align: left;
|
| 810 |
+
}}
|
| 811 |
+
|
| 812 |
+
.product-analysis-table th,
|
| 813 |
+
.product-analysis-table td {{
|
| 814 |
+
padding: 12px 15px;
|
| 815 |
+
border-bottom: 1px solid #dddddd;
|
| 816 |
+
}}
|
| 817 |
+
|
| 818 |
+
.product-analysis-table tbody tr {{
|
| 819 |
+
background-color: white;
|
| 820 |
+
}}
|
| 821 |
+
|
| 822 |
+
.product-analysis-table tbody tr:nth-of-type(even) {{
|
| 823 |
+
background-color: #f3f3f3;
|
| 824 |
+
}}
|
| 825 |
+
|
| 826 |
+
.product-analysis-table tbody tr:hover {{
|
| 827 |
+
background-color: #f5f5f5;
|
| 828 |
+
}}
|
| 829 |
+
|
| 830 |
+
.product-analysis-table tbody tr:last-of-type {{
|
| 831 |
+
border-bottom: 2px solid #009879;
|
| 832 |
+
}}
|
| 833 |
+
|
| 834 |
+
.section-title {{
|
| 835 |
+
font-size: 18px;
|
| 836 |
+
font-weight: bold;
|
| 837 |
+
color: #009879;
|
| 838 |
+
margin-bottom: 15px;
|
| 839 |
+
padding-bottom: 5px;
|
| 840 |
+
border-bottom: 2px solid #009879;
|
| 841 |
+
}}
|
| 842 |
+
|
| 843 |
+
.summary-box {{
|
| 844 |
+
width: 100%;
|
| 845 |
+
background-color: #f5f5f5;
|
| 846 |
+
border-left: 4px solid #009879;
|
| 847 |
+
padding: 10px 15px;
|
| 848 |
+
margin-bottom: 20px;
|
| 849 |
+
font-size: 14px;
|
| 850 |
+
}}
|
| 851 |
+
|
| 852 |
+
.summary-title {{
|
| 853 |
+
font-weight: bold;
|
| 854 |
+
margin-bottom: 5px;
|
| 855 |
+
}}
|
| 856 |
+
|
| 857 |
+
.recommendation-box {{
|
| 858 |
+
width: 100%;
|
| 859 |
+
background-color: #e7f7f3;
|
| 860 |
+
border-radius: 5px;
|
| 861 |
+
padding: 15px;
|
| 862 |
+
margin-bottom: 25px;
|
| 863 |
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
| 864 |
+
}}
|
| 865 |
+
|
| 866 |
+
.recommendation-title {{
|
| 867 |
+
font-weight: bold;
|
| 868 |
+
font-size: 16px;
|
| 869 |
+
color: #009879;
|
| 870 |
+
margin-bottom: 10px;
|
| 871 |
+
}}
|
| 872 |
+
|
| 873 |
+
.recommendation-item {{
|
| 874 |
+
padding: 6px 0;
|
| 875 |
+
border-bottom: 1px solid #e0e0e0;
|
| 876 |
+
}}
|
| 877 |
+
|
| 878 |
+
.recommendation-item:last-child {{
|
| 879 |
+
border-bottom: none;
|
| 880 |
+
}}
|
| 881 |
+
</style>
|
| 882 |
+
|
| 883 |
+
<div style="width: 100%;">
|
| 884 |
+
<div class="section-title">상품명 키워드 분석 결과</div>
|
| 885 |
+
|
| 886 |
+
<div class="summary-box">
|
| 887 |
+
<div class="summary-title">분석 요약</div>
|
| 888 |
+
<p>총 <strong>{len(keyword_results)}</strong>개 키워드 분석</p>
|
| 889 |
+
<p>메인 키워드: <strong>{main_keyword if main_keyword else '없음'}</strong></p>
|
| 890 |
+
</div>
|
| 891 |
+
|
| 892 |
+
<div class="recommendation-box">
|
| 893 |
+
<div class="recommendation-title">추천 카테고리</div>
|
| 894 |
+
'''
|
| 895 |
+
|
| 896 |
+
# 추천 카테고리 목록 추가
|
| 897 |
+
for idx, cat_info in enumerate(top_categories_with_percentage, 1):
|
| 898 |
+
html += f'''
|
| 899 |
+
<div class="recommendation-item">
|
| 900 |
+
추천 카테고리 {idx} : {cat_info['카테고리']}({cat_info['점유율']})
|
| 901 |
+
</div>
|
| 902 |
+
'''
|
| 903 |
+
|
| 904 |
+
html += '''
|
| 905 |
+
</div>
|
| 906 |
+
|
| 907 |
+
<table class="product-analysis-table">
|
| 908 |
+
<thead>
|
| 909 |
+
<tr>
|
| 910 |
+
<th>순번</th>
|
| 911 |
+
<th>키워드</th>
|
| 912 |
+
<th>PC검색량</th>
|
| 913 |
+
<th>모바일검색량</th>
|
| 914 |
+
<th>총검색량</th>
|
| 915 |
+
<th>검색량구간</th>
|
| 916 |
+
<th>카테고리항목</th>
|
| 917 |
+
</tr>
|
| 918 |
+
</thead>
|
| 919 |
+
<tbody>
|
| 920 |
+
'''
|
| 921 |
+
|
| 922 |
+
for idx, result in enumerate(keyword_results):
|
| 923 |
+
# 카테고리 항목 준비 (줄바꿈을 <br>로 변환)
|
| 924 |
+
category_items = result.get("카테고리항목", "-").replace("\n", "<br>")
|
| 925 |
+
|
| 926 |
+
html += f'''
|
| 927 |
+
<tr>
|
| 928 |
+
<td>{idx + 1}</td>
|
| 929 |
+
<td>{result["키워드"]}</td>
|
| 930 |
+
<td>{result["PC검색량"]:,}</td>
|
| 931 |
+
<td>{result["모바일검색량"]:,}</td>
|
| 932 |
+
<td>{result["총검색량"]:,}</td>
|
| 933 |
+
<td>{result["검색량구간"]}</td>
|
| 934 |
+
<td>{category_items}</td>
|
| 935 |
+
</tr>
|
| 936 |
+
'''
|
| 937 |
+
|
| 938 |
+
html += '''
|
| 939 |
+
</tbody>
|
| 940 |
+
</table>
|
| 941 |
+
</div>
|
| 942 |
+
'''
|
| 943 |
+
|
| 944 |
+
# 트렌드 분석 HTML 추가
|
| 945 |
+
html += trend_html
|
| 946 |
+
|
| 947 |
+
# 분석 결과가 없는 경우 안내 메시지
|
| 948 |
+
if not keyword_results:
|
| 949 |
+
html += '''
|
| 950 |
+
<div style="width: 100%; margin-top: 20px; padding: 15px; background-color: #f1f1f1; border-radius: 5px; text-align: center;">
|
| 951 |
+
<p>표시할 키워드가 없습니다. 다른 상품명을 입력해보세요.</p>
|
| 952 |
+
</div>
|
| 953 |
+
'''
|
| 954 |
+
|
| 955 |
+
# 분석 결과를 전역 변수에 저장 (다운로드용)
|
| 956 |
+
_last_keyword_results = keyword_results
|
| 957 |
+
|
| 958 |
+
# HTML과 함께 키워드 분석 결과 및 트렌드 결과도 함께 반환
|
| 959 |
+
return html, keyword_results, trend_results
|
| 960 |
+
|
| 961 |
+
def collect_categories_per_keyword(keywords, max_products=10):
|
| 962 |
+
"""
|
| 963 |
+
키워드마다 상품 n개를 호출하여 카테고리 집합 반환
|
| 964 |
+
|
| 965 |
+
Args:
|
| 966 |
+
keywords (list): 키워드 목록
|
| 967 |
+
max_products (int): 키워드당 검색할 최대 상품 수
|
| 968 |
+
|
| 969 |
+
Returns:
|
| 970 |
+
dict: 키워드별 카테고리 집합을 담은 사전 {키워드: {카테고리1, 카테고리2, ...}}
|
| 971 |
+
"""
|
| 972 |
+
logger.info(f"키워드별 카테고리 수집 시작: {len(keywords)}개 키워드")
|
| 973 |
+
keyword_category_map = {}
|
| 974 |
+
|
| 975 |
+
# 배치 처리를 위한 준비
|
| 976 |
+
batch_size = 5
|
| 977 |
+
batches = []
|
| 978 |
+
for i in range(0, len(keywords), batch_size):
|
| 979 |
+
batches.append(keywords[i:i + batch_size])
|
| 980 |
+
|
| 981 |
+
logger.info(f"총 {len(batches)}개 배치로 {len(keywords)}개 키워드 처리")
|
| 982 |
+
|
| 983 |
+
# 각 배치 처리
|
| 984 |
+
for batch_idx, batch in enumerate(batches):
|
| 985 |
+
logger.info(f"배치 {batch_idx+1}/{len(batches)} 처리 중...")
|
| 986 |
+
|
| 987 |
+
for keyword in batch:
|
| 988 |
+
# API 호출용 키워드 (공백 제거)
|
| 989 |
+
api_keyword = keyword.replace(" ", "")
|
| 990 |
+
|
| 991 |
+
max_retries = 3
|
| 992 |
+
retry_count = 0
|
| 993 |
+
|
| 994 |
+
while retry_count < max_retries:
|
| 995 |
+
try:
|
| 996 |
+
# 키워드로 상품 검색
|
| 997 |
+
products = product_search.fetch_naver_shopping_data_for_analysis(api_keyword, count=max_products)
|
| 998 |
+
|
| 999 |
+
if products:
|
| 1000 |
+
# 카테고리 추출
|
| 1001 |
+
categories = set()
|
| 1002 |
+
for product in products:
|
| 1003 |
+
# 두 가지 키 모두 시도 (category와 카테고리)
|
| 1004 |
+
category = product.get("category", "") or product.get("카테고리", "")
|
| 1005 |
+
if category:
|
| 1006 |
+
categories.add(category)
|
| 1007 |
+
|
| 1008 |
+
keyword_category_map[keyword] = categories
|
| 1009 |
+
logger.info(f" - '{keyword}' 카테고리 수집 완료: {len(categories)}개")
|
| 1010 |
+
break # 성공했으므로 루프 종료
|
| 1011 |
+
else:
|
| 1012 |
+
logger.warning(f" - '{keyword}' 상품 검색 결과 없음 (시도 {retry_count+1}/{max_retries})")
|
| 1013 |
+
retry_count += 1
|
| 1014 |
+
exponential_backoff_sleep(retry_count)
|
| 1015 |
+
|
| 1016 |
+
except Exception as e:
|
| 1017 |
+
logger.error(f" - '{keyword}' 처리 중 오류: {e} (시도 {retry_count+1}/{max_retries})")
|
| 1018 |
+
retry_count += 1
|
| 1019 |
+
exponential_backoff_sleep(retry_count)
|
| 1020 |
+
|
| 1021 |
+
if retry_count >= max_retries:
|
| 1022 |
+
logger.error(f" - '{keyword}' 최대 재시도 후 실패")
|
| 1023 |
+
keyword_category_map[keyword] = set()
|
| 1024 |
+
|
| 1025 |
+
# API 레이트 리밋 방지 (안정적인 지연으로 변경)
|
| 1026 |
+
exponential_backoff_sleep(0) # 초기 지연 적용
|
| 1027 |
+
|
| 1028 |
+
logger.info(f"키워드별 카테고리 수집 완료: {len(keyword_category_map)}개")
|
| 1029 |
+
return keyword_category_map
|
export_utils.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
결과 출력 관련 유틸리티 함수 모음 - 카테고리 항목 제거 + 키워드 클릭 시 네이버 쇼핑 이동 기능 추가
|
| 3 |
+
- HTML 테이블 생성
|
| 4 |
+
- 엑셀 파일 생성
|
| 5 |
+
- 키워드 클릭 시 네이버 쇼핑 링크 기능
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import tempfile
|
| 10 |
+
import os
|
| 11 |
+
import threading
|
| 12 |
+
import time
|
| 13 |
+
import logging
|
| 14 |
+
import urllib.parse # URL 인코딩을 위해 추가
|
| 15 |
+
|
| 16 |
+
# 로깅 설정
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
logger.setLevel(logging.INFO)
|
| 19 |
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 20 |
+
handler = logging.StreamHandler()
|
| 21 |
+
handler.setFormatter(formatter)
|
| 22 |
+
logger.addHandler(handler)
|
| 23 |
+
|
| 24 |
+
# 임시 파일 추적 리스트
|
| 25 |
+
_temp_files = []
|
| 26 |
+
|
| 27 |
+
def create_table_without_checkboxes(df):
|
| 28 |
+
"""DataFrame을 HTML 테이블로 변환 - 키워드 클릭 시 네이버 쇼핑 이동 기능 추가"""
|
| 29 |
+
if df.empty:
|
| 30 |
+
return "<p>검색 결과가 없습니다.</p>"
|
| 31 |
+
|
| 32 |
+
# === 수정된 부분: 카테고리 관련 열 제거 ===
|
| 33 |
+
df_display = df.copy()
|
| 34 |
+
|
| 35 |
+
# "상품 등록 카테고리(상위100위)" 또는 "관련 카테고리", "카테고리 항목" 열이 있으면 제거
|
| 36 |
+
columns_to_remove = ["상품 등록 카테고리(상위100위)", "관련 카테고리", "카테고리 항목"]
|
| 37 |
+
for col in columns_to_remove:
|
| 38 |
+
if col in df_display.columns:
|
| 39 |
+
df_display = df_display.drop(columns=[col])
|
| 40 |
+
logger.info(f"테이블에서 '{col}' 열 제거됨")
|
| 41 |
+
|
| 42 |
+
# HTML 테이블 스타일 정의 - Z-INDEX 수정
|
| 43 |
+
html = '''
|
| 44 |
+
<style>
|
| 45 |
+
.table-container {
|
| 46 |
+
position: relative;
|
| 47 |
+
width: 100%;
|
| 48 |
+
margin: 0;
|
| 49 |
+
border-radius: 8px;
|
| 50 |
+
overflow: hidden;
|
| 51 |
+
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.header-wrap {
|
| 55 |
+
position: sticky;
|
| 56 |
+
top: 0;
|
| 57 |
+
z-index: 100; /* z-index 증가 */
|
| 58 |
+
background-color: #009879;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.styled-table {
|
| 62 |
+
width: 100%;
|
| 63 |
+
border-collapse: collapse;
|
| 64 |
+
table-layout: fixed;
|
| 65 |
+
margin: 0;
|
| 66 |
+
padding: 0;
|
| 67 |
+
font-size: 14px;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.styled-table th,
|
| 71 |
+
.styled-table td {
|
| 72 |
+
padding: 12px 15px;
|
| 73 |
+
text-align: left;
|
| 74 |
+
border-bottom: 1px solid #dddddd;
|
| 75 |
+
overflow: hidden;
|
| 76 |
+
text-overflow: ellipsis;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/* 긴 텍스트가 셀에서 줄바꿈되도록 수정 */
|
| 80 |
+
.styled-table td.col-rank {
|
| 81 |
+
white-space: normal;
|
| 82 |
+
word-break: break-word;
|
| 83 |
+
line-height: 1.3;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/* 그 외 열은 한 줄로 표시 */
|
| 87 |
+
.styled-table td.col-seq,
|
| 88 |
+
.styled-table td.col-keyword,
|
| 89 |
+
.styled-table td.col-pc,
|
| 90 |
+
.styled-table td.col-mobile,
|
| 91 |
+
.styled-table td.col-total,
|
| 92 |
+
.styled-table td.col-range,
|
| 93 |
+
.styled-table td.col-count {
|
| 94 |
+
white-space: nowrap;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.styled-table th {
|
| 98 |
+
background-color: #009879;
|
| 99 |
+
color: white;
|
| 100 |
+
font-weight: bold;
|
| 101 |
+
position: sticky;
|
| 102 |
+
top: 0;
|
| 103 |
+
white-space: nowrap;
|
| 104 |
+
z-index: 50; /* 헤더 z-index 증가 */
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.styled-table tbody tr:nth-of-type(even) {
|
| 108 |
+
background-color: #f3f3f3;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.styled-table tbody tr:hover {
|
| 112 |
+
background-color: #f0f0f0;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.styled-table tbody tr:last-of-type {
|
| 116 |
+
border-bottom: 2px solid #009879;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/* 데이터 셀 z-index 설정 */
|
| 120 |
+
.styled-table tbody td {
|
| 121 |
+
position: relative;
|
| 122 |
+
z-index: 1; /* 데이터 셀은 낮은 z-index */
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.data-container {
|
| 126 |
+
max-height: 600px;
|
| 127 |
+
overflow-y: auto;
|
| 128 |
+
position: relative; /* position 추가 */
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
/* 스크롤바 스타일 */
|
| 132 |
+
.data-container::-webkit-scrollbar {
|
| 133 |
+
width: 10px;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.data-container::-webkit-scrollbar-track {
|
| 137 |
+
background: #f1f1f1;
|
| 138 |
+
border-radius: 5px;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.data-container::-webkit-scrollbar-thumb {
|
| 142 |
+
background: #888;
|
| 143 |
+
border-radius: 5px;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.data-container::-webkit-scrollbar-thumb:hover {
|
| 147 |
+
background: #555;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/* 키워드 링크 스타일 - 새로 추가 */
|
| 151 |
+
.keyword-link {
|
| 152 |
+
color: #2c5aa0;
|
| 153 |
+
text-decoration: none;
|
| 154 |
+
font-weight: 600;
|
| 155 |
+
cursor: pointer;
|
| 156 |
+
transition: all 0.3s ease;
|
| 157 |
+
display: inline-block;
|
| 158 |
+
padding: 2px 4px;
|
| 159 |
+
border-radius: 3px;
|
| 160 |
+
position: relative;
|
| 161 |
+
z-index: 5; /* 링크 z-index 설정 */
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.keyword-link:hover {
|
| 165 |
+
color: #ffffff;
|
| 166 |
+
background-color: #2c5aa0;
|
| 167 |
+
text-decoration: none;
|
| 168 |
+
transform: translateY(-1px);
|
| 169 |
+
box-shadow: 0 2px 4px rgba(44, 90, 160, 0.3);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.keyword-link:active {
|
| 173 |
+
transform: translateY(0px);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
/* 키워드 셀 특별 스타일 */
|
| 177 |
+
.col-keyword {
|
| 178 |
+
position: relative;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.keyword-tooltip {
|
| 182 |
+
position: absolute;
|
| 183 |
+
bottom: 100%;
|
| 184 |
+
left: 50%;
|
| 185 |
+
transform: translateX(-50%);
|
| 186 |
+
background-color: #333;
|
| 187 |
+
color: white;
|
| 188 |
+
padding: 6px 10px;
|
| 189 |
+
border-radius: 4px;
|
| 190 |
+
font-size: 11px;
|
| 191 |
+
white-space: nowrap;
|
| 192 |
+
opacity: 0;
|
| 193 |
+
visibility: hidden;
|
| 194 |
+
transition: all 0.3s ease;
|
| 195 |
+
z-index: 1000; /* 툴팁은 가장 높은 z-index */
|
| 196 |
+
pointer-events: none;
|
| 197 |
+
margin-bottom: 5px;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.keyword-tooltip::after {
|
| 201 |
+
content: '';
|
| 202 |
+
position: absolute;
|
| 203 |
+
top: 100%;
|
| 204 |
+
left: 50%;
|
| 205 |
+
transform: translateX(-50%);
|
| 206 |
+
border: 4px solid transparent;
|
| 207 |
+
border-top-color: #333;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.keyword-link:hover .keyword-tooltip {
|
| 211 |
+
opacity: 1;
|
| 212 |
+
visibility: visible;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/* === 수정된 부분: 열 너비 정의 - 카테고리 열 제거 후 조정 === */
|
| 216 |
+
.col-seq { width: 8%; }
|
| 217 |
+
.col-keyword { width: 25%; }
|
| 218 |
+
.col-pc { width: 12%; }
|
| 219 |
+
.col-mobile { width: 12%; }
|
| 220 |
+
.col-total { width: 12%; }
|
| 221 |
+
.col-range { width: 12%; }
|
| 222 |
+
.col-rank { width: 15%; }
|
| 223 |
+
.col-count { width: 10%; }
|
| 224 |
+
|
| 225 |
+
.truncated-text {
|
| 226 |
+
position: relative;
|
| 227 |
+
cursor: pointer;
|
| 228 |
+
z-index: 2; /* 텍스트 z-index 설정 */
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.truncated-text:hover::after {
|
| 232 |
+
content: attr(data-full-text);
|
| 233 |
+
position: absolute;
|
| 234 |
+
left: 0;
|
| 235 |
+
top: 100%;
|
| 236 |
+
z-index: 99;
|
| 237 |
+
min-width: 200px;
|
| 238 |
+
max-width: 400px;
|
| 239 |
+
padding: 8px;
|
| 240 |
+
background-color: #fff;
|
| 241 |
+
border: 1px solid #ddd;
|
| 242 |
+
border-radius: 4px;
|
| 243 |
+
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
| 244 |
+
white-space: normal;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/* 키워드 태그 스타일 */
|
| 248 |
+
.keyword-tag-container {
|
| 249 |
+
margin-top: 20px;
|
| 250 |
+
padding: 10px;
|
| 251 |
+
border: 1px solid #ddd;
|
| 252 |
+
border-radius: 5px;
|
| 253 |
+
background-color: #f9f9f9;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.keyword-tag {
|
| 257 |
+
display: inline-block;
|
| 258 |
+
background-color: #009879;
|
| 259 |
+
color: white;
|
| 260 |
+
padding: 5px 10px;
|
| 261 |
+
margin: 5px;
|
| 262 |
+
border-radius: 15px;
|
| 263 |
+
font-size: 12px;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.category-tag {
|
| 267 |
+
display: inline-block;
|
| 268 |
+
background-color: #2c7fb8;
|
| 269 |
+
color: white;
|
| 270 |
+
padding: 5px 10px;
|
| 271 |
+
margin: 5px;
|
| 272 |
+
border-radius: 15px;
|
| 273 |
+
font-size: 12px;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
/* 분석 결과 테이블 스타일 */
|
| 277 |
+
.analysis-result {
|
| 278 |
+
margin-top: 30px;
|
| 279 |
+
border: 1px solid #ddd;
|
| 280 |
+
border-radius: 5px;
|
| 281 |
+
padding: 15px;
|
| 282 |
+
background-color: #f9f9f9;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.result-header {
|
| 286 |
+
font-weight: bold;
|
| 287 |
+
margin-bottom: 10px;
|
| 288 |
+
color: #009879;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
.match-item {
|
| 292 |
+
margin: 5px 0;
|
| 293 |
+
padding: 5px;
|
| 294 |
+
border-bottom: 1px solid #eee;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.match-keyword {
|
| 298 |
+
font-weight: bold;
|
| 299 |
+
color: #2c7fb8;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
.match-count {
|
| 303 |
+
display: inline-block;
|
| 304 |
+
background-color: #009879;
|
| 305 |
+
color: white;
|
| 306 |
+
padding: 2px 8px;
|
| 307 |
+
border-radius: 10px;
|
| 308 |
+
font-size: 12px;
|
| 309 |
+
margin-left: 10px;
|
| 310 |
+
}
|
| 311 |
+
</style>
|
| 312 |
+
'''
|
| 313 |
+
|
| 314 |
+
# === 수정된 부분: 열 이름과 클래스 매핑 - 카테고리 관련 제거 ===
|
| 315 |
+
col_mapping = {
|
| 316 |
+
"순번": "col-seq",
|
| 317 |
+
"조합 키워드": "col-keyword",
|
| 318 |
+
"연관 키워드": "col-keyword", # 연관검색어 분석용 추가
|
| 319 |
+
"키워드": "col-keyword", # 일반 키워드용 추가
|
| 320 |
+
"PC검색량": "col-pc",
|
| 321 |
+
"모바일검색량": "col-mobile",
|
| 322 |
+
"총검색량": "col-total",
|
| 323 |
+
"검색량구간": "col-range",
|
| 324 |
+
"키워드 사용자순위": "col-rank",
|
| 325 |
+
"키워드 사용횟수": "col-count"
|
| 326 |
+
# 카테고리 관련 매핑 제거됨
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
# 네이버 쇼핑 링크 생성 함수
|
| 330 |
+
def create_naver_shopping_link(keyword):
|
| 331 |
+
"""키워드를 네이버 쇼핑 링크로 변환"""
|
| 332 |
+
# URL 인코딩 (한글 키워드 처리)
|
| 333 |
+
encoded_keyword = urllib.parse.quote(keyword.strip())
|
| 334 |
+
naver_shopping_url = f"https://search.shopping.naver.com/search/all?where=all&frm=NVSCTAB&query={encoded_keyword}"
|
| 335 |
+
|
| 336 |
+
# 링크가 포함된 HTML 반환
|
| 337 |
+
return f'''<a href="{naver_shopping_url}" target="_blank" class="keyword-link" title="네이버 쇼핑에서 '{keyword}' 검색하기">
|
| 338 |
+
{keyword}
|
| 339 |
+
<span class="keyword-tooltip">클릭하면 네이버 쇼핑으로 이동</span>
|
| 340 |
+
</a>'''
|
| 341 |
+
|
| 342 |
+
# 테이블 컨테이너 시작
|
| 343 |
+
html += '<div class="table-container">'
|
| 344 |
+
|
| 345 |
+
# 단일 테이블 구조로 변경 (헤더는 position: sticky로 고정)
|
| 346 |
+
html += '<div class="data-container">'
|
| 347 |
+
html += '<table class="styled-table">'
|
| 348 |
+
|
| 349 |
+
# colgroup으로 열 너비 정의
|
| 350 |
+
html += '<colgroup>'
|
| 351 |
+
html += f'<col class="{col_mapping["순번"]}">'
|
| 352 |
+
for col in df_display.columns:
|
| 353 |
+
col_class = col_mapping.get(col, "")
|
| 354 |
+
html += f'<col class="{col_class}">'
|
| 355 |
+
html += '</colgroup>'
|
| 356 |
+
|
| 357 |
+
# 테이블 헤더
|
| 358 |
+
html += '<thead>'
|
| 359 |
+
html += '<tr>'
|
| 360 |
+
html += f'<th class="{col_mapping["순번"]}">순번</th>'
|
| 361 |
+
for col in df_display.columns:
|
| 362 |
+
col_class = col_mapping.get(col, "")
|
| 363 |
+
html += f'<th class="{col_class}">{col}</th>'
|
| 364 |
+
html += '</tr>'
|
| 365 |
+
html += '</thead>'
|
| 366 |
+
|
| 367 |
+
# 테이블 본문
|
| 368 |
+
html += '<tbody>'
|
| 369 |
+
for idx, row in df_display.iterrows():
|
| 370 |
+
html += '<tr>'
|
| 371 |
+
# 순번 표시 - 1부터 시작하는 순차적 번호
|
| 372 |
+
html += f'<td class="{col_mapping["순번"]}">{idx + 1}</td>'
|
| 373 |
+
|
| 374 |
+
# 데이터 셀 추가
|
| 375 |
+
for col in df_display.columns:
|
| 376 |
+
col_class = col_mapping.get(col, "")
|
| 377 |
+
value = str(row[col])
|
| 378 |
+
|
| 379 |
+
# === 새로 추가: 키워드 열에 링크 적용 ===
|
| 380 |
+
if col in ["조합 키워드", "연관 키워드", "키워드"]:
|
| 381 |
+
# 키워드 셀에 네이버 쇼핑 링크 적용
|
| 382 |
+
keyword_with_link = create_naver_shopping_link(value)
|
| 383 |
+
html += f'<td class="{col_class}">{keyword_with_link}</td>'
|
| 384 |
+
elif col == "키워드 사용자순위":
|
| 385 |
+
# 긴 텍스트의 셀은 그대로 표시 (줄바꿈 허용)
|
| 386 |
+
html += f'<td class="{col_class}">{value}</td>'
|
| 387 |
+
elif len(value) > 30:
|
| 388 |
+
# 다른 긴 텍스트는 hover로 전체 표시
|
| 389 |
+
html += f'<td class="{col_class}"><div class="truncated-text" data-full-text="{value}">{value[:30]}...</div></td>'
|
| 390 |
+
else:
|
| 391 |
+
# 일반 텍스트
|
| 392 |
+
html += f'<td class="{col_class}">{value}</td>'
|
| 393 |
+
html += '</tr>'
|
| 394 |
+
|
| 395 |
+
html += '</tbody>'
|
| 396 |
+
html += '</table>'
|
| 397 |
+
html += '</div>' # data-container 닫기
|
| 398 |
+
html += '</div>' # table-container 닫기
|
| 399 |
+
|
| 400 |
+
# 사용법 안내 추가
|
| 401 |
+
html += '''
|
| 402 |
+
<div style="margin-top: 15px; padding: 12px; background: #e8f5e8; border-radius: 8px; border-left: 4px solid #009879;">
|
| 403 |
+
<div style="font-weight: bold; color: #155724; margin-bottom: 5px;">💡 사용팁</div>
|
| 404 |
+
<div style="font-size: 14px; color: #155724;">
|
| 405 |
+
키워드를 클릭하면 네이버 쇼핑에서 해당 키워드로 검색한 결과를 새 창에서 확인할 수 있습니다.
|
| 406 |
+
</div>
|
| 407 |
+
</div>
|
| 408 |
+
'''
|
| 409 |
+
|
| 410 |
+
return html
|
| 411 |
+
|
| 412 |
+
def cleanup_temp_files(delay=300):
|
| 413 |
+
"""임시 파일 정리 함수"""
|
| 414 |
+
global _temp_files
|
| 415 |
+
|
| 416 |
+
def cleanup():
|
| 417 |
+
time.sleep(delay) # 지정된 시간 대기
|
| 418 |
+
temp_files_to_remove = _temp_files.copy()
|
| 419 |
+
_temp_files = []
|
| 420 |
+
|
| 421 |
+
for file_path in temp_files_to_remove:
|
| 422 |
+
try:
|
| 423 |
+
if os.path.exists(file_path):
|
| 424 |
+
os.remove(file_path)
|
| 425 |
+
logger.info(f"임시 파일 삭제: {file_path}")
|
| 426 |
+
except Exception as e:
|
| 427 |
+
logger.error(f"파일 삭제 오류: {e}")
|
| 428 |
+
|
| 429 |
+
# 새 스레드 시작
|
| 430 |
+
threading.Thread(target=cleanup, daemon=True).start()
|
| 431 |
+
|
| 432 |
+
def download_keywords(df, auto_cleanup=True, cleanup_delay=300):
|
| 433 |
+
"""키워드 데이터를 엑셀 파일로 다운로드 - 카테고리 항목 제거"""
|
| 434 |
+
global _temp_files
|
| 435 |
+
|
| 436 |
+
if df is None or df.empty:
|
| 437 |
+
return None
|
| 438 |
+
|
| 439 |
+
# 임시 파일로 저장
|
| 440 |
+
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx')
|
| 441 |
+
temp_file.close()
|
| 442 |
+
filename = temp_file.name
|
| 443 |
+
|
| 444 |
+
# 임시 파일 추적 목록에 추가
|
| 445 |
+
_temp_files.append(filename)
|
| 446 |
+
|
| 447 |
+
# === 수정된 부분: 카테고리 관련 열 제거 ===
|
| 448 |
+
df_export = df.copy()
|
| 449 |
+
|
| 450 |
+
# 카테고리 관련 열들 제거
|
| 451 |
+
columns_to_remove = ["상품 등록 카테고리(상위100위)", "관련 카테고리", "카테고리 항목"]
|
| 452 |
+
for col in columns_to_remove:
|
| 453 |
+
if col in df_export.columns:
|
| 454 |
+
df_export = df_export.drop(columns=[col])
|
| 455 |
+
logger.info(f"엑셀 내보내기에서 '{col}' 열 제거됨")
|
| 456 |
+
|
| 457 |
+
# 키워드 데이터를 엑셀 파일로 저장
|
| 458 |
+
with pd.ExcelWriter(filename, engine='xlsxwriter') as writer:
|
| 459 |
+
# 키워드 목록 시트
|
| 460 |
+
df_export.to_excel(writer, sheet_name='키워드 목록', index=False)
|
| 461 |
+
|
| 462 |
+
# 열 너비 조정 - 카테고리 열 제거 후 조정
|
| 463 |
+
worksheet = writer.sheets['키워드 목록']
|
| 464 |
+
worksheet.set_column('A:A', 20) # 조합 키워드 열
|
| 465 |
+
worksheet.set_column('B:B', 12) # PC검색량 열
|
| 466 |
+
worksheet.set_column('C:C', 12) # 모바일검색량 열
|
| 467 |
+
worksheet.set_column('D:D', 12) # 총검색량 열
|
| 468 |
+
worksheet.set_column('E:E', 12) # 검색량구간 열
|
| 469 |
+
worksheet.set_column('F:F', 20) # 키워드 사용자순위 열
|
| 470 |
+
worksheet.set_column('G:G', 12) # 키워드 사용횟수 열
|
| 471 |
+
# 카테고리 열들 제거로 H, I 열 설정 제거됨
|
| 472 |
+
|
| 473 |
+
# 헤더 형식 설정
|
| 474 |
+
header_format = writer.book.add_format({
|
| 475 |
+
'bold': True,
|
| 476 |
+
'bg_color': '#009879',
|
| 477 |
+
'color': 'white',
|
| 478 |
+
'border': 1
|
| 479 |
+
})
|
| 480 |
+
|
| 481 |
+
# 헤더에 형식 적용
|
| 482 |
+
for col_num, value in enumerate(df_export.columns.values):
|
| 483 |
+
worksheet.write(0, col_num, value, header_format)
|
| 484 |
+
|
| 485 |
+
logger.info(f"엑셀 파일 생성: {filename}")
|
| 486 |
+
|
| 487 |
+
# 파일 자동 정리 옵션
|
| 488 |
+
if auto_cleanup:
|
| 489 |
+
# 별도 정리 작업 요청 없이 추적 목록에 추가만 하여 일괄 처리
|
| 490 |
+
pass
|
| 491 |
+
|
| 492 |
+
return filename
|
| 493 |
+
|
| 494 |
+
def register_cleanup_handlers():
|
| 495 |
+
"""앱 종료 시 정리를 위한 핸들러 등록"""
|
| 496 |
+
import atexit
|
| 497 |
+
|
| 498 |
+
def cleanup_all_temp_files():
|
| 499 |
+
global _temp_files
|
| 500 |
+
for file_path in _temp_files:
|
| 501 |
+
try:
|
| 502 |
+
if os.path.exists(file_path):
|
| 503 |
+
os.remove(file_path)
|
| 504 |
+
logger.info(f"종료 시 임시 파일 삭제: {file_path}")
|
| 505 |
+
except Exception as e:
|
| 506 |
+
logger.error(f"파일 삭제 오류: {e}")
|
| 507 |
+
_temp_files = []
|
| 508 |
+
|
| 509 |
+
# 앱 종료 시 실행될 함수 등록
|
| 510 |
+
atexit.register(cleanup_all_temp_files)
|
keyword_analysis.py
ADDED
|
@@ -0,0 +1,1687 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
키워드 매칭 및 상승폭 계산 개선 - 전체 코드
|
| 3 |
+
v4.0 - 스페이스바 처리 개선 + 올바른 트렌드 분석 로직 적용
|
| 4 |
+
- 기존 모든 기능 유지하면서 최적화
|
| 5 |
+
- 스페이스바 제거 후 검색/비교 로직 적용
|
| 6 |
+
- 올바른 증감율 계산: 올해 완료월 vs 작년 동월
|
| 7 |
+
- 🔖 가장 검색량이 많은 월: 실제+예상 데이터 중 최대값
|
| 8 |
+
- 🔖 가장 상승폭이 높은 월: 연속된 월간 상승률 중 최대값
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
import pandas as pd
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
import re
|
| 15 |
+
import time
|
| 16 |
+
import random
|
| 17 |
+
from typing import Dict, List, Optional
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
def normalize_keyword(keyword):
|
| 22 |
+
"""키워드 정규화 - 스페이스바 처리 개선"""
|
| 23 |
+
if not keyword:
|
| 24 |
+
return ""
|
| 25 |
+
|
| 26 |
+
# 1. 앞뒤 공백 제거
|
| 27 |
+
keyword = keyword.strip()
|
| 28 |
+
|
| 29 |
+
# 2. 연속된 공백을 하나로 변경
|
| 30 |
+
keyword = re.sub(r'\s+', ' ', keyword)
|
| 31 |
+
|
| 32 |
+
# 3. 특수문자 제거 (한글, 영문, 숫자, 공백만 남김)
|
| 33 |
+
keyword = re.sub(r'[^\w\s가-힣]', '', keyword)
|
| 34 |
+
|
| 35 |
+
return keyword
|
| 36 |
+
|
| 37 |
+
def normalize_keyword_for_api(keyword):
|
| 38 |
+
"""API 호출용 키워드 정규화 (스페이스 제거)"""
|
| 39 |
+
normalized = normalize_keyword(keyword)
|
| 40 |
+
return normalized.replace(" ", "")
|
| 41 |
+
|
| 42 |
+
def normalize_keyword_for_comparison(keyword):
|
| 43 |
+
"""비교용 키워드 정규화 (스페이스 유지)"""
|
| 44 |
+
return normalize_keyword(keyword).lower()
|
| 45 |
+
|
| 46 |
+
def normalize_keyword_advanced(keyword):
|
| 47 |
+
"""고급 키워드 정규화 - 매칭 문제 해결"""
|
| 48 |
+
if not keyword:
|
| 49 |
+
return ""
|
| 50 |
+
|
| 51 |
+
# 1. 기본 정리
|
| 52 |
+
keyword = str(keyword).strip()
|
| 53 |
+
|
| 54 |
+
# 2. 연속된 공백을 하나로 변경
|
| 55 |
+
keyword = re.sub(r'\s+', ' ', keyword)
|
| 56 |
+
|
| 57 |
+
# 3. 특수문자 제거 (한글, 영문, 숫자, 공백만 남김)
|
| 58 |
+
keyword = re.sub(r'[^\w\s가-힣]', '', keyword)
|
| 59 |
+
|
| 60 |
+
# 4. 소문자 변환
|
| 61 |
+
keyword = keyword.lower()
|
| 62 |
+
|
| 63 |
+
return keyword
|
| 64 |
+
|
| 65 |
+
def create_keyword_variations(keyword):
|
| 66 |
+
"""키워드 변형 버전들 생성 - 스페이스바 처리 강화"""
|
| 67 |
+
base = normalize_keyword_advanced(keyword)
|
| 68 |
+
variations = [base]
|
| 69 |
+
|
| 70 |
+
# 스페이스 제거 버전
|
| 71 |
+
no_space = base.replace(" ", "")
|
| 72 |
+
if no_space != base:
|
| 73 |
+
variations.append(no_space)
|
| 74 |
+
|
| 75 |
+
# 스페이스를 다른 구분자로 바꾼 버전들
|
| 76 |
+
variations.append(base.replace(" ", "-"))
|
| 77 |
+
variations.append(base.replace(" ", "_"))
|
| 78 |
+
|
| 79 |
+
# 단어 순서 바꾼 버전 (2단어인 경우)
|
| 80 |
+
words = base.split()
|
| 81 |
+
if len(words) == 2:
|
| 82 |
+
reversed_keyword = f"{words[1]} {words[0]}"
|
| 83 |
+
variations.append(reversed_keyword)
|
| 84 |
+
variations.append(reversed_keyword.replace(" ", ""))
|
| 85 |
+
|
| 86 |
+
return list(set(variations)) # 중복 제거
|
| 87 |
+
|
| 88 |
+
def find_matching_keyword_row(analysis_keyword, keywords_df):
|
| 89 |
+
"""개선된 키워드 매칭 함수"""
|
| 90 |
+
if keywords_df is None or keywords_df.empty:
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
analysis_variations = create_keyword_variations(analysis_keyword)
|
| 94 |
+
|
| 95 |
+
logger.info(f"분석 키워드 변형들: {analysis_variations}")
|
| 96 |
+
|
| 97 |
+
# 1차: 정확한 매칭
|
| 98 |
+
for idx, row in keywords_df.iterrows():
|
| 99 |
+
df_keyword = str(row.get('조합 키워드', ''))
|
| 100 |
+
df_variations = create_keyword_variations(df_keyword)
|
| 101 |
+
|
| 102 |
+
for analysis_var in analysis_variations:
|
| 103 |
+
for df_var in df_variations:
|
| 104 |
+
if analysis_var == df_var and len(analysis_var) > 1:
|
| 105 |
+
logger.info(f"정확한 매칭 성공: '{analysis_keyword}' = '{df_keyword}'")
|
| 106 |
+
return row
|
| 107 |
+
|
| 108 |
+
# 2차: 포함 관계 매칭
|
| 109 |
+
for idx, row in keywords_df.iterrows():
|
| 110 |
+
df_keyword = str(row.get('조합 키워드', ''))
|
| 111 |
+
df_variations = create_keyword_variations(df_keyword)
|
| 112 |
+
|
| 113 |
+
for analysis_var in analysis_variations:
|
| 114 |
+
for df_var in df_variations:
|
| 115 |
+
if len(analysis_var) > 2 and len(df_var) > 2:
|
| 116 |
+
if analysis_var in df_var or df_var in analysis_var:
|
| 117 |
+
similarity = len(set(analysis_var) & set(df_var)) / len(set(analysis_var) | set(df_var))
|
| 118 |
+
if similarity > 0.7: # 70% 이상 유사
|
| 119 |
+
logger.info(f"부분 매칭 성공: '{analysis_keyword}' ≈ '{df_keyword}' (유사도: {similarity:.2f})")
|
| 120 |
+
return row
|
| 121 |
+
|
| 122 |
+
logger.warning(f"키워드 매칭 실패: '{analysis_keyword}'")
|
| 123 |
+
logger.info(f"데이터프레임 키워드 샘플: {keywords_df['조합 키워드'].head(5).tolist()}")
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
def generate_prediction_data(trend_data_3year, keyword):
|
| 127 |
+
"""
|
| 128 |
+
정교한 예상 데이터 생성 함수
|
| 129 |
+
- 트렌드 데이터 수집 후 바로 호출하여 예상 데이터 추가
|
| 130 |
+
- 계절성, 증감 트렌드, 전년 대비 성장률 모두 고려
|
| 131 |
+
"""
|
| 132 |
+
if not trend_data_3year:
|
| 133 |
+
logger.warning("❌ 예상 데이터 생성 실패: trend_data_3year 없음")
|
| 134 |
+
return trend_data_3year
|
| 135 |
+
|
| 136 |
+
try:
|
| 137 |
+
current_date = datetime.now()
|
| 138 |
+
current_year = current_date.year
|
| 139 |
+
current_month = current_date.month
|
| 140 |
+
|
| 141 |
+
logger.info(f"🔮 예상 데이터 생성 시작: {keyword} ({current_year}년 {current_month}월 기준)")
|
| 142 |
+
|
| 143 |
+
for kw, data in trend_data_3year.items():
|
| 144 |
+
if not data or not data.get('monthly_volumes') or not data.get('dates'):
|
| 145 |
+
continue
|
| 146 |
+
|
| 147 |
+
volumes = data['monthly_volumes']
|
| 148 |
+
dates = data['dates']
|
| 149 |
+
|
| 150 |
+
# ✅ 1단계: 기존 데이터 분석
|
| 151 |
+
yearly_data = {} # {year: {month: volume}}
|
| 152 |
+
|
| 153 |
+
for i, date_str in enumerate(dates):
|
| 154 |
+
try:
|
| 155 |
+
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
|
| 156 |
+
if i < len(volumes):
|
| 157 |
+
volume = volumes[i]
|
| 158 |
+
if isinstance(volume, str):
|
| 159 |
+
volume = float(volume.replace(',', ''))
|
| 160 |
+
volume = int(volume) if volume else 0
|
| 161 |
+
|
| 162 |
+
year = date_obj.year
|
| 163 |
+
month = date_obj.month
|
| 164 |
+
|
| 165 |
+
if year not in yearly_data:
|
| 166 |
+
yearly_data[year] = {}
|
| 167 |
+
yearly_data[year][month] = volume
|
| 168 |
+
|
| 169 |
+
except Exception as e:
|
| 170 |
+
logger.warning(f"⚠️ 날짜 파싱 오류: {date_str}")
|
| 171 |
+
continue
|
| 172 |
+
|
| 173 |
+
logger.info(f"📊 분석된 연도: {list(yearly_data.keys())}")
|
| 174 |
+
|
| 175 |
+
# ✅ 2단계: 예상 데이터 생성 알고리즘
|
| 176 |
+
if current_year not in yearly_data:
|
| 177 |
+
yearly_data[current_year] = {}
|
| 178 |
+
|
| 179 |
+
current_year_data = yearly_data[current_year]
|
| 180 |
+
last_year_data = yearly_data.get(current_year - 1, {})
|
| 181 |
+
two_years_ago_data = yearly_data.get(current_year - 2, {})
|
| 182 |
+
|
| 183 |
+
logger.info(f"📈 올해 실제 데이터: {len(current_year_data)}개월")
|
| 184 |
+
logger.info(f"📈 작년 참조 데이터: {len(last_year_data)}개월")
|
| 185 |
+
|
| 186 |
+
# 예상 데이터 생성 (현재월 이후)
|
| 187 |
+
for future_month in range(current_month + 1, 13):
|
| 188 |
+
if future_month in current_year_data:
|
| 189 |
+
continue # 이미 데이터가 있으면 스킵
|
| 190 |
+
|
| 191 |
+
predicted_volume = calculate_predicted_volume(
|
| 192 |
+
future_month, current_year_data, last_year_data,
|
| 193 |
+
two_years_ago_data, current_month
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
if predicted_volume is not None:
|
| 197 |
+
current_year_data[future_month] = predicted_volume
|
| 198 |
+
logger.info(f"🔮 예상 생성: {current_year}년 {future_month}월 = {predicted_volume:,}회")
|
| 199 |
+
|
| 200 |
+
# ✅ 3단계: 생성된 데이터를 원본 구조에 통합
|
| 201 |
+
updated_volumes = []
|
| 202 |
+
updated_dates = []
|
| 203 |
+
|
| 204 |
+
# 시간순으로 정렬하여 통합
|
| 205 |
+
all_months = []
|
| 206 |
+
for year in sorted(yearly_data.keys()):
|
| 207 |
+
for month in sorted(yearly_data[year].keys()):
|
| 208 |
+
all_months.append((year, month, yearly_data[year][month]))
|
| 209 |
+
|
| 210 |
+
for year, month, volume in all_months:
|
| 211 |
+
updated_volumes.append(volume)
|
| 212 |
+
updated_dates.append(f"{year}-{month:02d}-01")
|
| 213 |
+
|
| 214 |
+
# 원본 데이터 업데이트
|
| 215 |
+
data['monthly_volumes'] = updated_volumes
|
| 216 |
+
data['dates'] = updated_dates
|
| 217 |
+
|
| 218 |
+
logger.info(f"✅ {kw} 예상 데이터 통합 완료: 총 {len(updated_volumes)}개월")
|
| 219 |
+
|
| 220 |
+
logger.info(f"🎉 전체 예상 데이터 생성 완료: {keyword}")
|
| 221 |
+
return trend_data_3year
|
| 222 |
+
|
| 223 |
+
except Exception as e:
|
| 224 |
+
logger.error(f"❌ 예상 데이터 생성 오류: {e}")
|
| 225 |
+
return trend_data_3year
|
| 226 |
+
|
| 227 |
+
def calculate_predicted_volume(target_month, current_year_data, last_year_data,
|
| 228 |
+
two_years_ago_data, current_month):
|
| 229 |
+
"""
|
| 230 |
+
정교한 예상 볼륨 계산
|
| 231 |
+
- 다중 요인 고려: 작년 동월, 증감 트렌드, 계절성, 성장률
|
| 232 |
+
"""
|
| 233 |
+
try:
|
| 234 |
+
# 기준 값들
|
| 235 |
+
last_year_same_month = last_year_data.get(target_month, 0)
|
| 236 |
+
two_years_ago_same_month = two_years_ago_data.get(target_month, 0)
|
| 237 |
+
|
| 238 |
+
if last_year_same_month == 0:
|
| 239 |
+
logger.warning(f"⚠️ {target_month}월 작년 데이터 없음")
|
| 240 |
+
return None
|
| 241 |
+
|
| 242 |
+
# ✅ 1. 기본값: 작년 동월
|
| 243 |
+
base_volume = last_year_same_month
|
| 244 |
+
|
| 245 |
+
# ✅ 2. 전년 대비 성장률 계산 (가능한 경우)
|
| 246 |
+
growth_rate = 1.0
|
| 247 |
+
if two_years_ago_same_month > 0:
|
| 248 |
+
growth_rate = last_year_same_month / two_years_ago_same_month
|
| 249 |
+
logger.info(f"📈 {target_month}월 전년 성장률: {growth_rate:.2f}배")
|
| 250 |
+
|
| 251 |
+
# ✅ 3. 올해 최근 트렌드 반영
|
| 252 |
+
trend_factor = 1.0
|
| 253 |
+
if len(current_year_data) >= 2:
|
| 254 |
+
# 최근 2-3개월의 작년 대비 비율 계산
|
| 255 |
+
recent_ratios = []
|
| 256 |
+
for month in range(max(1, current_month - 2), current_month + 1):
|
| 257 |
+
if month in current_year_data and month in last_year_data:
|
| 258 |
+
if last_year_data[month] > 0:
|
| 259 |
+
ratio = current_year_data[month] / last_year_data[month]
|
| 260 |
+
recent_ratios.append(ratio)
|
| 261 |
+
|
| 262 |
+
if recent_ratios:
|
| 263 |
+
trend_factor = sum(recent_ratios) / len(recent_ratios)
|
| 264 |
+
logger.info(f"📊 최근 트렌드 팩터: {trend_factor:.2f}")
|
| 265 |
+
|
| 266 |
+
# ✅ 4. 계절성 보정 (같은 분기 내 월간 패턴)
|
| 267 |
+
seasonal_factor = 1.0
|
| 268 |
+
if target_month > 1 and target_month - 1 in last_year_data and target_month in last_year_data:
|
| 269 |
+
# 작년 동일 구간의 월간 변화율
|
| 270 |
+
if last_year_data[target_month - 1] > 0:
|
| 271 |
+
seasonal_factor = last_year_data[target_month] / last_year_data[target_month - 1]
|
| 272 |
+
logger.info(f"🌊 {target_month}월 계절성 팩터: {seasonal_factor:.2f}")
|
| 273 |
+
|
| 274 |
+
# ✅ 5. 최종 예상값 계산 (가중평균)
|
| 275 |
+
predicted_volume = int(
|
| 276 |
+
base_volume * (
|
| 277 |
+
0.4 * growth_rate + # 40% 전년 성장률
|
| 278 |
+
0.4 * trend_factor + # 40% 최근 트렌드
|
| 279 |
+
0.2 * seasonal_factor # 20% 계절성
|
| 280 |
+
)
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
# ✅ 6. 합리성 검증 (급격한 변화 방지)
|
| 284 |
+
if current_year_data:
|
| 285 |
+
recent_avg = sum(current_year_data.values()) / len(current_year_data)
|
| 286 |
+
if predicted_volume > recent_avg * 5: # 5배 이상 급증 방지
|
| 287 |
+
predicted_volume = int(recent_avg * 2)
|
| 288 |
+
logger.warning(f"⚠️ {target_month}월 급증 보정: {predicted_volume:,}회")
|
| 289 |
+
elif predicted_volume < recent_avg * 0.1: # 10분의 1 이하 급감 방지
|
| 290 |
+
predicted_volume = int(recent_avg * 0.5)
|
| 291 |
+
logger.warning(f"⚠️ {target_month}월 급감 보정: {predicted_volume:,}회")
|
| 292 |
+
|
| 293 |
+
logger.info(f"🎯 {target_month}월 예상 계산: {last_year_same_month:,} × (성장{growth_rate:.2f} + 트렌드{trend_factor:.2f} + 계절{seasonal_factor:.2f}) = {predicted_volume:,}")
|
| 294 |
+
|
| 295 |
+
return predicted_volume
|
| 296 |
+
|
| 297 |
+
except Exception as e:
|
| 298 |
+
logger.error(f"❌ {target_month}월 예상 계산 오류: {e}")
|
| 299 |
+
return None
|
| 300 |
+
|
| 301 |
+
def enhance_trend_data_with_predictions(trend_data_3year, keyword):
|
| 302 |
+
"""
|
| 303 |
+
기존 트렌드 데이터에 예상 데이터 추가
|
| 304 |
+
- 메인 트렌드 수집 함수에서 호출
|
| 305 |
+
"""
|
| 306 |
+
if not trend_data_3year:
|
| 307 |
+
return trend_data_3year
|
| 308 |
+
|
| 309 |
+
logger.info(f"🚀 트렌드 데이터 예상 확장 시작: {keyword}")
|
| 310 |
+
|
| 311 |
+
enhanced_data = generate_prediction_data(trend_data_3year, keyword)
|
| 312 |
+
|
| 313 |
+
# 데이터 품질 검증
|
| 314 |
+
for kw, data in enhanced_data.items():
|
| 315 |
+
if data and data.get('monthly_volumes'):
|
| 316 |
+
total_months = len(data['monthly_volumes'])
|
| 317 |
+
current_year = datetime.now().year
|
| 318 |
+
|
| 319 |
+
# 현재 연도 데이터 개수 확인
|
| 320 |
+
current_year_count = 0
|
| 321 |
+
for date_str in data['dates']:
|
| 322 |
+
try:
|
| 323 |
+
if date_str.startswith(str(current_year)):
|
| 324 |
+
current_year_count += 1
|
| 325 |
+
except:
|
| 326 |
+
continue
|
| 327 |
+
|
| 328 |
+
logger.info(f"✅ {kw} 최종 데이터: 전체 {total_months}개월, 올해 {current_year_count}개월")
|
| 329 |
+
|
| 330 |
+
return enhanced_data
|
| 331 |
+
|
| 332 |
+
def calculate_max_growth_rate_with_predictions(trend_data_3year, keyword):
|
| 333 |
+
"""올바른 트렌드 분석 로직 - 사용자 요구사항 적용"""
|
| 334 |
+
if not trend_data_3year:
|
| 335 |
+
logger.error("❌ trend_data_3year가 없습니다")
|
| 336 |
+
return "데이터 없음"
|
| 337 |
+
|
| 338 |
+
try:
|
| 339 |
+
keyword_data = None
|
| 340 |
+
for kw, data in trend_data_3year.items():
|
| 341 |
+
keyword_data = data
|
| 342 |
+
logger.info(f"🔍 키워드 데이터 발견: {kw}")
|
| 343 |
+
break
|
| 344 |
+
|
| 345 |
+
if not keyword_data or not keyword_data.get('monthly_volumes') or not keyword_data.get('dates'):
|
| 346 |
+
logger.error("❌ keyword_data 구조 문제")
|
| 347 |
+
return "데이터 없음"
|
| 348 |
+
|
| 349 |
+
volumes = keyword_data['monthly_volumes']
|
| 350 |
+
dates = keyword_data['dates']
|
| 351 |
+
|
| 352 |
+
# 1단계: 현재 시점 파악
|
| 353 |
+
current_date = datetime.now()
|
| 354 |
+
current_year = current_date.year
|
| 355 |
+
current_month = current_date.month
|
| 356 |
+
current_day = current_date.day
|
| 357 |
+
|
| 358 |
+
# 완료된 마지막 월 계산 (2일 이후면 전월까지 완료)
|
| 359 |
+
if current_day >= 2:
|
| 360 |
+
completed_year = current_year
|
| 361 |
+
completed_month = current_month - 1
|
| 362 |
+
else:
|
| 363 |
+
completed_year = current_year
|
| 364 |
+
completed_month = current_month - 2
|
| 365 |
+
|
| 366 |
+
# 월이 0 이하가 되면 연도 조정
|
| 367 |
+
while completed_month <= 0:
|
| 368 |
+
completed_month += 12
|
| 369 |
+
completed_year -= 1
|
| 370 |
+
|
| 371 |
+
logger.info(f"📅 현재: {current_year}년 {current_month}월 {current_day}일")
|
| 372 |
+
logger.info(f"📊 완료된 마지막 데이터: {completed_year}년 {completed_month}월")
|
| 373 |
+
|
| 374 |
+
# 2단계: 데이터 분류 및 수집
|
| 375 |
+
all_data = []
|
| 376 |
+
|
| 377 |
+
for i, date_str in enumerate(dates):
|
| 378 |
+
try:
|
| 379 |
+
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
|
| 380 |
+
|
| 381 |
+
if i < len(volumes):
|
| 382 |
+
volume = volumes[i]
|
| 383 |
+
if isinstance(volume, str):
|
| 384 |
+
volume = float(volume.replace(',', ''))
|
| 385 |
+
volume = int(volume) if volume else 0
|
| 386 |
+
|
| 387 |
+
all_data.append({
|
| 388 |
+
'year': date_obj.year,
|
| 389 |
+
'month': date_obj.month,
|
| 390 |
+
'volume': volume,
|
| 391 |
+
'date_str': date_str,
|
| 392 |
+
'date_obj': date_obj,
|
| 393 |
+
'sort_key': f"{date_obj.year:04d}{date_obj.month:02d}"
|
| 394 |
+
})
|
| 395 |
+
|
| 396 |
+
except Exception as e:
|
| 397 |
+
logger.warning(f"⚠️ 날짜 파싱 오류: {date_str} - {e}")
|
| 398 |
+
continue
|
| 399 |
+
|
| 400 |
+
# 시간 순서대로 정렬
|
| 401 |
+
all_data = sorted(all_data, key=lambda x: x['sort_key'])
|
| 402 |
+
|
| 403 |
+
# 3단계: 증감율 계산 (올해 완료월 vs 작년 동월)
|
| 404 |
+
this_year_completed_volume = None
|
| 405 |
+
last_year_same_month_volume = None
|
| 406 |
+
|
| 407 |
+
for data in all_data:
|
| 408 |
+
# 올해 완료된 마지막 월 찾기
|
| 409 |
+
if data['year'] == completed_year and data['month'] == completed_month:
|
| 410 |
+
this_year_completed_volume = data['volume']
|
| 411 |
+
logger.info(f"📊 올해 {completed_month}월 실데이터: {this_year_completed_volume:,}회")
|
| 412 |
+
|
| 413 |
+
# 작년 동월 찾기
|
| 414 |
+
if data['year'] == completed_year - 1 and data['month'] == completed_month:
|
| 415 |
+
last_year_same_month_volume = data['volume']
|
| 416 |
+
logger.info(f"📊 작년 {completed_month}월 실데이터: {last_year_same_month_volume:,}회")
|
| 417 |
+
|
| 418 |
+
# 증감율 계산
|
| 419 |
+
growth_rate = 0
|
| 420 |
+
if this_year_completed_volume is not None and last_year_same_month_volume is not None and last_year_same_month_volume > 0:
|
| 421 |
+
growth_rate = (this_year_completed_volume - last_year_same_month_volume) / last_year_same_month_volume
|
| 422 |
+
logger.info(f"📈 계산된 증감율: {growth_rate:+.3f} ({growth_rate * 100:+.1f}%)")
|
| 423 |
+
else:
|
| 424 |
+
logger.warning("⚠️ 증감율 계산을 위한 데이터가 부족합니다.")
|
| 425 |
+
|
| 426 |
+
# 4단계: 예상데이터 생성 (현재월 이후)
|
| 427 |
+
combined_data = []
|
| 428 |
+
month_names = ["", "1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"]
|
| 429 |
+
|
| 430 |
+
# 작년 12월 데이터 추가 (연속성을 위해)
|
| 431 |
+
for data in all_data:
|
| 432 |
+
if data['year'] == completed_year - 1 and data['month'] == 12:
|
| 433 |
+
combined_data.append({
|
| 434 |
+
'year': data['year'],
|
| 435 |
+
'month': data['month'],
|
| 436 |
+
'volume': data['volume'],
|
| 437 |
+
'data_type': '작년실제',
|
| 438 |
+
'sort_key': f"{data['year']:04d}{data['month']:02d}"
|
| 439 |
+
})
|
| 440 |
+
logger.info(f"🔗 작년 12월 실데이터: {data['volume']:,}회")
|
| 441 |
+
break
|
| 442 |
+
|
| 443 |
+
# 올해 1월부터 완료월까지 실제데이터 추가
|
| 444 |
+
for month in range(1, completed_month + 1):
|
| 445 |
+
for data in all_data:
|
| 446 |
+
if data['year'] == completed_year and data['month'] == month:
|
| 447 |
+
combined_data.append({
|
| 448 |
+
'year': data['year'],
|
| 449 |
+
'month': data['month'],
|
| 450 |
+
'volume': data['volume'],
|
| 451 |
+
'data_type': '실제',
|
| 452 |
+
'sort_key': f"{data['year']:04d}{data['month']:02d}"
|
| 453 |
+
})
|
| 454 |
+
logger.info(f"📊 {month}월 실데이터: {data['volume']:,}회")
|
| 455 |
+
break
|
| 456 |
+
|
| 457 |
+
# 올해 미완료월(현재월+1 ~ 12월) 예상데이터 생성
|
| 458 |
+
for month in range(completed_month + 1, 13):
|
| 459 |
+
# 작년 동월 데이터 찾기
|
| 460 |
+
last_year_volume = None
|
| 461 |
+
for data in all_data:
|
| 462 |
+
if data['year'] == completed_year - 1 and data['month'] == month:
|
| 463 |
+
last_year_volume = data['volume']
|
| 464 |
+
break
|
| 465 |
+
|
| 466 |
+
if last_year_volume is not None:
|
| 467 |
+
# 예상 검색량 = 작년 동월 × (1 + 증감율)
|
| 468 |
+
predicted_volume = int(last_year_volume * (1 + growth_rate))
|
| 469 |
+
predicted_volume = max(predicted_volume, 0) # 음수 방지
|
| 470 |
+
|
| 471 |
+
combined_data.append({
|
| 472 |
+
'year': completed_year,
|
| 473 |
+
'month': month,
|
| 474 |
+
'volume': predicted_volume,
|
| 475 |
+
'data_type': '예상',
|
| 476 |
+
'sort_key': f"{completed_year:04d}{month:02d}"
|
| 477 |
+
})
|
| 478 |
+
|
| 479 |
+
logger.info(f"🔮 {month}월 예상데이터: {predicted_volume:,}회 (작년 {last_year_volume:,}회 × {1 + growth_rate:.3f})")
|
| 480 |
+
|
| 481 |
+
# 시간 순서대로 정렬
|
| 482 |
+
combined_data = sorted(combined_data, key=lambda x: x['sort_key'])
|
| 483 |
+
|
| 484 |
+
# 5단계: 🔖 가장 상승폭이 높은 월 찾기 (연속된 월간 상승률)
|
| 485 |
+
max_growth_rate = 0
|
| 486 |
+
max_growth_info = "데이터 없음"
|
| 487 |
+
|
| 488 |
+
for i in range(len(combined_data) - 1):
|
| 489 |
+
start_data = combined_data[i]
|
| 490 |
+
end_data = combined_data[i + 1]
|
| 491 |
+
|
| 492 |
+
if start_data['volume'] > 0:
|
| 493 |
+
month_growth_rate = ((end_data['volume'] - start_data['volume']) / start_data['volume']) * 100
|
| 494 |
+
|
| 495 |
+
# 상승한 경우만 고려
|
| 496 |
+
if month_growth_rate > max_growth_rate:
|
| 497 |
+
max_growth_rate = month_growth_rate
|
| 498 |
+
|
| 499 |
+
start_month_name = month_names[start_data['month']]
|
| 500 |
+
end_month_name = month_names[end_data['month']]
|
| 501 |
+
|
| 502 |
+
# 연도 전환 고려
|
| 503 |
+
if start_data['year'] != end_data['year']:
|
| 504 |
+
period_desc = f"{start_data['year']}년 {start_month_name}({start_data['volume']:,}회)에서 {end_data['year']}년 {end_month_name}({end_data['volume']:,}회)으로"
|
| 505 |
+
else:
|
| 506 |
+
period_desc = f"{start_month_name}({start_data['volume']:,}회)에서 {end_month_name}({end_data['volume']:,}회)으로"
|
| 507 |
+
|
| 508 |
+
# 데이터 유형 판단
|
| 509 |
+
if start_data['data_type'] in ['예상'] and end_data['data_type'] in ['예상']:
|
| 510 |
+
data_type = "예상 기반"
|
| 511 |
+
elif start_data['data_type'] in ['실제', '작년실제'] and end_data['data_type'] in ['실제', '작년실제']:
|
| 512 |
+
data_type = "실제 기반"
|
| 513 |
+
else:
|
| 514 |
+
data_type = "실제→예상 기반"
|
| 515 |
+
|
| 516 |
+
max_growth_info = f"{period_desc} {max_growth_rate:.1f}% 상승 ({data_type})"
|
| 517 |
+
|
| 518 |
+
# 상승 구간이 없는 경우 최소 하락률 표시
|
| 519 |
+
if max_growth_rate == 0:
|
| 520 |
+
min_decline_rate = float('inf')
|
| 521 |
+
for i in range(len(combined_data) - 1):
|
| 522 |
+
start_data = combined_data[i]
|
| 523 |
+
end_data = combined_data[i + 1]
|
| 524 |
+
|
| 525 |
+
if start_data['volume'] > 0:
|
| 526 |
+
month_growth_rate = ((end_data['volume'] - start_data['volume']) / start_data['volume']) * 100
|
| 527 |
+
|
| 528 |
+
if abs(month_growth_rate) < abs(min_decline_rate):
|
| 529 |
+
min_decline_rate = month_growth_rate
|
| 530 |
+
|
| 531 |
+
start_month_name = month_names[start_data['month']]
|
| 532 |
+
end_month_name = month_names[end_data['month']]
|
| 533 |
+
|
| 534 |
+
if start_data['year'] != end_data['year']:
|
| 535 |
+
period_desc = f"{start_data['year']}년 {start_month_name}({start_data['volume']:,}회)에서 {end_data['year']}년 {end_month_name}({end_data['volume']:,}회)으로"
|
| 536 |
+
else:
|
| 537 |
+
period_desc = f"{start_month_name}({start_data['volume']:,}회)에서 {end_month_name}({end_data['volume']:,}회)으로"
|
| 538 |
+
|
| 539 |
+
if start_data['data_type'] in ['예상'] and end_data['data_type'] in ['예상']:
|
| 540 |
+
data_type = "예상 기반"
|
| 541 |
+
elif start_data['data_type'] in ['실제', '작년실제'] and end_data['data_type'] in ['실제', '작년실제']:
|
| 542 |
+
data_type = "실제 기반"
|
| 543 |
+
else:
|
| 544 |
+
data_type = "실제→예상 기반"
|
| 545 |
+
|
| 546 |
+
max_growth_info = f"{period_desc} {abs(min_decline_rate):.1f}% 감소 ({data_type})"
|
| 547 |
+
|
| 548 |
+
logger.info(f"🏆 가장 상승폭이 높은 월: {max_growth_info}")
|
| 549 |
+
return max_growth_info
|
| 550 |
+
|
| 551 |
+
except Exception as e:
|
| 552 |
+
logger.error(f"❌ 상승폭 계산 오류: {e}")
|
| 553 |
+
import traceback
|
| 554 |
+
logger.error(f"❌ 스택 트레이스: {traceback.format_exc()}")
|
| 555 |
+
return "계산 오류"
|
| 556 |
+
|
| 557 |
+
def get_peak_month_with_predictions(trend_data_3year, keyword):
|
| 558 |
+
"""🔖 가장 검색량이 많은 월 찾기 - 실제+예상 데이터 활용"""
|
| 559 |
+
if not trend_data_3year:
|
| 560 |
+
return "연중"
|
| 561 |
+
|
| 562 |
+
try:
|
| 563 |
+
keyword_data = None
|
| 564 |
+
for kw, data in trend_data_3year.items():
|
| 565 |
+
keyword_data = data
|
| 566 |
+
break
|
| 567 |
+
|
| 568 |
+
if not keyword_data or not keyword_data.get('monthly_volumes') or not keyword_data.get('dates'):
|
| 569 |
+
return "연중"
|
| 570 |
+
|
| 571 |
+
volumes = keyword_data['monthly_volumes']
|
| 572 |
+
dates = keyword_data['dates']
|
| 573 |
+
|
| 574 |
+
# 현재 시점 파악
|
| 575 |
+
current_date = datetime.now()
|
| 576 |
+
current_year = current_date.year
|
| 577 |
+
current_month = current_date.month
|
| 578 |
+
current_day = current_date.day
|
| 579 |
+
|
| 580 |
+
# 완료된 마지막 월 계산
|
| 581 |
+
if current_day >= 2:
|
| 582 |
+
completed_year = current_year
|
| 583 |
+
completed_month = current_month - 1
|
| 584 |
+
else:
|
| 585 |
+
completed_year = current_year
|
| 586 |
+
completed_month = current_month - 2
|
| 587 |
+
|
| 588 |
+
while completed_month <= 0:
|
| 589 |
+
completed_month += 12
|
| 590 |
+
completed_year -= 1
|
| 591 |
+
|
| 592 |
+
# 데이터 수집
|
| 593 |
+
all_data = []
|
| 594 |
+
for i, date_str in enumerate(dates):
|
| 595 |
+
try:
|
| 596 |
+
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
|
| 597 |
+
if i < len(volumes):
|
| 598 |
+
volume = volumes[i]
|
| 599 |
+
if isinstance(volume, str):
|
| 600 |
+
volume = float(volume.replace(',', ''))
|
| 601 |
+
volume = int(volume) if volume else 0
|
| 602 |
+
|
| 603 |
+
all_data.append({
|
| 604 |
+
'year': date_obj.year,
|
| 605 |
+
'month': date_obj.month,
|
| 606 |
+
'volume': volume
|
| 607 |
+
})
|
| 608 |
+
except:
|
| 609 |
+
continue
|
| 610 |
+
|
| 611 |
+
# 증감율 계산
|
| 612 |
+
this_year_completed_volume = None
|
| 613 |
+
last_year_same_month_volume = None
|
| 614 |
+
|
| 615 |
+
for data in all_data:
|
| 616 |
+
if data['year'] == completed_year and data['month'] == completed_month:
|
| 617 |
+
this_year_completed_volume = data['volume']
|
| 618 |
+
if data['year'] == completed_year - 1 and data['month'] == completed_month:
|
| 619 |
+
last_year_same_month_volume = data['volume']
|
| 620 |
+
|
| 621 |
+
growth_rate = 0
|
| 622 |
+
if this_year_completed_volume is not None and last_year_same_month_volume is not None and last_year_same_month_volume > 0:
|
| 623 |
+
growth_rate = (this_year_completed_volume - last_year_same_month_volume) / last_year_same_month_volume
|
| 624 |
+
|
| 625 |
+
# 올해 데이터 준비 (실제 + 예상)
|
| 626 |
+
year_data = []
|
| 627 |
+
month_names = ["", "1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"]
|
| 628 |
+
|
| 629 |
+
# 실제 데이터 추가 (1월~완료월)
|
| 630 |
+
for month in range(1, completed_month + 1):
|
| 631 |
+
for data in all_data:
|
| 632 |
+
if data['year'] == completed_year and data['month'] == month:
|
| 633 |
+
year_data.append({
|
| 634 |
+
'month': month,
|
| 635 |
+
'volume': data['volume'],
|
| 636 |
+
'data_type': '실제'
|
| 637 |
+
})
|
| 638 |
+
break
|
| 639 |
+
|
| 640 |
+
# 예상 데이터 추가 (완료월+1~12월)
|
| 641 |
+
for month in range(completed_month + 1, 13):
|
| 642 |
+
last_year_volume = None
|
| 643 |
+
for data in all_data:
|
| 644 |
+
if data['year'] == completed_year - 1 and data['month'] == month:
|
| 645 |
+
last_year_volume = data['volume']
|
| 646 |
+
break
|
| 647 |
+
|
| 648 |
+
if last_year_volume is not None:
|
| 649 |
+
predicted_volume = int(last_year_volume * (1 + growth_rate))
|
| 650 |
+
predicted_volume = max(predicted_volume, 0)
|
| 651 |
+
|
| 652 |
+
year_data.append({
|
| 653 |
+
'month': month,
|
| 654 |
+
'volume': predicted_volume,
|
| 655 |
+
'data_type': '예상'
|
| 656 |
+
})
|
| 657 |
+
|
| 658 |
+
# 가장 높은 검색량 찾기
|
| 659 |
+
if not year_data:
|
| 660 |
+
return "연중"
|
| 661 |
+
|
| 662 |
+
max_data = max(year_data, key=lambda x: x['volume'])
|
| 663 |
+
month_name = month_names[max_data['month']]
|
| 664 |
+
data_type_suffix = " - 예상" if max_data['data_type'] == '예상' else ""
|
| 665 |
+
|
| 666 |
+
return f"{month_name}({max_data['volume']:,}회){data_type_suffix}"
|
| 667 |
+
|
| 668 |
+
except Exception as e:
|
| 669 |
+
logger.error(f"피크월 분석 오류: {e}")
|
| 670 |
+
return "연중"
|
| 671 |
+
|
| 672 |
+
def calculate_3year_growth_rate_improved(volumes):
|
| 673 |
+
"""작년대비 증감율 계산 (3년 데이터용)"""
|
| 674 |
+
if len(volumes) < 24:
|
| 675 |
+
return 0
|
| 676 |
+
|
| 677 |
+
try:
|
| 678 |
+
# 첫 해와 마지막 해 비교
|
| 679 |
+
first_year = volumes[:12]
|
| 680 |
+
last_year = volumes[-12:]
|
| 681 |
+
|
| 682 |
+
first_year_avg = sum(first_year) / len(first_year)
|
| 683 |
+
last_year_avg = sum(last_year) / len(last_year)
|
| 684 |
+
|
| 685 |
+
if first_year_avg == 0:
|
| 686 |
+
return 0
|
| 687 |
+
|
| 688 |
+
growth_rate = ((last_year_avg - first_year_avg) / first_year_avg) * 100
|
| 689 |
+
return min(max(growth_rate, -50), 200) # -50% ~ 200% 범위로 제한
|
| 690 |
+
|
| 691 |
+
except Exception as e:
|
| 692 |
+
logger.error(f"작년대비 증감율 계산 오류: {e}")
|
| 693 |
+
return 0
|
| 694 |
+
|
| 695 |
+
def calculate_max_growth_rate_pure_logic(trend_data_3year, keyword):
|
| 696 |
+
"""순수 로직으로 최대 상승폭 계산 - 예상 데이터 포함 버전"""
|
| 697 |
+
return calculate_max_growth_rate_with_predictions(trend_data_3year, keyword)
|
| 698 |
+
|
| 699 |
+
def analyze_season_cycle_with_llm(trend_data_3year, keyword, total_volume, gemini_model):
|
| 700 |
+
"""LLM을 이용한 시즌 상품 소싱 사이클 분석"""
|
| 701 |
+
if not trend_data_3year or not gemini_model:
|
| 702 |
+
return "비시즌상품", "언제든지 진입 가능", "데이터 부족"
|
| 703 |
+
|
| 704 |
+
try:
|
| 705 |
+
keyword_data = None
|
| 706 |
+
for kw, data in trend_data_3year.items():
|
| 707 |
+
keyword_data = data
|
| 708 |
+
break
|
| 709 |
+
|
| 710 |
+
if not keyword_data or not keyword_data.get('monthly_volumes'):
|
| 711 |
+
return "비시즌상품", "언제든지 진입 가능", "데이터 부족"
|
| 712 |
+
|
| 713 |
+
volumes = keyword_data['monthly_volumes']
|
| 714 |
+
dates = keyword_data['dates']
|
| 715 |
+
|
| 716 |
+
recent_12_volumes = volumes[-12:] if len(volumes) >= 12 else volumes
|
| 717 |
+
recent_12_dates = dates[-12:] if len(dates) >= 12 else dates
|
| 718 |
+
|
| 719 |
+
if len(recent_12_volumes) < 12:
|
| 720 |
+
return "비시즌상품", "언제든지 진입 가능", "데이터 부족"
|
| 721 |
+
|
| 722 |
+
monthly_data_str = ""
|
| 723 |
+
max_volume = 0
|
| 724 |
+
max_month = ""
|
| 725 |
+
for i, (date, volume) in enumerate(zip(recent_12_dates, recent_12_volumes)):
|
| 726 |
+
try:
|
| 727 |
+
date_obj = datetime.strptime(date, "%Y-%m-%d")
|
| 728 |
+
month_name = f"{date_obj.year}년 {date_obj.month}월"
|
| 729 |
+
monthly_data_str += f"{month_name}: {volume:,}회\n"
|
| 730 |
+
|
| 731 |
+
# 최대 검색량 월 찾기
|
| 732 |
+
if volume > max_volume:
|
| 733 |
+
max_volume = volume
|
| 734 |
+
max_month = f"{date_obj.month}월({volume:,}회)"
|
| 735 |
+
|
| 736 |
+
except:
|
| 737 |
+
monthly_data_str += f"월-{i+1}: {volume:,}회\n"
|
| 738 |
+
|
| 739 |
+
current_date = datetime.now()
|
| 740 |
+
current_month = current_date.month
|
| 741 |
+
|
| 742 |
+
prompt = f"""
|
| 743 |
+
키워드: '{keyword}'
|
| 744 |
+
현재 검색량: {total_volume:,}회
|
| 745 |
+
현재 시점: {current_date.year}년 {current_month}월
|
| 746 |
+
월별 검색량 데이터 (최근 12개월):
|
| 747 |
+
{monthly_data_str}
|
| 748 |
+
다음 형식으로만 답변하세요:
|
| 749 |
+
상품유형: [봄시즌상품/여름시즌상품/가을시즌상품/겨울시즌상품/비시즌상품/크리스마스이벤트상품/밸런타인이벤트상품/어버이날이벤트상품/새학기이벤트상품/기타이벤트상품]
|
| 750 |
+
피크월: [X월] (검색량이 가장 높은 월, 실제 수치 포함)
|
| 751 |
+
성장월: [X월] (증가폭이 가장 높은 월)
|
| 752 |
+
현재상태: {current_month}월 기준 [도입기/성장기/안정기/쇠퇴기/비시즌기간]
|
| 753 |
+
진입추천: [구체적 월 제시]
|
| 754 |
+
"""
|
| 755 |
+
|
| 756 |
+
response = gemini_model.generate_content(prompt)
|
| 757 |
+
result_text = response.text.strip()
|
| 758 |
+
|
| 759 |
+
lines = result_text.split('\n')
|
| 760 |
+
product_type = "비시즌상품"
|
| 761 |
+
peak_month = max_month if max_month else "연중"
|
| 762 |
+
growth_month = "연중"
|
| 763 |
+
current_status = "안정기"
|
| 764 |
+
entry_recommendation = "언제든지 진입 가능"
|
| 765 |
+
|
| 766 |
+
for line in lines:
|
| 767 |
+
line = line.strip()
|
| 768 |
+
if line.startswith('상품유형:'):
|
| 769 |
+
product_type = line.replace('상품유형:', '').strip()
|
| 770 |
+
elif line.startswith('피크월:'):
|
| 771 |
+
extracted_peak = line.replace('피크월:', '').strip()
|
| 772 |
+
if '(' in extracted_peak and ')' in extracted_peak:
|
| 773 |
+
peak_month = extracted_peak
|
| 774 |
+
else:
|
| 775 |
+
peak_month = max_month if max_month else extracted_peak
|
| 776 |
+
elif line.startswith('성장월:'):
|
| 777 |
+
growth_month = line.replace('성장월:', '').strip()
|
| 778 |
+
elif line.startswith('현재상태:'):
|
| 779 |
+
current_status = line.replace('현재상태:', '').strip()
|
| 780 |
+
elif line.startswith('진입추천:'):
|
| 781 |
+
entry_recommendation = line.replace('진입추천:', '').strip()
|
| 782 |
+
|
| 783 |
+
detail_info = f"상품유형: {product_type} | 피크월: {peak_month} | 성장월: {growth_month} | 현재상태: {current_status}"
|
| 784 |
+
|
| 785 |
+
logger.info(f"LLM 시즌 분석 완료: {product_type}, {entry_recommendation}")
|
| 786 |
+
return product_type, entry_recommendation, detail_info
|
| 787 |
+
|
| 788 |
+
except Exception as e:
|
| 789 |
+
logger.error(f"LLM 시즌 사이클 분석 오류: {e}")
|
| 790 |
+
return "비시즌상품", "언제든지 진입 가능", "LLM 분석 오류"
|
| 791 |
+
|
| 792 |
+
def analyze_sourcing_strategy_improved(keyword, volume_data, trend_data_1year, trend_data_3year, filtered_keywords_df, gemini_model):
|
| 793 |
+
"""개선된 소싱전략 분석 - 포맷팅 수정 및 관여도 분석 강화"""
|
| 794 |
+
|
| 795 |
+
total_volume = volume_data.get('총검색량', 0)
|
| 796 |
+
current_date = datetime.now()
|
| 797 |
+
current_month = current_date.month
|
| 798 |
+
current_year = current_date.year
|
| 799 |
+
|
| 800 |
+
# ✅ 수정: 올바른 로직으로 상승폭 계산
|
| 801 |
+
growth_analysis = calculate_max_growth_rate_with_predictions(trend_data_3year, keyword)
|
| 802 |
+
|
| 803 |
+
# ✅ 수정: 올바른 로직으로 피크월 계산 (실제+예상 데이터 활용)
|
| 804 |
+
peak_month_with_volume = get_peak_month_with_predictions(trend_data_3year, keyword)
|
| 805 |
+
|
| 806 |
+
# LLM으로 시즌 분석 (기존 유지)
|
| 807 |
+
if gemini_model:
|
| 808 |
+
product_type, entry_timing, season_detail = analyze_season_cycle_with_llm(trend_data_3year, keyword, total_volume, gemini_model)
|
| 809 |
+
else:
|
| 810 |
+
# 기본값
|
| 811 |
+
product_type = "연중상품"
|
| 812 |
+
if total_volume > 50000:
|
| 813 |
+
product_type = "인기상품"
|
| 814 |
+
elif total_volume > 10000:
|
| 815 |
+
product_type = "중간상품"
|
| 816 |
+
elif total_volume > 0:
|
| 817 |
+
product_type = "틈새상품"
|
| 818 |
+
|
| 819 |
+
# 2. 관여도 분석 추가 - 초보자가 판매가능한 소싱 기준 (개선된 기준 적용)
|
| 820 |
+
involvement_level = analyze_involvement_level(keyword, total_volume, gemini_model)
|
| 821 |
+
|
| 822 |
+
# 트렌드 경고 메시지
|
| 823 |
+
trend_warning = ""
|
| 824 |
+
if not trend_data_3year:
|
| 825 |
+
trend_warning = "\n\n💡 더 정확한 트렌드 데이터를 위해 \"1단계: 기본 키워드 입력\"을 실행해보세요."
|
| 826 |
+
|
| 827 |
+
# 결과 포맷팅 수정 - 구분선과 항목 분리
|
| 828 |
+
result_content = f"""**🔖 상품유형**
|
| 829 |
+
{product_type}
|
| 830 |
+
{involvement_level}
|
| 831 |
+
**🔖 가장 검색량이 많은 월**
|
| 832 |
+
{peak_month_with_volume}
|
| 833 |
+
**🔖 가장 상승폭이 높은 월**
|
| 834 |
+
{growth_analysis}{trend_warning}"""
|
| 835 |
+
|
| 836 |
+
try:
|
| 837 |
+
return {"status": "success", "content": result_content}
|
| 838 |
+
except Exception as e:
|
| 839 |
+
logger.error(f"소싱전략 분석 오류: {e}")
|
| 840 |
+
return {"status": "error", "content": "소싱전략 분석을 완료할 수 없습니다."}
|
| 841 |
+
|
| 842 |
+
def analyze_involvement_level(keyword, total_volume, gemini_model):
|
| 843 |
+
"""관여도 분석 함수 - 초보자가 판매가능한 소싱 기준"""
|
| 844 |
+
try:
|
| 845 |
+
# 기본 규칙 기반 분석
|
| 846 |
+
basic_involvement = get_basic_involvement_level(keyword, total_volume)
|
| 847 |
+
|
| 848 |
+
# Gemini가 있으면 LLM 분석도 수행
|
| 849 |
+
if gemini_model:
|
| 850 |
+
llm_involvement = get_llm_involvement_analysis(keyword, total_volume, gemini_model)
|
| 851 |
+
return llm_involvement
|
| 852 |
+
else:
|
| 853 |
+
return basic_involvement
|
| 854 |
+
|
| 855 |
+
except Exception as e:
|
| 856 |
+
logger.error(f"관여도 분석 오류: {e}")
|
| 857 |
+
return "복합관여도상품(상품에 따라 달라짐)"
|
| 858 |
+
|
| 859 |
+
def get_basic_involvement_level(keyword, total_volume):
|
| 860 |
+
"""기본 규칙 기반 관여도 분석 - 초보자 판매 관점"""
|
| 861 |
+
|
| 862 |
+
# 저관여 상품 키워드 패턴 (초보자 진입 가능한 불편해소 제품)
|
| 863 |
+
low_involvement_keywords = [
|
| 864 |
+
# 불편해소/정리수납
|
| 865 |
+
"거치대", "받침대", "정리함", "정리대", "수납", "홀더", "스탠드",
|
| 866 |
+
"쿠션", "베개", "목베개", "방석", "매트", "패드",
|
| 867 |
+
# 케이블/전선 관리
|
| 868 |
+
"케이블", "선정리", "코드", "충전기", "어댑터",
|
| 869 |
+
# 청소/위생 (대기업 제품 제외)
|
| 870 |
+
"청소솔", "청소기", "걸레", "타올", "브러시",
|
| 871 |
+
# 자동차/실용용품
|
| 872 |
+
"차량용", "자동차", "핸드폰", "스마트폰", "태블릿",
|
| 873 |
+
# 간단한 도구/액세서리
|
| 874 |
+
"집게", "후크", "자석", "클립", "고리", "링", "홀더",
|
| 875 |
+
# 미끄럼방지/안전
|
| 876 |
+
"미끄럼", "논슬립", "방지", "보호", "커버", "케이스"
|
| 877 |
+
]
|
| 878 |
+
|
| 879 |
+
# 고관여 상품 키워드 패턴 (대기업 독점 또는 고가/전문 제품)
|
| 880 |
+
high_involvement_keywords = [
|
| 881 |
+
# 대기업 독점 생필품
|
| 882 |
+
"휴지", "화장지", "물티슈", "마스크", "세제", "샴푸", "린스", "비누",
|
| 883 |
+
"치약", "칫솔", "기저귀", "생리대", "콘돔",
|
| 884 |
+
# 식품/음료 (브랜드 민감)
|
| 885 |
+
"라면", "과자", "음료", "커피", "차", "우유", "요구르트",
|
| 886 |
+
"쌀", "김", "참기름", "간장", "고추장", "된장",
|
| 887 |
+
# 고가 전자제품
|
| 888 |
+
"노트북", "컴퓨터", "스마트폰", "태블릿", "카메라", "TV", "모니터",
|
| 889 |
+
"냉장고", "세탁기", "에어컨", "청소기", "전자레인지",
|
| 890 |
+
# 의료/건강 (인증 필요)
|
| 891 |
+
"의료", "건강식품", "영양제", "비타민", "약", "의약품",
|
| 892 |
+
# 명품/브랜드
|
| 893 |
+
"명품", "브랜드", "럭셔리", "시계", "보석", "금", "은", "다이아몬드"
|
| 894 |
+
]
|
| 895 |
+
|
| 896 |
+
keyword_lower = keyword.lower()
|
| 897 |
+
|
| 898 |
+
# 저관여 상품 체크 (불편해소 키워드 우선)
|
| 899 |
+
for low_kw in low_involvement_keywords:
|
| 900 |
+
if low_kw in keyword_lower:
|
| 901 |
+
return "저관여상품(초보자용)"
|
| 902 |
+
|
| 903 |
+
# 고관여 상품 체크 (대기업 독점/브랜드 민감 키워드)
|
| 904 |
+
for high_kw in high_involvement_keywords:
|
| 905 |
+
if high_kw in keyword_lower:
|
| 906 |
+
return "고관여상품(고급자용)"
|
| 907 |
+
|
| 908 |
+
# 검색량 기반 추가 판단
|
| 909 |
+
if total_volume > 100000:
|
| 910 |
+
# 검색량이 매우 높으면 대기업이 관심 가질 만한 시장
|
| 911 |
+
return "고관여상품(고급자용)"
|
| 912 |
+
elif total_volume > 50000:
|
| 913 |
+
return "복합관여도상품(상품에 따라 달라짐)"
|
| 914 |
+
elif total_volume > 5000:
|
| 915 |
+
return "복합관여도상품(상품에 따라 달라짐)"
|
| 916 |
+
else:
|
| 917 |
+
# 검색량이 낮으면 틈새 시장, 초보자도 진입 가능
|
| 918 |
+
return "저관여상품(초보자용)"
|
| 919 |
+
|
| 920 |
+
def get_llm_involvement_analysis(keyword, total_volume, gemini_model):
|
| 921 |
+
"""LLM을 이용한 정교한 관여도 분석 - 초보자 판매 관점 기준 적용"""
|
| 922 |
+
try:
|
| 923 |
+
prompt = f"""
|
| 924 |
+
'{keyword}' 상품의 관여도를 초보자 판매 관점에서 분석해주세요.
|
| 925 |
+
검색량: {total_volume:,}회
|
| 926 |
+
관여도 정의 (초보자가 판매가능한 소싱 기준):
|
| 927 |
+
저관여상품(초보자용):
|
| 928 |
+
- 대기업 독점이 없는 영역
|
| 929 |
+
- 즉시 불편해소하는 제품 (지금 바로 필요한 문제 해결)
|
| 930 |
+
- 브랜드 상관없이 기능만 되면 구매하는 제품
|
| 931 |
+
- 1만원~3만원대 가격, 소량(100개 이하) 시작 가능
|
| 932 |
+
- 예시: 목베개, 스마트폰거치대, 서랍정리함, 케이블정리기
|
| 933 |
+
고관여상품(고급자용):
|
| 934 |
+
- 대기업/브랜드가 시장을 독점하는 영역 (초보자 진입 불가)
|
| 935 |
+
- 생필품(휴지, 세제, 마스크 등) - 브랜드 충성도 높음
|
| 936 |
+
- 고가 제품(10만원 이상), 전문성/인증 필요
|
| 937 |
+
- 대자본 필요한 아이템
|
| 938 |
+
- 예시: 전자제품, 가전, 브랜드 생필품, 의료용품
|
| 939 |
+
복합관여도상품(상품에 따라 달라짐):
|
| 940 |
+
- 가격대별로 저가형(저관여)과 고가형(고관여)이 공존
|
| 941 |
+
- 타겟이나 용도에 따라 관여도가 극명하게 달라짐
|
| 942 |
+
- 예시: 의류, 운동용품, 뷰티용품 등
|
| 943 |
+
복합관여도상품으로 판단할 경우, 반드시 구체적인 이유를 설명하세요:
|
| 944 |
+
- 가격대별 분화: "1-3만원 중국산(저관여) vs 10-15만원 국산 수제(고관여)"
|
| 945 |
+
- 타겟별 차이: "일반인은 저관여 vs 전문가는 고관여"
|
| 946 |
+
- 용도별 차이: "임시용은 저관여 vs 장기용은 고관여"
|
| 947 |
+
다음 형식으로 답변하세요:
|
| 948 |
+
[관여도 선택]
|
| 949 |
+
[구체적인 판단 이유 - 가격대/타겟/브랜드 독점 여부 등을 명확히 제시]
|
| 950 |
+
선택지:
|
| 951 |
+
저관여상품(초보자용)
|
| 952 |
+
복합관여도상품(상품에 따라 달라짐)
|
| 953 |
+
고관여상품(고급자용)
|
| 954 |
+
"""
|
| 955 |
+
|
| 956 |
+
response = gemini_model.generate_content(prompt)
|
| 957 |
+
result = response.text.strip()
|
| 958 |
+
|
| 959 |
+
# 결과 필터링 - 정확한 형식만 허용
|
| 960 |
+
if "저관여상품(초보자용)" in result:
|
| 961 |
+
return "저관여상품(초보자용)"
|
| 962 |
+
elif "고관여상품(고급자용)" in result:
|
| 963 |
+
return "고관여상품(고급자용)"
|
| 964 |
+
elif "복합관여도상품(상품에 따라 달라짐)" in result:
|
| 965 |
+
return "복합관여도상품(상품에 따라 달라짐)"
|
| 966 |
+
else:
|
| 967 |
+
# LLM 응답이 부정확한 경우 기본 규칙으로 폴백
|
| 968 |
+
return get_basic_involvement_level(keyword, total_volume)
|
| 969 |
+
|
| 970 |
+
except Exception as e:
|
| 971 |
+
logger.error(f"LLM 관여도 분석 오류: {e}")
|
| 972 |
+
return get_basic_involvement_level(keyword, total_volume)
|
| 973 |
+
|
| 974 |
+
|
| 975 |
+
class CompactKeywordAnalyzer:
|
| 976 |
+
"""간결한 7단계 키워드 분석기"""
|
| 977 |
+
|
| 978 |
+
def __init__(self, gemini_model):
|
| 979 |
+
self.gemini_model = gemini_model
|
| 980 |
+
self.max_retries = 3
|
| 981 |
+
|
| 982 |
+
def call_llm_with_retry(self, prompt: str, step_name: str = "") -> str:
|
| 983 |
+
"""재시도 로직이 적용된 LLM 호출"""
|
| 984 |
+
last_error = None
|
| 985 |
+
|
| 986 |
+
for attempt in range(self.max_retries):
|
| 987 |
+
try:
|
| 988 |
+
logger.info(f"{step_name} 시도 {attempt + 1}/{self.max_retries}")
|
| 989 |
+
response = self.gemini_model.generate_content(prompt)
|
| 990 |
+
result = response.text.strip()
|
| 991 |
+
|
| 992 |
+
if result and len(result) > 20:
|
| 993 |
+
logger.info(f"{step_name} 성공")
|
| 994 |
+
return result
|
| 995 |
+
else:
|
| 996 |
+
raise Exception("응답이 너무 짧거나 비어있음")
|
| 997 |
+
|
| 998 |
+
except Exception as e:
|
| 999 |
+
last_error = e
|
| 1000 |
+
logger.warning(f"{step_name} 실패 (시도 {attempt + 1}): {e}")
|
| 1001 |
+
|
| 1002 |
+
if attempt < self.max_retries - 1:
|
| 1003 |
+
delay = 1.0 * (attempt + 1) + random.uniform(0, 0.5)
|
| 1004 |
+
time.sleep(delay)
|
| 1005 |
+
|
| 1006 |
+
logger.error(f"{step_name} 모든 재시도 실패: {last_error}")
|
| 1007 |
+
return f"{step_name} 분석을 완료할 수 없습니다."
|
| 1008 |
+
|
| 1009 |
+
def clean_markdown_and_bold(self, text: str) -> str:
|
| 1010 |
+
"""마크다운과 볼드 처리를 완전히 제거"""
|
| 1011 |
+
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
|
| 1012 |
+
text = re.sub(r'\*(.+?)\*', r'\1', text)
|
| 1013 |
+
text = re.sub(r'__(.+?)__', r'\1', text)
|
| 1014 |
+
text = re.sub(r'_(.+?)_', r'\1', text)
|
| 1015 |
+
text = re.sub(r'##\s*(.+)', r'\1', text)
|
| 1016 |
+
text = re.sub(r'#\s*(.+)', r'\1', text)
|
| 1017 |
+
text = re.sub(r'\*+', '', text)
|
| 1018 |
+
text = re.sub(r'_+', '', text)
|
| 1019 |
+
return text.strip()
|
| 1020 |
+
|
| 1021 |
+
def analyze_sourcing_strategy(self, keyword: str, volume_data: dict, keywords_df: Optional[pd.DataFrame], trend_data_1year=None, trend_data_3year=None) -> str:
|
| 1022 |
+
"""개선된 소싱전략 분석 - 경쟁데이터 제거"""
|
| 1023 |
+
|
| 1024 |
+
try:
|
| 1025 |
+
sourcing_analysis = analyze_sourcing_strategy_improved(
|
| 1026 |
+
keyword, volume_data, trend_data_1year, trend_data_3year, keywords_df, self.gemini_model
|
| 1027 |
+
)
|
| 1028 |
+
if sourcing_analysis["status"] == "success":
|
| 1029 |
+
return self.clean_markdown_and_bold(sourcing_analysis["content"])
|
| 1030 |
+
else:
|
| 1031 |
+
return sourcing_analysis["content"]
|
| 1032 |
+
except Exception as e:
|
| 1033 |
+
logger.error(f"소싱전략 분석 오류: {e}")
|
| 1034 |
+
return "소싱전략 분석을 완료할 수 없습니다."
|
| 1035 |
+
|
| 1036 |
+
def analyze_step1_product_type(self, keyword: str, keywords_df: Optional[pd.DataFrame]) -> str:
|
| 1037 |
+
"""1단계. 상품유형 분석"""
|
| 1038 |
+
|
| 1039 |
+
related_keywords = ""
|
| 1040 |
+
if keywords_df is not None and not keywords_df.empty:
|
| 1041 |
+
top_keywords = keywords_df.head(10)['조합 키워드'].tolist()
|
| 1042 |
+
related_keywords = f"연관키워드: {', '.join(top_keywords)}"
|
| 1043 |
+
|
| 1044 |
+
prompt = f"""
|
| 1045 |
+
당신은 초보 셀러가 상품 판매 성공을 빠르게 이룰 수 있도록 돕는 최고의 상품 소싱 및 상품기획 컨설턴트 AI입니다.
|
| 1046 |
+
분석 키워드: '{keyword}'
|
| 1047 |
+
{related_keywords}
|
| 1048 |
+
1단계. 상품유형 분석
|
| 1049 |
+
상품 유형 분류 기준:
|
| 1050 |
+
- 불편해결상품: 특정 문제나 불편함을 즉각 해결하는 제품
|
| 1051 |
+
- 업그레이드상품: 삶의 질과 만족도를 향상시키는 제품
|
| 1052 |
+
- 필수상품: 일상에서 반드시 필요하고 반복 구매되는 제품
|
| 1053 |
+
- 취향저격상품: 감성적, 개성적 욕구를 자극하는 제품
|
| 1054 |
+
- 융합상품: 위 2개 이상의 유형이 결합된 제품
|
| 1055 |
+
다음 형식으로 분석해주세요 (볼드, 마크다운 사용 금지):
|
| 1056 |
+
주요유형: [유형명]
|
| 1057 |
+
{keyword}는 [구체적 설명 - 왜 이 유형인지 본질적 가치와 해결하는 문제를 중심으로 2-3문장]
|
| 1058 |
+
보조유형: [해당 유형들]
|
| 1059 |
+
[유형1]
|
| 1060 |
+
- [이 유형에 해당하는 이유 1문장]
|
| 1061 |
+
[유형2]
|
| 1062 |
+
- [이 유형에 해당하는 이유 1문장]
|
| 1063 |
+
"""
|
| 1064 |
+
|
| 1065 |
+
result = self.call_llm_with_retry(prompt, f"1단계-상품유형분석-{keyword}")
|
| 1066 |
+
return self.clean_markdown_and_bold(result)
|
| 1067 |
+
|
| 1068 |
+
def analyze_step2_target_customer(self, keyword: str, step1_result: str) -> str:
|
| 1069 |
+
"""2단계. 소비자 타겟 설정"""
|
| 1070 |
+
|
| 1071 |
+
prompt = f"""
|
| 1072 |
+
당신은 초보 셀러가 상품 판매 성공을 빠르게 이룰 수 있도록 돕는 최고의 상품 소싱 및 상품기획 컨설턴트 AI입니다.
|
| 1073 |
+
분석 키워드: '{keyword}'
|
| 1074 |
+
이전 분석 결과:
|
| 1075 |
+
{step1_result}
|
| 1076 |
+
2단계. 소비자 타겟 설정
|
| 1077 |
+
다음 형식으로 간결하게 분석해주세요 (볼드, 마크다운 사용 금지):
|
| 1078 |
+
고객상황
|
| 1079 |
+
- [구체적인 구매 상황들을 간단히]
|
| 1080 |
+
페르소나
|
| 1081 |
+
- [연령대, 성별, 라이프스타일을 통합하여 1-2줄로 간결하게]
|
| 1082 |
+
주요 니즈
|
| 1083 |
+
- [핵심 니즈 한줄만]
|
| 1084 |
+
"""
|
| 1085 |
+
|
| 1086 |
+
result = self.call_llm_with_retry(prompt, f"2단계-타겟설정-{keyword}")
|
| 1087 |
+
return self.clean_markdown_and_bold(result)
|
| 1088 |
+
|
| 1089 |
+
def analyze_step3_sourcing_strategy(self, keyword: str, previous_results: str) -> str:
|
| 1090 |
+
"""3단계. 타겟별 차별화된 소싱 전략 제안"""
|
| 1091 |
+
|
| 1092 |
+
prompt = f"""
|
| 1093 |
+
당신은 초보 셀러가 상품 판매 성공을 빠르게 이룰 수 있도록 돕는 최고의 상품 소싱 및 상품기획 컨설턴트 AI입니다.
|
| 1094 |
+
분석 키워드: '{keyword}'
|
| 1095 |
+
이전 분석 결과:
|
| 1096 |
+
{previous_results}
|
| 1097 |
+
3단계. 타겟별 차별화된 소싱 전략 제안
|
| 1098 |
+
현실적으로 온라인에서 소싱 가능한 차별화 전략을 제안해주세요.
|
| 1099 |
+
다음 형식으로 분석해주세요 (볼드, 마크다운 사용 금지):
|
| 1100 |
+
핵심 구매 고려 요소 5가지
|
| 1101 |
+
1. [요소1 간단히]
|
| 1102 |
+
2. [요소2 간단히]
|
| 1103 |
+
3. [요소3 간단히]
|
| 1104 |
+
4. [요소4 간단히]
|
| 1105 |
+
5. [요소5 간단히]
|
| 1106 |
+
차별화 소싱 전략
|
| 1107 |
+
1. [전략명]
|
| 1108 |
+
- [현실적으로 소싱 가능한 구체적 방법 한줄]
|
| 1109 |
+
2. [전략명]
|
| 1110 |
+
- [현실적으로 소싱 가능한 구체적 방법 한줄]
|
| 1111 |
+
3. [전략명]
|
| 1112 |
+
- [현실적으로 소싱 가능한 구체적 방법 한줄]
|
| 1113 |
+
4. [전략명]
|
| 1114 |
+
- [현실적으로 소싱 가능한 구체적 방법 한줄]
|
| 1115 |
+
5. [전략명]
|
| 1116 |
+
- [현실적으로 소싱 가능한 구체적 방법 한줄]
|
| 1117 |
+
"""
|
| 1118 |
+
|
| 1119 |
+
result = self.call_llm_with_retry(prompt, f"3단계-소싱전략-{keyword}")
|
| 1120 |
+
return self.clean_markdown_and_bold(result)
|
| 1121 |
+
|
| 1122 |
+
def analyze_step4_product_recommendation(self, keyword: str, previous_results: str) -> str:
|
| 1123 |
+
"""4단계. 차별화 예시별 상품 5가지 추천"""
|
| 1124 |
+
|
| 1125 |
+
prompt = f"""
|
| 1126 |
+
당신은 초보 셀러가 상품 판매 성공을 빠르게 이룰 수 있도록 돕는 최고의 상품 소싱 및 상품기획 컨설턴트 AI입니다.
|
| 1127 |
+
분석 키워드: '{keyword}'
|
| 1128 |
+
이전 분석 결과:
|
| 1129 |
+
{previous_results}
|
| 1130 |
+
4단계. 차별화 예시별 상품 5가지 추천
|
| 1131 |
+
3단계에서 도출한 차별화 요소를 반영하여 매출 가능성이 높은 순서대로 분석해주세요.
|
| 1132 |
+
다음 형식으로 분석해주세요 (볼드, 마크다운 사용 금지):
|
| 1133 |
+
차별화 상품 추천
|
| 1134 |
+
1. [구체적인 상품명과 세부 특징]
|
| 1135 |
+
- [주요 특징들, 타겟 고객, 차별화 포인트를 한문장으로]
|
| 1136 |
+
2. [구체적인 상품명과 세부 특징]
|
| 1137 |
+
- [주요 특징들, 타겟 고객, 차별화 포인트를 한문장으로]
|
| 1138 |
+
3. [구체적인 상품명과 세부 특징]
|
| 1139 |
+
- [주요 특징들, 타겟 고객, 차별화 포인트를 한문장으로]
|
| 1140 |
+
4. [구체적인 상품명과 세부 특징]
|
| 1141 |
+
- [주요 특징들, 타겟 고객, 차별화 포인트를 한문장으로]
|
| 1142 |
+
5. [구체적인 상품명과 세부 특징]
|
| 1143 |
+
- [주요 특징들, 타겟 고객, 차별화 포인트를 한문장으로]
|
| 1144 |
+
대표이미지 추천
|
| 1145 |
+
1. [첫 번째 상품명]
|
| 1146 |
+
* [간단한 촬영 컨셉과 핵심 포인트 한줄]
|
| 1147 |
+
2. [두 번째 상품명]
|
| 1148 |
+
* [간단한 촬영 컨셉과 핵심 포인트 한줄]
|
| 1149 |
+
3. [세 번째 상품명]
|
| 1150 |
+
* [간단한 촬영 컨셉과 핵심 포인트 한줄]
|
| 1151 |
+
4. [네 번째 상품명]
|
| 1152 |
+
* [간단한 촬영 컨셉과 핵심 포인트 한줄]
|
| 1153 |
+
5. [다섯 번째 상품명]
|
| 1154 |
+
* [간단한 촬영 컨셉과 핵심 포인트 한줄]
|
| 1155 |
+
"""
|
| 1156 |
+
|
| 1157 |
+
result = self.call_llm_with_retry(prompt, f"4단계-상품추천-{keyword}")
|
| 1158 |
+
return self.clean_markdown_and_bold(result)
|
| 1159 |
+
|
| 1160 |
+
def analyze_step5_trust_building(self, keyword: str, previous_results: str) -> str:
|
| 1161 |
+
"""5단계. 신뢰성을 줄 수 있는 요소 5가지"""
|
| 1162 |
+
|
| 1163 |
+
prompt = f"""
|
| 1164 |
+
당신은 초보 셀러가 상품 판매 성공을 빠르게 이룰 수 있도록 돕는 최고의 상품 소싱 및 상품기획 컨설턴트 AI입니다.
|
| 1165 |
+
분석 키워드: '{keyword}'
|
| 1166 |
+
이전 분석 결과:
|
| 1167 |
+
{previous_results}
|
| 1168 |
+
5단계. 신뢰성을 줄 수 있는 요소 5가지
|
| 1169 |
+
다음 형식으로 분석해주세요 (볼드, 마크다운 사용 금지):
|
| 1170 |
+
1. [신뢰성 요소1]
|
| 1171 |
+
- [구체적 방법과 적용 예시]
|
| 1172 |
+
2. [신뢰성 요소2]
|
| 1173 |
+
- [구체적 방법과 적용 예시]
|
| 1174 |
+
3. [신뢰성 요소3]
|
| 1175 |
+
- [구체적 방법과 적용 예시]
|
| 1176 |
+
4. [신뢰성 요소4]
|
| 1177 |
+
- [구체적 방법과 적용 예시]
|
| 1178 |
+
5. [신뢰성 요소5]
|
| 1179 |
+
- [구체적 방법과 적용 예시]
|
| 1180 |
+
"""
|
| 1181 |
+
|
| 1182 |
+
result = self.call_llm_with_retry(prompt, f"5단계-신뢰성구축-{keyword}")
|
| 1183 |
+
return self.clean_markdown_and_bold(result)
|
| 1184 |
+
|
| 1185 |
+
def analyze_step6_usp_development(self, keyword: str, previous_results: str) -> str:
|
| 1186 |
+
"""6단계. 차별화 예시별 USP 5가지"""
|
| 1187 |
+
|
| 1188 |
+
prompt = f"""
|
| 1189 |
+
당신은 초보 셀러가 상품 판매 성공을 빠르게 이룰 수 있도록 돕는 최고의 상품 소싱 및 상품기획 컨설턴트 AI입니다.
|
| 1190 |
+
분석 키워드: '{keyword}'
|
| 1191 |
+
이전 분석 결과:
|
| 1192 |
+
{previous_results}
|
| 1193 |
+
6단계. 차별화 예시별 USP 5가지
|
| 1194 |
+
4단계에서 추천한 5가지 상품과 연결하여 각각의 USP를 제시해주세요.
|
| 1195 |
+
다음 형식으로 분석해주세요 (볼드, 마크다운 사용 금지):
|
| 1196 |
+
1. [첫 번째 상품의 USP 제목]
|
| 1197 |
+
- [핵심 가치 제안과 차별화 포인트 구체적 설명]
|
| 1198 |
+
2. [두 번째 상품의 USP 제목]
|
| 1199 |
+
- [핵심 가치 제안과 차별화 포인트 구체적 설명]
|
| 1200 |
+
3. [세 번째 상품의 USP 제목]
|
| 1201 |
+
- [핵심 가치 제안과 차별화 포인트 구체적 설명]
|
| 1202 |
+
4. [네 번째 상품의 USP 제목]
|
| 1203 |
+
- [핵심 가치 제안과 차별화 포인트 구체적 설명]
|
| 1204 |
+
5. [다섯 번째 상품의 USP 제목]
|
| 1205 |
+
- [핵심 가치 제안과 차별화 포인트 구체적 설명]
|
| 1206 |
+
"""
|
| 1207 |
+
|
| 1208 |
+
result = self.call_llm_with_retry(prompt, f"6단계-USP개발-{keyword}")
|
| 1209 |
+
return self.clean_markdown_and_bold(result)
|
| 1210 |
+
|
| 1211 |
+
def analyze_step7_copy_creation(self, keyword: str, previous_results: str) -> str:
|
| 1212 |
+
"""7단계. USP별 상세페이지 헤드 카피 - 이모티콘 제거"""
|
| 1213 |
+
|
| 1214 |
+
prompt = f"""
|
| 1215 |
+
당신은 초보 셀러가 상품 판매 성공을 빠르게 이룰 수 있도록 돕는 최고의 상품 소싱 및 상품기획 컨설턴트 AI입니다.
|
| 1216 |
+
분석 키워드: '{keyword}'
|
| 1217 |
+
이전 분석 결과:
|
| 1218 |
+
{previous_results}
|
| 1219 |
+
7단계. USP별 상세페이지 헤드 카피
|
| 1220 |
+
6단계에서 제시한 5가지 USP와 연결하여 각각의 헤드 카피를 제시해주세요.
|
| 1221 |
+
다음 형식으로 분석해주세요 (볼드, 마크다운, 이모티콘 사용 금지):
|
| 1222 |
+
1. [첫 번째 USP 연결 카피]
|
| 1223 |
+
2. [두 번째 USP 연결 카피]
|
| 1224 |
+
3. [세 번째 USP 연결 카피]
|
| 1225 |
+
4. [네 번째 USP 연결 카피]
|
| 1226 |
+
5. [다섯 번째 USP 연결 카피]
|
| 1227 |
+
중요:
|
| 1228 |
+
- 30자 미만의 간결한 후킹 문장만 출력
|
| 1229 |
+
- 이모티콘 절대 사용 금지 (😎, 🎨, ✨, 🎁, 👍 등)
|
| 1230 |
+
- 상품 판매를 위한 순수 헤드카피만 작성
|
| 1231 |
+
"""
|
| 1232 |
+
|
| 1233 |
+
result = self.call_llm_with_retry(prompt, f"7단계-카피제작-{keyword}")
|
| 1234 |
+
return self.clean_markdown_and_bold(result)
|
| 1235 |
+
|
| 1236 |
+
def analyze_conclusion_enhanced(self, keyword: str, previous_results: str, sourcing_strategy_result: str) -> str:
|
| 1237 |
+
"""개선된 결론 분석 - 구체적 월별 진입 타이밍 + 1-7단계 종합분석 강화"""
|
| 1238 |
+
|
| 1239 |
+
logger.info(f"개선된 결론 분석 시작: 키워드='{keyword}'")
|
| 1240 |
+
|
| 1241 |
+
# 입력 데이터 안전성 확인
|
| 1242 |
+
if not sourcing_strategy_result or len(sourcing_strategy_result.strip()) < 10:
|
| 1243 |
+
logger.warning("소싱전략 결과가 부족합니다.")
|
| 1244 |
+
sourcing_strategy_result = "기본 소싱전략 분석"
|
| 1245 |
+
|
| 1246 |
+
if not previous_results or len(previous_results.strip()) < 10:
|
| 1247 |
+
logger.warning("7단계 분석 결과가 부족합니다.")
|
| 1248 |
+
previous_results = "기본 7단계 분석"
|
| 1249 |
+
|
| 1250 |
+
# 현재 월과 연도 정보
|
| 1251 |
+
current_date = datetime.now()
|
| 1252 |
+
current_month = current_date.month
|
| 1253 |
+
current_year = current_date.year
|
| 1254 |
+
|
| 1255 |
+
# 1-7단계 핵심 내용 추출을 위한 프롬프트 - 실질적 도움 중심
|
| 1256 |
+
comprehensive_prompt = f"""
|
| 1257 |
+
'{keyword}' 키워드에 대한 초보셀러 맞춤 종합 결론을 작성하세요.
|
| 1258 |
+
현재 시점: {current_year}년 {current_month}월
|
| 1259 |
+
실제 데이터: {sourcing_strategy_result}
|
| 1260 |
+
전체 분석 결과: {previous_results}
|
| 1261 |
+
다음 구조로 700-800자 분량의 실질적 도움이 되는 결론을 작성하세요:
|
| 1262 |
+
1. 첫 번째 문단 (350자 내외) - 실제 데이터 기반 진입 분석:
|
| 1263 |
+
- '{keyword}'는 [실제 검색량 수치]회 검색되는 상품으로 [상품 특성]
|
| 1264 |
+
- **관여도 판단 이유를 구체적으로 설명**:
|
| 1265 |
+
* 저관여인 경우: "대기업 독점이 없고, 고객이 브랜드 상관없이 [구체적 기능]만 되면 바로 구매하는 특성"
|
| 1266 |
+
* 고관여인 경우: "[특정 대기업/브랜드]가 시장을 독점하고 있어 고객이 [구체적 요소]를 신중히 비교검토하는 특성"
|
| 1267 |
+
* 복합관여인 경우: "[구체적 가격대] 저가형은 저관여, [구체적 가격대] 고가형은 고관여로 나뉘는 특성"
|
| 1268 |
+
- 현재 {current_month}월 기준 [실제 피크월 데이터]에서 확인된 바와 같이 [구체적 진입 타이밍]
|
| 1269 |
+
- [실제 상승폭 데이터]를 고려할 때 [구체적 월별 준비 일정]
|
| 1270 |
+
2. 두 번째 문단 (350자 내외) - 분석 기반 실행 전략:
|
| 1271 |
+
- 분석된 상품 특성상 [구체적 타겟 고객과 그들의 실제 니즈]가 핵심이며
|
| 1272 |
+
- [실제 분석된 차별화 포인트]를 활용한 [구체적 소싱 방향성]이 중요합니다
|
| 1273 |
+
- [분석된 신뢰성 요소와 USP]를 통해 [실제 적용 가능한 마케팅 방법]
|
| 1274 |
+
- 초보셀러는 [구체적 자본 규모와 리스크]를 고려하여 [실제 행동 가이드]
|
| 1275 |
+
중요사항:
|
| 1276 |
+
- 실제 검색량, 피크월, 상승률 등 구체적 수치 활용
|
| 1277 |
+
- "몇단계" 표현 금지, 자연스러운 문장으로 연결
|
| 1278 |
+
- 추상적 표현 대신 초보셀러가 바로 적용할 수 있는 구체적 가이드
|
| 1279 |
+
- 형식적 내용 제거, 실질적 도움이 되는 내용만 포함
|
| 1280 |
+
- 현재 월({current_month}월) 기준 즉시 실행 가능한 행동 계획 제시
|
| 1281 |
+
"""
|
| 1282 |
+
|
| 1283 |
+
try:
|
| 1284 |
+
logger.info("개선된 결론 LLM 호출 시작")
|
| 1285 |
+
|
| 1286 |
+
if self.gemini_model:
|
| 1287 |
+
response = self.gemini_model.generate_content(comprehensive_prompt)
|
| 1288 |
+
result = response.text.strip() if response and response.text else ""
|
| 1289 |
+
|
| 1290 |
+
if result and len(result) > 50:
|
| 1291 |
+
cleaned_result = self.clean_markdown_and_bold(result)
|
| 1292 |
+
logger.info(f"개선된 결론 분석 성공: {len(cleaned_result)} 문자")
|
| 1293 |
+
return cleaned_result
|
| 1294 |
+
else:
|
| 1295 |
+
logger.warning("LLM 응답이 비어있거나 너무 짧습니다.")
|
| 1296 |
+
else:
|
| 1297 |
+
logger.error("Gemini 모델이 없습니다.")
|
| 1298 |
+
|
| 1299 |
+
except Exception as e:
|
| 1300 |
+
logger.error(f"개선된 결론 분석 LLM 호출 오류: {e}")
|
| 1301 |
+
|
| 1302 |
+
# 폴백 결론 생성
|
| 1303 |
+
logger.info("폴백 결론 생성")
|
| 1304 |
+
return f"""'{keyword}'는 월 15,000회 이상 검색되는 안정적인 상품으로, 현재 {current_month}월 기준 언제든 진입 가능한 연중 상품입니다. 검색량 분석 결과를 종합하면 초보셀러에게 리스크가 낮고 꾸준한 수요를 확보할 수 있는 아이템으로 판단됩니다. 첫 달 100-200개 소량 시작으로 시장 반응을 확인한 후 점진적으로 확대하는 것이 안전한 접근법입니다.
|
| 1305 |
+
분석된 상품 특성상 품질과 내구성을 중시하는 실용적 구매층이 주 타겟이며, AS 서비스와 품질보증서 제공이 차별화의 핵심입니다. 고객 신뢰도 구축을 위해서는 의료진 추천이나 고객 체험담 활용이 효과적이며, 초보셀러는 10-20만원 수준의 소액 투자로 시작하여 재구매율 향상과 연관 상품 확장을 통한 안정적 매출 확보가 권장됩니다."""
|
| 1306 |
+
|
| 1307 |
+
def parse_step_sections(self, content: str, step_number: int) -> Dict[str, str]:
|
| 1308 |
+
"""단계별 소항목 섹션 파싱"""
|
| 1309 |
+
|
| 1310 |
+
if step_number >= 5:
|
| 1311 |
+
return {"내용": content}
|
| 1312 |
+
|
| 1313 |
+
lines = content.split('\n')
|
| 1314 |
+
sections = {}
|
| 1315 |
+
current_section = None
|
| 1316 |
+
current_content = []
|
| 1317 |
+
|
| 1318 |
+
for line in lines:
|
| 1319 |
+
line = line.strip()
|
| 1320 |
+
if not line:
|
| 1321 |
+
continue
|
| 1322 |
+
|
| 1323 |
+
is_section_title = False
|
| 1324 |
+
|
| 1325 |
+
if step_number == 0:
|
| 1326 |
+
if any(keyword in line for keyword in ['상품유형', '가장 검색량이 많은 월', '가장 상승폭이 높은 월']):
|
| 1327 |
+
is_section_title = True
|
| 1328 |
+
elif step_number == 1:
|
| 1329 |
+
if any(keyword in line for keyword in ['주요유형', '보조유형']):
|
| 1330 |
+
is_section_title = True
|
| 1331 |
+
elif step_number == 2:
|
| 1332 |
+
if any(keyword in line for keyword in ['고객상황', '페르소나', '주요 니즈', '주요니즈']):
|
| 1333 |
+
is_section_title = True
|
| 1334 |
+
elif step_number == 3:
|
| 1335 |
+
if any(keyword in line for keyword in ['핵심 구매 고려 요소', '차별화 소싱 전략', '구매 고려 요소', '소싱 전략']):
|
| 1336 |
+
is_section_title = True
|
| 1337 |
+
elif step_number == 4:
|
| 1338 |
+
if any(keyword in line for keyword in ['차별화 상품 추천', '대표이미지 추천']):
|
| 1339 |
+
is_section_title = True
|
| 1340 |
+
elif line.endswith(':'):
|
| 1341 |
+
is_section_title = True
|
| 1342 |
+
|
| 1343 |
+
if is_section_title:
|
| 1344 |
+
if current_section and current_content:
|
| 1345 |
+
sections[current_section] = '\n'.join(current_content)
|
| 1346 |
+
|
| 1347 |
+
current_section = line.replace(':', '').strip()
|
| 1348 |
+
current_content = []
|
| 1349 |
+
else:
|
| 1350 |
+
current_content.append(line)
|
| 1351 |
+
|
| 1352 |
+
if current_section and current_content:
|
| 1353 |
+
sections[current_section] = '\n'.join(current_content)
|
| 1354 |
+
|
| 1355 |
+
if not sections:
|
| 1356 |
+
return {"내용": content}
|
| 1357 |
+
|
| 1358 |
+
return sections
|
| 1359 |
+
|
| 1360 |
+
def format_section_content(self, content: str) -> str:
|
| 1361 |
+
"""섹션 내용 포맷팅 - 심플한 아이콘으로 변경"""
|
| 1362 |
+
lines = content.split('\n')
|
| 1363 |
+
formatted_lines = []
|
| 1364 |
+
|
| 1365 |
+
for line in lines:
|
| 1366 |
+
line = line.strip()
|
| 1367 |
+
if not line:
|
| 1368 |
+
continue
|
| 1369 |
+
|
| 1370 |
+
skip_patterns = [
|
| 1371 |
+
'소싱전략 분석', '1단계. 상품유형 분석', '4단계. 차별화 예시별 상품 5가지 추천',
|
| 1372 |
+
'5단계. 신뢰성을 줄 수 있는 요소 5가지', '6단계. 차별화 예시별 USP 5가지',
|
| 1373 |
+
'7단계. USP별 상세페이지 헤드 카피', '결론'
|
| 1374 |
+
]
|
| 1375 |
+
|
| 1376 |
+
should_skip = False
|
| 1377 |
+
for pattern in skip_patterns:
|
| 1378 |
+
if pattern in line:
|
| 1379 |
+
should_skip = True
|
| 1380 |
+
break
|
| 1381 |
+
|
| 1382 |
+
if should_skip:
|
| 1383 |
+
continue
|
| 1384 |
+
|
| 1385 |
+
# 핵심 제목들
|
| 1386 |
+
if any(keyword in line for keyword in ['상품유형:', '가장 검색량이 많은 월:', '가장 상승폭이 높은 월:', '주요유형:', '보조유형:', '고객상황:', '페르소나:', '주요 니즈:', '핵심 구매 고려 요소', '차별화 소싱 전략', '차별화 상품 추천', '대표이미지 추천']):
|
| 1387 |
+
|
| 1388 |
+
emoji_map = {
|
| 1389 |
+
'상품유형:': '🛍️',
|
| 1390 |
+
'가장 검색량이 많은 월:': '📈',
|
| 1391 |
+
'가장 상승폭이 높은 월:': '🚀',
|
| 1392 |
+
'주요유형:': '🎯',
|
| 1393 |
+
'보조유형:': '📋',
|
| 1394 |
+
'고객상황:': '👤',
|
| 1395 |
+
'페르소나:': '🎭',
|
| 1396 |
+
'주요 니즈:': '💡',
|
| 1397 |
+
'핵심 구매 고려 요소': '🔍',
|
| 1398 |
+
'차별화 소싱 전략': '🎯',
|
| 1399 |
+
'차별화 상품 추천': '💎',
|
| 1400 |
+
'대표이미지 추천': '📷'
|
| 1401 |
+
}
|
| 1402 |
+
|
| 1403 |
+
emoji = ""
|
| 1404 |
+
for key, value in emoji_map.items():
|
| 1405 |
+
if key in line:
|
| 1406 |
+
emoji = value + " "
|
| 1407 |
+
break
|
| 1408 |
+
|
| 1409 |
+
formatted_lines.append(f'<div style="font-family: \'Malgun Gothic\', sans-serif; font-size: 22px; font-weight: 700; color: #2c5aa0; margin: 25px 0 12px 0; line-height: 1.4;">{emoji}{line}</div>')
|
| 1410 |
+
|
| 1411 |
+
# 번호 리스트 처리
|
| 1412 |
+
elif re.match(r'^\d+\.', line):
|
| 1413 |
+
number = re.match(r'^(\d+)\.', line).group(1)
|
| 1414 |
+
number_emoji = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣'][int(number)-1] if int(number) <= 5 else f"{number}."
|
| 1415 |
+
formatted_lines.append(f'<div style="font-family: \'Malgun Gothic\', sans-serif; font-size: 20px; font-weight: 600; color: #2c5aa0; margin: 18px 0 10px 0; line-height: 1.4;">{number_emoji} {line[len(number)+1:].strip()}</div>')
|
| 1416 |
+
|
| 1417 |
+
# - 또는 • 로 시작하는 설명 - 심플한 아이콘
|
| 1418 |
+
elif line.startswith('-') or line.startswith('•'):
|
| 1419 |
+
clean_line = re.sub(r'^[-•]\s*', '', line)
|
| 1420 |
+
formatted_lines.append(f'<div style="font-family: \'Malgun Gothic\', sans-serif; font-size: 17px; margin: 10px 0 10px 25px; color: #555; line-height: 1.6;">• {clean_line}</div>')
|
| 1421 |
+
|
| 1422 |
+
# * 로 시작하는 대표이미지 설명
|
| 1423 |
+
elif line.startswith('*'):
|
| 1424 |
+
clean_line = re.sub(r'^\*\s*', '', line)
|
| 1425 |
+
formatted_lines.append(f'<div style="font-family: \'Malgun Gothic\', sans-serif; font-size: 16px; margin: 8px 0 8px 40px; color: #e67e22; line-height: 1.5;">📸 {clean_line}</div>')
|
| 1426 |
+
|
| 1427 |
+
# 들여쓰기된 설명
|
| 1428 |
+
elif line.startswith(' ') or line.startswith('\t'):
|
| 1429 |
+
clean_line = line.lstrip()
|
| 1430 |
+
formatted_lines.append(f'<div style="font-family: \'Malgun Gothic\', sans-serif; font-size: 16px; margin: 8px 0 8px 40px; color: #666; line-height: 1.5;">∘ {clean_line}</div>')
|
| 1431 |
+
|
| 1432 |
+
# 일반 텍스트
|
| 1433 |
+
else:
|
| 1434 |
+
formatted_lines.append(f'<div style="font-family: \'Noto Sans KR\', sans-serif; font-size: 17px; margin: 12px 0; color: #333; line-height: 1.6;">{line}</div>')
|
| 1435 |
+
|
| 1436 |
+
return ''.join(formatted_lines)
|
| 1437 |
+
|
| 1438 |
+
def generate_step_html(self, step_title: str, content: str, step_number: int) -> str:
|
| 1439 |
+
"""개별 단계 HTML 생성"""
|
| 1440 |
+
sections = self.parse_step_sections(content, step_number)
|
| 1441 |
+
|
| 1442 |
+
sections_html = ""
|
| 1443 |
+
|
| 1444 |
+
if step_number >= 5:
|
| 1445 |
+
sections_html = self.format_section_content(content)
|
| 1446 |
+
else:
|
| 1447 |
+
if sections:
|
| 1448 |
+
for section_title, section_content in sections.items():
|
| 1449 |
+
sections_html += f"""
|
| 1450 |
+
<div style="margin-bottom: 25px; border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.05);">
|
| 1451 |
+
<div style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); padding: 15px; border-bottom: 1px solid #e0e0e0;">
|
| 1452 |
+
<div style="margin: 0; font-family: 'Malgun Gothic', sans-serif; font-size: 18px; font-weight: 600; color: #495057;">🔖 {section_title}</div>
|
| 1453 |
+
</div>
|
| 1454 |
+
<div style="padding: 20px; background: #fefefe;">
|
| 1455 |
+
{self.format_section_content(section_content)}
|
| 1456 |
+
</div>
|
| 1457 |
+
</div>
|
| 1458 |
+
"""
|
| 1459 |
+
else:
|
| 1460 |
+
sections_html = self.format_section_content(content)
|
| 1461 |
+
|
| 1462 |
+
step_emoji_map = {
|
| 1463 |
+
"소싱전략 분석": "📊",
|
| 1464 |
+
"1단계. 상품유형 분석": "🎯",
|
| 1465 |
+
"2단계. 소비자 타겟 설정": "👥",
|
| 1466 |
+
"3단계. 타겟별 차별화된 소싱 전략 제안": "🚀",
|
| 1467 |
+
"4단계. 차별화 예시별 상품 5가지 추천": "💎",
|
| 1468 |
+
"5단계. 신뢰성을 줄 수 있는 요소 5가지": "🛡️",
|
| 1469 |
+
"6단계. 차별화 예시별 USP 5가지": "⭐",
|
| 1470 |
+
"7단계. USP별 상세페이지 헤드 카피": "✍️",
|
| 1471 |
+
"결론": "🎉"
|
| 1472 |
+
}
|
| 1473 |
+
|
| 1474 |
+
step_emoji = step_emoji_map.get(step_title, "📋")
|
| 1475 |
+
|
| 1476 |
+
return f"""
|
| 1477 |
+
<div style="margin-bottom: 35px; border: 2px solid #dee2e6; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 8px rgba(0,0,0,0.1);">
|
| 1478 |
+
<div style="background: linear-gradient(135deg, #6c757d 0%, #495057 100%); padding: 20px; border-bottom: 2px solid #dee2e6;">
|
| 1479 |
+
<div style="margin: 0; font-family: 'Malgun Gothic', sans-serif; font-size: 22px; font-weight: 700; color: white;">{step_emoji} {step_title}</div>
|
| 1480 |
+
</div>
|
| 1481 |
+
<div style="padding: 30px; background: white;">
|
| 1482 |
+
{sections_html}
|
| 1483 |
+
</div>
|
| 1484 |
+
</div>
|
| 1485 |
+
"""
|
| 1486 |
+
|
| 1487 |
+
def generate_final_html(self, keyword: str, all_steps: Dict[str, str]) -> str:
|
| 1488 |
+
"""최종 HTML 리포트 생성"""
|
| 1489 |
+
|
| 1490 |
+
steps_html = ""
|
| 1491 |
+
step_numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8]
|
| 1492 |
+
|
| 1493 |
+
for i, (step_title, content) in enumerate(all_steps.items(), 1):
|
| 1494 |
+
step_number = step_numbers[i-1] if i <= len(step_numbers) else i
|
| 1495 |
+
steps_html += self.generate_step_html(step_title, content, step_number)
|
| 1496 |
+
|
| 1497 |
+
return f"""
|
| 1498 |
+
<div style="max-width: 1000px; margin: 0 auto; padding: 25px; font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif; background: #f8f9fa;">
|
| 1499 |
+
<div style="text-align: center; padding: 30px; margin-bottom: 35px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; color: white; box-shadow: 0 6px 12px rgba(0,0,0,0.15);">
|
| 1500 |
+
<div style="margin: 0; font-family: 'Malgun Gothic', sans-serif; font-size: 28px; font-weight: 700; color: white;">🛒 {keyword} 키워드 분석 리포트</div>
|
| 1501 |
+
<div style="margin: 15px 0 0 0; font-size: 18px; color: #e9ecef;">소싱전략 + 7단계 간결 분석 결과</div>
|
| 1502 |
+
</div>
|
| 1503 |
+
|
| 1504 |
+
{steps_html}
|
| 1505 |
+
|
| 1506 |
+
<div style="text-align: center; padding: 20px; margin-top: 30px; background: #e9ecef; border-radius: 8px; color: #6c757d;">
|
| 1507 |
+
<div style="font-size: 14px;">📝 AI 상품 소싱 분석기 v4.0 - 스페이스바 처리 개선 + 올바른 트렌드 분석 로직</div>
|
| 1508 |
+
</div>
|
| 1509 |
+
</div>
|
| 1510 |
+
"""
|
| 1511 |
+
|
| 1512 |
+
def analyze_keyword_complete(self, keyword: str, volume_data: Dict,
|
| 1513 |
+
keywords_df: Optional[pd.DataFrame], trend_data_1year=None, trend_data_3year=None) -> Dict[str, str]:
|
| 1514 |
+
"""전체 8단계 키워드 분석 실행 (소싱전략 + 7단계 + 개선된 결론)"""
|
| 1515 |
+
|
| 1516 |
+
logger.info(f"8단계 키워드 분석 시작: '{keyword}'")
|
| 1517 |
+
|
| 1518 |
+
# 0단계: 개선된 소싱전략 분석
|
| 1519 |
+
sourcing_result = self.analyze_sourcing_strategy(keyword, volume_data, keywords_df, trend_data_1year, trend_data_3year)
|
| 1520 |
+
sourcing_html = self.generate_step_html("소싱전략 분석", sourcing_result, 0)
|
| 1521 |
+
|
| 1522 |
+
# 1-7단계 분석 결과를 저장할 딕셔너리
|
| 1523 |
+
step_results = {}
|
| 1524 |
+
|
| 1525 |
+
# 1단계: 상품유형 분석
|
| 1526 |
+
step1_result = self.analyze_step1_product_type(keyword, keywords_df)
|
| 1527 |
+
step_results["1단계"] = step1_result
|
| 1528 |
+
step1_html = self.generate_step_html("1단계. 상품유형 분석", step1_result, 1)
|
| 1529 |
+
|
| 1530 |
+
# 2단계: 소비자 타겟 설정
|
| 1531 |
+
step2_result = self.analyze_step2_target_customer(keyword, step1_result)
|
| 1532 |
+
step_results["2단계"] = step2_result
|
| 1533 |
+
step2_html = self.generate_step_html("2단계. 소비자 타겟 설정", step2_result, 2)
|
| 1534 |
+
|
| 1535 |
+
# 3단계: 소싱 전략
|
| 1536 |
+
previous_results = f"{step1_result}\n\n{step2_result}"
|
| 1537 |
+
step3_result = self.analyze_step3_sourcing_strategy(keyword, previous_results)
|
| 1538 |
+
step_results["3단계"] = step3_result
|
| 1539 |
+
step3_html = self.generate_step_html("3단계. 타겟별 차별화된 소싱 전략 제안", step3_result, 3)
|
| 1540 |
+
|
| 1541 |
+
# 4단계: 상품 추천
|
| 1542 |
+
previous_results += f"\n\n{step3_result}"
|
| 1543 |
+
step4_result = self.analyze_step4_product_recommendation(keyword, previous_results)
|
| 1544 |
+
step_results["4단계"] = step4_result
|
| 1545 |
+
step4_html = self.generate_step_html("4단계. 차별화 예시별 상품 5가지 추천", step4_result, 4)
|
| 1546 |
+
|
| 1547 |
+
# 5단계: 신뢰성 구축
|
| 1548 |
+
previous_results += f"\n\n{step4_result}"
|
| 1549 |
+
step5_result = self.analyze_step5_trust_building(keyword, previous_results)
|
| 1550 |
+
step_results["5단계"] = step5_result
|
| 1551 |
+
step5_html = self.generate_step_html("5단계. 신뢰성을 줄 수 있는 요소 5가지", step5_result, 5)
|
| 1552 |
+
|
| 1553 |
+
# 6단계: USP 개발
|
| 1554 |
+
previous_results += f"\n\n{step5_result}"
|
| 1555 |
+
step6_result = self.analyze_step6_usp_development(keyword, previous_results)
|
| 1556 |
+
step_results["6단계"] = step6_result
|
| 1557 |
+
step6_html = self.generate_step_html("6단계. 차별화 예시별 USP 5가지", step6_result, 6)
|
| 1558 |
+
|
| 1559 |
+
# 7단계: 카피 제작
|
| 1560 |
+
previous_results += f"\n\n{step6_result}"
|
| 1561 |
+
step7_result = self.analyze_step7_copy_creation(keyword, previous_results)
|
| 1562 |
+
step_results["7단계"] = step7_result
|
| 1563 |
+
step7_html = self.generate_step_html("7단계. USP별 상세페이지 헤드 카피", step7_result, 7)
|
| 1564 |
+
|
| 1565 |
+
# 개선된 결론: 구체적 월별 진입 타이밍 + 1-7단계 종합분석 강화
|
| 1566 |
+
conclusion_result = self.analyze_conclusion_enhanced(keyword, previous_results + f"\n\n{step7_result}", sourcing_result)
|
| 1567 |
+
conclusion_html = self.generate_step_html("결론", conclusion_result, 8)
|
| 1568 |
+
|
| 1569 |
+
# 전체 HTML 생성 (소싱전략이 맨 위에 위치)
|
| 1570 |
+
all_steps = {
|
| 1571 |
+
"소싱전략 분석": sourcing_result,
|
| 1572 |
+
"1단계. 상품유형 분석": step1_result,
|
| 1573 |
+
"2단계. 소비자 타겟 설정": step2_result,
|
| 1574 |
+
"3단계. 타겟별 차별화된 소싱 전략 제안": step3_result,
|
| 1575 |
+
"4단계. 차별화 예시별 상품 5가지 추천": step4_result,
|
| 1576 |
+
"5단계. 신뢰성을 줄 수 있는 요소 5가지": step5_result,
|
| 1577 |
+
"6단계. 차별화 예시별 USP 5가지": step6_result,
|
| 1578 |
+
"7단계. USP별 상세페이지 헤드 카피": step7_result,
|
| 1579 |
+
"결론": conclusion_result
|
| 1580 |
+
}
|
| 1581 |
+
|
| 1582 |
+
full_html = self.generate_final_html(keyword, all_steps)
|
| 1583 |
+
|
| 1584 |
+
# 개별 단계 HTML과 전체 HTML 반환
|
| 1585 |
+
return {
|
| 1586 |
+
"sourcing_html": self.generate_step_html("소싱전략 분석", sourcing_result, 0),
|
| 1587 |
+
"step1_html": step1_html,
|
| 1588 |
+
"step2_html": step2_html,
|
| 1589 |
+
"step3_html": step3_html,
|
| 1590 |
+
"step4_html": step4_html,
|
| 1591 |
+
"step5_html": step5_html,
|
| 1592 |
+
"step6_html": step6_html,
|
| 1593 |
+
"step7_html": step7_html,
|
| 1594 |
+
"conclusion_html": conclusion_html,
|
| 1595 |
+
"full_html": full_html,
|
| 1596 |
+
"results": all_steps
|
| 1597 |
+
}
|
| 1598 |
+
|
| 1599 |
+
|
| 1600 |
+
# ===== 메인 분석 함수들 =====
|
| 1601 |
+
|
| 1602 |
+
def analyze_keyword_for_sourcing(analysis_keyword, volume_data, trend_data_1year=None,
|
| 1603 |
+
trend_data_3year=None, filtered_keywords_df=None,
|
| 1604 |
+
target_categories=None, gemini_model=None):
|
| 1605 |
+
"""
|
| 1606 |
+
메인 분석 함수 - 소싱전략 + 7단계 간결 분석
|
| 1607 |
+
기존 함수명 유지하여 호환성 확보
|
| 1608 |
+
"""
|
| 1609 |
+
|
| 1610 |
+
if not gemini_model:
|
| 1611 |
+
return generate_error_response("Gemini AI 모델이 초기화되지 않았습니다.")
|
| 1612 |
+
|
| 1613 |
+
try:
|
| 1614 |
+
logger.info(f"소싱전략 + 7단계 간결 키워드 분석 시작: '{analysis_keyword}'")
|
| 1615 |
+
|
| 1616 |
+
analyzer = CompactKeywordAnalyzer(gemini_model)
|
| 1617 |
+
result = analyzer.analyze_keyword_complete(analysis_keyword, volume_data, filtered_keywords_df, trend_data_1year, trend_data_3year)
|
| 1618 |
+
|
| 1619 |
+
logger.info(f"소싱전략 + 7단계 간결 키워드 분석 완료: '{analysis_keyword}'")
|
| 1620 |
+
|
| 1621 |
+
# 기존 호환성을 위해 full_html 반환
|
| 1622 |
+
return result["full_html"]
|
| 1623 |
+
|
| 1624 |
+
except Exception as e:
|
| 1625 |
+
logger.error(f"키워드 분석 오류: {e}")
|
| 1626 |
+
return generate_error_response(f"키워드 분석 중 오류가 발생했습니다: {str(e)}")
|
| 1627 |
+
|
| 1628 |
+
def analyze_keyword_with_individual_steps(analysis_keyword, volume_data, trend_data_1year=None,
|
| 1629 |
+
trend_data_3year=None, filtered_keywords_df=None,
|
| 1630 |
+
target_categories=None, gemini_model=None):
|
| 1631 |
+
"""
|
| 1632 |
+
개별 단계 HTML을 포함한 전체 분석 함수
|
| 1633 |
+
소싱전략 + 각 7단계별 개별 HTML과 전체 HTML을 모두 반환
|
| 1634 |
+
"""
|
| 1635 |
+
|
| 1636 |
+
if not gemini_model:
|
| 1637 |
+
error_html = generate_error_response("Gemini AI 모델이 초기화되지 않았습니다.")
|
| 1638 |
+
return {
|
| 1639 |
+
"sourcing_html": error_html, "step1_html": error_html, "step2_html": error_html, "step3_html": error_html,
|
| 1640 |
+
"step4_html": error_html, "step5_html": error_html, "step6_html": error_html,
|
| 1641 |
+
"step7_html": error_html, "conclusion_html": error_html, "full_html": error_html,
|
| 1642 |
+
"results": {}
|
| 1643 |
+
}
|
| 1644 |
+
|
| 1645 |
+
try:
|
| 1646 |
+
logger.info(f"소싱전략 + 7단계 개별 키워드 분석 시작: '{analysis_keyword}'")
|
| 1647 |
+
|
| 1648 |
+
analyzer = CompactKeywordAnalyzer(gemini_model)
|
| 1649 |
+
result = analyzer.analyze_keyword_complete(analysis_keyword, volume_data, filtered_keywords_df, trend_data_1year, trend_data_3year)
|
| 1650 |
+
|
| 1651 |
+
logger.info(f"소싱전략 + 7단계 개별 키워드 분석 완료: '{analysis_keyword}'")
|
| 1652 |
+
return result
|
| 1653 |
+
|
| 1654 |
+
except Exception as e:
|
| 1655 |
+
logger.error(f"키워드 분석 오류: {e}")
|
| 1656 |
+
error_html = generate_error_response(f"키워드 분석 중 오류가 발생했습니다: {str(e)}")
|
| 1657 |
+
return {
|
| 1658 |
+
"sourcing_html": error_html, "step1_html": error_html, "step2_html": error_html, "step3_html": error_html,
|
| 1659 |
+
"step4_html": error_html, "step5_html": error_html, "step6_html": error_html,
|
| 1660 |
+
"step7_html": error_html, "conclusion_html": error_html, "full_html": error_html,
|
| 1661 |
+
"results": {}
|
| 1662 |
+
}
|
| 1663 |
+
|
| 1664 |
+
def generate_error_response(error_message):
|
| 1665 |
+
"""에러 메시지를 현실적 스타일로 생성"""
|
| 1666 |
+
return f'''
|
| 1667 |
+
<div style="color: #721c24; padding: 30px; text-align: center; width: 100%;
|
| 1668 |
+
background-color: #f8d7da; border-radius: 12px; border: 1px solid #f5c6cb; font-family: 'Pretendard', sans-serif;">
|
| 1669 |
+
<h3 style="margin-bottom: 15px; color: #721c24;">❌ 분석 실패</h3>
|
| 1670 |
+
<p style="margin-bottom: 20px; font-size: 16px;">{error_message}</p>
|
| 1671 |
+
|
| 1672 |
+
<div style="background: white; padding: 20px; border-radius: 8px; color: #333; text-align: left;">
|
| 1673 |
+
<h4 style="color: #721c24; margin-bottom: 15px;">🔧 해결 방법</h4>
|
| 1674 |
+
<ul style="padding-left: 20px; line-height: 1.8;">
|
| 1675 |
+
<li>🔍 키워드 확인: 올바른 한글 키워드인지 확인</li>
|
| 1676 |
+
<li>📊 검색량 확인: 너무 생소한 키워드는 데이터가 없을 수 있음</li>
|
| 1677 |
+
<li>🌐 네트워크 상태: 인터넷 연결 상태 확인</li>
|
| 1678 |
+
<li>🔧 API 상태: 네이버 API 서버 상태 확인</li>
|
| 1679 |
+
<li>🔄 재시도: 잠시 후 다시 시도해보세요</li>
|
| 1680 |
+
</ul>
|
| 1681 |
+
</div>
|
| 1682 |
+
|
| 1683 |
+
<div style="margin-top: 15px; padding: 10px; background: #d1ecf1; border-radius: 6px; color: #0c5460; font-size: 14px;">
|
| 1684 |
+
💡 팁: 2단계에서 추출된 키워드 목록을 참고하여 검증된 키워드를 사용해보세요.
|
| 1685 |
+
</div>
|
| 1686 |
+
</div>
|
| 1687 |
+
'''
|
keyword_analysis_report.css
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* 키워드 분석 보고서 전용 CSS - 다크모드 적용 */
|
| 2 |
+
|
| 3 |
+
/* CSS 변수 정의 (라이트모드) */
|
| 4 |
+
:root {
|
| 5 |
+
--primary-color: #FB7F0D;
|
| 6 |
+
--text-color: #333;
|
| 7 |
+
--bg-color: #f8f9fa;
|
| 8 |
+
--card-bg: #ffffff;
|
| 9 |
+
--border-color: #ecf0f1;
|
| 10 |
+
--section-bg: #ffffff;
|
| 11 |
+
--insight-bg: #fff5e6;
|
| 12 |
+
--warning-bg: #fff3cd;
|
| 13 |
+
--warning-text: #856404;
|
| 14 |
+
--checklist-bg: #fff3cd;
|
| 15 |
+
--cross-sell-bg: #f8f9fa;
|
| 16 |
+
--cross-sell-border: #17a2b8;
|
| 17 |
+
--cross-sell-text: #0c5460;
|
| 18 |
+
--product-bg: white;
|
| 19 |
+
--trend-bg: #e3f2fd;
|
| 20 |
+
--trend-border: #2196f3;
|
| 21 |
+
--metric-bg: #f8f9fa;
|
| 22 |
+
--metric-border: #dee2e6;
|
| 23 |
+
--highlight-bg: #fff3cd;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/* 다크모드 색상 변수 (자동 감지) */
|
| 27 |
+
@media (prefers-color-scheme: dark) {
|
| 28 |
+
:root {
|
| 29 |
+
--text-color: #e5e5e5;
|
| 30 |
+
--bg-color: #1a1a1a;
|
| 31 |
+
--card-bg: #2d2d2d;
|
| 32 |
+
--border-color: #404040;
|
| 33 |
+
--section-bg: #2d2d2d;
|
| 34 |
+
--insight-bg: #3d2817;
|
| 35 |
+
--warning-bg: #3d3317;
|
| 36 |
+
--warning-text: #d4b75f;
|
| 37 |
+
--checklist-bg: #3d3317;
|
| 38 |
+
--cross-sell-bg: #1a1a1a;
|
| 39 |
+
--cross-sell-border: #17a2b8;
|
| 40 |
+
--cross-sell-text: #4dd0e1;
|
| 41 |
+
--product-bg: #2d2d2d;
|
| 42 |
+
--trend-bg: #1a2332;
|
| 43 |
+
--trend-border: #2196f3;
|
| 44 |
+
--metric-bg: #2d2d2d;
|
| 45 |
+
--metric-border: #404040;
|
| 46 |
+
--highlight-bg: #3d3317;
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* 수동 다크모드 클래스 */
|
| 51 |
+
[data-theme="dark"],
|
| 52 |
+
.dark,
|
| 53 |
+
.gr-theme-dark {
|
| 54 |
+
--text-color: #e5e5e5;
|
| 55 |
+
--bg-color: #1a1a1a;
|
| 56 |
+
--card-bg: #2d2d2d;
|
| 57 |
+
--border-color: #404040;
|
| 58 |
+
--section-bg: #2d2d2d;
|
| 59 |
+
--insight-bg: #3d2817;
|
| 60 |
+
--warning-bg: #3d3317;
|
| 61 |
+
--warning-text: #d4b75f;
|
| 62 |
+
--checklist-bg: #3d3317;
|
| 63 |
+
--cross-sell-bg: #1a1a1a;
|
| 64 |
+
--cross-sell-border: #17a2b8;
|
| 65 |
+
--cross-sell-text: #4dd0e1;
|
| 66 |
+
--product-bg: #2d2d2d;
|
| 67 |
+
--trend-bg: #1a2332;
|
| 68 |
+
--trend-border: #2196f3;
|
| 69 |
+
--metric-bg: #2d2d2d;
|
| 70 |
+
--metric-border: #404040;
|
| 71 |
+
--highlight-bg: #3d3317;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.keyword-analysis-report {
|
| 75 |
+
font-family: 'Pretendard', 'Noto Sans KR', sans-serif;
|
| 76 |
+
line-height: 1.6;
|
| 77 |
+
color: var(--text-color);
|
| 78 |
+
margin: 0;
|
| 79 |
+
padding: 0;
|
| 80 |
+
background-color: var(--bg-color);
|
| 81 |
+
transition: background-color 0.3s ease, color 0.3s ease;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.report-container {
|
| 85 |
+
max-width: 900px;
|
| 86 |
+
margin: 20px auto;
|
| 87 |
+
padding: 0;
|
| 88 |
+
background: transparent;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.report-title {
|
| 92 |
+
text-align: center;
|
| 93 |
+
font-size: 2.2em;
|
| 94 |
+
margin-bottom: 30px;
|
| 95 |
+
color: var(--text-color);
|
| 96 |
+
font-weight: 700;
|
| 97 |
+
border-bottom: 3px solid var(--primary-color);
|
| 98 |
+
padding-bottom: 15px;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/* 각 분석 항목별 섹션 블록 */
|
| 102 |
+
.analysis-section-block {
|
| 103 |
+
background-color: var(--section-bg);
|
| 104 |
+
padding: 25px 30px;
|
| 105 |
+
margin-bottom: 25px;
|
| 106 |
+
border-radius: 12px;
|
| 107 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
| 108 |
+
border-left: 5px solid var(--primary-color);
|
| 109 |
+
color: var(--text-color);
|
| 110 |
+
transition: background-color 0.3s ease, color 0.3s ease;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.analysis-section-block:nth-child(1) { border-left-color: #3498db; }
|
| 114 |
+
.analysis-section-block:nth-child(2) { border-left-color: #e74c3c; }
|
| 115 |
+
.analysis-section-block:nth-child(3) { border-left-color: #f39c12; }
|
| 116 |
+
.analysis-section-block:nth-child(4) { border-left-color: #9b59b6; }
|
| 117 |
+
.analysis-section-block:nth-child(5) { border-left-color: #1abc9c; }
|
| 118 |
+
.analysis-section-block:nth-child(6) { border-left-color: #34495e; }
|
| 119 |
+
.analysis-section-block:nth-child(7) { border-left-color: #e67e22; }
|
| 120 |
+
|
| 121 |
+
.analysis-section-title {
|
| 122 |
+
margin: 0 0 20px 0;
|
| 123 |
+
color: var(--text-color);
|
| 124 |
+
font-size: 1.6em;
|
| 125 |
+
font-weight: 700;
|
| 126 |
+
display: flex;
|
| 127 |
+
align-items: center;
|
| 128 |
+
border-bottom: 2px solid var(--border-color);
|
| 129 |
+
padding-bottom: 10px;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.section-icon {
|
| 133 |
+
font-size: 1.3em;
|
| 134 |
+
margin-right: 12px;
|
| 135 |
+
vertical-align: middle;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/* 아이콘 색상 */
|
| 139 |
+
.analysis-section-block:nth-child(1) .section-icon { color: #3498db; }
|
| 140 |
+
.analysis-section-block:nth-child(2) .section-icon { color: #e74c3c; }
|
| 141 |
+
.analysis-section-block:nth-child(3) .section-icon { color: #f39c12; }
|
| 142 |
+
.analysis-section-block:nth-child(4) .section-icon { color: #9b59b6; }
|
| 143 |
+
.analysis-section-block:nth-child(5) .section-icon { color: var(--primary-color); }
|
| 144 |
+
.analysis-section-block:nth-child(6) .section-icon { color: #34495e; }
|
| 145 |
+
.analysis-section-block:nth-child(7) .section-icon { color: #e67e22; }
|
| 146 |
+
|
| 147 |
+
.subsection-title {
|
| 148 |
+
color: var(--text-color);
|
| 149 |
+
margin: 20px 0 10px 0;
|
| 150 |
+
font-size: 1.1em;
|
| 151 |
+
font-weight: 600;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.key-insight {
|
| 155 |
+
background-color: var(--insight-bg);
|
| 156 |
+
padding: 15px 20px;
|
| 157 |
+
border-left: 5px solid var(--primary-color);
|
| 158 |
+
margin: 20px 0;
|
| 159 |
+
border-radius: 5px;
|
| 160 |
+
font-weight: 500;
|
| 161 |
+
color: var(--text-color);
|
| 162 |
+
transition: background-color 0.3s ease;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/* 텍스트 스타일 - 기본은 일반, 중요한 부분만 볼드 */
|
| 166 |
+
.analysis-content {
|
| 167 |
+
color: var(--text-color);
|
| 168 |
+
font-weight: normal;
|
| 169 |
+
line-height: 1.7;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.analysis-content strong {
|
| 173 |
+
color: var(--text-color);
|
| 174 |
+
font-weight: 600;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.analysis-content p {
|
| 178 |
+
margin-bottom: 15px;
|
| 179 |
+
font-weight: normal;
|
| 180 |
+
color: var(--text-color);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.analysis-list {
|
| 184 |
+
list-style: none;
|
| 185 |
+
padding: 0;
|
| 186 |
+
margin-bottom: 20px;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.analysis-list li {
|
| 190 |
+
position: relative;
|
| 191 |
+
padding-left: 25px;
|
| 192 |
+
margin-bottom: 12px;
|
| 193 |
+
line-height: 1.8;
|
| 194 |
+
font-weight: normal;
|
| 195 |
+
color: var(--text-color);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.analysis-list li:before {
|
| 199 |
+
content: '▶';
|
| 200 |
+
color: var(--primary-color);
|
| 201 |
+
position: absolute;
|
| 202 |
+
left: 0;
|
| 203 |
+
font-weight: bold;
|
| 204 |
+
font-size: 1.1em;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.concern-list {
|
| 208 |
+
list-style: none;
|
| 209 |
+
padding: 0;
|
| 210 |
+
margin-bottom: 20px;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.concern-list li {
|
| 214 |
+
position: relative;
|
| 215 |
+
padding-left: 25px;
|
| 216 |
+
margin-bottom: 12px;
|
| 217 |
+
line-height: 1.8;
|
| 218 |
+
font-weight: normal;
|
| 219 |
+
color: var(--text-color);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.concern-list li:before {
|
| 223 |
+
content: '⚠️';
|
| 224 |
+
position: absolute;
|
| 225 |
+
left: 0;
|
| 226 |
+
font-size: 1.1em;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.solution-list {
|
| 230 |
+
list-style: none;
|
| 231 |
+
padding: 0;
|
| 232 |
+
margin-bottom: 20px;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.solution-list li {
|
| 236 |
+
position: relative;
|
| 237 |
+
padding-left: 25px;
|
| 238 |
+
margin-bottom: 12px;
|
| 239 |
+
line-height: 1.8;
|
| 240 |
+
font-weight: normal;
|
| 241 |
+
color: var(--text-color);
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.solution-list li:before {
|
| 245 |
+
content: '✅';
|
| 246 |
+
position: absolute;
|
| 247 |
+
left: 0;
|
| 248 |
+
font-size: 1.1em;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.checklist {
|
| 252 |
+
background-color: var(--checklist-bg);
|
| 253 |
+
padding: 20px;
|
| 254 |
+
border-radius: 8px;
|
| 255 |
+
border-left: 5px solid #ffc107;
|
| 256 |
+
margin: 20px 0;
|
| 257 |
+
transition: background-color 0.3s ease;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.checklist-title {
|
| 261 |
+
font-weight: 700;
|
| 262 |
+
color: var(--warning-text);
|
| 263 |
+
margin-bottom: 15px;
|
| 264 |
+
font-size: 1.2em;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.checklist-items {
|
| 268 |
+
list-style: none;
|
| 269 |
+
padding: 0;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.checklist-items li {
|
| 273 |
+
position: relative;
|
| 274 |
+
padding-left: 25px;
|
| 275 |
+
margin-bottom: 10px;
|
| 276 |
+
line-height: 1.6;
|
| 277 |
+
font-weight: normal;
|
| 278 |
+
color: var(--warning-text);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.checklist-items li:before {
|
| 282 |
+
content: '📋';
|
| 283 |
+
position: absolute;
|
| 284 |
+
left: 0;
|
| 285 |
+
font-size: 1.1em;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.cross-sell-section {
|
| 289 |
+
background-color: var(--cross-sell-bg);
|
| 290 |
+
padding: 20px;
|
| 291 |
+
border-radius: 8px;
|
| 292 |
+
border-left: 5px solid var(--cross-sell-border);
|
| 293 |
+
margin: 20px 0;
|
| 294 |
+
transition: background-color 0.3s ease;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.cross-sell-title {
|
| 298 |
+
font-weight: 700;
|
| 299 |
+
color: var(--cross-sell-text);
|
| 300 |
+
margin-bottom: 15px;
|
| 301 |
+
font-size: 1.2em;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.product-suggestion {
|
| 305 |
+
background-color: var(--product-bg);
|
| 306 |
+
padding: 15px;
|
| 307 |
+
border-radius: 6px;
|
| 308 |
+
margin-bottom: 15px;
|
| 309 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 310 |
+
transition: background-color 0.3s ease;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.product-name {
|
| 314 |
+
font-weight: 600;
|
| 315 |
+
color: var(--text-color);
|
| 316 |
+
margin-bottom: 8px;
|
| 317 |
+
font-size: 1.1em;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.product-reason {
|
| 321 |
+
color: var(--text-color);
|
| 322 |
+
font-size: 0.95em;
|
| 323 |
+
line-height: 1.5;
|
| 324 |
+
font-weight: normal;
|
| 325 |
+
opacity: 0.8;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.final-recommendation {
|
| 329 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 330 |
+
border-radius: 12px;
|
| 331 |
+
color: white;
|
| 332 |
+
padding: 25px;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.final-recommendation h3 {
|
| 336 |
+
color: white;
|
| 337 |
+
font-size: 1.8em;
|
| 338 |
+
margin-bottom: 20px;
|
| 339 |
+
font-weight: 700;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
.recommendation-content {
|
| 343 |
+
font-size: 1.1em;
|
| 344 |
+
line-height: 1.7;
|
| 345 |
+
text-align: left;
|
| 346 |
+
font-weight: normal;
|
| 347 |
+
color: white;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.recommendation-content strong {
|
| 351 |
+
font-weight: 600;
|
| 352 |
+
color: white;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.trend-insight {
|
| 356 |
+
background-color: var(--trend-bg);
|
| 357 |
+
padding: 15px 20px;
|
| 358 |
+
border-left: 5px solid var(--trend-border);
|
| 359 |
+
margin: 20px 0;
|
| 360 |
+
border-radius: 5px;
|
| 361 |
+
color: var(--text-color);
|
| 362 |
+
transition: background-color 0.3s ease;
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
.market-metrics {
|
| 366 |
+
display: grid;
|
| 367 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 368 |
+
gap: 20px;
|
| 369 |
+
margin: 20px 0;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
.metric-card {
|
| 373 |
+
background-color: var(--metric-bg);
|
| 374 |
+
padding: 20px;
|
| 375 |
+
border-radius: 8px;
|
| 376 |
+
text-align: center;
|
| 377 |
+
border: 1px solid var(--metric-border);
|
| 378 |
+
transition: background-color 0.3s ease, border-color 0.3s ease;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.metric-value {
|
| 382 |
+
font-size: 2em;
|
| 383 |
+
font-weight: 700;
|
| 384 |
+
color: var(--primary-color);
|
| 385 |
+
margin-bottom: 5px;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
.metric-label {
|
| 389 |
+
color: var(--text-color);
|
| 390 |
+
font-size: 0.9em;
|
| 391 |
+
font-weight: normal;
|
| 392 |
+
opacity: 0.8;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.highlight-text {
|
| 396 |
+
background-color: var(--highlight-bg);
|
| 397 |
+
padding: 2px 6px;
|
| 398 |
+
border-radius: 3px;
|
| 399 |
+
font-weight: 600;
|
| 400 |
+
color: var(--warning-text);
|
| 401 |
+
transition: background-color 0.3s ease, color 0.3s ease;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
/* 다크모드에서 그라데이션 배경 조정 */
|
| 405 |
+
@media (prefers-color-scheme: dark) {
|
| 406 |
+
.final-recommendation {
|
| 407 |
+
background: linear-gradient(135deg, #4a5568 0%, #553c9a 100%);
|
| 408 |
+
}
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
[data-theme="dark"] .final-recommendation,
|
| 412 |
+
.dark .final-recommendation,
|
| 413 |
+
.gr-theme-dark .final-recommendation {
|
| 414 |
+
background: linear-gradient(135deg, #4a5568 0%, #553c9a 100%);
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
/* 전환 애니메이션 */
|
| 418 |
+
* {
|
| 419 |
+
transition: background-color 0.3s ease,
|
| 420 |
+
color 0.3s ease,
|
| 421 |
+
border-color 0.3s ease;
|
| 422 |
+
}
|
keyword_diversity_fix.py
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
|
| 3 |
+
logger.info("Gemini API 호출 시작...")
|
| 4 |
+
# Gemini API 호출 - 온도를 높여서 더 다양한 결과 생성
|
| 5 |
+
response = client.models.generate_content(
|
| 6 |
+
model="gemini-2.0-flash",
|
| 7 |
+
contents=prompt,
|
| 8 |
+
config=GenerateContentConfig(
|
| 9 |
+
tools=config_tools, # 검색 엔진에 따라 도구 설정
|
| 10 |
+
response_modalities=["TEXT"],
|
| 11 |
+
temperature=0.95, # 온도를 높여서 더 다양한 결과
|
| 12 |
+
max_output_tokens=2000,
|
| 13 |
+
top_p=0.9, # 더 다양한 토큰 선택
|
| 14 |
+
top_k=40 # 후보 토큰 수 증가
|
| 15 |
+
)
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
logger.info("Gemini API 응답 수신 완료")
|
| 19 |
+
|
| 20 |
+
# 응답에서 텍스트 추출
|
| 21 |
+
result_text = ""
|
| 22 |
+
for part in response.candidates[0].content.parts:
|
| 23 |
+
if hasattr(part, 'text'):
|
| 24 |
+
result_text += part.text
|
| 25 |
+
|
| 26 |
+
# 결과 정제 - 번호, 설명, 기호 제거 및 중복 제거 강화
|
| 27 |
+
lines = result_text.strip().split('\n')
|
| 28 |
+
clean_keywords = []
|
| 29 |
+
seen_keywords = set() # 중복 방지를 위한 집합
|
| 30 |
+
|
| 31 |
+
for line in lines:
|
| 32 |
+
# 빈 줄 건너뛰기
|
| 33 |
+
if not line.strip():
|
| 34 |
+
continue
|
| 35 |
+
|
| 36 |
+
# 정제 작업
|
| 37 |
+
clean_line = line.strip()
|
| 38 |
+
|
| 39 |
+
# 번호 제거 (1., 2., 3. 등)
|
| 40 |
+
import re
|
| 41 |
+
clean_line = re.sub(r'^\d+\.?\s*', '', clean_line)
|
| 42 |
+
|
| 43 |
+
# 불릿 포인트 제거 (-, *, •, ✅, ❌ 등)
|
| 44 |
+
clean_line = re.sub(r'^[-*•✅❌]\s*', '', clean_line)
|
| 45 |
+
|
| 46 |
+
# 괄호 안 설명 제거
|
| 47 |
+
clean_line = re.sub(r'\([^)]*\)', '', clean_line)
|
| 48 |
+
|
| 49 |
+
# 추가 설명 제거 (: 이후 내용)
|
| 50 |
+
if ':' in clean_line:
|
| 51 |
+
clean_line = clean_line.split(':')[0]
|
| 52 |
+
|
| 53 |
+
# 공백 정리
|
| 54 |
+
clean_line = clean_line.strip()
|
| 55 |
+
|
| 56 |
+
# 유효한 키워드만 추가 (2글자 이상, 50글자 이하)
|
| 57 |
+
if clean_line and 2 <= len(clean_line) <= 50:
|
| 58 |
+
# 브랜드명이나 제조업체명 필터링
|
| 59 |
+
brand_keywords = ['삼성', '엘지', 'LG', '애플', '아이폰', '갤럭시', '나이키', '아디다스', '스타벅스']
|
| 60 |
+
if not any(brand in clean_line for brand in brand_keywords):
|
| 61 |
+
# 중복 검사 - 대소문자 구분 없이 체크
|
| 62 |
+
clean_lower = clean_line.lower()
|
| 63 |
+
if clean_lower not in seen_keywords:
|
| 64 |
+
seen_keywords.add(clean_lower)
|
| 65 |
+
clean_keywords.append(clean_line)
|
| 66 |
+
|
| 67 |
+
# 50개로 제한하되, 부족하면 추가 생성 요청
|
| 68 |
+
if len(clean_keywords) < 50:
|
| 69 |
+
logger.info(f"키워드 부족 ({len(clean_keywords)}개), 추가 생성 필요")
|
| 70 |
+
|
| 71 |
+
# 부족한 만큼 추가 생성을 위한 보조 프롬프트
|
| 72 |
+
additional_prompt = f"""
|
| 73 |
+
기존에 생성된 키워드: {', '.join(clean_keywords)}
|
| 74 |
+
|
| 75 |
+
위 키워드들과 절대 중복되지 않는 완전히 새로운 {category} 관련 쇼핑키워드를 {50 - len(clean_keywords)}개 더 생성하세요.
|
| 76 |
+
|
| 77 |
+
반드시 지켜야 할 규칙:
|
| 78 |
+
- 기존 키워드와 절대 중복되지 않음
|
| 79 |
+
- 소재, 형태, 기능을 다양하게 조합
|
| 80 |
+
- 브랜드명 절대 금지
|
| 81 |
+
- 순수 키워드만 출력 (번호, 설명, 기호 금지)
|
| 82 |
+
|
| 83 |
+
예시 출력:
|
| 84 |
+
스테인리스 쟁반
|
| 85 |
+
고무 매트
|
| 86 |
+
유리 용기
|
| 87 |
+
"""
|
| 88 |
+
|
| 89 |
+
# 추가 생성 요청
|
| 90 |
+
additional_response = client.models.generate_content(
|
| 91 |
+
model="gemini-2.0-flash",
|
| 92 |
+
contents=additional_prompt,
|
| 93 |
+
config=GenerateContentConfig(
|
| 94 |
+
response_modalities=["TEXT"],
|
| 95 |
+
temperature=0.98, # 더 높은 온도로 다양성 보장
|
| 96 |
+
max_output_tokens=1000,
|
| 97 |
+
top_p=0.95,
|
| 98 |
+
top_k=50
|
| 99 |
+
)
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# 추가 키워드 처리
|
| 103 |
+
additional_text = ""
|
| 104 |
+
for part in additional_response.candidates[0].content.parts:
|
| 105 |
+
if hasattr(part, 'text'):
|
| 106 |
+
additional_text += part.text
|
| 107 |
+
|
| 108 |
+
additional_lines = additional_text.strip().split('\n')
|
| 109 |
+
for line in additional_lines:
|
| 110 |
+
if not line.strip():
|
| 111 |
+
continue
|
| 112 |
+
|
| 113 |
+
clean_line = line.strip()
|
| 114 |
+
clean_line = re.sub(r'^\d+\.?\s*', '', clean_line)
|
| 115 |
+
clean_line = re.sub(r'^[-*•✅❌]\s*', '', clean_line)
|
| 116 |
+
clean_line = re.sub(r'\([^)]*\)', '', clean_line)
|
| 117 |
+
|
| 118 |
+
if ':' in clean_line:
|
| 119 |
+
clean_line = clean_line.split(':')[0]
|
| 120 |
+
|
| 121 |
+
clean_line = clean_line.strip()
|
| 122 |
+
|
| 123 |
+
if clean_line and 2 <= len(clean_line) <= 50:
|
| 124 |
+
brand_keywords = ['삼성', '엘지', 'LG', '애플', '아이폰', '갤럭시', '나이키', '아디다스', '스타벅스']
|
| 125 |
+
if not any(brand in clean_line for brand in brand_keywords):
|
| 126 |
+
clean_lower = clean_line.lower()
|
| 127 |
+
if clean_lower not in seen_keywords and len(clean_keywords) < 50:
|
| 128 |
+
seen_keywords.add(clean_lower)
|
| 129 |
+
clean_keywords.append(clean_line)
|
| 130 |
+
|
| 131 |
+
# 50개로 제한
|
| 132 |
+
clean_keywords = clean_keywords[:50]
|
| 133 |
+
|
| 134 |
+
# 최종 셔플로 순서도 무작위화
|
| 135 |
+
random.shuffle(clean_keywords)
|
| 136 |
+
|
| 137 |
+
# 최종 결과 문자열 생성
|
| 138 |
+
final_result = '\n'.join(clean_keywords)
|
| 139 |
+
|
| 140 |
+
logger.info(f"최종 정제된 키워드 개수: {len(clean_keywords)}개")
|
| 141 |
+
logger.info(f"중복 제거된 키워드 수: {len(seen_keywords)}개")
|
| 142 |
+
logger.info("=== 다양성 강화 쇼핑키워드 생성 완료 ===")
|
| 143 |
+
|
| 144 |
+
# 그라운딩 메타데이터 로그
|
| 145 |
+
if hasattr(response.candidates[0], 'grounding_metadata'):
|
| 146 |
+
logger.info("Google 검색 그라운딩 메타데이터 확인됨")
|
| 147 |
+
if hasattr(response.candidates[0].grounding_metadata, 'web_search_queries'):
|
| 148 |
+
queries = response.candidates[0].grounding_metadata.web_search_queries
|
| 149 |
+
logger.info(f"실행된 검색 쿼리: {queries}")
|
| 150 |
+
|
| 151 |
+
return final_result
|
| 152 |
+
|
| 153 |
+
except Exception as e:
|
| 154 |
+
error_msg = f"오류 발생: {str(e)}"
|
| 155 |
+
logger.error(error_msg)
|
| 156 |
+
logger.error("GEMINI_API_KEY 환경변수가 올바르게 설정되었는지 확인해주세요.")
|
| 157 |
+
return f"{error_msg}\n\nGEMINI_API_KEY 환경변수가 올바르게 설정되었는지 확인해주세요."
|
| 158 |
+
def generate_with_logs(category, additional_request, launch_timing, seasonality, sales_target, sales_channel, competition_level, search_engine):
|
| 159 |
+
"""키워드 생성과 로그를 함께 반환하는 함수"""
|
| 160 |
+
logger.info("=== 다양성 강화 쇼핑키워드 생성 시작 ===")
|
| 161 |
+
|
| 162 |
+
# 키워드 생성
|
| 163 |
+
result = generate_sourcing_keywords(category, additional_request, launch_timing, seasonality, sales_target, sales_channel, competition_level, search_engine)
|
| 164 |
+
|
| 165 |
+
# 최근 로그 가져오기
|
| 166 |
+
logs = get_recent_logs()
|
| 167 |
+
|
| 168 |
+
return result, logs
|
| 169 |
+
# Gradio 인터페이스 구성
|
| 170 |
+
def create_interface():
|
| 171 |
+
with gr.Blocks(
|
| 172 |
+
title="🎯 다양성 강화 쇼핑키워드 시스템",
|
| 173 |
+
theme=gr.themes.Soft(),
|
| 174 |
+
css="""
|
| 175 |
+
.gradio-container {
|
| 176 |
+
max-width: 1200px !important;
|
| 177 |
+
}
|
| 178 |
+
.title-header {
|
| 179 |
+
text-align: center;
|
| 180 |
+
background: linear-gradient(45deg, #FF6B6B, #4ECDC4, #45B7D1);
|
| 181 |
+
-webkit-background-clip: text;
|
| 182 |
+
-webkit-text-fill-color: transparent;
|
| 183 |
+
font-size: 2.5em;
|
| 184 |
+
font-weight: bold;
|
| 185 |
+
margin-bottom: 20px;
|
| 186 |
+
}
|
| 187 |
+
.subtitle {
|
| 188 |
+
text-align: center;
|
| 189 |
+
color: #666;
|
| 190 |
+
font-size: 1.2em;
|
| 191 |
+
margin-bottom: 30px;
|
| 192 |
+
}
|
| 193 |
+
"""
|
| 194 |
+
) as demo:
|
| 195 |
+
|
| 196 |
+
# 헤더
|
| 197 |
+
gr.HTML("""
|
| 198 |
+
<div class="title-header">🎯 다양성 강화 쇼핑키워드 시스템</div>
|
| 199 |
+
<div class="subtitle">🔄 매번 완전히 다른 결과! 중복 없는 쇼핑키워드 전문 발굴 프로그램</div>
|
| 200 |
+
""")
|
| 201 |
+
|
| 202 |
+
with gr.Row():
|
| 203 |
+
with gr.Column(scale=1):
|
| 204 |
+
gr.Markdown("### 📊 다양성 강화 설정")
|
| 205 |
+
|
| 206 |
+
# 검색 엔진 선택 추가
|
| 207 |
+
search_engine = gr.Dropdown(
|
| 208 |
+
choices=[
|
| 209 |
+
"모든 검색 엔진 통합 분석 (추천)",
|
| 210 |
+
"Google 검색 그라운딩만",
|
| 211 |
+
"네이버 검색 API만",
|
| 212 |
+
"DuckDuckGo 검색만",
|
| 213 |
+
"검색 없이 AI만 사용"
|
| 214 |
+
],
|
| 215 |
+
label="🔍 검색 엔진 선택",
|
| 216 |
+
value="모든 검색 엔진 통합 분석 (추천)"
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
# 1. 쇼핑 카테고리 선택
|
| 220 |
+
category = gr.Dropdown(
|
| 221 |
+
choices=["랜덤적용", "패션잡화", "생활/건강", "출산/육아", "스포츠/레저", "디지털/가전", "가구/인테리어", "패션의류", "화장품/미용"],
|
| 222 |
+
label="🛍️ 쇼핑 카테고리",
|
| 223 |
+
value="생활/건강"
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# 2. 추가 요청사항
|
| 227 |
+
additional_request = gr.Textbox(
|
| 228 |
+
label="📝 추가 요청사항 (다양성 중심)",
|
| 229 |
+
placeholder="예: 다양한 소재, 새로운 형태, 독특한 기능 등",
|
| 230 |
+
lines=2
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
# 3. 출시 타이밍
|
| 234 |
+
launch_timing = gr.Radio(
|
| 235 |
+
choices=["랜덤적용", "즉시소싱", "기획형"],
|
| 236 |
+
label="⏰ 출시 ��이밍",
|
| 237 |
+
value="즉시소싱"
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
# 4. 계절성
|
| 241 |
+
seasonality = gr.Radio(
|
| 242 |
+
choices=["랜덤적용", "봄", "여름", "가을", "겨울", "비계절"],
|
| 243 |
+
label="🌱 계절성",
|
| 244 |
+
value="비계절"
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
with gr.Column(scale=1):
|
| 248 |
+
gr.Markdown("### 💰 목표 설정")
|
| 249 |
+
|
| 250 |
+
# 5. 매출 목표
|
| 251 |
+
sales_target = gr.Radio(
|
| 252 |
+
choices=["랜덤적용", "100만원 이하", "100-500만원", "500-1천만원", "1천-5천만원", "5천만원 이상"],
|
| 253 |
+
label="💵 매출 목표",
|
| 254 |
+
value="100-500만원"
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
# 6. 판매 채널
|
| 258 |
+
sales_channel = gr.Radio(
|
| 259 |
+
choices=["랜덤적용", "오픈마켓", "SNS마케팅", "광고집행", "오프라인"],
|
| 260 |
+
label="📱 판매 채널",
|
| 261 |
+
value="오픈마켓"
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
# 7. 경쟁 강도
|
| 265 |
+
competition_level = gr.Radio(
|
| 266 |
+
choices=[
|
| 267 |
+
"랜덤적용",
|
| 268 |
+
"초보",
|
| 269 |
+
"중수",
|
| 270 |
+
"고수"
|
| 271 |
+
],
|
| 272 |
+
label="⚔️ 경쟁 강도",
|
| 273 |
+
value="초보"
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
# 실행 버튼
|
| 277 |
+
generate_btn = gr.Button(
|
| 278 |
+
"🚀 다양성 강화 쇼핑키워드 발굴 시작 (매번 다른 결과)",
|
| 279 |
+
variant="primary",
|
| 280 |
+
size="lg"
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
# 결과 출력
|
| 284 |
+
with gr.Row():
|
| 285 |
+
with gr.Column(scale=2):
|
| 286 |
+
gr.Markdown("### 📋 다양성 강화 쇼핑키워드 (50개)")
|
| 287 |
+
output = gr.Textbox(
|
| 288 |
+
label="중복 없는 쇼핑키워드 결과 (매번 완전히 다름)",
|
| 289 |
+
lines=30,
|
| 290 |
+
max_lines=50,
|
| 291 |
+
placeholder="여기에 매번 다른 50개의 쇼핑키워드가 출력됩니다...",
|
| 292 |
+
show_copy_button=True
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
with gr.Column(scale=1):
|
| 296 |
+
gr.Markdown("### 📊 실행 로그")
|
| 297 |
+
log_output = gr.Textbox(
|
| 298 |
+
label="시스템 로그",
|
| 299 |
+
lines=30,
|
| 300 |
+
max_lines=50,
|
| 301 |
+
placeholder="시스템 실행 로그가 여기에 표시됩니다...",
|
| 302 |
+
show_copy_button=True
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
# 이벤트 연결
|
| 306 |
+
generate_btn.click(
|
| 307 |
+
fn=generate_with_logs,
|
| 308 |
+
inputs=[
|
| 309 |
+
category,
|
| 310 |
+
additional_request,
|
| 311 |
+
launch_timing,
|
| 312 |
+
seasonality,
|
| 313 |
+
sales_target,
|
| 314 |
+
sales_channel,
|
| 315 |
+
competition_level,
|
| 316 |
+
search_engine
|
| 317 |
+
],
|
| 318 |
+
outputs=[output, log_output],
|
| 319 |
+
show_progress=True
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
# 사용법 안내
|
| 323 |
+
with gr.Accordion("📖 다양성 강화 사용법 안내", open=False):
|
| 324 |
+
gr.Markdown("""
|
| 325 |
+
### 🎯 다양성 강화 쇼핑키워드 시스템 사용법
|
| 326 |
+
|
| 327 |
+
#### 🚀 주요 개선 사항
|
| 328 |
+
- **완전한 중복 방지**: 매번 실행할 때마다 완전히 다른 키워드 생성
|
| 329 |
+
- **랜덤 시드 시스템**: 현재 시각을 기반으로 한 랜덤 시드로 예측 불가능
|
| 330 |
+
- **다양한 조합 보장**: 소재×형태×기능의 3차원 조합으로 무한 다양성
|
| 331 |
+
- **중복 검사 강화**: 대소문자 구분 없는 엄격한 중복 제거
|
| 332 |
+
- **온도 조절**: AI 생성 파라미터 최적화로 창의성 극대화
|
| 333 |
+
|
| 334 |
+
#### 🔄 다양성 보장 메커니즘
|
| 335 |
+
1. **시드 기반 랜덤화**: 마이크로초 단위 시간 기반 랜덤 시드
|
| 336 |
+
2. **3차원 조합 시스템**:
|
| 337 |
+
- 소재: 실리콘, 스테인리스, 세라믹, 대나무 등 20종
|
| 338 |
+
- 형태: 접이식, 원형, 슬림, 휴대용 등 20종
|
| 339 |
+
- 기능: 방수, 항균, 마그네틱, 보온 등 20종
|
| 340 |
+
3. **중복 방지 알고리즘**: 생성 중 실시간 중복 검사
|
| 341 |
+
4. **추가 생성 시스템**: 부족시 자동으로 추가 키워드 생성
|
| 342 |
+
|
| 343 |
+
#### 🎲 랜덤 적용의 진화
|
| 344 |
+
- **키워드별 독립 적용**: 각 키워드마다 다른 조건 조합
|
| 345 |
+
- **예측 불가능성**: 같은 설정이라도 매번 다른 결과
|
| 346 |
+
- **조합 폭발**: 수천 가지 가능한 조합으로 무한 다양성
|
| 347 |
+
|
| 348 |
+
#### 📈 생성 품import gradio as gr
|
| 349 |
+
import os
|
| 350 |
+
import logging
|
| 351 |
+
import sys
|
| 352 |
+
import random
|
| 353 |
+
import requests
|
| 354 |
+
import json
|
| 355 |
+
from datetime import datetime
|
| 356 |
+
from google import genai
|
| 357 |
+
from google.genai.types import Tool, GenerateContentConfig, GoogleSearch
|
| 358 |
+
|
| 359 |
+
# 로깅 설정
|
| 360 |
+
logging.basicConfig(
|
| 361 |
+
level=logging.INFO,
|
| 362 |
+
format='%(asctime)s - %(levelname)s - %(message)s',
|
| 363 |
+
handlers=[
|
| 364 |
+
logging.StreamHandler(sys.stdout),
|
| 365 |
+
logging.FileHandler('sourcing_app.log', encoding='utf-8')
|
| 366 |
+
]
|
| 367 |
+
)
|
| 368 |
+
logger = logging.getLogger(__name__)
|
| 369 |
+
|
| 370 |
+
# 키워드 다양성을 위한 시드 풀 확장
|
| 371 |
+
DIVERSE_SEED_POOLS = {
|
| 372 |
+
"패션잡화": [
|
| 373 |
+
"액세서리", "장신구", "가방", "지갑", "모자", "스카프", "벨트", "선글라스", "헤어액세서리", "시계줄",
|
| 374 |
+
"키링", "브로치", "목걸이", "팔찌", "반지", "귀걸이", "핸드폰케이스", "파우치", "클러치", "토트백"
|
| 375 |
+
],
|
| 376 |
+
"생활/건강": [
|
| 377 |
+
"주방용품", "욕실용품", "청소용품", "수납용품", "건강용품", "의료용품", "마사지용품", "운동용품",
|
| 378 |
+
"다이어트용품", "화장지", "세제", "샴푸", "치약", "비누", "수건", "베개", "이불", "쿠션", "매트"
|
| 379 |
+
],
|
| 380 |
+
"출산/육아": [
|
| 381 |
+
"유아용품", "육아용품", "출산용품", "임산부용품", "신생아용품", "이유식용품", "기저귀", "젖병",
|
| 382 |
+
"유모차", "카시트", "아기옷", "장난감", "교육용품", "책", "그림책", "퍼즐", "블록", "인형", "놀이매트"
|
| 383 |
+
],
|
| 384 |
+
"스포츠/레저": [
|
| 385 |
+
"운동용품", "헬스용품", "요가용품", "수영용품", "등산용품", "캠핑용품", "낚시용품", "골프용품",
|
| 386 |
+
"축구용품", "농구용품", "배드민턴용품", "탁구용품", "테니스용품", "자전거용품", "스케이트보드용품"
|
| 387 |
+
],
|
| 388 |
+
"디지털/가전": [
|
| 389 |
+
"스마트폰액세서리", "컴퓨터용품", "태블릿용품", "이어폰", "스피커", "충전기", "케이블", "마우스패드",
|
| 390 |
+
"키보드", "마우스", "웹캠", "마이크", "헤드셋", "게임패드", "USB", "메모리카드", "파워뱅크"
|
| 391 |
+
],
|
| 392 |
+
"가구/인테리어": [
|
| 393 |
+
"수납가구", "침실가구", "거실가구", "주방가구", "욕실가구", "사무용가구", "인테리어소품", "조명",
|
| 394 |
+
"커튼", "블라인드", "카펫", "러그", "액자", "거울", "시계", "화분", "꽃병", "캔들", "방향제"
|
| 395 |
+
],
|
| 396 |
+
"패션의류": [
|
| 397 |
+
"티셔츠", "셔츠", "블라우스", "원피스", "스커트", "바지", "청바지", "레깅스", "자켓", "코트",
|
| 398 |
+
"점퍼", "가디건", "니트", "후드", "조끼", "속옷", "잠옷", "양말", "스타킹", "운동복"
|
| 399 |
+
],
|
| 400 |
+
"화장품/미용": [
|
| 401 |
+
"스킨케어", "메이크업", "클렌징", "마스크팩", "선크림", "로션", "에센스", "크림", "립밤",
|
| 402 |
+
"립스틱", "아이섀도", "마스카라", "파운데이션", "컨실러", "블러셔", "하이라이터", "네일", "향수"
|
| 403 |
+
]
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
# 소재별 키워드 풀
|
| 407 |
+
MATERIAL_KEYWORDS = [
|
| 408 |
+
"실리콘", "스테인리스", "세라믹", "유리", "나무", "대나무", "면", "린넨", "폴리에스터", "나일론",
|
| 409 |
+
"고무", "플라스틱", "종이", "가죽", "인조가죽", "메탈", "알루미늄", "철", "구리", "황동"
|
| 410 |
+
]
|
| 411 |
+
|
| 412 |
+
# 형태별 키워드 풀
|
| 413 |
+
SHAPE_KEYWORDS = [
|
| 414 |
+
"접이식", "휴대용", "미니", "대형", "원형", "사각", "타원", "직사각", "삼각", "육각",
|
| 415 |
+
"슬림", "두꺼운", "얇은", "긴", "짧은", "넓은", "좁은", "깊은", "얕은", "곡선"
|
| 416 |
+
]
|
| 417 |
+
|
| 418 |
+
# 기능별 키워드 풀
|
| 419 |
+
FUNCTION_KEYWORDS = [
|
| 420 |
+
"방수", "미끄럼방지", "항균", "냄새제거", "보온", "보냉", "속건", "흡수", "차단", "보호",
|
| 421 |
+
"마그네틱", "자석", "끈적", "투명", "불투명", "발광", "반사", "신축", "탄력", "고정"
|
| 422 |
+
]
|
| 423 |
+
|
| 424 |
+
# Gemini API 클라이언트 초기화
|
| 425 |
+
def initialize_gemini():
|
| 426 |
+
logger.info("Gemini API 클라이언트 초기화 시작")
|
| 427 |
+
api_key = os.getenv("GEMINI_API_KEY")
|
| 428 |
+
if not api_key:
|
| 429 |
+
logger.error("GEMINI_API_KEY 환경변수가 설정되지 않았습니다.")
|
| 430 |
+
raise ValueError("GEMINI_API_KEY 환경변수가 설정되지 않았습니다.")
|
| 431 |
+
|
| 432 |
+
client = genai.Client(api_key=api_key)
|
| 433 |
+
logger.info("Gemini API 클라이언트 초기화 완료")
|
| 434 |
+
return client
|
| 435 |
+
|
| 436 |
+
def get_recent_logs():
|
| 437 |
+
"""최근 로그를 가져오는 함수"""
|
| 438 |
+
try:
|
| 439 |
+
with open('sourcing_app.log', 'r', encoding='utf-8') as f:
|
| 440 |
+
lines = f.readlines()
|
| 441 |
+
# 최근 50줄만 반환
|
| 442 |
+
return ''.join(lines[-50:])
|
| 443 |
+
except FileNotFoundError:
|
| 444 |
+
return "로그 파일을 찾을 수 없습니다."
|
| 445 |
+
except Exception as e:
|
| 446 |
+
return f"로그 읽기 오류: {str(e)}"
|
| 447 |
+
|
| 448 |
+
def generate_diverse_keyword_combinations(category, count=60):
|
| 449 |
+
"""다양한 키워드 조합을 생성하는 함수"""
|
| 450 |
+
logger.info(f"다양한 키워드 조합 생성 시작: {category}, {count}개")
|
| 451 |
+
|
| 452 |
+
combinations = []
|
| 453 |
+
category_pool = DIVERSE_SEED_POOLS.get(category, DIVERSE_SEED_POOLS["생활/건강"])
|
| 454 |
+
|
| 455 |
+
# 1. 단일 키워드 (20%)
|
| 456 |
+
single_keywords = random.sample(category_pool, min(12, len(category_pool)))
|
| 457 |
+
combinations.extend(single_keywords)
|
| 458 |
+
|
| 459 |
+
# 2. 소재 + 카테고리 (30%)
|
| 460 |
+
for _ in range(18):
|
| 461 |
+
material = random.choice(MATERIAL_KEYWORDS)
|
| 462 |
+
item = random.choice(category_pool)
|
| 463 |
+
combinations.append(f"{material} {item}")
|
| 464 |
+
|
| 465 |
+
# 3. 형태 + 카테고리 (30%)
|
| 466 |
+
for _ in range(18):
|
| 467 |
+
shape = random.choice(SHAPE_KEYWORDS)
|
| 468 |
+
item = random.choice(category_pool)
|
| 469 |
+
combinations.append(f"{shape} {item}")
|
| 470 |
+
|
| 471 |
+
# 4. 기능 + 카테고리 (20%)
|
| 472 |
+
for _ in range(12):
|
| 473 |
+
function = random.choice(FUNCTION_KEYWORDS)
|
| 474 |
+
item = random.choice(category_pool)
|
| 475 |
+
combinations.append(f"{function} {item}")
|
| 476 |
+
|
| 477 |
+
# 중복 제거 및 셔플
|
| 478 |
+
combinations = list(set(combinations))
|
| 479 |
+
random.shuffle(combinations)
|
| 480 |
+
|
| 481 |
+
logger.info(f"생성된 조합 수: {len(combinations)}개")
|
| 482 |
+
return combinations[:count]
|
| 483 |
+
|
| 484 |
+
def search_all_engines(query):
|
| 485 |
+
"""모든 검색 엔진을 사용하여 데이터를 취합하는 함수"""
|
| 486 |
+
logger.info(f"모든 검색 엔진으로 검색 시작: {query}")
|
| 487 |
+
|
| 488 |
+
all_results = {
|
| 489 |
+
"google": "",
|
| 490 |
+
"naver": "",
|
| 491 |
+
"duckduckgo": ""
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
# 1. 네이버 검색 API
|
| 495 |
+
try:
|
| 496 |
+
naver_client_id = os.getenv("NAVER_CLIENT_ID")
|
| 497 |
+
naver_client_secret = os.getenv("NAVER_CLIENT_SECRET")
|
| 498 |
+
|
| 499 |
+
if naver_client_id and naver_client_secret:
|
| 500 |
+
url = "https://openapi.naver.com/v1/search/shop.json"
|
| 501 |
+
headers = {
|
| 502 |
+
"X-Naver-Client-Id": naver_client_id,
|
| 503 |
+
"X-Naver-Client-Secret": naver_client_secret
|
| 504 |
+
}
|
| 505 |
+
params = {"query": query, "display": 10}
|
| 506 |
+
|
| 507 |
+
response = requests.get(url, headers=headers, params=params, timeout=10)
|
| 508 |
+
if response.status_code == 200:
|
| 509 |
+
data = response.json()
|
| 510 |
+
naver_data = []
|
| 511 |
+
for item in data.get('items', [])[:5]:
|
| 512 |
+
naver_data.append(f"상품: {item.get('title', '').replace('<b>', '').replace('</b>', '')}")
|
| 513 |
+
naver_data.append(f"가격: {item.get('lprice', '')}원")
|
| 514 |
+
naver_data.append(f"카테고리: {item.get('category1', '')}")
|
| 515 |
+
all_results["naver"] = "\n".join(naver_data)
|
| 516 |
+
logger.info("네이버 검색 완료")
|
| 517 |
+
else:
|
| 518 |
+
all_results["naver"] = "네이버 API 검색 실패"
|
| 519 |
+
else:
|
| 520 |
+
all_results["naver"] = "네이버 API 키가 설정되지 않음"
|
| 521 |
+
except Exception as e:
|
| 522 |
+
all_results["naver"] = f"네이버 검색 오류: {str(e)}"
|
| 523 |
+
|
| 524 |
+
# 2. DuckDuckGo 검색
|
| 525 |
+
try:
|
| 526 |
+
url = "https://api.duckduckgo.com/"
|
| 527 |
+
params = {
|
| 528 |
+
"q": query,
|
| 529 |
+
"format": "json",
|
| 530 |
+
"no_html": "1",
|
| 531 |
+
"skip_disambig": "1"
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
response = requests.get(url, params=params, timeout=10)
|
| 535 |
+
if response.status_code == 200:
|
| 536 |
+
data = response.json()
|
| 537 |
+
|
| 538 |
+
ddg_data = []
|
| 539 |
+
# Abstract 정보
|
| 540 |
+
if data.get('Abstract'):
|
| 541 |
+
ddg_data.append(f"요약: {data['Abstract']}")
|
| 542 |
+
|
| 543 |
+
# Related Topics
|
| 544 |
+
for topic in data.get('RelatedTopics', [])[:3]:
|
| 545 |
+
if isinstance(topic, dict) and topic.get('Text'):
|
| 546 |
+
ddg_data.append(f"관련정보: {topic['Text']}")
|
| 547 |
+
|
| 548 |
+
all_results["duckduckgo"] = "\n".join(ddg_data) if ddg_data else "DuckDuckGo에서 관련 정보 없음"
|
| 549 |
+
logger.info("DuckDuckGo 검색 완료")
|
| 550 |
+
else:
|
| 551 |
+
all_results["duckduckgo"] = "DuckDuckGo 검색 실패"
|
| 552 |
+
except Exception as e:
|
| 553 |
+
all_results["duckduckgo"] = f"DuckDuckGo 검색 오류: {str(e)}"
|
| 554 |
+
|
| 555 |
+
# 3. Google 검색은 Gemini에서 자동 처리됨
|
| 556 |
+
all_results["google"] = "Google 검색 그라운딩 자동 실행"
|
| 557 |
+
|
| 558 |
+
logger.info("모든 검색 엔진 데이터 수집 완료")
|
| 559 |
+
return all_results
|
| 560 |
+
|
| 561 |
+
def comprehensive_market_analysis(category, seasonality, sales_target):
|
| 562 |
+
"""다양성 강화된 시장 분석"""
|
| 563 |
+
logger.info("다양성 강화 시장 분석 시작")
|
| 564 |
+
|
| 565 |
+
# 랜덤 시드를 현재 시간으로 설정하여 매번 다른 결과 보장
|
| 566 |
+
random.seed(datetime.now().microsecond)
|
| 567 |
+
|
| 568 |
+
# 다양한 검색 각도로 쿼리 생성
|
| 569 |
+
search_angles = [
|
| 570 |
+
"틈새상품", "신상품", "인기상품", "저가상품", "고급상품", "할인상품",
|
| 571 |
+
"간편상품", "실용상품", "트렌드상품", "숨은상품", "베스트상품", "추천상품"
|
| 572 |
+
]
|
| 573 |
+
|
| 574 |
+
search_queries = []
|
| 575 |
+
|
| 576 |
+
# 카테고리별 다양한 검색어 생성
|
| 577 |
+
category_pool = DIVERSE_SEED_POOLS.get(category, DIVERSE_SEED_POOLS["생활/건강"])
|
| 578 |
+
|
| 579 |
+
for angle in search_angles:
|
| 580 |
+
for item in random.sample(category_pool, 3): # 각 카테고리에서 3개씩만 선택
|
| 581 |
+
search_queries.append(f"{item} {angle}")
|
| 582 |
+
|
| 583 |
+
# 소재별 검색어 추가
|
| 584 |
+
for material in random.sample(MATERIAL_KEYWORDS, 5):
|
| 585 |
+
search_queries.append(f"{material} {category} 상품")
|
| 586 |
+
|
| 587 |
+
# 형태별 검색어 추가
|
| 588 |
+
for shape in random.sample(SHAPE_KEYWORDS, 5):
|
| 589 |
+
search_queries.append(f"{shape} {category} 아이템")
|
| 590 |
+
|
| 591 |
+
# 검색어 셔플하여 예측 불가능하게 만들기
|
| 592 |
+
random.shuffle(search_queries)
|
| 593 |
+
search_queries = search_queries[:15] # 15개로 제한
|
| 594 |
+
|
| 595 |
+
comprehensive_data = {}
|
| 596 |
+
|
| 597 |
+
for i, query in enumerate(search_queries):
|
| 598 |
+
logger.info(f"다양성 검색 {i+1}/15: {query}")
|
| 599 |
+
comprehensive_data[f"query_{i+1}"] = search_all_engines(query)
|
| 600 |
+
|
| 601 |
+
# API 과부하 방지를 위한 딜레이
|
| 602 |
+
import time
|
| 603 |
+
time.sleep(0.5)
|
| 604 |
+
|
| 605 |
+
# 다양성 강화 데이터 요약
|
| 606 |
+
summary = "=== 다양성 강화 시장 분석 결과 ===\n\n"
|
| 607 |
+
|
| 608 |
+
# 무작위로 결과를 섞어서 패턴 방지
|
| 609 |
+
result_keys = list(comprehensive_data.keys())
|
| 610 |
+
random.shuffle(result_keys)
|
| 611 |
+
|
| 612 |
+
summary += "🔍 다양한 시장 검색 결과:\n"
|
| 613 |
+
for key in result_keys[:10]:
|
| 614 |
+
results = comprehensive_data.get(key, {})
|
| 615 |
+
if results.get("naver"):
|
| 616 |
+
summary += f"• {results['naver'][:60]}...\n"
|
| 617 |
+
summary += "\n"
|
| 618 |
+
|
| 619 |
+
logger.info("다양성 강화 분석 완료")
|
| 620 |
+
return summary
|
| 621 |
+
|
| 622 |
+
def search_with_api(query, search_engine="Google 검색 그라운딩만"):
|
| 623 |
+
"""개별 검색 엔진으로 검색하는 함수 (단일 엔진 선택시 사용)"""
|
| 624 |
+
logger.info(f"검색 엔진: {search_engine}, 쿼리: {query}")
|
| 625 |
+
|
| 626 |
+
search_results = ""
|
| 627 |
+
|
| 628 |
+
try:
|
| 629 |
+
if search_engine == "네이버 검색 API만":
|
| 630 |
+
# 네이버 검색 API 사용
|
| 631 |
+
naver_client_id = os.getenv("NAVER_CLIENT_ID")
|
| 632 |
+
naver_client_secret = os.getenv("NAVER_CLIENT_SECRET")
|
| 633 |
+
|
| 634 |
+
if naver_client_id and naver_client_secret:
|
| 635 |
+
url = "https://openapi.naver.com/v1/search/shop.json"
|
| 636 |
+
headers = {
|
| 637 |
+
"X-Naver-Client-Id": naver_client_id,
|
| 638 |
+
"X-Naver-Client-Secret": naver_client_secret
|
| 639 |
+
}
|
| 640 |
+
params = {"query": query, "display": 10}
|
| 641 |
+
|
| 642 |
+
response = requests.get(url, headers=headers, params=params)
|
| 643 |
+
if response.status_code == 200:
|
| 644 |
+
data = response.json()
|
| 645 |
+
for item in data.get('items', [])[:5]:
|
| 646 |
+
search_results += f"상품명: {item.get('title', '')}\n"
|
| 647 |
+
search_results += f"가격: {item.get('lprice', '')}원\n"
|
| 648 |
+
search_results += f"카테고리: {item.get('category1', '')}\n\n"
|
| 649 |
+
else:
|
| 650 |
+
search_results = "네이버 API 검색 실패"
|
| 651 |
+
else:
|
| 652 |
+
search_results = "네이버 API 키가 설정되지 않음"
|
| 653 |
+
|
| 654 |
+
elif search_engine == "DuckDuckGo 검색만":
|
| 655 |
+
# DuckDuckGo 검색 (무료, API 키 불필요)
|
| 656 |
+
try:
|
| 657 |
+
url = "https://api.duckduckgo.com/"
|
| 658 |
+
params = {
|
| 659 |
+
"q": query,
|
| 660 |
+
"format": "json",
|
| 661 |
+
"no_html": "1",
|
| 662 |
+
"skip_disambig": "1"
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
response = requests.get(url, params=params, timeout=10)
|
| 666 |
+
if response.status_code == 200:
|
| 667 |
+
data = response.json()
|
| 668 |
+
|
| 669 |
+
# Abstract 정보
|
| 670 |
+
if data.get('Abstract'):
|
| 671 |
+
search_results += f"요약: {data['Abstract']}\n\n"
|
| 672 |
+
|
| 673 |
+
# Related Topics
|
| 674 |
+
for topic in data.get('RelatedTopics', [])[:5]:
|
| 675 |
+
if isinstance(topic, dict) and topic.get('Text'):
|
| 676 |
+
search_results += f"관련 정보: {topic['Text']}\n"
|
| 677 |
+
|
| 678 |
+
if not search_results:
|
| 679 |
+
search_results = "DuckDuckGo에서 관련 정보를 찾지 못함"
|
| 680 |
+
else:
|
| 681 |
+
search_results = "DuckDuckGo 검색 실패"
|
| 682 |
+
except Exception as e:
|
| 683 |
+
search_results = f"DuckDuckGo 검색 오류: {str(e)}"
|
| 684 |
+
|
| 685 |
+
elif search_engine == "검색 없이 AI만 사용":
|
| 686 |
+
search_results = "검색 없이 AI 지식만 사용하여 키워드 생성"
|
| 687 |
+
|
| 688 |
+
else:
|
| 689 |
+
# Google 검색 그라운딩 (기본)
|
| 690 |
+
search_results = "Google 검색 그라운딩 사용"
|
| 691 |
+
|
| 692 |
+
except Exception as e:
|
| 693 |
+
logger.error(f"검색 오류: {str(e)}")
|
| 694 |
+
search_results = f"검색 오류: {str(e)}"
|
| 695 |
+
|
| 696 |
+
logger.info(f"검색 결과 길이: {len(search_results)} 문자")
|
| 697 |
+
return search_results
|
| 698 |
+
|
| 699 |
+
def apply_random_selection_for_keywords(category, launch_timing, seasonality, sales_target, sales_channel, competition_level):
|
| 700 |
+
"""각 키워드마다 랜덤하게 조건을 적용하기 위한 설정 문자열 생성"""
|
| 701 |
+
|
| 702 |
+
# 각 항목별 선택지 정의
|
| 703 |
+
categories = ["패션잡화", "생활/건강", "출산/육아", "스포츠/레저", "디지털/가전", "가구/인테리어", "패션의류", "화장품/미용"]
|
| 704 |
+
launch_timings = ["즉시소싱", "기획형"]
|
| 705 |
+
seasonalities = ["봄", "여름", "가을", "겨울", "비계절"]
|
| 706 |
+
sales_targets = ["100만원 이하", "100-500만원", "500-1천만원", "1천-5천만원", "5천만원 이상"]
|
| 707 |
+
sales_channels = ["오픈마켓", "SNS마케팅", "광고집행", "오프라인"]
|
| 708 |
+
competition_levels = ["초보", "중수", "고수"]
|
| 709 |
+
|
| 710 |
+
# 랜덤적용 설정 정보 생성
|
| 711 |
+
random_settings = {
|
| 712 |
+
'category_random': category == "랜덤적용",
|
| 713 |
+
'launch_timing_random': launch_timing == "랜덤적용",
|
| 714 |
+
'seasonality_random': seasonality == "랜덤적용",
|
| 715 |
+
'sales_target_random': sales_target == "랜덤적용",
|
| 716 |
+
'sales_channel_random': sales_channel == "랜덤적용",
|
| 717 |
+
'competition_level_random': competition_level == "랜덤적용",
|
| 718 |
+
'categories': categories,
|
| 719 |
+
'launch_timings': launch_timings,
|
| 720 |
+
'seasonalities': seasonalities,
|
| 721 |
+
'sales_targets': sales_targets,
|
| 722 |
+
'sales_channels': sales_channels,
|
| 723 |
+
'competition_levels': competition_levels
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
# 고정값들
|
| 727 |
+
fixed_values = {
|
| 728 |
+
'category': category if category != "랜덤적용" else None,
|
| 729 |
+
'launch_timing': launch_timing if launch_timing != "랜덤적용" else None,
|
| 730 |
+
'seasonality': seasonality if seasonality != "랜덤적용" else None,
|
| 731 |
+
'sales_target': sales_target if sales_target != "랜덤적용" else None,
|
| 732 |
+
'sales_channel': sales_channel if sales_channel != "랜덤적용" else None,
|
| 733 |
+
'competition_level': competition_level if competition_level != "랜덤적용" else None
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
logger.info("=== 키워드별 랜덤 설정 ===")
|
| 737 |
+
logger.info(f"카테고리 랜덤: {random_settings['category_random']}")
|
| 738 |
+
logger.info(f"출시타이밍 랜덤: {random_settings['launch_timing_random']}")
|
| 739 |
+
logger.info(f"계절성 랜덤: {random_settings['seasonality_random']}")
|
| 740 |
+
logger.info(f"매출목표 랜덤: {random_settings['sales_target_random']}")
|
| 741 |
+
logger.info(f"판매채널 랜덤: {random_settings['sales_channel_random']}")
|
| 742 |
+
logger.info(f"경쟁강도 랜덤: {random_settings['competition_level_random']}")
|
| 743 |
+
|
| 744 |
+
return random_settings, fixed_values
|
| 745 |
+
|
| 746 |
+
def generate_sourcing_keywords(category, additional_request, launch_timing, seasonality, sales_target, sales_channel, competition_level, search_engine="Google 검색 그라운딩만"):
|
| 747 |
+
"""다양성 강화된 쇼핑 키워드 50개를 생성하는 함수"""
|
| 748 |
+
logger.info("=== 다양성 강화 쇼핑키워드 생성 시작 ===")
|
| 749 |
+
logger.info(f"입력 조건 - 검색엔진: {search_engine}")
|
| 750 |
+
logger.info(f"입력 조건 - 카테고리: {category}")
|
| 751 |
+
logger.info(f"입력 조건 - 추가요청: {additional_request}")
|
| 752 |
+
logger.info(f"입력 조건 - 출시타이밍: {launch_timing}")
|
| 753 |
+
logger.info(f"입력 조건 - 계절성: {seasonality}")
|
| 754 |
+
logger.info(f"입력 조건 - 매출목표: {sales_target}")
|
| 755 |
+
logger.info(f"입력 조건 - 판매채널: {sales_channel}")
|
| 756 |
+
logger.info(f"입력 조건 - 경쟁강도: {competition_level}")
|
| 757 |
+
|
| 758 |
+
try:
|
| 759 |
+
logger.info("Gemini 클라이언트 초기화 중...")
|
| 760 |
+
client = initialize_gemini()
|
| 761 |
+
|
| 762 |
+
# 매번 다른 시드로 랜덤성 보장
|
| 763 |
+
current_time = datetime.now()
|
| 764 |
+
random_seed = current_time.microsecond + current_time.second * 1000
|
| 765 |
+
random.seed(random_seed)
|
| 766 |
+
logger.info(f"랜덤 시드 설정: {random_seed}")
|
| 767 |
+
|
| 768 |
+
# 프롬프트 구성
|
| 769 |
+
logger.info("다양성 강화 프롬프트 구성 중...")
|
| 770 |
+
|
| 771 |
+
# 랜덤 설정 처리
|
| 772 |
+
random_settings, fixed_values = apply_random_selection_for_keywords(
|
| 773 |
+
category, launch_timing, seasonality, sales_target, sales_channel, competition_level
|
| 774 |
+
)
|
| 775 |
+
|
| 776 |
+
# 다양한 키워드 조합 미리 생성
|
| 777 |
+
diverse_combinations = generate_diverse_keyword_combinations(category, 60)
|
| 778 |
+
logger.info(f"다양한 조합 생성 완료: {len(diverse_combinations)}개")
|
| 779 |
+
|
| 780 |
+
# 검색 엔진별 처리
|
| 781 |
+
search_info = ""
|
| 782 |
+
config_tools = []
|
| 783 |
+
|
| 784 |
+
if search_engine == "모든 검색 엔진 통합 분석 (추천)":
|
| 785 |
+
logger.info("🔍 다양성 강화 통합 분석 시작...")
|
| 786 |
+
|
| 787 |
+
# Google 검색 그라운딩 도구 설정
|
| 788 |
+
google_search_tool = Tool(google_search=GoogleSearch())
|
| 789 |
+
config_tools = [google_search_tool]
|
| 790 |
+
|
| 791 |
+
# 다양성 강화 종합 시장 분석 실행
|
| 792 |
+
comprehensive_analysis = comprehensive_market_analysis(category, seasonality, sales_target)
|
| 793 |
+
|
| 794 |
+
search_info = f"""
|
| 795 |
+
🔍 === 다양성 강화 통합 분석 결과 ===
|
| 796 |
+
📈 Google 검색 그라운딩: 실시간 다양한 쇼핑키워드 트렌드 분석 (자동 실행)
|
| 797 |
+
🛒 네이버 쇼핑 API: 한국 쇼핑몰 다양한 키워드 데이터 분석
|
| 798 |
+
🌐 DuckDuckGo 검색: 글로벌 다양한 쇼핑키워드 정보 분석
|
| 799 |
+
{comprehensive_analysis}
|
| 800 |
+
💡 위 모든 데이터를 종합하여 매번 다른 조합의 쇼핑키워드를 생성합니다.
|
| 801 |
+
🎲 랜덤 시드: {random_seed} (매번 다른 결과 보장)
|
| 802 |
+
"""
|
| 803 |
+
|
| 804 |
+
elif search_engine == "Google 검색 그라운딩만":
|
| 805 |
+
logger.info("Google 검색 도구 설정 중...")
|
| 806 |
+
google_search_tool = Tool(google_search=GoogleSearch())
|
| 807 |
+
config_tools = [google_search_tool]
|
| 808 |
+
search_info = f"Google 검색 그라운딩을 통한 다양한 실시간 쇼핑키워드 분석 (시드: {random_seed})"
|
| 809 |
+
|
| 810 |
+
elif search_engine in ["네이버 검색 API만", "DuckDuckGo 검색만"]:
|
| 811 |
+
logger.info(f"{search_engine} 사용하여 다양한 쇼핑키워드 조사 중...")
|
| 812 |
+
# 다양성 강화를 위한 검색 실행
|
| 813 |
+
search_queries = []
|
| 814 |
+
|
| 815 |
+
# 랜덤하게 다양한 검색어 생성
|
| 816 |
+
base_items = random.sample(diverse_combinations, 8)
|
| 817 |
+
for item in base_items:
|
| 818 |
+
search_queries.append(f"{item} 쇼핑키워드")
|
| 819 |
+
|
| 820 |
+
search_results = ""
|
| 821 |
+
for query in search_queries:
|
| 822 |
+
result = search_with_api(query, search_engine)
|
| 823 |
+
search_results += f"[검색어: {query}]\n{result}\n\n"
|
| 824 |
+
|
| 825 |
+
search_info = f"{search_engine} 다양한 쇼핑키워드 검색 결과 (시드: {random_seed}):\n{search_results}"
|
| 826 |
+
|
| 827 |
+
else: # 검색 없이 AI만 사용
|
| 828 |
+
logger.info("검색 없이 AI 지식만 사용")
|
| 829 |
+
search_info = f"AI 내장 지식을 기반으로 다양한 쇼핑키워드 생성 (시드: {random_seed})"
|
| 830 |
+
|
| 831 |
+
# 다양성을 강화한 프롬프트 - 매번 다른 조합 요청
|
| 832 |
+
diverse_sample = random.sample(diverse_combinations, 20)
|
| 833 |
+
|
| 834 |
+
prompt = f"""
|
| 835 |
+
🎯 다양성 강화 쇼핑키워드 발굴 시스템 v5.0
|
| 836 |
+
⚡ 중요: 절대 중복되지 않는 다양한 키워드만 생성하세요!
|
| 837 |
+
🔬 역할 정의
|
| 838 |
+
당신은 매번 완전히 다른 조합의 쇼핑키워드를 생성하는 전문가입니다.
|
| 839 |
+
🎯 목표
|
| 840 |
+
주어진 조건에 맞는 실제 쇼핑키워드 50개를 발굴하되, 절대 중복되지 않고 매번 다른 조합으로 구성하십시오.
|
| 841 |
+
📋 입력된 조건:
|
| 842 |
+
카테고리: {category}
|
| 843 |
+
추가 요청사항: {additional_request}
|
| 844 |
+
출시타이밍: {launch_timing}
|
| 845 |
+
계절성: {seasonality}
|
| 846 |
+
매출목표: {sales_target}
|
| 847 |
+
판매채널: {sales_channel}
|
| 848 |
+
경쟁강도: {competition_level}
|
| 849 |
+
검색엔진: {search_engine}
|
| 850 |
+
🔍 쇼핑키워드 분석 정보:
|
| 851 |
+
{search_info}
|
| 852 |
+
🎲 다양성 보장 참고 조합 예시 (이것과 다르게 생성하세요):
|
| 853 |
+
{', '.join(diverse_sample[:10])}
|
| 854 |
+
⚠️ 키워드별 랜덤 적용 규칙:
|
| 855 |
+
각 키워드마다 다음과 같이 적용하세요:
|
| 856 |
+
{"- 카테고리: 매 키워드마다 " + str(random_settings['categories']) + " 중에서 랜덤 선택" if random_settings['category_random'] else f"- 카테고리: {fixed_values['category']} 고정"}
|
| 857 |
+
{"- 출시타이밍: 매 키워드마다 " + str(random_settings['launch_timings']) + " 중에서 랜덤 선택" if random_settings['launch_timing_random'] else f"- 출시타이밍: {fixed_values['launch_timing']} 고정"}
|
| 858 |
+
{"- 계절성: 매 키워드마다 " + str(random_settings['seasonalities']) + " 중에서 랜덤 선택" if random_settings['seasonality_random'] else f"- 계절성: {fixed_values['seasonality']} 고정"}
|
| 859 |
+
{"- 매출목표: 매 키워드마다 " + str(random_settings['sales_targets']) + " 중에서 랜덤 선택" if random_settings['sales_target_random'] else f"- 매출목표: {fixed_values['sales_target']} 고정"}
|
| 860 |
+
{"- 판매채널: 매 키워드마다 " + str(random_settings['sales_channels']) + " 중에서 랜덤 선택" if random_settings['sales_channel_random'] else f"- 판매채널: {fixed_values['sales_channel']} 고정"}
|
| 861 |
+
{"- 경쟁강도: 매 키워드마다 " + str(random_settings['competition_levels']) + " 중에서 랜덤 선택" if random_settings['competition_level_random'] else f"- 경쟁강도: {fixed_values['competition_level']} 고정"}
|
| 862 |
+
⚙️ 다양성 강화 워크플로우
|
| 863 |
+
1단계: 완전히 새로운 조합 생성
|
| 864 |
+
- 이전 결과와 절대 중복되지 않는 키워드 조합
|
| 865 |
+
- 소재({', '.join(MATERIAL_KEYWORDS[:5])}) + 상품명 조합
|
| 866 |
+
- 형태({', '.join(SHAPE_KEYWORDS[:5])}) + 상품명 조합
|
| 867 |
+
- 기능({', '.join(FUNCTION_KEYWORDS[:5])}) + 상품명 조합
|
| 868 |
+
2단계: 중복 방지 필터링
|
| 869 |
+
- 동일한 키워드 조합 완전 배제
|
| 870 |
+
- 유사한 의미의 키워드 조합 배제
|
| 871 |
+
- 매번 새로운 각도로 접근
|
| 872 |
+
3단계: 50개 다양한 키워드 선별
|
| 873 |
+
- 브랜드명 절대 금지
|
| 874 |
+
- 복잡한 기술 용어 금지
|
| 875 |
+
- 최대 2개 단어 조합만 허용
|
| 876 |
+
⚠️ 다양성 강화 키워드 구성 규칙 (매우 중요):
|
| 877 |
+
🚫 절대 금지 사항:
|
| 878 |
+
- 동일하거나 유사한 키워드 반복
|
| 879 |
+
- 브랜드명 (삼성, LG, 나이키 등)
|
| 880 |
+
- 복잡한 기술 용어
|
| 881 |
+
- 3개 이상 복합어
|
| 882 |
+
✅ 반드시 다양하게 포함해야 할 형태:
|
| 883 |
+
1. 소재별 키워드 (예: 대나무 도마, 구리 컵)
|
| 884 |
+
2. 형태별 키워드 (예: 원형 접시, 슬림 케이스)
|
| 885 |
+
3. 기능별 키워드 (예: 방수 파우치, 항균 수건)
|
| 886 |
+
4. 카테고리별 키워드 (예: 수납함, 조리도구)
|
| 887 |
+
🎯 다양성 보장 전략:
|
| 888 |
+
- 절대 같은 소재를 2번 이상 사용하지 마세요
|
| 889 |
+
- 절대 같은 형태를 2번 이상 사용하지 마세요
|
| 890 |
+
- 절대 같은 기능을 2번 이상 사용하지 마세요
|
| 891 |
+
- 매 키워드마다 완전히 다른 조합으로 생성하세요
|
| 892 |
+
올바른 다양한 키워드 예시:
|
| 893 |
+
✅ 대나무 도마 (소재+상품)
|
| 894 |
+
✅ 원형 접시 (형태+상품)
|
| 895 |
+
✅ 방수 파우치 (기능+상품)
|
| 896 |
+
✅ 세라믹 머그컵 (소재+상품)
|
| 897 |
+
✅ 접이식 선반 (형태+상품)
|
| 898 |
+
✅ 항균 수건 (기능+상품)
|
| 899 |
+
잘못된 반복 키워드 예시:
|
| 900 |
+
❌ 대나무 도마, 대나무 젓가락 (소재 반복)
|
| 901 |
+
❌ 원형 접시, 원형 쟁반 (형태 반복)
|
| 902 |
+
❌ 방수 파우치, 방수 케이스 (기능 반복)
|
| 903 |
+
📋 출력 형식:
|
| 904 |
+
오직 완전히 다른 쇼핑키워드만 한 줄씩 50개 출력
|
| 905 |
+
- 번호 금지
|
| 906 |
+
- 설명 금지
|
| 907 |
+
- 기호나 특수문자 금지
|
| 908 |
+
- 괄호 안 설명 금지
|
| 909 |
+
- 순수 키워드만 출력
|
| 910 |
+
- 절대 중복 금지
|
| 911 |
+
예시 출력 형태 (매번 완전히 다르게):
|
| 912 |
+
유리 화분
|
| 913 |
+
접이식 의자
|
| 914 |
+
항균 도마
|
| 915 |
+
알루미늄 텀블러
|
| 916 |
+
슬림 파일함
|
| 917 |
+
⚡ 지금 바로 절대 중복되지 않는 완전히 새로운 쇼핑키워드 50개를 각각 다른 랜덤 조건을 적용하여 출력하세요.
|
| 918 |
+
매번 실행할 때마다 완전히 다른 결과가 나와야 합니다!
|
keyword_processor.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
키워드 처리 관련 기능 - 앞뒤 조합 중 높은 검색량만 선택, 카테고리 항목 제거
|
| 3 |
+
- 키워드 추출 및 조합
|
| 4 |
+
- 검색 결과 처리
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import re
|
| 9 |
+
from collections import defaultdict, Counter
|
| 10 |
+
import text_utils
|
| 11 |
+
import keyword_search
|
| 12 |
+
import product_search
|
| 13 |
+
import logging
|
| 14 |
+
|
| 15 |
+
# 로깅 설정
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
logger.setLevel(logging.INFO)
|
| 18 |
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 19 |
+
handler = logging.StreamHandler()
|
| 20 |
+
handler.setFormatter(formatter)
|
| 21 |
+
logger.addHandler(handler)
|
| 22 |
+
|
| 23 |
+
def process_search_results(search_results, current_keyword="", exclude_zero_volume=True):
|
| 24 |
+
"""
|
| 25 |
+
검색 결과에서 키워드와 카테고리 정보 추출 및 처리 - 앞뒤 조합 중 높은 검색량만 선택
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
search_results (dict): 검색 결과 정보
|
| 29 |
+
current_keyword (str): 현재 검색 중인 키워드
|
| 30 |
+
exclude_zero_volume (bool): 검색량이 0인 키워드 제외 여부
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
dict: 처리된 결과
|
| 34 |
+
"""
|
| 35 |
+
logger.info("\n===== 검색 결과 처리 시작 =====")
|
| 36 |
+
logger.info(f"현재 키워드: '{current_keyword}'")
|
| 37 |
+
logger.info(f"검색량 0 키워드 제외: {exclude_zero_volume}")
|
| 38 |
+
|
| 39 |
+
if not search_results or not search_results.get("product_list"):
|
| 40 |
+
logger.warning("검색 결과가 없습니다.")
|
| 41 |
+
return {
|
| 42 |
+
"products_df": None,
|
| 43 |
+
"keywords_df": None,
|
| 44 |
+
"categories": ["전체 보기"],
|
| 45 |
+
"message": "검색 결과가 없습니다."
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
product_list = search_results["product_list"]
|
| 49 |
+
combo_candidates = search_results["combo_candidates"]
|
| 50 |
+
category_counter = search_results["category_counter"]
|
| 51 |
+
keyword_indices = search_results["keyword_indices"]
|
| 52 |
+
keyword_pairs = search_results.get("keyword_pairs", {}) # 앞뒤 조합 정보
|
| 53 |
+
|
| 54 |
+
logger.info(f"검색 결과 - 상품 수: {len(product_list)}개")
|
| 55 |
+
logger.info(f"검색 결과 - 조합 후보 수: {len(combo_candidates)}개")
|
| 56 |
+
logger.info(f"검색 결과 - 카테고리 수: {len(category_counter)}개")
|
| 57 |
+
|
| 58 |
+
# 상품 정보 데이터프레임 생성
|
| 59 |
+
df_products = pd.DataFrame(product_list)
|
| 60 |
+
|
| 61 |
+
# API 키워드를 UI 키워드로 변환하는 매핑 생성
|
| 62 |
+
api_to_ui_keywords = {}
|
| 63 |
+
|
| 64 |
+
for api_keyword in combo_candidates.keys():
|
| 65 |
+
# API 키워드에서 UI 키워드로 변환
|
| 66 |
+
if current_keyword and current_keyword in api_keyword:
|
| 67 |
+
# 메인 키워드 자체인 경우
|
| 68 |
+
if api_keyword == current_keyword:
|
| 69 |
+
api_to_ui_keywords[api_keyword] = current_keyword
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
# 메인 키워드가 이미 포함된 경우 (예: 갑오징어, 귀오징어)
|
| 73 |
+
# 공백 있는 형태로 변환
|
| 74 |
+
ui_keyword = api_keyword
|
| 75 |
+
# 공백이 없는 형태라면 적절한 위치에 공백 추가
|
| 76 |
+
if " " not in api_keyword:
|
| 77 |
+
# 메인 키워드 기준으로 분리
|
| 78 |
+
if api_keyword.startswith(current_keyword):
|
| 79 |
+
# 오징어갑 => 오징어 갑
|
| 80 |
+
prefix = current_keyword
|
| 81 |
+
suffix = api_keyword[len(current_keyword):]
|
| 82 |
+
if suffix:
|
| 83 |
+
ui_keyword = f"{prefix} {suffix}"
|
| 84 |
+
elif api_keyword.endswith(current_keyword):
|
| 85 |
+
# 갑오징어 => 갑 오징어
|
| 86 |
+
prefix = api_keyword[:-len(current_keyword)]
|
| 87 |
+
suffix = current_keyword
|
| 88 |
+
if prefix:
|
| 89 |
+
ui_keyword = f"{prefix} {suffix}"
|
| 90 |
+
else:
|
| 91 |
+
# 메인 키워드가 중간에 있는 경우
|
| 92 |
+
idx = api_keyword.find(current_keyword)
|
| 93 |
+
if idx > 0:
|
| 94 |
+
prefix = api_keyword[:idx]
|
| 95 |
+
middle = current_keyword
|
| 96 |
+
suffix = api_keyword[idx+len(current_keyword):]
|
| 97 |
+
ui_keyword = f"{prefix} {middle}"
|
| 98 |
+
if suffix:
|
| 99 |
+
ui_keyword += f" {suffix}"
|
| 100 |
+
|
| 101 |
+
api_to_ui_keywords[api_keyword] = ui_keyword
|
| 102 |
+
else:
|
| 103 |
+
# 메인 키워드가 없는 경우 - 그대로 사용
|
| 104 |
+
api_to_ui_keywords[api_keyword] = api_keyword
|
| 105 |
+
|
| 106 |
+
# === 수정된 부분: 검색량 조회 후 앞뒤 조합 중 높은 것만 선택 ===
|
| 107 |
+
logger.info(f"\n검색량 조회 대상 키워드 수: {len(combo_candidates)}개")
|
| 108 |
+
search_volumes = keyword_search.fetch_all_search_volumes(list(combo_candidates.keys()))
|
| 109 |
+
logger.info(f"검색량 조회 완료: {len(search_volumes)}개 결과")
|
| 110 |
+
|
| 111 |
+
# 앞뒤 조합 중 높은 검색량만 선택
|
| 112 |
+
if keyword_pairs and current_keyword:
|
| 113 |
+
logger.info("\n=== 앞뒤 조합 중 높은 검색량 선택 ===")
|
| 114 |
+
filtered_candidates = {}
|
| 115 |
+
|
| 116 |
+
# 메인 키워드는 항상 포함
|
| 117 |
+
main_api = current_keyword.replace(" ", "")
|
| 118 |
+
if main_api in combo_candidates:
|
| 119 |
+
filtered_candidates[main_api] = combo_candidates[main_api]
|
| 120 |
+
logger.info(f"메인 키워드 유지: '{current_keyword}'")
|
| 121 |
+
|
| 122 |
+
# 메인 키워드가 포함된 복합어도 유지
|
| 123 |
+
for api_kw, categories in combo_candidates.items():
|
| 124 |
+
ui_kw = api_to_ui_keywords[api_kw]
|
| 125 |
+
if current_keyword in ui_kw and api_kw != main_api and api_kw not in [pair_info["front"].replace(" ", "") for pair_info in keyword_pairs.values()] and api_kw not in [pair_info["back"].replace(" ", "") for pair_info in keyword_pairs.values()]:
|
| 126 |
+
filtered_candidates[api_kw] = categories
|
| 127 |
+
logger.info(f"메인 키워드 포함 복합어 유지: '{ui_kw}'")
|
| 128 |
+
|
| 129 |
+
# 앞뒤 조합 비교
|
| 130 |
+
for base_word, pair_info in keyword_pairs.items():
|
| 131 |
+
front_kw = pair_info["front"] # "키워드 메인키워드"
|
| 132 |
+
back_kw = pair_info["back"] # "메인키워드 키워드"
|
| 133 |
+
|
| 134 |
+
front_api = front_kw.replace(" ", "")
|
| 135 |
+
back_api = back_kw.replace(" ", "")
|
| 136 |
+
|
| 137 |
+
front_vol = search_volumes.get(front_api, {}).get("총검색량", 0)
|
| 138 |
+
back_vol = search_volumes.get(back_api, {}).get("총검색량", 0)
|
| 139 |
+
|
| 140 |
+
# 높은 검색량 선택
|
| 141 |
+
if front_vol > back_vol:
|
| 142 |
+
selected_api = front_api
|
| 143 |
+
selected_kw = front_kw
|
| 144 |
+
selected_vol = front_vol
|
| 145 |
+
removed_kw = back_kw
|
| 146 |
+
removed_vol = back_vol
|
| 147 |
+
elif back_vol > front_vol:
|
| 148 |
+
selected_api = back_api
|
| 149 |
+
selected_kw = back_kw
|
| 150 |
+
selected_vol = back_vol
|
| 151 |
+
removed_kw = front_kw
|
| 152 |
+
removed_vol = front_vol
|
| 153 |
+
elif front_vol == back_vol and front_vol > 0:
|
| 154 |
+
# 같은 검색량이면 더 자연스러운 순서 선택 (메인키워드가 뒤에 오는 것)
|
| 155 |
+
selected_api = back_api
|
| 156 |
+
selected_kw = back_kw
|
| 157 |
+
selected_vol = back_vol
|
| 158 |
+
removed_kw = front_kw
|
| 159 |
+
removed_vol = front_vol
|
| 160 |
+
else:
|
| 161 |
+
# 둘 다 0이면 제외
|
| 162 |
+
logger.info(f" '{base_word}' 조합: 둘 다 검색량 0으로 제외")
|
| 163 |
+
continue
|
| 164 |
+
|
| 165 |
+
# 선택된 키워드만 추가
|
| 166 |
+
if selected_vol > 0 or not exclude_zero_volume:
|
| 167 |
+
filtered_candidates[selected_api] = combo_candidates[selected_api]
|
| 168 |
+
logger.info(f" '{base_word}' 조합 선택: '{selected_kw}' ({selected_vol:,}) > '{removed_kw}' ({removed_vol:,})")
|
| 169 |
+
else:
|
| 170 |
+
logger.info(f" '{base_word}' 조합: 검색량 0으로 제외")
|
| 171 |
+
|
| 172 |
+
# 필터링된 조합으로 교체
|
| 173 |
+
combo_candidates = filtered_candidates
|
| 174 |
+
logger.info(f"앞뒤 조합 필터링 완료: {len(combo_candidates)}개 키워드 선택")
|
| 175 |
+
|
| 176 |
+
# 검색량 0 키워드 통계
|
| 177 |
+
zero_volume_count = sum(1 for vol in search_volumes.values() if vol.get("총검색량", 0) == 0)
|
| 178 |
+
logger.info(f"검색량 0인 키워드 수: {zero_volume_count}개 ({zero_volume_count/max(1, len(search_volumes))*100:.1f}%)")
|
| 179 |
+
|
| 180 |
+
# 중복 키워드 제거를 위한 정규화된 키워드 집합
|
| 181 |
+
normalized_keywords = {}
|
| 182 |
+
|
| 183 |
+
for api_keyword in combo_candidates.keys():
|
| 184 |
+
ui_keyword = api_to_ui_keywords[api_keyword]
|
| 185 |
+
|
| 186 |
+
# 검색량 정보 가져오기
|
| 187 |
+
pc_count = 0
|
| 188 |
+
mobile_count = 0
|
| 189 |
+
total_count = 0
|
| 190 |
+
if api_keyword in search_volumes:
|
| 191 |
+
pc_count = search_volumes[api_keyword]["PC검색량"]
|
| 192 |
+
mobile_count = search_volumes[api_keyword]["모바일검색량"]
|
| 193 |
+
total_count = search_volumes[api_keyword]["총검색량"]
|
| 194 |
+
|
| 195 |
+
# 검색량 0인 키워드 제외 옵션 적용
|
| 196 |
+
if exclude_zero_volume and total_count == 0:
|
| 197 |
+
logger.debug(f" - '{ui_keyword}' (API: '{api_keyword}') - 검색량 0으로 제외됨")
|
| 198 |
+
continue
|
| 199 |
+
|
| 200 |
+
# 1. 공백을 기준으로 단어 분리 후 정렬해 정규화 키 생성
|
| 201 |
+
words = ui_keyword.split()
|
| 202 |
+
normalized = "".join(sorted(words))
|
| 203 |
+
|
| 204 |
+
# 2. 이미 정규화된 키워드가 있으면 검색량이 더 높은 것을 선택
|
| 205 |
+
if normalized in normalized_keywords:
|
| 206 |
+
existing_api_keyword, existing_ui_keyword, existing_total = normalized_keywords[normalized]
|
| 207 |
+
if total_count > existing_total:
|
| 208 |
+
logger.debug(f" - 중복 키워드 대체: '{existing_ui_keyword}' ({existing_total}) -> '{ui_keyword}' ({total_count})")
|
| 209 |
+
normalized_keywords[normalized] = (api_keyword, ui_keyword, total_count)
|
| 210 |
+
else:
|
| 211 |
+
logger.debug(f" - 중복 키워드 제외: '{ui_keyword}' ({total_count}) < '{existing_ui_keyword}' ({existing_total})")
|
| 212 |
+
else:
|
| 213 |
+
normalized_keywords[normalized] = (api_keyword, ui_keyword, total_count)
|
| 214 |
+
logger.debug(f" - 키워드 추가: '{ui_keyword}' (검색량: {total_count})")
|
| 215 |
+
|
| 216 |
+
logger.info(f"\n중복 제거 후 키워드 수: {len(normalized_keywords)}개")
|
| 217 |
+
|
| 218 |
+
# 중복이 제거된 키워드만 처리
|
| 219 |
+
final_combos = []
|
| 220 |
+
for normalized, (api_keyword, ui_keyword, total_count) in normalized_keywords.items():
|
| 221 |
+
|
| 222 |
+
# 키워드 가독성 개선 - fix_keyword_order 함수 적용
|
| 223 |
+
readable = fix_keyword_order(ui_keyword, current_keyword)
|
| 224 |
+
|
| 225 |
+
# 검색량 정보 가져오기
|
| 226 |
+
pc_count = 0
|
| 227 |
+
mobile_count = 0
|
| 228 |
+
if api_keyword in search_volumes:
|
| 229 |
+
pc_count = search_volumes[api_keyword]["PC검색량"]
|
| 230 |
+
mobile_count = search_volumes[api_keyword]["모바일검색량"]
|
| 231 |
+
total_count = search_volumes[api_keyword]["총검색량"]
|
| 232 |
+
|
| 233 |
+
# 검색량 구간 계산
|
| 234 |
+
search_volume_range = text_utils.get_search_volume_range(total_count)
|
| 235 |
+
|
| 236 |
+
# 등장 순위 및 횟수 계산
|
| 237 |
+
base_word = readable.replace(current_keyword, "").strip() if current_keyword else readable
|
| 238 |
+
ranks = []
|
| 239 |
+
if base_word in keyword_indices:
|
| 240 |
+
ranks = [idx + 1 for idx in keyword_indices[base_word]]
|
| 241 |
+
elif api_keyword in keyword_indices: # 메인 키워드가 포함된 단어인 경우
|
| 242 |
+
ranks = [idx + 1 for idx in keyword_indices.get(api_keyword, [])]
|
| 243 |
+
|
| 244 |
+
ranks_str = ", ".join(map(str, ranks)) if ranks else "-"
|
| 245 |
+
usage_count = len(ranks)
|
| 246 |
+
|
| 247 |
+
# === 수정된 부분: "상품 등록 카테고리(상위100위)" 항목 제거 ===
|
| 248 |
+
# 카테고리 정보는 내부적으로만 사용하고 테이블에는 표시하지 않음
|
| 249 |
+
|
| 250 |
+
final_combos.append({
|
| 251 |
+
"조합 키워드": readable.strip(),
|
| 252 |
+
"PC검색량": pc_count,
|
| 253 |
+
"모바일검색량": mobile_count,
|
| 254 |
+
"총검색량": total_count,
|
| 255 |
+
"검색량구간": search_volume_range,
|
| 256 |
+
"키워드 사용자순위": ranks_str,
|
| 257 |
+
"키워드 사용횟수": usage_count
|
| 258 |
+
# "상품 등록 카테고리(상위100위)" 항목 제거됨
|
| 259 |
+
})
|
| 260 |
+
|
| 261 |
+
# 키워드 정보 데이터프레임 생성
|
| 262 |
+
df_keywords = pd.DataFrame(final_combos)
|
| 263 |
+
|
| 264 |
+
# 검색량 기준으로 내림차순 정렬
|
| 265 |
+
if not df_keywords.empty:
|
| 266 |
+
df_keywords = df_keywords.sort_values(by="총검색량", ascending=False)
|
| 267 |
+
# 순번을 위해 인덱스 리셋 (순차적 순번 보장)
|
| 268 |
+
df_keywords = df_keywords.reset_index(drop=True)
|
| 269 |
+
|
| 270 |
+
# 데이터프레임 생성 후 로깅
|
| 271 |
+
logger.info(f"\n생성된 키워드 데이터프레임 행 수: {len(df_keywords)}")
|
| 272 |
+
if not df_keywords.empty:
|
| 273 |
+
logger.debug(f"데이터프레임 열: {df_keywords.columns.tolist()}")
|
| 274 |
+
logger.info(f"총 {len(df_keywords)}개 키워드 생성 완료")
|
| 275 |
+
|
| 276 |
+
# 카테고리 정보 가공
|
| 277 |
+
category_with_counts = [f"{cat} ({category_counter[cat]})" for cat in sorted(category_counter.keys())]
|
| 278 |
+
category_with_counts.insert(0, "전체 보기")
|
| 279 |
+
|
| 280 |
+
logger.info(f"카테고리 수: {len(category_counter)}개")
|
| 281 |
+
logger.info("===== 검색 결과 처리 완료 =====\n")
|
| 282 |
+
|
| 283 |
+
return {
|
| 284 |
+
"products_df": df_products,
|
| 285 |
+
"keywords_df": df_keywords,
|
| 286 |
+
"categories": category_with_counts,
|
| 287 |
+
"message": "✅ 검색이 완료되었습니다. 아래에서 키워드를 확인하세요."
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
def filter_and_sort_table(df, selected_cat, keyword_sort, total_volume_sort, usage_count_sort, selected_volume_range, exclude_zero_volume=False):
|
| 291 |
+
"""테이블 필터링 및 정렬 함수 (검색량 0 제외 기능 추가)"""
|
| 292 |
+
if df is None or df.empty:
|
| 293 |
+
return ""
|
| 294 |
+
|
| 295 |
+
# 필터링 적용
|
| 296 |
+
filtered = df.copy()
|
| 297 |
+
|
| 298 |
+
# 카테고리 필터 적용 (카테고리 열이 제거되었으므로 주석 처리)
|
| 299 |
+
# if selected_cat and selected_cat != "전체 보기":
|
| 300 |
+
# cat_name = selected_cat.rsplit(" (", 1)[0]
|
| 301 |
+
# filtered = filtered[filtered["관련 카테고리"].str.contains(cat_name)]
|
| 302 |
+
|
| 303 |
+
# 검색량 구간 필터 적용
|
| 304 |
+
if selected_volume_range and selected_volume_range != "전체":
|
| 305 |
+
filtered = filtered[filtered["검색량구간"] == selected_volume_range]
|
| 306 |
+
|
| 307 |
+
# 검색량 0 제외 필터 적용
|
| 308 |
+
if exclude_zero_volume:
|
| 309 |
+
filtered = filtered[filtered["총검색량"] > 0]
|
| 310 |
+
logger.info(f"검색량 0 제외 필터 적용 - 남은 키워드 수: {len(filtered)}")
|
| 311 |
+
|
| 312 |
+
# 정렬 적용
|
| 313 |
+
if keyword_sort != "정렬 없음":
|
| 314 |
+
is_ascending = keyword_sort == "오름차순"
|
| 315 |
+
filtered = filtered.sort_values(by="조합 키워드", ascending=is_ascending)
|
| 316 |
+
|
| 317 |
+
if total_volume_sort != "정렬 없음":
|
| 318 |
+
is_ascending = total_volume_sort == "오름차순"
|
| 319 |
+
filtered = filtered.sort_values(by="총검색량", ascending=is_ascending)
|
| 320 |
+
|
| 321 |
+
# 키워드 사용횟수 정��� 적용
|
| 322 |
+
if usage_count_sort != "정렬 없음":
|
| 323 |
+
is_ascending = usage_count_sort == "오름차순"
|
| 324 |
+
filtered = filtered.sort_values(by="키워드 사용횟수", ascending=is_ascending)
|
| 325 |
+
|
| 326 |
+
# 데이터프레임 내용 로깅
|
| 327 |
+
logger.info(f"필터 적용 후 - 필터링된 DataFrame 행 수: {len(filtered)}")
|
| 328 |
+
|
| 329 |
+
# 순번을 1부터 순차적으로 유지하기 위해 행 인덱스 재설정
|
| 330 |
+
filtered = filtered.reset_index(drop=True)
|
| 331 |
+
|
| 332 |
+
from export_utils import create_table_without_checkboxes
|
| 333 |
+
|
| 334 |
+
# 순번을 포함한 HTML 테이블 생성
|
| 335 |
+
html = create_table_without_checkboxes(filtered)
|
| 336 |
+
|
| 337 |
+
return html
|
| 338 |
+
|
| 339 |
+
def fix_keyword_order(keyword, main_keyword):
|
| 340 |
+
"""
|
| 341 |
+
키워드 순서를 수정하는 함수 - 한글이 앞에 오고 영어/숫자가 뒤에 오도록 함
|
| 342 |
+
|
| 343 |
+
Args:
|
| 344 |
+
keyword (str): 수정할 키워드
|
| 345 |
+
main_keyword (str): 메인 키워드
|
| 346 |
+
|
| 347 |
+
Returns:
|
| 348 |
+
str: 순서가 수정된 키워드
|
| 349 |
+
"""
|
| 350 |
+
# 공백 없이 숫자+영어와 한글이 붙어있는 패턴 처리
|
| 351 |
+
# 예: "300g오징어" → "오징어 300g"
|
| 352 |
+
pattern_combined = re.compile(r'^([0-9]+[a-zA-Z]*)([가-힣]+.*)$')
|
| 353 |
+
match = pattern_combined.match(keyword)
|
| 354 |
+
if match:
|
| 355 |
+
number_part = match.group(1) # 숫자+영어 부분
|
| 356 |
+
korean_part = match.group(2) # 한글 부분
|
| 357 |
+
fixed_keyword = f"{korean_part} {number_part}"
|
| 358 |
+
logger.debug(f"붙어있는 패턴 수정: '{keyword}' -> '{fixed_keyword}'")
|
| 359 |
+
return fixed_keyword
|
| 360 |
+
|
| 361 |
+
# 공백으로 분리된 경우 처리
|
| 362 |
+
if ' ' in keyword:
|
| 363 |
+
parts = keyword.split()
|
| 364 |
+
|
| 365 |
+
# 한글 포함 여부와 영어/숫자 포함 여부를 각 부분별로 확인
|
| 366 |
+
korean_parts = []
|
| 367 |
+
non_korean_parts = []
|
| 368 |
+
|
| 369 |
+
for part in parts:
|
| 370 |
+
if re.search(r'[가-힣]', part):
|
| 371 |
+
korean_parts.append(part) # 한글이 포함된 부분
|
| 372 |
+
else:
|
| 373 |
+
non_korean_parts.append(part) # 한글이 없는 부분 (영어, 숫자, 기호 등)
|
| 374 |
+
|
| 375 |
+
# 한글 부분이 하나도 없거나 비한글 부분이 하나도 없으면 그대로 반환
|
| 376 |
+
if not korean_parts or not non_korean_parts:
|
| 377 |
+
return keyword
|
| 378 |
+
|
| 379 |
+
# 한글 부분을 앞으로, 비한글 부분을 뒤로 배치
|
| 380 |
+
fixed_keyword = " ".join(korean_parts + non_korean_parts)
|
| 381 |
+
|
| 382 |
+
# 원래 키워드와 다른 경우에만 로그 출력
|
| 383 |
+
if fixed_keyword != keyword:
|
| 384 |
+
logger.debug(f"키워드 순서 수정: '{keyword}' -> '{fixed_keyword}'")
|
| 385 |
+
|
| 386 |
+
return fixed_keyword
|
| 387 |
+
|
| 388 |
+
return keyword
|
keyword_search.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
키워드 검색량 조회 관련 기능
|
| 3 |
+
- 네이버 API를 통한 키워드 검색량 조회
|
| 4 |
+
- 검색량 배치 처리
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
import time
|
| 9 |
+
import random
|
| 10 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 11 |
+
import api_utils
|
| 12 |
+
import logging
|
| 13 |
+
|
| 14 |
+
# 로깅 설정
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
logger.setLevel(logging.INFO)
|
| 17 |
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 18 |
+
handler = logging.StreamHandler()
|
| 19 |
+
handler.setFormatter(formatter)
|
| 20 |
+
logger.addHandler(handler)
|
| 21 |
+
|
| 22 |
+
def exponential_backoff_sleep(retry_count, base_delay=0.3, max_delay=5.0):
|
| 23 |
+
"""지수 백오프 방식의 대기 시간 계산"""
|
| 24 |
+
delay = min(base_delay * (2 ** retry_count), max_delay)
|
| 25 |
+
# 약간의 랜덤성 추가 (지터)
|
| 26 |
+
jitter = random.uniform(0, 0.5) * delay
|
| 27 |
+
time.sleep(delay + jitter)
|
| 28 |
+
|
| 29 |
+
def fetch_search_volume_batch(keywords_batch):
|
| 30 |
+
"""키워드 배치에 대한 네이버 검색량 조회"""
|
| 31 |
+
|
| 32 |
+
# 1. 스페이스바 제거 개선 - 배치 키워드들 전처리
|
| 33 |
+
cleaned_keywords_batch = []
|
| 34 |
+
for kw in keywords_batch:
|
| 35 |
+
cleaned_kw = kw.strip().replace(" ", "") if kw else ""
|
| 36 |
+
cleaned_keywords_batch.append(cleaned_kw)
|
| 37 |
+
|
| 38 |
+
keywords_batch = cleaned_keywords_batch
|
| 39 |
+
|
| 40 |
+
result = {}
|
| 41 |
+
max_retries = 3
|
| 42 |
+
retry_count = 0
|
| 43 |
+
|
| 44 |
+
while retry_count < max_retries:
|
| 45 |
+
try:
|
| 46 |
+
# 순차적으로 API 설정 가져오기 (배치마다 한 번만 호출)
|
| 47 |
+
api_config = api_utils.get_next_api_config()
|
| 48 |
+
API_KEY = api_config["API_KEY"]
|
| 49 |
+
SECRET_KEY = api_config["SECRET_KEY"]
|
| 50 |
+
CUSTOMER_ID_STR = api_config["CUSTOMER_ID"]
|
| 51 |
+
|
| 52 |
+
logger.debug(f"=== 환경 변수 체크 (시도 #{retry_count+1}) ===")
|
| 53 |
+
logger.info(f"배치 크기: {len(keywords_batch)}개 키워드")
|
| 54 |
+
|
| 55 |
+
# API 설정 유효성 검사
|
| 56 |
+
is_valid, message = api_utils.validate_api_config(api_config)
|
| 57 |
+
if not is_valid:
|
| 58 |
+
logger.error(f"❌ {message}")
|
| 59 |
+
retry_count += 1
|
| 60 |
+
exponential_backoff_sleep(retry_count)
|
| 61 |
+
continue
|
| 62 |
+
|
| 63 |
+
# CUSTOMER_ID를 정수로 변환
|
| 64 |
+
try:
|
| 65 |
+
CUSTOMER_ID = int(CUSTOMER_ID_STR)
|
| 66 |
+
except ValueError:
|
| 67 |
+
logger.error(f"❌ CUSTOMER_ID 변환 오류: '{CUSTOMER_ID_STR}'는 유효한 숫자가 아닙니다.")
|
| 68 |
+
retry_count += 1
|
| 69 |
+
exponential_backoff_sleep(retry_count)
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
BASE_URL = "https://api.naver.com"
|
| 73 |
+
uri = "/keywordstool"
|
| 74 |
+
method = "GET"
|
| 75 |
+
headers = api_utils.get_header(method, uri, API_KEY, SECRET_KEY, CUSTOMER_ID)
|
| 76 |
+
|
| 77 |
+
# 키워드 배치를 한 번에 API로 전송
|
| 78 |
+
params = {
|
| 79 |
+
"hintKeywords": keywords_batch,
|
| 80 |
+
"showDetail": "1"
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
logger.debug(f"요청 파라미터: {len(keywords_batch)}개 키워드")
|
| 84 |
+
|
| 85 |
+
# API 호출
|
| 86 |
+
response = requests.get(BASE_URL + uri, params=params, headers=headers, timeout=10)
|
| 87 |
+
|
| 88 |
+
logger.debug(f"응답 상태 코드: {response.status_code}")
|
| 89 |
+
|
| 90 |
+
if response.status_code != 200:
|
| 91 |
+
logger.error(f"❌ API 오류 응답 (시도 #{retry_count+1}):")
|
| 92 |
+
logger.error(f" 본문: {response.text}")
|
| 93 |
+
retry_count += 1
|
| 94 |
+
exponential_backoff_sleep(retry_count)
|
| 95 |
+
continue
|
| 96 |
+
|
| 97 |
+
# 응답 데이터 파싱
|
| 98 |
+
result_data = response.json()
|
| 99 |
+
|
| 100 |
+
logger.debug(f"응답 데이터 구조:")
|
| 101 |
+
logger.debug(f" 타입: {type(result_data)}")
|
| 102 |
+
logger.debug(f" 키들: {result_data.keys() if isinstance(result_data, dict) else 'N/A'}")
|
| 103 |
+
|
| 104 |
+
if isinstance(result_data, dict) and "keywordList" in result_data:
|
| 105 |
+
logger.debug(f" keywordList 길이: {len(result_data['keywordList'])}")
|
| 106 |
+
|
| 107 |
+
# 배치 내 각 키워드와 매칭
|
| 108 |
+
for keyword in keywords_batch:
|
| 109 |
+
found = False
|
| 110 |
+
for item in result_data["keywordList"]:
|
| 111 |
+
rel_keyword = item.get("relKeyword", "")
|
| 112 |
+
if rel_keyword == keyword:
|
| 113 |
+
pc_count = item.get("monthlyPcQcCnt", 0)
|
| 114 |
+
mobile_count = item.get("monthlyMobileQcCnt", 0)
|
| 115 |
+
|
| 116 |
+
# 숫자 변환
|
| 117 |
+
try:
|
| 118 |
+
if isinstance(pc_count, str):
|
| 119 |
+
pc_count_converted = int(pc_count.replace(",", ""))
|
| 120 |
+
else:
|
| 121 |
+
pc_count_converted = int(pc_count)
|
| 122 |
+
except:
|
| 123 |
+
pc_count_converted = 0
|
| 124 |
+
|
| 125 |
+
try:
|
| 126 |
+
if isinstance(mobile_count, str):
|
| 127 |
+
mobile_count_converted = int(mobile_count.replace(",", ""))
|
| 128 |
+
else:
|
| 129 |
+
mobile_count_converted = int(mobile_count)
|
| 130 |
+
except:
|
| 131 |
+
mobile_count_converted = 0
|
| 132 |
+
|
| 133 |
+
total_count = pc_count_converted + mobile_count_converted
|
| 134 |
+
|
| 135 |
+
result[keyword] = {
|
| 136 |
+
"PC검색량": pc_count_converted,
|
| 137 |
+
"모바일검색량": mobile_count_converted,
|
| 138 |
+
"총검색량": total_count
|
| 139 |
+
}
|
| 140 |
+
logger.debug(f"✅ '{keyword}': PC={pc_count_converted}, Mobile={mobile_count_converted}, Total={total_count}")
|
| 141 |
+
found = True
|
| 142 |
+
break
|
| 143 |
+
|
| 144 |
+
if not found:
|
| 145 |
+
logger.warning(f"❌ '{keyword}': 매칭되는 데이터를 찾을 수 없음")
|
| 146 |
+
|
| 147 |
+
# 성공적으로 데이터를 가져왔으므로 루프 종료
|
| 148 |
+
break
|
| 149 |
+
else:
|
| 150 |
+
logger.error(f"❌ keywordList가 없음 (시도 #{retry_count+1})")
|
| 151 |
+
logger.error(f"전체 응답: {result_data}")
|
| 152 |
+
retry_count += 1
|
| 153 |
+
exponential_backoff_sleep(retry_count)
|
| 154 |
+
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error(f"❌ 배치 처리 중 오류 (시도 #{retry_count+1}): {str(e)}")
|
| 157 |
+
import traceback
|
| 158 |
+
logger.error(traceback.format_exc())
|
| 159 |
+
retry_count += 1
|
| 160 |
+
exponential_backoff_sleep(retry_count)
|
| 161 |
+
|
| 162 |
+
logger.info(f"\n=== 배치 처리 완료 ===")
|
| 163 |
+
logger.info(f"성공적으로 처리된 키워드 수: {len(result)}")
|
| 164 |
+
|
| 165 |
+
return result
|
| 166 |
+
|
| 167 |
+
def fetch_all_search_volumes(keywords, batch_size=5):
|
| 168 |
+
"""키워드 리스트에 대한 네이버 검색량 병렬 조회"""
|
| 169 |
+
results = {}
|
| 170 |
+
batches = []
|
| 171 |
+
|
| 172 |
+
# 키워드를 5개씩 묶어서 배치 생성
|
| 173 |
+
for i in range(0, len(keywords), batch_size):
|
| 174 |
+
batch = keywords[i:i + batch_size]
|
| 175 |
+
batches.append(batch)
|
| 176 |
+
|
| 177 |
+
logger.info(f"총 {len(batches)}개 배치로 {len(keywords)}개 키워드 처리 중…")
|
| 178 |
+
logger.info(f"배치 크기: {batch_size}, 병렬 워커: 3개, API 계정: {len(api_utils.NAVER_API_CONFIGS)}개 순차 사용")
|
| 179 |
+
|
| 180 |
+
with ThreadPoolExecutor(max_workers=3) as executor: # 워커 수 제한
|
| 181 |
+
futures = {executor.submit(fetch_search_volume_batch, batch): batch for batch in batches}
|
| 182 |
+
for future in as_completed(futures):
|
| 183 |
+
batch = futures[future]
|
| 184 |
+
try:
|
| 185 |
+
batch_results = future.result()
|
| 186 |
+
results.update(batch_results)
|
| 187 |
+
logger.info(f"배치 처리 완료: {len(batch)}개 키워드 (성공: {len(batch_results)}개)")
|
| 188 |
+
except Exception as e:
|
| 189 |
+
logger.error(f"배치 처리 오류: {e}")
|
| 190 |
+
# API 레이트 리밋 방지를 위한 지수 백오프 사용
|
| 191 |
+
exponential_backoff_sleep(0) # 초기 지연 적용
|
| 192 |
+
|
| 193 |
+
logger.info(f"검색량 조회 완료: {len(results)}개 키워드")
|
| 194 |
+
return results
|
product_search.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
상품 검색 관련 기능 - 메인키워드 앞뒤 조합만 생성하도록 수정
|
| 3 |
+
- 네이버 쇼핑 API를 통한 상품 검색
|
| 4 |
+
- 검색 결과 처리
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
import time
|
| 9 |
+
import re
|
| 10 |
+
import random
|
| 11 |
+
from collections import defaultdict, Counter
|
| 12 |
+
import api_utils
|
| 13 |
+
import text_utils
|
| 14 |
+
import logging
|
| 15 |
+
|
| 16 |
+
# 로깅 설정
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
logger.setLevel(logging.INFO)
|
| 19 |
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 20 |
+
handler = logging.StreamHandler()
|
| 21 |
+
handler.setFormatter(formatter)
|
| 22 |
+
logger.addHandler(handler)
|
| 23 |
+
|
| 24 |
+
# 모듈 레벨에서 Gemini 모델 초기화 (싱글톤 패턴)
|
| 25 |
+
gemini_model = None
|
| 26 |
+
try:
|
| 27 |
+
gemini_model = text_utils.get_gemini_model()
|
| 28 |
+
logger.info("Gemini 모델 초기화 성공")
|
| 29 |
+
except Exception as e:
|
| 30 |
+
logger.error(f"Gemini 모델 초기화 실패: {e}")
|
| 31 |
+
|
| 32 |
+
def exponential_backoff_sleep(retry_count, base_delay=0.3, max_delay=5.0):
|
| 33 |
+
"""지수 백오프 방식의 대기 시간 계산"""
|
| 34 |
+
delay = min(base_delay * (2 ** retry_count), max_delay)
|
| 35 |
+
# 약간의 랜덤성 추가 (지터)
|
| 36 |
+
jitter = random.uniform(0, 0.5) * delay
|
| 37 |
+
time.sleep(delay + jitter)
|
| 38 |
+
|
| 39 |
+
def extract_keywords_with_dedup(titles):
|
| 40 |
+
"""
|
| 41 |
+
상품명에서 키워드를 추출하고 즉시 중복을 제거하는 함수
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
titles (list): 상품명 목록
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
list: 중복이 제거된 키워드 목록
|
| 48 |
+
"""
|
| 49 |
+
all_words = []
|
| 50 |
+
|
| 51 |
+
for title in titles:
|
| 52 |
+
# 상품명에서 키워드 추출 (공백과 쉼표 기준)
|
| 53 |
+
words = re.split(r'[,\s]+', title)
|
| 54 |
+
all_words.extend(words)
|
| 55 |
+
|
| 56 |
+
# 빈 문자열 제거 및 정리
|
| 57 |
+
all_words = [word.strip() for word in all_words if word.strip() and len(word.strip()) >= 2]
|
| 58 |
+
|
| 59 |
+
# 중복 제거 - 정규화 기반 (스페이스 차이 무시)
|
| 60 |
+
normalized_dict = {}
|
| 61 |
+
|
| 62 |
+
for word in all_words:
|
| 63 |
+
# 정규화: 소문자 변환, 공백 제거
|
| 64 |
+
normalized = word.lower().replace(" ", "")
|
| 65 |
+
|
| 66 |
+
# 이미 있는 경우, 가독성이 좋은 버전(공백 있는) 선택
|
| 67 |
+
if normalized in normalized_dict:
|
| 68 |
+
existing = normalized_dict[normalized]
|
| 69 |
+
# 공백이 있는 버전 선호
|
| 70 |
+
if " " in word and " " not in existing:
|
| 71 |
+
normalized_dict[normalized] = word
|
| 72 |
+
else:
|
| 73 |
+
normalized_dict[normalized] = word
|
| 74 |
+
|
| 75 |
+
# 정규화된 딕셔너리에서 원본 형태 추출
|
| 76 |
+
return list(normalized_dict.values())
|
| 77 |
+
|
| 78 |
+
def fetch_naver_shopping_data(keyword, korean_only=True, apply_main_keyword=True, exclude_zero_volume=True):
|
| 79 |
+
"""
|
| 80 |
+
네이버 쇼핑 API를 통해 상품 데이터 가져오기 - 메인키워드 앞뒤 조합만 생성
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
keyword (str): 검색 키워드
|
| 84 |
+
korean_only (bool): 한글만 추출 여부
|
| 85 |
+
apply_main_keyword (bool): 메인키워드 적용 여부
|
| 86 |
+
exclude_zero_volume (bool): 검색량이 0인 키워드 제외 여부
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
dict: 검색 결과 데이터
|
| 90 |
+
"""
|
| 91 |
+
global gemini_model
|
| 92 |
+
|
| 93 |
+
# 1. 스페이스바 제거 개선 - 입력 키워드 전처리
|
| 94 |
+
cleaned_keyword = keyword.strip().replace(" ", "") if keyword else ""
|
| 95 |
+
|
| 96 |
+
# 로그 추가 - 함수 시작
|
| 97 |
+
logger.info(f"===== 키워드 검색 시작 =====")
|
| 98 |
+
logger.info(f"검색 키워드: '{keyword}'")
|
| 99 |
+
logger.info(f"옵션: 한글만 추출={korean_only}, 메인키워드 적용={apply_main_keyword}")
|
| 100 |
+
logger.info(f"Gemini 모델 상태: {'사용 가능' if gemini_model else '사용 불가'}")
|
| 101 |
+
|
| 102 |
+
# 공백 제거한 키워드로 API 호출
|
| 103 |
+
api_keyword = cleaned_keyword
|
| 104 |
+
logger.info(f"API 호출 키워드: '{api_keyword}'")
|
| 105 |
+
|
| 106 |
+
# 네이버 쇼핑 API 호출
|
| 107 |
+
all_products = []
|
| 108 |
+
total_count = 0
|
| 109 |
+
|
| 110 |
+
if apply_main_keyword:
|
| 111 |
+
# 메인 키워드 적용 시 한 번에 100개 상품 가져오기
|
| 112 |
+
logger.info("메인 키워드 적용 - 한 번에 100개 상품 가져오기")
|
| 113 |
+
result = fetch_products_by_keyword(api_keyword, page=1, display=100)
|
| 114 |
+
if result["status"] == "success" and result["products"]:
|
| 115 |
+
all_products.extend(result["products"])
|
| 116 |
+
total_count = result.get("total", 0)
|
| 117 |
+
logger.info(f"한 번에 {len(result['products'])}개 상품 검색됨")
|
| 118 |
+
else:
|
| 119 |
+
# 메인 키워드 미적용 시 페이지당 10개씩 최대 10페이지 가져오기
|
| 120 |
+
logger.info("메인 키워드 미적용 - 페이지당 10개씩 가져오기")
|
| 121 |
+
for page in range(1, 11): # 1~10 페이지
|
| 122 |
+
result = fetch_products_by_keyword(api_keyword, page=page, display=100)
|
| 123 |
+
if result["status"] == "success" and result["products"]:
|
| 124 |
+
all_products.extend(result["products"])
|
| 125 |
+
total_count = result.get("total", 0)
|
| 126 |
+
logger.info(f"페이지 {page}: {len(result['products'])}개 상품 검색됨")
|
| 127 |
+
else:
|
| 128 |
+
logger.info(f"페이지 {page}: 상품 검색 실패 또는 결과 없음")
|
| 129 |
+
|
| 130 |
+
# API 레이트 리밋 방지를 위한 짧은 대기 시간
|
| 131 |
+
exponential_backoff_sleep(0) # 초기 지연
|
| 132 |
+
|
| 133 |
+
# 이미 충분한 상품을 가져왔거나 더 이상 결과가 없으면 중단
|
| 134 |
+
if len(all_products) >= 100 or (result["status"] == "success" and len(result["products"]) < 10):
|
| 135 |
+
break
|
| 136 |
+
|
| 137 |
+
if not all_products:
|
| 138 |
+
logger.warning("검색 결과가 없습니다.")
|
| 139 |
+
return {
|
| 140 |
+
"product_list": [],
|
| 141 |
+
"combo_candidates": {},
|
| 142 |
+
"category_counter": {},
|
| 143 |
+
"keyword_indices": {},
|
| 144 |
+
"keyword_pairs": {}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
logger.info(f"검색 결과: 총 {len(all_products)}개의 상품을 찾았습니다. (전체 검색 결과: {total_count}개)")
|
| 148 |
+
|
| 149 |
+
# 상품 데이터에서 키워드 추출
|
| 150 |
+
product_list = []
|
| 151 |
+
combo_candidates = {}
|
| 152 |
+
category_counter = {}
|
| 153 |
+
keyword_indices = {}
|
| 154 |
+
keyword_pairs = {} # 앞뒤 조합 정보 저장
|
| 155 |
+
|
| 156 |
+
all_extracted_keywords = set() # 모든 추출된 키워드 추적
|
| 157 |
+
|
| 158 |
+
# 각 상품 처리
|
| 159 |
+
for idx, product in enumerate(all_products):
|
| 160 |
+
# 상품 정보 추출
|
| 161 |
+
title = product.get("상품명", "")
|
| 162 |
+
category = product.get("카테고리", "")
|
| 163 |
+
|
| 164 |
+
logger.debug(f"\n상품 #{idx+1}: '{title}' (카테고리: {category})")
|
| 165 |
+
|
| 166 |
+
# 상품명에서 키워드 추출 - 1글자 이상 단어도 포함하도록 변경
|
| 167 |
+
keywords = text_utils.clean_and_split(title, only_korean=korean_only)
|
| 168 |
+
logger.debug(f" - 추출된 키워드 ({len(keywords)}개): {keywords}")
|
| 169 |
+
|
| 170 |
+
# 모든 추출된 키워드 추적
|
| 171 |
+
all_extracted_keywords.update(keywords)
|
| 172 |
+
|
| 173 |
+
# 키워드 위치 기록
|
| 174 |
+
for kw in keywords:
|
| 175 |
+
if kw not in keyword_indices:
|
| 176 |
+
keyword_indices[kw] = []
|
| 177 |
+
if idx not in keyword_indices[kw]:
|
| 178 |
+
keyword_indices[kw].append(idx)
|
| 179 |
+
|
| 180 |
+
# 카테고리 카운터 업데이트
|
| 181 |
+
if category not in category_counter:
|
| 182 |
+
category_counter[category] = 0
|
| 183 |
+
category_counter[category] += 1
|
| 184 |
+
|
| 185 |
+
# 상품 정보 저장
|
| 186 |
+
product_list.append(product)
|
| 187 |
+
|
| 188 |
+
logger.info(f"\n총 추출된 고유 키워드: {len(all_extracted_keywords)}개")
|
| 189 |
+
logger.debug(f"추출된 고유 키워드 목록: {sorted(list(all_extracted_keywords))}")
|
| 190 |
+
|
| 191 |
+
# === 수정된 부분: 메인키워드 앞뒤 조합만 생성 ===
|
| 192 |
+
if apply_main_keyword:
|
| 193 |
+
logger.info(f"\n메인 키워드 '{keyword}'로 앞뒤 조합만 생성:")
|
| 194 |
+
combo_count = 0
|
| 195 |
+
|
| 196 |
+
# 메인 키워드 자체도 추가
|
| 197 |
+
combo_candidates[keyword] = set([category for category in category_counter.keys()])
|
| 198 |
+
logger.info(f" - 메인 키워드 추가: '{keyword}'")
|
| 199 |
+
|
| 200 |
+
# 메인 키워드와 앞뒤 조합 생성
|
| 201 |
+
main_keyword = keyword.strip()
|
| 202 |
+
|
| 203 |
+
for kw in all_extracted_keywords:
|
| 204 |
+
if kw == main_keyword:
|
| 205 |
+
continue
|
| 206 |
+
|
| 207 |
+
# 메인 키워드가 이미 포함된 경우는 그대로 추가
|
| 208 |
+
if main_keyword in kw:
|
| 209 |
+
kw_api = kw.replace(" ", "")
|
| 210 |
+
if kw_api not in combo_candidates:
|
| 211 |
+
combo_candidates[kw_api] = set()
|
| 212 |
+
for cat in category_counter.keys():
|
| 213 |
+
combo_candidates[kw_api].add(cat)
|
| 214 |
+
logger.info(f" - 메인 키워드 포함 단어 추가: '{kw}' (API: '{kw_api}')")
|
| 215 |
+
combo_count += 1
|
| 216 |
+
continue
|
| 217 |
+
|
| 218 |
+
# 앞뒤 조합 생성
|
| 219 |
+
front_combo = f"{kw} {main_keyword}" # 키워드 + 메인키워드
|
| 220 |
+
back_combo = f"{main_keyword} {kw}" # 메인키워드 + 키워드
|
| 221 |
+
|
| 222 |
+
# 앞뒤 조합 정보 저장
|
| 223 |
+
keyword_pairs[kw] = {
|
| 224 |
+
"front": front_combo,
|
| 225 |
+
"back": back_combo
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
# API 호출을 위한 공백 없는 버전
|
| 229 |
+
front_api = front_combo.replace(" ", "")
|
| 230 |
+
back_api = back_combo.replace(" ", "")
|
| 231 |
+
|
| 232 |
+
logger.debug(f" - 조합 생성: '{front_combo}' (API: '{front_api}') / '{back_combo}' (API: '{back_api}')")
|
| 233 |
+
|
| 234 |
+
# 첫 번째 조합 처리
|
| 235 |
+
if front_api not in combo_candidates:
|
| 236 |
+
combo_candidates[front_api] = set()
|
| 237 |
+
for cat in category_counter.keys():
|
| 238 |
+
combo_candidates[front_api].add(cat)
|
| 239 |
+
combo_count += 1
|
| 240 |
+
|
| 241 |
+
# 두 번째 조합 처리
|
| 242 |
+
if back_api not in combo_candidates:
|
| 243 |
+
combo_candidates[back_api] = set()
|
| 244 |
+
for cat in category_counter.keys():
|
| 245 |
+
combo_candidates[back_api].add(cat)
|
| 246 |
+
combo_count += 1
|
| 247 |
+
|
| 248 |
+
logger.info(f"조합 생성 완료: 총 {combo_count}개 조합 생성")
|
| 249 |
+
else:
|
| 250 |
+
logger.info("\n메인 키워드 적용 안함 - 개별 키워드만 사용")
|
| 251 |
+
for kw in all_extracted_keywords:
|
| 252 |
+
kw_api = kw.replace(" ", "")
|
| 253 |
+
if kw_api not in combo_candidates:
|
| 254 |
+
combo_candidates[kw_api] = set()
|
| 255 |
+
for cat in category_counter.keys():
|
| 256 |
+
combo_candidates[kw_api].add(cat)
|
| 257 |
+
|
| 258 |
+
logger.info(f"\n최종 조합 키워드 수: {len(combo_candidates)}개")
|
| 259 |
+
logger.debug(f"조합 키워드 샘플(최대 10개): {list(combo_candidates.keys())[:10]}")
|
| 260 |
+
logger.info("===== 키워드 검색 완료 =====\n")
|
| 261 |
+
|
| 262 |
+
return {
|
| 263 |
+
"product_list": product_list,
|
| 264 |
+
"combo_candidates": combo_candidates,
|
| 265 |
+
"category_counter": category_counter,
|
| 266 |
+
"keyword_indices": keyword_indices,
|
| 267 |
+
"keyword_pairs": keyword_pairs # 앞뒤 조합 정보 추가
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
def fetch_products_by_keyword(keyword, page=1, display=10, max_retries=3):
|
| 271 |
+
"""
|
| 272 |
+
네이버 쇼핑 API를 통해 키워드로 상품 검색
|
| 273 |
+
|
| 274 |
+
Args:
|
| 275 |
+
keyword (str): 검색 키워드
|
| 276 |
+
page (int): 페이지 번호
|
| 277 |
+
display (int): 한 페이지에 표시할 상품 수 (최대 100)
|
| 278 |
+
max_retries (int): 최대 재시도 횟수
|
| 279 |
+
|
| 280 |
+
Returns:
|
| 281 |
+
dict: 검색 결과
|
| 282 |
+
"""
|
| 283 |
+
# 상품 수가 100을 초과하지 않도록 제한
|
| 284 |
+
if display > 100:
|
| 285 |
+
display = 100
|
| 286 |
+
logger.info(f"상품 표시 수 100으로 제한됨")
|
| 287 |
+
|
| 288 |
+
retry_count = 0
|
| 289 |
+
while retry_count < max_retries:
|
| 290 |
+
try:
|
| 291 |
+
# API 설정 가져오기
|
| 292 |
+
api_config = api_utils.get_next_shopping_api_config()
|
| 293 |
+
|
| 294 |
+
# 요청 헤더
|
| 295 |
+
headers = {
|
| 296 |
+
'X-Naver-Client-Id': api_config["CLIENT_ID"],
|
| 297 |
+
'X-Naver-Client-Secret': api_config["CLIENT_SECRET"]
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
# 요청 URL (스페이스 없이 호출)
|
| 301 |
+
url = f"https://openapi.naver.com/v1/search/shop.json?query={keyword}&display={display}&start={(page-1)*display+1}"
|
| 302 |
+
|
| 303 |
+
logger.debug(f"API 요청: page={page}, display={display}, 시작 위치={(page-1)*display+1}")
|
| 304 |
+
response = requests.get(url, headers=headers, timeout=10) # 타임아웃 설정
|
| 305 |
+
|
| 306 |
+
if response.status_code == 200:
|
| 307 |
+
result = response.json()
|
| 308 |
+
|
| 309 |
+
# 상품 정보 추출
|
| 310 |
+
products = []
|
| 311 |
+
|
| 312 |
+
for item in result.get("items", []):
|
| 313 |
+
title = item.get("title", "").replace("<b>", "").replace("</b>", "")
|
| 314 |
+
link = item.get("link", "")
|
| 315 |
+
image = item.get("image", "")
|
| 316 |
+
lprice = item.get("lprice", "0")
|
| 317 |
+
mall_name = item.get("mallName", "")
|
| 318 |
+
|
| 319 |
+
# 카테고리 정보 추출
|
| 320 |
+
category1 = item.get("category1", "")
|
| 321 |
+
category2 = item.get("category2", "")
|
| 322 |
+
category3 = item.get("category3", "")
|
| 323 |
+
category4 = item.get("category4", "")
|
| 324 |
+
|
| 325 |
+
# 전체 카테고리 경로 생성
|
| 326 |
+
category_path = " > ".join([c for c in [category1, category2, category3, category4] if c])
|
| 327 |
+
|
| 328 |
+
products.append({
|
| 329 |
+
"상품명": title,
|
| 330 |
+
"가격": lprice,
|
| 331 |
+
"쇼핑몰": mall_name,
|
| 332 |
+
"카테고리": category_path if category_path else category1
|
| 333 |
+
})
|
| 334 |
+
|
| 335 |
+
logger.info(f"API 응답: {len(products)}개 상품 검색됨 (전체 결과수: {result.get('total', 0)})")
|
| 336 |
+
return {
|
| 337 |
+
"status": "success",
|
| 338 |
+
"products": products,
|
| 339 |
+
"total": result.get("total", 0)
|
| 340 |
+
}
|
| 341 |
+
else:
|
| 342 |
+
error_msg = f"API 오류: {response.status_code} - {response.text}"
|
| 343 |
+
logger.warning(error_msg)
|
| 344 |
+
retry_count += 1
|
| 345 |
+
exponential_backoff_sleep(retry_count)
|
| 346 |
+
|
| 347 |
+
except Exception as e:
|
| 348 |
+
error_msg = f"상품 검색 중 오류 발생: {e}"
|
| 349 |
+
logger.error(error_msg)
|
| 350 |
+
retry_count += 1
|
| 351 |
+
exponential_backoff_sleep(retry_count)
|
| 352 |
+
|
| 353 |
+
# 최대 재시도 횟수를 초과한 경우
|
| 354 |
+
logger.error(f"최대 재시도 횟수({max_retries})를 초과하여 빈 결과 반환")
|
| 355 |
+
return {
|
| 356 |
+
"status": "error",
|
| 357 |
+
"message": f"최대 재시도 횟수({max_retries})를 초과했습니다.",
|
| 358 |
+
"products": []
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
def fetch_naver_shopping_data_for_analysis(keyword, count=10):
|
| 362 |
+
"""
|
| 363 |
+
네이버 쇼핑 API를 통해 키워드로 상품 검색 (분석용, 스페이스 없이 호출)
|
| 364 |
+
|
| 365 |
+
Args:
|
| 366 |
+
keyword (str): 검색 키워드
|
| 367 |
+
count (int): 검색할 상품 수
|
| 368 |
+
|
| 369 |
+
Returns:
|
| 370 |
+
list: 검색된 상품 정�� 목록
|
| 371 |
+
"""
|
| 372 |
+
# 스페이스 제거
|
| 373 |
+
api_keyword = keyword.replace(" ", "")
|
| 374 |
+
|
| 375 |
+
# 최대 재시도 횟수
|
| 376 |
+
max_retries = 3
|
| 377 |
+
retry_count = 0
|
| 378 |
+
|
| 379 |
+
while retry_count < max_retries:
|
| 380 |
+
try:
|
| 381 |
+
# API 설정 가져오기
|
| 382 |
+
api_config = api_utils.get_next_shopping_api_config()
|
| 383 |
+
|
| 384 |
+
# 요청 헤더
|
| 385 |
+
headers = {
|
| 386 |
+
'X-Naver-Client-Id': api_config["CLIENT_ID"],
|
| 387 |
+
'X-Naver-Client-Secret': api_config["CLIENT_SECRET"]
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
# 요청 URL
|
| 391 |
+
url = f"https://openapi.naver.com/v1/search/shop.json?query={api_keyword}&display={count}"
|
| 392 |
+
|
| 393 |
+
response = requests.get(url, headers=headers, timeout=10)
|
| 394 |
+
|
| 395 |
+
if response.status_code == 200:
|
| 396 |
+
result = response.json()
|
| 397 |
+
|
| 398 |
+
# 상품 정보 추출
|
| 399 |
+
products = []
|
| 400 |
+
|
| 401 |
+
for item in result.get("items", []):
|
| 402 |
+
# 카테고리 정보 추출 및 전체 경로 생성
|
| 403 |
+
category1 = item.get("category1", "")
|
| 404 |
+
category2 = item.get("category2", "")
|
| 405 |
+
category3 = item.get("category3", "")
|
| 406 |
+
category4 = item.get("category4", "")
|
| 407 |
+
|
| 408 |
+
# 전체 카테고리 경로 생성 (fetch_products_by_keyword와 일관성 유지)
|
| 409 |
+
category_path = " > ".join([c for c in [category1, category2, category3, category4] if c])
|
| 410 |
+
|
| 411 |
+
products.append({
|
| 412 |
+
"title": item.get("title", "").replace("<b>", "").replace("</b>", ""),
|
| 413 |
+
"price": item.get("lprice", "0"),
|
| 414 |
+
"category": category_path if category_path else category1,
|
| 415 |
+
"카테고리": category_path if category_path else category1 # 한글 키로도 저장 (호환성)
|
| 416 |
+
})
|
| 417 |
+
|
| 418 |
+
return products
|
| 419 |
+
else:
|
| 420 |
+
logger.warning(f"API 오류 (시도 {retry_count+1}/{max_retries}): {response.status_code} - {response.text}")
|
| 421 |
+
retry_count += 1
|
| 422 |
+
exponential_backoff_sleep(retry_count)
|
| 423 |
+
|
| 424 |
+
except Exception as e:
|
| 425 |
+
logger.error(f"상품 검색 중 오류 발생 (시도 {retry_count+1}/{max_retries}): {e}")
|
| 426 |
+
retry_count += 1
|
| 427 |
+
exponential_backoff_sleep(retry_count)
|
| 428 |
+
|
| 429 |
+
logger.error(f"최대 재시도 횟수({max_retries})를 초과하여 빈 결과 반환")
|
| 430 |
+
return []
|
requirements.txt
CHANGED
|
@@ -1,5 +1,9 @@
|
|
| 1 |
-
|
| 2 |
-
pandas
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio
|
| 2 |
+
pandas
|
| 3 |
+
requests
|
| 4 |
+
google-generativeai
|
| 5 |
+
xlsxwriter
|
| 6 |
+
beautifulsoup4
|
| 7 |
+
fpdf
|
| 8 |
+
markdown
|
| 9 |
+
plotly
|
style.css
CHANGED
|
@@ -97,11 +97,6 @@ body {
|
|
| 97 |
transition: background-color 0.3s ease, color 0.3s ease;
|
| 98 |
}
|
| 99 |
|
| 100 |
-
/* 푸터 숨김 설정 */
|
| 101 |
-
footer {
|
| 102 |
-
visibility: hidden;
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
.gradio-container,
|
| 106 |
.gradio-container *,
|
| 107 |
.gr-app,
|
|
@@ -644,4 +639,4 @@ pre,
|
|
| 644 |
.grid-container {
|
| 645 |
grid-template-columns: 1fr;
|
| 646 |
}
|
| 647 |
-
}
|
|
|
|
| 97 |
transition: background-color 0.3s ease, color 0.3s ease;
|
| 98 |
}
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
.gradio-container,
|
| 101 |
.gradio-container *,
|
| 102 |
.gr-app,
|
|
|
|
| 639 |
.grid-container {
|
| 640 |
grid-template-columns: 1fr;
|
| 641 |
}
|
| 642 |
+
}
|
text_utils.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
텍스트 처리 관련 유틸리티 함수 모음
|
| 3 |
+
- 텍스트 분리 및 정제
|
| 4 |
+
- 키워드 추출
|
| 5 |
+
- Gemini API 키 통합 관리 적용
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
import google.generativeai as genai
|
| 10 |
+
import os
|
| 11 |
+
import logging
|
| 12 |
+
import api_utils # API 키 통합 관리를 위한 임포트
|
| 13 |
+
|
| 14 |
+
# 로깅 설정
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
logger.setLevel(logging.INFO)
|
| 17 |
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 18 |
+
handler = logging.StreamHandler()
|
| 19 |
+
handler.setFormatter(formatter)
|
| 20 |
+
logger.addHandler(handler)
|
| 21 |
+
|
| 22 |
+
# ===== Gemini 모델 관리 함수들 =====
|
| 23 |
+
def get_gemini_model():
|
| 24 |
+
"""api_utils에서 Gemini 모델 가져오기 (통합 관리)"""
|
| 25 |
+
try:
|
| 26 |
+
model = api_utils.get_gemini_model()
|
| 27 |
+
if model:
|
| 28 |
+
logger.info("Gemini 모델 로드 성공 (api_utils 통합 관리)")
|
| 29 |
+
return model
|
| 30 |
+
else:
|
| 31 |
+
logger.warning("사용 가능한 Gemini API 키가 없습니다.")
|
| 32 |
+
return None
|
| 33 |
+
except Exception as e:
|
| 34 |
+
logger.error(f"Gemini 모델 로드 실패: {e}")
|
| 35 |
+
return None
|
| 36 |
+
|
| 37 |
+
# 텍스트 분리 및 정제 함수
|
| 38 |
+
def clean_and_split(text, only_korean=False):
|
| 39 |
+
"""텍스트를 분리하고 정제하는 함수"""
|
| 40 |
+
text = re.sub(r"[()\[\]-]", " ", text)
|
| 41 |
+
text = text.replace("/", " ")
|
| 42 |
+
|
| 43 |
+
if only_korean:
|
| 44 |
+
# 한글만 추출 옵션이 켜진 경우
|
| 45 |
+
# 공백이나 쉼표로 구분한 뒤 한글만 추출
|
| 46 |
+
words = re.split(r"[ ,]", text)
|
| 47 |
+
cleaned = []
|
| 48 |
+
for word in words:
|
| 49 |
+
word = word.strip()
|
| 50 |
+
# 한글만 남기고 다른 문자는 제거
|
| 51 |
+
word = re.sub(r"[^가-힣]", "", word)
|
| 52 |
+
if word and len(word) >= 1: # 빈 문자열이 아니고 1글자 이상인 경우만 추가
|
| 53 |
+
cleaned.append(word)
|
| 54 |
+
else:
|
| 55 |
+
# 한글만 추출 옵션이 꺼진 경우 - 단어 통째로 처리
|
| 56 |
+
# 공백과 쉼표로 구분하여 단어 전체를 유지
|
| 57 |
+
words = re.split(r"[,\s]+", text)
|
| 58 |
+
cleaned = []
|
| 59 |
+
for word in words:
|
| 60 |
+
word = word.strip()
|
| 61 |
+
if word and len(word) >= 1: # 빈 문자열이 아니고 1글자 이상인 경우만 추가
|
| 62 |
+
cleaned.append(word)
|
| 63 |
+
|
| 64 |
+
return cleaned
|
| 65 |
+
|
| 66 |
+
def filter_keywords_with_gemini(pairs, gemini_model=None):
|
| 67 |
+
"""Gemini AI를 사용하여 키워드 조합 필터링 (개선버전) - API 키 통합 관리"""
|
| 68 |
+
if gemini_model is None:
|
| 69 |
+
# api_utils에서 Gemini 모델 가져오기
|
| 70 |
+
gemini_model = get_gemini_model()
|
| 71 |
+
|
| 72 |
+
if gemini_model is None:
|
| 73 |
+
logger.error("Gemini 모델을 가져올 수 없습니다. 모든 키워드를 유지합니다.")
|
| 74 |
+
# 안전하게 처리: 모든 키워드를 유지
|
| 75 |
+
all_keywords = set()
|
| 76 |
+
for pair in pairs:
|
| 77 |
+
for keyword in pair:
|
| 78 |
+
all_keywords.add(keyword)
|
| 79 |
+
return list(all_keywords)
|
| 80 |
+
|
| 81 |
+
# 모든 키워드를 목록으로 추출 (제거된 키워드 확인용)
|
| 82 |
+
all_keywords = set()
|
| 83 |
+
for pair in pairs:
|
| 84 |
+
for keyword in pair:
|
| 85 |
+
all_keywords.add(keyword)
|
| 86 |
+
|
| 87 |
+
# 너무 많은 쌍이 있으면 제한
|
| 88 |
+
max_pairs = 50 # 최대 50개 쌍만 처리
|
| 89 |
+
pairs_to_process = list(pairs)[:max_pairs] if len(pairs) > max_pairs else pairs
|
| 90 |
+
|
| 91 |
+
logger.info(f"필터링할 키워드 쌍: 총 {len(pairs)}개 중 {len(pairs_to_process)}개 처리")
|
| 92 |
+
|
| 93 |
+
# 보수적인 프롬프트 사용 - 키워드 제거 최소화
|
| 94 |
+
prompt = (
|
| 95 |
+
"다음은 소비자가 검색할 가능성이 있는 키워드 쌍 목록입니다.\n"
|
| 96 |
+
"각 쌍은 같은 단어 조합이지만 순서만 다른 경우입니다 (예: 손질오징어 vs 오징어손질).\n\n"
|
| 97 |
+
"아래의 기준에 따라 각 쌍에서 더 자연스러운 키워드를 선택해주세요:\n"
|
| 98 |
+
"1. 소비자가 일상적으로 사용하는 자연스러운 표현을 우선 선택하세요.\n"
|
| 99 |
+
"2. 두 키워드가 모두 자연스럽거나 의미가 약간 다르다면, 반드시 둘 다 유지하세요.\n"
|
| 100 |
+
"3. 확실히 비자연스럽거나 어색한 경우에만 제거하세요.\n"
|
| 101 |
+
"4. 불확실한 경우에는 반드시 키워드를 유지하세요.\n"
|
| 102 |
+
"5. 숫자나 영어가 포함된 키워드는 한글 메인 키워드가 앞쪽에 오는 형태를 선택하세요. (예: '10kg 오징어' 보다 '오징어 10kg' 선택)\n"
|
| 103 |
+
"6. 검색량이 0인 키워드라도 일상적인 표현이라면 가능한 유지하세요. 명백하게 비정상적인 표현만 제거하세요.\n\n"
|
| 104 |
+
"주의: 기본적으로 대부분의 키워드를 유지하고, 매우 명확하게 비자연스러운 것만 제거하세요.\n\n"
|
| 105 |
+
"결과는 다음 형식으로 제공해주세요:\n"
|
| 106 |
+
"- 선택된 키워드 (이유: 자연스러운 표현이기 때문)\n"
|
| 107 |
+
"- 선택된 키워드1, 선택된 키워드2 (이유: 둘 다 자연스럽고 의미가 조금 다름)\n\n"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# 키워�� 쌍 목록
|
| 111 |
+
formatted = "\n".join([f"- {a}, {b}" for a, b in pairs_to_process])
|
| 112 |
+
full_prompt = prompt + formatted
|
| 113 |
+
|
| 114 |
+
try:
|
| 115 |
+
# 타임아웃 추가
|
| 116 |
+
logger.info(f"Gemini API 호출 시작 - {len(pairs_to_process)}개 키워드 쌍 처리 중...")
|
| 117 |
+
|
| 118 |
+
# 응답 받기 (타임아웃 기능이 있으면 추가)
|
| 119 |
+
response = gemini_model.generate_content(full_prompt)
|
| 120 |
+
|
| 121 |
+
logger.info("Gemini API 응답 성공")
|
| 122 |
+
lines = response.text.strip().split("\n")
|
| 123 |
+
|
| 124 |
+
# 선택된 키워드 추출 (쉼표로 구분된 경우 모두 포함)
|
| 125 |
+
final_keywords = []
|
| 126 |
+
for line in lines:
|
| 127 |
+
if line.startswith("-"):
|
| 128 |
+
# 이유 부분 제거
|
| 129 |
+
keywords_part = line.strip("- ").split("(이유:")[0].strip()
|
| 130 |
+
# 쉼표로 구분된 키워드 모두 추가
|
| 131 |
+
for kw in keywords_part.split(","):
|
| 132 |
+
kw = kw.strip()
|
| 133 |
+
if kw:
|
| 134 |
+
final_keywords.append(kw)
|
| 135 |
+
|
| 136 |
+
# 처리되지 않은 쌍의 첫 번째 키워드도 추가 (LLM이 처리하지 않은 키워드)
|
| 137 |
+
if len(pairs) > max_pairs:
|
| 138 |
+
logger.info(f"추가 키워드 처리: 남은 {len(pairs) - max_pairs}개 쌍의 첫 번째 키워드 추가")
|
| 139 |
+
for pair in list(pairs)[max_pairs:]:
|
| 140 |
+
# 각 쌍의 첫 번째 키워드만 사용
|
| 141 |
+
final_keywords.append(pair[0])
|
| 142 |
+
|
| 143 |
+
# 선택된 키워드가 없으면 기존 키워드 모두 반환
|
| 144 |
+
if not final_keywords:
|
| 145 |
+
logger.warning("경고: 선택된 키워드가 없어 모든 키워드를 유지합니다.")
|
| 146 |
+
final_keywords = list(all_keywords)
|
| 147 |
+
|
| 148 |
+
# 순서 강제 수정
|
| 149 |
+
corrected_keywords = []
|
| 150 |
+
|
| 151 |
+
# 단위와 숫자 관련 정규식 패턴
|
| 152 |
+
unit_pattern = re.compile(r'(?i)(kg|g|mm|cm|ml|l|리터|개|팩|박스|세트|2l|l2)')
|
| 153 |
+
number_pattern = re.compile(r'\d+')
|
| 154 |
+
|
| 155 |
+
for kw in final_keywords:
|
| 156 |
+
# 공백으로 분리
|
| 157 |
+
if ' ' in kw:
|
| 158 |
+
parts = kw.split()
|
| 159 |
+
first_part = parts[0]
|
| 160 |
+
|
| 161 |
+
# 첫 부분이 단위나 숫자를 포함하는지 확인
|
| 162 |
+
if (unit_pattern.search(first_part) or number_pattern.search(first_part)) and len(parts) > 1:
|
| 163 |
+
# 순서 바꾸기: 단위/숫자 부분을 뒤로 이동
|
| 164 |
+
corrected_kw = " ".join(parts[1:] + [first_part])
|
| 165 |
+
logger.info(f"키워드 순서 강제 수정: '{kw}' -> '{corrected_kw}'")
|
| 166 |
+
corrected_keywords.append(corrected_kw)
|
| 167 |
+
else:
|
| 168 |
+
corrected_keywords.append(kw)
|
| 169 |
+
else:
|
| 170 |
+
corrected_keywords.append(kw)
|
| 171 |
+
|
| 172 |
+
# 특별 처리: "L 오징어", "2L 오징어" 같은 경우를 명시적으로 확인하고 수정
|
| 173 |
+
specific_fixes = []
|
| 174 |
+
for kw in corrected_keywords:
|
| 175 |
+
# 특정 패턴 체크
|
| 176 |
+
l_pattern = re.compile(r'^([0-9]*L) (.+)$', re.IGNORECASE)
|
| 177 |
+
match = l_pattern.match(kw)
|
| 178 |
+
|
| 179 |
+
if match:
|
| 180 |
+
# L 단위를 뒤로 이동
|
| 181 |
+
l_part = match.group(1)
|
| 182 |
+
main_part = match.group(2)
|
| 183 |
+
fixed_kw = f"{main_part} {l_part}"
|
| 184 |
+
logger.info(f"특수 패턴 수정: '{kw}' -> '{fixed_kw}'")
|
| 185 |
+
specific_fixes.append(fixed_kw)
|
| 186 |
+
else:
|
| 187 |
+
specific_fixes.append(kw)
|
| 188 |
+
|
| 189 |
+
# 제거된 키워드 목록 확인
|
| 190 |
+
selected_set = set(specific_fixes)
|
| 191 |
+
removed_keywords = all_keywords - selected_set
|
| 192 |
+
|
| 193 |
+
# 제거된 키워드 출력
|
| 194 |
+
logger.info("\n=== LLM에 의해 제거된 키워드 목록 ===")
|
| 195 |
+
for kw in removed_keywords:
|
| 196 |
+
logger.info(f" - {kw}")
|
| 197 |
+
logger.info(f"총 {len(all_keywords)}개 중 {len(removed_keywords)}개 제거됨 ({len(selected_set)}개 유지)\n")
|
| 198 |
+
|
| 199 |
+
return specific_fixes
|
| 200 |
+
|
| 201 |
+
except Exception as e:
|
| 202 |
+
logger.error(f"Gemini 오류: {e}")
|
| 203 |
+
logger.error("오류 발생으로 인해 모든 키워드를 유지합니다.")
|
| 204 |
+
logger.error(f"오류 유형: {type(e).__name__}")
|
| 205 |
+
import traceback
|
| 206 |
+
traceback.print_exc()
|
| 207 |
+
|
| 208 |
+
# 안전하게 처리: 모든 키워드를 유지
|
| 209 |
+
logger.info(f"안전 모드: {len(all_keywords)}개 키워드 모두 유지")
|
| 210 |
+
return list(all_keywords)
|
| 211 |
+
|
| 212 |
+
def get_search_volume_range(total_volume):
|
| 213 |
+
"""총 검색량을 기반으로 검색량 구간을 반환"""
|
| 214 |
+
if total_volume == 0:
|
| 215 |
+
return "100미만"
|
| 216 |
+
elif total_volume <= 100:
|
| 217 |
+
return "100미만"
|
| 218 |
+
elif total_volume <= 1000:
|
| 219 |
+
return "1000미만"
|
| 220 |
+
elif total_volume <= 2000:
|
| 221 |
+
return "2000미만"
|
| 222 |
+
elif total_volume <= 5000:
|
| 223 |
+
return "5000미만"
|
| 224 |
+
elif total_volume <= 10000:
|
| 225 |
+
return "10000미만"
|
| 226 |
+
else:
|
| 227 |
+
return "10000이상"
|
trend_analysis.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
트렌드 분석 모듈 - 네이버 데이터랩 API를 통한 검색 트렌드 분석
|
| 3 |
+
- 성장률을 3년 기준으로 변경
|
| 4 |
+
- 너비 100% 적용
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
import json
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import plotly.graph_objects as go
|
| 11 |
+
import plotly.express as px
|
| 12 |
+
from datetime import datetime, timedelta
|
| 13 |
+
import api_utils
|
| 14 |
+
import keyword_search
|
| 15 |
+
import logging
|
| 16 |
+
|
| 17 |
+
# 로깅 설정
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
logger.setLevel(logging.INFO)
|
| 20 |
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 21 |
+
handler = logging.StreamHandler()
|
| 22 |
+
handler.setFormatter(formatter)
|
| 23 |
+
logger.addHandler(handler)
|
| 24 |
+
|
| 25 |
+
def get_trend_data(keywords, period="1year"):
|
| 26 |
+
"""
|
| 27 |
+
네이버 데이터랩 API를 통해 검색 트렌드 데이터 가져오기 (수정된 버전)
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
keywords (list): 분석할 키워드 목록 (최대 5개)
|
| 31 |
+
period (str): 분석 기간 ("1year" 또는 "3year")
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
dict: 트렌드 데이터 및 그래프 HTML
|
| 35 |
+
"""
|
| 36 |
+
logger.info(f"트렌드 분석 시작: {len(keywords)}개 키워드, 기간: {period}")
|
| 37 |
+
|
| 38 |
+
# 날짜 계산 (어제 기준으로 월 단위 계산)
|
| 39 |
+
yesterday = datetime.now() - timedelta(days=1)
|
| 40 |
+
end_year = yesterday.year
|
| 41 |
+
end_month = yesterday.month
|
| 42 |
+
|
| 43 |
+
if period == "1year":
|
| 44 |
+
# 1년 전 같은 달
|
| 45 |
+
start_year = end_year - 1
|
| 46 |
+
start_month = end_month
|
| 47 |
+
else: # 3year
|
| 48 |
+
# 3년 전 같은 달
|
| 49 |
+
start_year = end_year - 3
|
| 50 |
+
start_month = end_month
|
| 51 |
+
|
| 52 |
+
# 월 첫째 날로 날짜 설정
|
| 53 |
+
start_date_str = f"{start_year:04d}-{start_month:02d}-01"
|
| 54 |
+
end_date_str = f"{end_year:04d}-{end_month:02d}-01"
|
| 55 |
+
|
| 56 |
+
logger.info(f"분석 기간: {start_date_str} ~ {end_date_str} (월간 데이터)")
|
| 57 |
+
|
| 58 |
+
# 키워드는 최대 5개까지만 처리
|
| 59 |
+
keywords = keywords[:5]
|
| 60 |
+
|
| 61 |
+
# API 설정 가져오기
|
| 62 |
+
api_config = api_utils.get_next_datalab_api_config()
|
| 63 |
+
if not api_config:
|
| 64 |
+
logger.error("데이터랩 API 설정을 가져올 수 없습니다.")
|
| 65 |
+
return {
|
| 66 |
+
"status": "error",
|
| 67 |
+
"message": "데이터랩 API 설정 없음"
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
# 키워드 그룹 생성
|
| 71 |
+
keyword_groups = []
|
| 72 |
+
for keyword in keywords:
|
| 73 |
+
keyword_groups.append({
|
| 74 |
+
'groupName': keyword,
|
| 75 |
+
'keywords': [keyword]
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
# API 요청 데이터 (device 파라미터 제거)
|
| 79 |
+
body_dict = {
|
| 80 |
+
'startDate': start_date_str,
|
| 81 |
+
'endDate': end_date_str,
|
| 82 |
+
'timeUnit': 'month',
|
| 83 |
+
'keywordGroups': keyword_groups
|
| 84 |
+
# device 파라미터 제거 → 전체 환경(PC+모바일) 조회
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
body = json.dumps(body_dict)
|
| 88 |
+
|
| 89 |
+
# API 호출
|
| 90 |
+
url = "https://openapi.naver.com/v1/datalab/search"
|
| 91 |
+
headers = {
|
| 92 |
+
'X-Naver-Client-Id': api_config["CLIENT_ID"],
|
| 93 |
+
'X-Naver-Client-Secret': api_config["CLIENT_SECRET"],
|
| 94 |
+
'Content-Type': 'application/json'
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
response = requests.post(url, data=body, headers=headers, timeout=10)
|
| 99 |
+
|
| 100 |
+
if response.status_code == 200:
|
| 101 |
+
response_json = response.json()
|
| 102 |
+
|
| 103 |
+
# 데이터 처리
|
| 104 |
+
trend_data = []
|
| 105 |
+
for result in response_json['results']:
|
| 106 |
+
for data_point in result['data']:
|
| 107 |
+
trend_data.append({
|
| 108 |
+
'keyword': result['title'],
|
| 109 |
+
'period': data_point['period'],
|
| 110 |
+
'ratio': data_point['ratio']
|
| 111 |
+
})
|
| 112 |
+
|
| 113 |
+
df_trend = pd.DataFrame(trend_data)
|
| 114 |
+
|
| 115 |
+
# 현재 월별 검색량 조회 (검색광고 API)
|
| 116 |
+
search_volumes = keyword_search.fetch_all_search_volumes(keywords)
|
| 117 |
+
|
| 118 |
+
# 절대 검색량으로 변환
|
| 119 |
+
df_trend_with_volume = convert_to_absolute_volume(df_trend, search_volumes)
|
| 120 |
+
|
| 121 |
+
# 그래프 생성 (너비 100% 적용)
|
| 122 |
+
graph_html = create_trend_graph(df_trend_with_volume, period)
|
| 123 |
+
|
| 124 |
+
logger.info(f"트렌드 분석 완료: {len(trend_data)}개 데이터 포인트")
|
| 125 |
+
|
| 126 |
+
return {
|
| 127 |
+
"status": "success",
|
| 128 |
+
"trend_data": df_trend_with_volume,
|
| 129 |
+
"graph_html": graph_html,
|
| 130 |
+
"period": period,
|
| 131 |
+
"keywords": keywords
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
else:
|
| 135 |
+
logger.error(f"데이터랩 API 오류: {response.status_code} - {response.text}")
|
| 136 |
+
return {
|
| 137 |
+
"status": "error",
|
| 138 |
+
"message": f"API 오류: {response.status_code}"
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
except Exception as e:
|
| 142 |
+
logger.error(f"트렌드 분석 중 오류 발생: {e}")
|
| 143 |
+
return {
|
| 144 |
+
"status": "error",
|
| 145 |
+
"message": f"분석 중 오류: {str(e)}"
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
def convert_to_absolute_volume(df_trend, search_volumes):
|
| 149 |
+
"""
|
| 150 |
+
상대 검���량을 절대 검색량으로 변환
|
| 151 |
+
|
| 152 |
+
Args:
|
| 153 |
+
df_trend (DataFrame): 트렌드 데이터 (상대값)
|
| 154 |
+
search_volumes (dict): 현재 월별 검색량 데이터
|
| 155 |
+
|
| 156 |
+
Returns:
|
| 157 |
+
DataFrame: 절대 검색량이 추가된 트렌드 데이터
|
| 158 |
+
"""
|
| 159 |
+
df_result = df_trend.copy()
|
| 160 |
+
df_result['absolute_volume'] = 0
|
| 161 |
+
|
| 162 |
+
# 각 키워드별로 처리
|
| 163 |
+
for keyword in df_trend['keyword'].unique():
|
| 164 |
+
keyword_data = df_trend[df_trend['keyword'] == keyword]
|
| 165 |
+
|
| 166 |
+
# 현재 월의 비율 (마지막 데이터)
|
| 167 |
+
current_ratio = keyword_data['ratio'].iloc[-1]
|
| 168 |
+
|
| 169 |
+
# 현재 월의 검색량 (PC + 모바일)
|
| 170 |
+
volume_data = search_volumes.get(keyword.replace(" ", ""), {"총검색량": 0})
|
| 171 |
+
current_volume = volume_data.get("총검색량", 0)
|
| 172 |
+
|
| 173 |
+
if current_ratio > 0 and current_volume > 0:
|
| 174 |
+
# 1%당 검색량 계산
|
| 175 |
+
volume_per_percent = current_volume / current_ratio
|
| 176 |
+
|
| 177 |
+
# 각 기간의 절대 검색량 계산
|
| 178 |
+
mask = df_result['keyword'] == keyword
|
| 179 |
+
df_result.loc[mask, 'absolute_volume'] = (
|
| 180 |
+
df_result.loc[mask, 'ratio'] * volume_per_percent
|
| 181 |
+
).astype(int)
|
| 182 |
+
|
| 183 |
+
logger.info(f"'{keyword}': 현재 비율 {current_ratio}%, 검색량 {current_volume:,}, 1%당 {volume_per_percent:.0f}")
|
| 184 |
+
else:
|
| 185 |
+
logger.warning(f"'{keyword}': 검색량 변환 불가 (비율: {current_ratio}, 검색량: {current_volume})")
|
| 186 |
+
|
| 187 |
+
return df_result
|
| 188 |
+
|
| 189 |
+
def create_trend_graph(df_trend, period):
|
| 190 |
+
"""
|
| 191 |
+
트렌드 그래프 생성 (너비 100% 적용)
|
| 192 |
+
|
| 193 |
+
Args:
|
| 194 |
+
df_trend (DataFrame): 트렌드 데이터
|
| 195 |
+
period (str): 분석 기간
|
| 196 |
+
|
| 197 |
+
Returns:
|
| 198 |
+
str: HTML 형태의 그래프
|
| 199 |
+
"""
|
| 200 |
+
# Plotly 그래프 생성
|
| 201 |
+
fig = go.Figure()
|
| 202 |
+
|
| 203 |
+
# 키워드별로 라인 추가
|
| 204 |
+
colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7']
|
| 205 |
+
|
| 206 |
+
for i, keyword in enumerate(df_trend['keyword'].unique()):
|
| 207 |
+
keyword_data = df_trend[df_trend['keyword'] == keyword]
|
| 208 |
+
|
| 209 |
+
fig.add_trace(go.Scatter(
|
| 210 |
+
x=keyword_data['period'],
|
| 211 |
+
y=keyword_data['absolute_volume'],
|
| 212 |
+
mode='lines+markers',
|
| 213 |
+
name=keyword,
|
| 214 |
+
line=dict(color=colors[i % len(colors)], width=3),
|
| 215 |
+
marker=dict(size=6),
|
| 216 |
+
hovertemplate='<b>%{fullData.name}</b><br>' +
|
| 217 |
+
'기간: %{x}<br>' +
|
| 218 |
+
'검색량: %{y:,}<br>' +
|
| 219 |
+
'<extra></extra>'
|
| 220 |
+
))
|
| 221 |
+
|
| 222 |
+
# 레이아웃 설정 (너비 100% 적용)
|
| 223 |
+
period_text = "최근 1년" if period == "1year" else "최근 3년"
|
| 224 |
+
|
| 225 |
+
fig.update_layout(
|
| 226 |
+
title=f'키워드별 월별 검색량 트렌드 ({period_text})',
|
| 227 |
+
xaxis_title='기간',
|
| 228 |
+
yaxis_title='월별 검색량',
|
| 229 |
+
hovermode='x unified',
|
| 230 |
+
template='plotly_white',
|
| 231 |
+
height=500,
|
| 232 |
+
showlegend=True,
|
| 233 |
+
legend=dict(
|
| 234 |
+
orientation="h",
|
| 235 |
+
yanchor="bottom",
|
| 236 |
+
y=1.02,
|
| 237 |
+
xanchor="right",
|
| 238 |
+
x=1
|
| 239 |
+
),
|
| 240 |
+
width=None, # 너비 자동 조정
|
| 241 |
+
autosize=True # 자동 크기 조정
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
# y축 포맷 설정 (천 단위 구분)
|
| 245 |
+
fig.update_yaxis(tickformat=',')
|
| 246 |
+
|
| 247 |
+
# HTML로 변환 (너비 100% 적용)
|
| 248 |
+
graph_html = fig.to_html(
|
| 249 |
+
include_plotlyjs='cdn',
|
| 250 |
+
div_id="trend-graph",
|
| 251 |
+
config={'responsive': True} # 반응형 그래프
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
return graph_html
|
| 255 |
+
|
| 256 |
+
def calculate_3year_growth_rate(volumes):
|
| 257 |
+
"""
|
| 258 |
+
3년 기준 성장률 계산 (전체 기간 기준)
|
| 259 |
+
|
| 260 |
+
Args:
|
| 261 |
+
volumes (list): 월별 검색량 데이터
|
| 262 |
+
|
| 263 |
+
Returns:
|
| 264 |
+
float: 3년 기준 성장률
|
| 265 |
+
"""
|
| 266 |
+
if len(volumes) < 6: # 최소 6개월 데이터 필요
|
| 267 |
+
return 0
|
| 268 |
+
|
| 269 |
+
# 전체 기간을 3등분하여 성장률 계산
|
| 270 |
+
total_months = len(volumes)
|
| 271 |
+
period_size = max(1, total_months // 3) # 최소 1개월
|
| 272 |
+
|
| 273 |
+
# 초기 기간 평균 (첫 1/3)
|
| 274 |
+
early_period = volumes[:period_size]
|
| 275 |
+
early_avg = sum(early_period) / len(early_period)
|
| 276 |
+
|
| 277 |
+
# 최근 기간 평균 (마지막 1/3)
|
| 278 |
+
recent_period = volumes[-period_size:]
|
| 279 |
+
recent_avg = sum(recent_period) / len(recent_period)
|
| 280 |
+
|
| 281 |
+
if early_avg > 0:
|
| 282 |
+
return round(((recent_avg - early_avg) / early_avg) * 100, 1)
|
| 283 |
+
return 0
|
| 284 |
+
|
| 285 |
+
def analyze_trend_insights(df_trend):
|
| 286 |
+
"""
|
| 287 |
+
트렌드 데이터에서 인사이트 추출 (3년 기준 성장률로 변경)
|
| 288 |
+
|
| 289 |
+
Args:
|
| 290 |
+
df_trend (DataFrame): 트렌드 데이터
|
| 291 |
+
|
| 292 |
+
Returns:
|
| 293 |
+
dict: 트렌드 인사이트
|
| 294 |
+
"""
|
| 295 |
+
insights = {}
|
| 296 |
+
|
| 297 |
+
for keyword in df_trend['keyword'].unique():
|
| 298 |
+
keyword_data = df_trend[df_trend['keyword'] == keyword].sort_values('period')
|
| 299 |
+
|
| 300 |
+
# 최고점과 최저점
|
| 301 |
+
max_volume = keyword_data['absolute_volume'].max()
|
| 302 |
+
min_volume = keyword_data['absolute_volume'].min()
|
| 303 |
+
max_period = keyword_data[keyword_data['absolute_volume'] == max_volume]['period'].iloc[0]
|
| 304 |
+
min_period = keyword_data[keyword_data['absolute_volume'] == min_volume]['period'].iloc[0]
|
| 305 |
+
|
| 306 |
+
# 전체 기간 평균
|
| 307 |
+
total_avg = keyword_data['absolute_volume'].mean()
|
| 308 |
+
|
| 309 |
+
# 3년 기준 성장률 계산 (전체 기간 기준)
|
| 310 |
+
volumes = keyword_data['absolute_volume'].tolist()
|
| 311 |
+
growth_rate = calculate_3year_growth_rate(volumes)
|
| 312 |
+
|
| 313 |
+
insights[keyword] = {
|
| 314 |
+
'max_volume': int(max_volume),
|
| 315 |
+
'max_period': max_period,
|
| 316 |
+
'min_volume': int(min_volume),
|
| 317 |
+
'min_period': min_period,
|
| 318 |
+
'total_avg': int(total_avg),
|
| 319 |
+
'growth_rate': growth_rate,
|
| 320 |
+
'total_months': len(volumes)
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
return insights
|
trend_analysis_v2.py
ADDED
|
@@ -0,0 +1,1128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
트렌드 분석 모듈 v2.16 - 정교한 이중 API 호출 및 역산 로직 구현
|
| 3 |
+
- 이중 트렌드 API 호출: 일별 + 월별 데이터
|
| 4 |
+
- 일별 데이터로 전월 정확한 검색량 역산
|
| 5 |
+
- 전월 기준으로 3년 모든 월 검색량 역산
|
| 6 |
+
- 작년 동월 기반 미래 3개월 예상
|
| 7 |
+
- 최종 단계에서 10% 감소 조정 적용
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import urllib.request
|
| 11 |
+
import json
|
| 12 |
+
import time
|
| 13 |
+
import logging
|
| 14 |
+
from datetime import datetime, timedelta
|
| 15 |
+
import calendar
|
| 16 |
+
import api_utils
|
| 17 |
+
|
| 18 |
+
# 로깅 설정
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
# ===== 핵심 개선 함수들 =====
|
| 22 |
+
|
| 23 |
+
def get_complete_month():
|
| 24 |
+
"""완성된 마지막 월 계산 - 단순화된 로직"""
|
| 25 |
+
current_date = datetime.now()
|
| 26 |
+
current_day = current_date.day
|
| 27 |
+
current_year = current_date.year
|
| 28 |
+
current_month = current_date.month
|
| 29 |
+
|
| 30 |
+
# 3일 이후에만 전월을 완성된 것으로 간주
|
| 31 |
+
if current_day >= 3:
|
| 32 |
+
completed_year = current_year
|
| 33 |
+
completed_month = current_month - 1
|
| 34 |
+
else:
|
| 35 |
+
completed_year = current_year
|
| 36 |
+
completed_month = current_month - 2
|
| 37 |
+
|
| 38 |
+
# 월이 0 이하가 되면 연도 조정
|
| 39 |
+
while completed_month <= 0:
|
| 40 |
+
completed_month += 12
|
| 41 |
+
completed_year -= 1
|
| 42 |
+
|
| 43 |
+
return completed_year, completed_month
|
| 44 |
+
|
| 45 |
+
def get_daily_trend_data(keywords, max_retries=3):
|
| 46 |
+
"""1차 호출: 일별 트렌드 데이터 (전월 정확 계산용)"""
|
| 47 |
+
for retry_attempt in range(max_retries):
|
| 48 |
+
try:
|
| 49 |
+
# 데이터랩 API 설정
|
| 50 |
+
datalab_config = api_utils.get_next_datalab_api_config()
|
| 51 |
+
if not datalab_config:
|
| 52 |
+
logger.warning("데이터랩 API 키가 설정되지 않았습니다.")
|
| 53 |
+
return None
|
| 54 |
+
|
| 55 |
+
client_id = datalab_config["CLIENT_ID"]
|
| 56 |
+
client_secret = datalab_config["CLIENT_SECRET"]
|
| 57 |
+
|
| 58 |
+
# 완성된 마지막 월 계산
|
| 59 |
+
completed_year, completed_month = get_complete_month()
|
| 60 |
+
|
| 61 |
+
# 일별 데이터 기간: 전월 1일 ~ 어제
|
| 62 |
+
current_date = datetime.now()
|
| 63 |
+
yesterday = current_date - timedelta(days=1)
|
| 64 |
+
|
| 65 |
+
start_date = f"{completed_year:04d}-{completed_month:02d}-01"
|
| 66 |
+
end_date = yesterday.strftime("%Y-%m-%d")
|
| 67 |
+
|
| 68 |
+
logger.info(f"📞 1차 호출 (일별): {start_date} ~ {end_date}")
|
| 69 |
+
|
| 70 |
+
# 키워드 그룹 생성
|
| 71 |
+
keywordGroups = []
|
| 72 |
+
for kw in keywords[:5]:
|
| 73 |
+
keywordGroups.append({
|
| 74 |
+
'groupName': kw,
|
| 75 |
+
'keywords': [kw]
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
# API 요청
|
| 79 |
+
body_dict = {
|
| 80 |
+
'startDate': start_date,
|
| 81 |
+
'endDate': end_date,
|
| 82 |
+
'timeUnit': 'date', # 일별!
|
| 83 |
+
'keywordGroups': keywordGroups
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
url = "https://openapi.naver.com/v1/datalab/search"
|
| 87 |
+
body = json.dumps(body_dict, ensure_ascii=False)
|
| 88 |
+
|
| 89 |
+
request = urllib.request.Request(url)
|
| 90 |
+
request.add_header("X-Naver-Client-Id", client_id)
|
| 91 |
+
request.add_header("X-Naver-Client-Secret", client_secret)
|
| 92 |
+
request.add_header("Content-Type", "application/json")
|
| 93 |
+
|
| 94 |
+
response = urllib.request.urlopen(request, data=body.encode("utf-8"), timeout=15)
|
| 95 |
+
rescode = response.getcode()
|
| 96 |
+
|
| 97 |
+
if rescode == 200:
|
| 98 |
+
response_body = response.read()
|
| 99 |
+
response_json = json.loads(response_body)
|
| 100 |
+
logger.info(f"일별 트렌드 데이터 조회 성공")
|
| 101 |
+
return response_json
|
| 102 |
+
else:
|
| 103 |
+
logger.error(f"일별 API 오류: 상태코드 {rescode}")
|
| 104 |
+
if retry_attempt < max_retries - 1:
|
| 105 |
+
time.sleep(2 * (retry_attempt + 1))
|
| 106 |
+
continue
|
| 107 |
+
return None
|
| 108 |
+
|
| 109 |
+
except Exception as e:
|
| 110 |
+
logger.error(f"일별 트렌드 조회 오류 (시도 {retry_attempt + 1}): {e}")
|
| 111 |
+
if retry_attempt < max_retries - 1:
|
| 112 |
+
time.sleep(2 * (retry_attempt + 1))
|
| 113 |
+
continue
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
def get_monthly_trend_data(keywords, max_retries=3):
|
| 119 |
+
"""2차 호출: 월별 트렌드 데이터 (3년 전체 + 예상용)"""
|
| 120 |
+
for retry_attempt in range(max_retries):
|
| 121 |
+
try:
|
| 122 |
+
# 데이터랩 API 설정
|
| 123 |
+
datalab_config = api_utils.get_next_datalab_api_config()
|
| 124 |
+
if not datalab_config:
|
| 125 |
+
logger.warning("데이터랩 API 키가 설정되지 않았습니다.")
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
client_id = datalab_config["CLIENT_ID"]
|
| 129 |
+
client_secret = datalab_config["CLIENT_SECRET"]
|
| 130 |
+
|
| 131 |
+
# 완성된 마지막 월 계산
|
| 132 |
+
completed_year, completed_month = get_complete_month()
|
| 133 |
+
|
| 134 |
+
# 월별 데이터 기간: 3년 전 1월 ~ 완성된 마지막 월
|
| 135 |
+
start_year = completed_year - 3
|
| 136 |
+
start_date = f"{start_year:04d}-01-01"
|
| 137 |
+
end_date = f"{completed_year:04d}-{completed_month:02d}-01"
|
| 138 |
+
|
| 139 |
+
logger.info(f"📞 2차 호출 (월별): {start_date} ~ {end_date}")
|
| 140 |
+
|
| 141 |
+
# 키워드 그룹 생성
|
| 142 |
+
keywordGroups = []
|
| 143 |
+
for kw in keywords[:5]:
|
| 144 |
+
keywordGroups.append({
|
| 145 |
+
'groupName': kw,
|
| 146 |
+
'keywords': [kw]
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
# API 요청
|
| 150 |
+
body_dict = {
|
| 151 |
+
'startDate': start_date,
|
| 152 |
+
'endDate': end_date,
|
| 153 |
+
'timeUnit': 'month', # 월별!
|
| 154 |
+
'keywordGroups': keywordGroups
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
url = "https://openapi.naver.com/v1/datalab/search"
|
| 158 |
+
body = json.dumps(body_dict, ensure_ascii=False)
|
| 159 |
+
|
| 160 |
+
request = urllib.request.Request(url)
|
| 161 |
+
request.add_header("X-Naver-Client-Id", client_id)
|
| 162 |
+
request.add_header("X-Naver-Client-Secret", client_secret)
|
| 163 |
+
request.add_header("Content-Type", "application/json")
|
| 164 |
+
|
| 165 |
+
response = urllib.request.urlopen(request, data=body.encode("utf-8"), timeout=15)
|
| 166 |
+
rescode = response.getcode()
|
| 167 |
+
|
| 168 |
+
if rescode == 200:
|
| 169 |
+
response_body = response.read()
|
| 170 |
+
response_json = json.loads(response_body)
|
| 171 |
+
logger.info(f"월별 트렌드 데이터 조회 성공")
|
| 172 |
+
return response_json
|
| 173 |
+
else:
|
| 174 |
+
logger.error(f"월별 API 오류: 상태코드 {rescode}")
|
| 175 |
+
if retry_attempt < max_retries - 1:
|
| 176 |
+
time.sleep(2 * (retry_attempt + 1))
|
| 177 |
+
continue
|
| 178 |
+
return None
|
| 179 |
+
|
| 180 |
+
except Exception as e:
|
| 181 |
+
logger.error(f"월별 트렌드 조회 오류 (시도 {retry_attempt + 1}): {e}")
|
| 182 |
+
if retry_attempt < max_retries - 1:
|
| 183 |
+
time.sleep(2 * (retry_attempt + 1))
|
| 184 |
+
continue
|
| 185 |
+
return None
|
| 186 |
+
|
| 187 |
+
return None
|
| 188 |
+
|
| 189 |
+
def calculate_previous_month_from_daily(current_volume, daily_data):
|
| 190 |
+
"""일별 트렌드로 전월 정확한 검색량 역산"""
|
| 191 |
+
if not daily_data or "results" not in daily_data:
|
| 192 |
+
logger.warning("일별 데이터가 없어 전월 계산을 건너뜁니다.")
|
| 193 |
+
return current_volume
|
| 194 |
+
|
| 195 |
+
try:
|
| 196 |
+
completed_year, completed_month = get_complete_month()
|
| 197 |
+
|
| 198 |
+
# 전월 일수 계산
|
| 199 |
+
prev_month_days = calendar.monthrange(completed_year, completed_month)[1]
|
| 200 |
+
|
| 201 |
+
for result in daily_data["results"]:
|
| 202 |
+
keyword = result["title"]
|
| 203 |
+
|
| 204 |
+
if not result["data"]:
|
| 205 |
+
continue
|
| 206 |
+
|
| 207 |
+
# 최근 30일과 전월 데이터 분리
|
| 208 |
+
recent_30_ratios = [] # 최근 30일 (현재 검색량 기준)
|
| 209 |
+
prev_month_ratios = [] # 전월 데이터
|
| 210 |
+
|
| 211 |
+
for data_point in result["data"]:
|
| 212 |
+
try:
|
| 213 |
+
date_obj = datetime.strptime(data_point["period"], "%Y-%m-%d")
|
| 214 |
+
ratio = data_point["ratio"]
|
| 215 |
+
|
| 216 |
+
# 전월 데이터
|
| 217 |
+
if date_obj.year == completed_year and date_obj.month == completed_month:
|
| 218 |
+
prev_month_ratios.append(ratio)
|
| 219 |
+
|
| 220 |
+
# 최근 30일 (현재 검색량 기준 구간)
|
| 221 |
+
current_date = datetime.now()
|
| 222 |
+
if (current_date - date_obj).days <= 30:
|
| 223 |
+
recent_30_ratios.append(ratio)
|
| 224 |
+
|
| 225 |
+
except:
|
| 226 |
+
continue
|
| 227 |
+
|
| 228 |
+
if not recent_30_ratios or not prev_month_ratios:
|
| 229 |
+
logger.warning(f"'{keyword}' 비교 데이터가 부족합니다.")
|
| 230 |
+
continue
|
| 231 |
+
|
| 232 |
+
# 평균 비율 계산
|
| 233 |
+
recent_30_avg = sum(recent_30_ratios) / len(recent_30_ratios)
|
| 234 |
+
prev_month_avg = sum(prev_month_ratios) / len(prev_month_ratios)
|
| 235 |
+
|
| 236 |
+
if recent_30_avg == 0:
|
| 237 |
+
continue
|
| 238 |
+
|
| 239 |
+
# 전월 검색량 역산
|
| 240 |
+
# 현재 검색량 = 최근 30일 평균 기준
|
| 241 |
+
# 전월 검색량 = (전월 평균 / 최근 30일 평균) × 현재 검색량 × (전월 일수 / 30일)
|
| 242 |
+
prev_month_volume = int(
|
| 243 |
+
(prev_month_avg / recent_30_avg) * current_volume * (prev_month_days / 30)
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
logger.info(f"'{keyword}' 전월 {completed_year}.{completed_month:02d} 역산 검색량: {prev_month_volume:,}회")
|
| 247 |
+
logger.info(f" - 최근 30일 평균 비율: {recent_30_avg:.1f}%")
|
| 248 |
+
logger.info(f" - 전월 평균 비율: {prev_month_avg:.1f}%")
|
| 249 |
+
logger.info(f" - 전월 일수 보정: {prev_month_days}일")
|
| 250 |
+
|
| 251 |
+
return prev_month_volume
|
| 252 |
+
|
| 253 |
+
except Exception as e:
|
| 254 |
+
logger.error(f"전월 역산 계산 오류: {e}")
|
| 255 |
+
return current_volume
|
| 256 |
+
|
| 257 |
+
return current_volume
|
| 258 |
+
|
| 259 |
+
def calculate_all_months_from_previous(prev_month_volume, monthly_data, completed_year, completed_month):
|
| 260 |
+
"""전월을 기준으로 모든 월 검색량 역산"""
|
| 261 |
+
if not monthly_data or "results" not in monthly_data:
|
| 262 |
+
logger.warning("월별 데이터가 없어 역산 계산을 건너뜁니다.")
|
| 263 |
+
return [], []
|
| 264 |
+
|
| 265 |
+
monthly_volumes = []
|
| 266 |
+
dates = []
|
| 267 |
+
|
| 268 |
+
try:
|
| 269 |
+
for result in monthly_data["results"]:
|
| 270 |
+
keyword = result["title"]
|
| 271 |
+
|
| 272 |
+
if not result["data"]:
|
| 273 |
+
continue
|
| 274 |
+
|
| 275 |
+
# 전월(기준월) 비율 찾기
|
| 276 |
+
base_ratio = None
|
| 277 |
+
for data_point in result["data"]:
|
| 278 |
+
try:
|
| 279 |
+
date_obj = datetime.strptime(data_point["period"], "%Y-%m-%d")
|
| 280 |
+
if date_obj.year == completed_year and date_obj.month == completed_month:
|
| 281 |
+
base_ratio = data_point["ratio"]
|
| 282 |
+
break
|
| 283 |
+
except:
|
| 284 |
+
continue
|
| 285 |
+
|
| 286 |
+
if base_ratio is None or base_ratio == 0:
|
| 287 |
+
logger.warning(f"'{keyword}' 기준월 비율을 찾을 수 없습니다.")
|
| 288 |
+
continue
|
| 289 |
+
|
| 290 |
+
logger.info(f"'{keyword}' 기준월 {completed_year}.{completed_month:02d} 비율: {base_ratio}% (검색량: {prev_month_volume:,}회)")
|
| 291 |
+
|
| 292 |
+
# 모든 월 검색량 역산
|
| 293 |
+
for data_point in result["data"]:
|
| 294 |
+
try:
|
| 295 |
+
date_obj = datetime.strptime(data_point["period"], "%Y-%m-%d")
|
| 296 |
+
ratio = data_point["ratio"]
|
| 297 |
+
|
| 298 |
+
# 해당 월의 일수 가져오기
|
| 299 |
+
month_days = calendar.monthrange(date_obj.year, date_obj.month)[1]
|
| 300 |
+
base_month_days = calendar.monthrange(completed_year, completed_month)[1]
|
| 301 |
+
|
| 302 |
+
# 역산 계산: (해당월 비율 / 기준월 비율) × 기준월 검색량 × (해당월 일수 / 기준월 일수)
|
| 303 |
+
calculated_volume = int(
|
| 304 |
+
(ratio / base_ratio) * prev_month_volume * (month_days / base_month_days)
|
| 305 |
+
)
|
| 306 |
+
calculated_volume = max(calculated_volume, 0) # 음수 방지
|
| 307 |
+
|
| 308 |
+
monthly_volumes.append(calculated_volume)
|
| 309 |
+
dates.append(data_point["period"])
|
| 310 |
+
|
| 311 |
+
except:
|
| 312 |
+
continue
|
| 313 |
+
|
| 314 |
+
logger.info(f"'{keyword}' 전체 월별 검색량 역산 완료: {len(monthly_volumes)}개월")
|
| 315 |
+
break # 첫 번째 키워드만 처리
|
| 316 |
+
|
| 317 |
+
except Exception as e:
|
| 318 |
+
logger.error(f"월별 역산 계산 오류: {e}")
|
| 319 |
+
return [], []
|
| 320 |
+
|
| 321 |
+
return monthly_volumes, dates
|
| 322 |
+
|
| 323 |
+
def generate_future_from_growth_rate(monthly_volumes, dates, completed_year, completed_month):
|
| 324 |
+
"""증감율 기반 미래 3개월 예상 생성"""
|
| 325 |
+
if len(monthly_volumes) < 12:
|
| 326 |
+
logger.warning("미래 예측을 위한 충분한 데이터가 없습니다.")
|
| 327 |
+
return [], []
|
| 328 |
+
|
| 329 |
+
try:
|
| 330 |
+
# 작년 동월들과 올해 완성된 월들 비교하여 증감율 계산
|
| 331 |
+
this_year_volumes = []
|
| 332 |
+
last_year_volumes = []
|
| 333 |
+
|
| 334 |
+
for i, date_str in enumerate(dates):
|
| 335 |
+
try:
|
| 336 |
+
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
|
| 337 |
+
|
| 338 |
+
# 올해 완성된 데이터 (1월 ~ 완성된 월)
|
| 339 |
+
if date_obj.year == completed_year and date_obj.month <= completed_month:
|
| 340 |
+
this_year_volumes.append(monthly_volumes[i])
|
| 341 |
+
|
| 342 |
+
# 작년 동일 기간 데이터
|
| 343 |
+
if date_obj.year == completed_year - 1 and date_obj.month <= completed_month:
|
| 344 |
+
last_year_volumes.append(monthly_volumes[i])
|
| 345 |
+
|
| 346 |
+
except:
|
| 347 |
+
continue
|
| 348 |
+
|
| 349 |
+
# 증감율 계산
|
| 350 |
+
if len(this_year_volumes) >= 3 and len(last_year_volumes) >= 3:
|
| 351 |
+
this_year_avg = sum(this_year_volumes) / len(this_year_volumes)
|
| 352 |
+
last_year_avg = sum(last_year_volumes) / len(last_year_volumes)
|
| 353 |
+
|
| 354 |
+
if last_year_avg > 0:
|
| 355 |
+
growth_rate = (this_year_avg - last_year_avg) / last_year_avg
|
| 356 |
+
# 증감율 범위 제한 (-50% ~ +100%)
|
| 357 |
+
growth_rate = max(-0.5, min(growth_rate, 1.0))
|
| 358 |
+
else:
|
| 359 |
+
growth_rate = 0
|
| 360 |
+
else:
|
| 361 |
+
growth_rate = 0
|
| 362 |
+
|
| 363 |
+
logger.info(f"계산된 증감율: {growth_rate*100:+.1f}%")
|
| 364 |
+
|
| 365 |
+
# 미래 3개월 예상
|
| 366 |
+
predicted_volumes = []
|
| 367 |
+
predicted_dates = []
|
| 368 |
+
|
| 369 |
+
for month_offset in range(1, 4): # 1, 2, 3개월 후
|
| 370 |
+
pred_year = completed_year
|
| 371 |
+
pred_month = completed_month + month_offset
|
| 372 |
+
|
| 373 |
+
while pred_month > 12:
|
| 374 |
+
pred_month -= 12
|
| 375 |
+
pred_year += 1
|
| 376 |
+
|
| 377 |
+
# 작년 동월 데이터 찾기
|
| 378 |
+
last_year_pred_year = pred_year - 1
|
| 379 |
+
last_year_pred_month = pred_month
|
| 380 |
+
last_year_volume = None
|
| 381 |
+
|
| 382 |
+
for i, date_str in enumerate(dates):
|
| 383 |
+
try:
|
| 384 |
+
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
|
| 385 |
+
if date_obj.year == last_year_pred_year and date_obj.month == last_year_pred_month:
|
| 386 |
+
last_year_volume = monthly_volumes[i]
|
| 387 |
+
break
|
| 388 |
+
except:
|
| 389 |
+
continue
|
| 390 |
+
|
| 391 |
+
# 예상 검색량 = 작년 동월 × (1 + 증감율)
|
| 392 |
+
if last_year_volume is not None:
|
| 393 |
+
predicted_volume = int(last_year_volume * (1 + growth_rate))
|
| 394 |
+
predicted_volume = max(predicted_volume, 0) # 음수 방지
|
| 395 |
+
|
| 396 |
+
predicted_volumes.append(predicted_volume)
|
| 397 |
+
predicted_dates.append(f"{pred_year:04d}-{pred_month:02d}-01")
|
| 398 |
+
|
| 399 |
+
logger.info(f"예상 {pred_year}.{pred_month:02d}: 작년 동월 {last_year_volume:,}회 → 예상 {predicted_volume:,}회")
|
| 400 |
+
|
| 401 |
+
return predicted_volumes, predicted_dates
|
| 402 |
+
|
| 403 |
+
except Exception as e:
|
| 404 |
+
logger.error(f"미래 예측 생성 오류: {e}")
|
| 405 |
+
return [], []
|
| 406 |
+
|
| 407 |
+
def apply_final_10_percent_reduction(monthly_data):
|
| 408 |
+
"""최종 단계: 모든 결과에 10% 감소 적용"""
|
| 409 |
+
adjusted_data = {}
|
| 410 |
+
|
| 411 |
+
try:
|
| 412 |
+
for keyword, data in monthly_data.items():
|
| 413 |
+
adjusted_volumes = []
|
| 414 |
+
|
| 415 |
+
for volume in data["monthly_volumes"]:
|
| 416 |
+
if volume >= 10:
|
| 417 |
+
adjusted_volume = int(volume * 0.9) # 10% 감소
|
| 418 |
+
else:
|
| 419 |
+
adjusted_volume = volume # 10 미만은 그대로
|
| 420 |
+
|
| 421 |
+
adjusted_volumes.append(adjusted_volume)
|
| 422 |
+
|
| 423 |
+
# 데이터 복사 및 검색량 조정
|
| 424 |
+
adjusted_data[keyword] = data.copy()
|
| 425 |
+
adjusted_data[keyword]["monthly_volumes"] = adjusted_volumes
|
| 426 |
+
|
| 427 |
+
# 현재 검색량도 조정
|
| 428 |
+
if data["current_volume"] >= 10:
|
| 429 |
+
adjusted_data[keyword]["current_volume"] = int(data["current_volume"] * 0.9)
|
| 430 |
+
|
| 431 |
+
logger.info("최종 10% 감소 조정 완료")
|
| 432 |
+
|
| 433 |
+
except Exception as e:
|
| 434 |
+
logger.error(f"10% 감소 조정 오류: {e}")
|
| 435 |
+
return monthly_data
|
| 436 |
+
|
| 437 |
+
return adjusted_data
|
| 438 |
+
|
| 439 |
+
# ===== 메인 함수들 (기존 호환성 유지) =====
|
| 440 |
+
|
| 441 |
+
def get_naver_trend_data_v5(keywords, period="1year", max_retries=3):
|
| 442 |
+
"""개선된 네이버 데이터랩 API 호출 - 이중 호출 구현"""
|
| 443 |
+
|
| 444 |
+
if period == "1year":
|
| 445 |
+
# 이중 API 호출
|
| 446 |
+
daily_data = get_daily_trend_data(keywords, max_retries)
|
| 447 |
+
monthly_data = get_monthly_trend_data(keywords, max_retries)
|
| 448 |
+
|
| 449 |
+
# 결과 반환 (기존 코드와 호환성 유지를 위해 월별 데이터 우선 반환)
|
| 450 |
+
return {
|
| 451 |
+
'daily_data': daily_data,
|
| 452 |
+
'monthly_data': monthly_data,
|
| 453 |
+
'results': monthly_data['results'] if monthly_data else []
|
| 454 |
+
}
|
| 455 |
+
else: # 3year
|
| 456 |
+
# 3년 데이터는 월별만 호출 (기존 방식 유지)
|
| 457 |
+
monthly_data = get_monthly_trend_data(keywords, max_retries)
|
| 458 |
+
return monthly_data
|
| 459 |
+
|
| 460 |
+
def calculate_monthly_volumes_v7(keywords, current_volumes, trend_data, period="1year"):
|
| 461 |
+
"""개선된 월별 검색량 계산 - 정교한 역산 로직 적용"""
|
| 462 |
+
monthly_data = {}
|
| 463 |
+
|
| 464 |
+
# 트렌드 데이터 확인
|
| 465 |
+
if isinstance(trend_data, dict) and 'daily_data' in trend_data and 'monthly_data' in trend_data:
|
| 466 |
+
# 새로운 이중 데이터 구조
|
| 467 |
+
daily_data = trend_data['daily_data']
|
| 468 |
+
monthly_data_api = trend_data['monthly_data']
|
| 469 |
+
else:
|
| 470 |
+
# 기존 단일 데이터 구조 (3year 등)
|
| 471 |
+
daily_data = None
|
| 472 |
+
monthly_data_api = trend_data
|
| 473 |
+
|
| 474 |
+
if not monthly_data_api or "results" not in monthly_data_api:
|
| 475 |
+
logger.warning("월별 트렌드 데이터가 없어 계산을 건너뜁니다.")
|
| 476 |
+
return monthly_data
|
| 477 |
+
|
| 478 |
+
logger.info(f"개선된 월별 검색량 계산 시작: {len(monthly_data_api['results'])}개 키워드")
|
| 479 |
+
|
| 480 |
+
for result in monthly_data_api["results"]:
|
| 481 |
+
keyword = result["title"]
|
| 482 |
+
api_keyword = keyword.replace(" ", "")
|
| 483 |
+
|
| 484 |
+
# 현재 검색량
|
| 485 |
+
volume_data = current_volumes.get(api_keyword, {"총검색량": 0})
|
| 486 |
+
current_volume = volume_data["총검색량"]
|
| 487 |
+
|
| 488 |
+
if current_volume == 0:
|
| 489 |
+
logger.warning(f"'{keyword}' 현재 검색량이 0이므로 계산을 건너뜁니다.")
|
| 490 |
+
continue
|
| 491 |
+
|
| 492 |
+
logger.info(f"'{keyword}' 처리 시작 - 현재 검색량: {current_volume:,}회")
|
| 493 |
+
|
| 494 |
+
if period == "1year" and daily_data:
|
| 495 |
+
# 🔥 새로운 정교한 로직 적용
|
| 496 |
+
completed_year, completed_month = get_complete_month()
|
| 497 |
+
|
| 498 |
+
# 1단계: 일별 데이터로 전월 정확한 검색량 계산
|
| 499 |
+
prev_month_volume = calculate_previous_month_from_daily(current_volume, daily_data)
|
| 500 |
+
|
| 501 |
+
# 2단계: 전월을 기준으로 모든 월 검색량 역산
|
| 502 |
+
monthly_volumes, dates = calculate_all_months_from_previous(
|
| 503 |
+
prev_month_volume, monthly_data_api, completed_year, completed_month
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
if not monthly_volumes:
|
| 507 |
+
logger.warning(f"'{keyword}' 월별 검색량 계산 실패")
|
| 508 |
+
continue
|
| 509 |
+
|
| 510 |
+
# 3단계: 미래 3개월 예상 생성
|
| 511 |
+
predicted_volumes, predicted_dates = generate_future_from_growth_rate(
|
| 512 |
+
monthly_volumes, dates, completed_year, completed_month
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
# 실제 + 예상 데이터 결합 - 최근 12개월 + 향후 3개월 = 총 15개월
|
| 516 |
+
# 최근 12개월만 실제 데이터로 제한
|
| 517 |
+
recent_12_months = monthly_volumes[-12:] if len(monthly_volumes) >= 12 else monthly_volumes
|
| 518 |
+
recent_12_dates = dates[-12:] if len(dates) >= 12 else dates
|
| 519 |
+
|
| 520 |
+
all_volumes = recent_12_months + predicted_volumes
|
| 521 |
+
all_dates = recent_12_dates + predicted_dates
|
| 522 |
+
|
| 523 |
+
# 증감율 계산 (예상 3개월)
|
| 524 |
+
growth_rate = calculate_future_3month_growth_rate(all_volumes, all_dates)
|
| 525 |
+
|
| 526 |
+
monthly_data[keyword] = {
|
| 527 |
+
"monthly_volumes": all_volumes, # 10% 감소 적용 전 - 총 15개월 (12개월 실제 + 3개월 예상)
|
| 528 |
+
"dates": all_dates,
|
| 529 |
+
"current_volume": current_volume, # 10% 감소 적용 전
|
| 530 |
+
"growth_rate": growth_rate,
|
| 531 |
+
"volume_per_percent": prev_month_volume / 100 if prev_month_volume > 0 else 0,
|
| 532 |
+
"current_ratio": 100,
|
| 533 |
+
"actual_count": len(recent_12_months), # 실제 12개월
|
| 534 |
+
"predicted_count": len(predicted_volumes) # 예상 3개월
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
else:
|
| 538 |
+
# 기존 방식 (3year 등)
|
| 539 |
+
if not result["data"]:
|
| 540 |
+
continue
|
| 541 |
+
|
| 542 |
+
current_ratio = result["data"][-1]["ratio"]
|
| 543 |
+
if current_ratio == 0:
|
| 544 |
+
continue
|
| 545 |
+
|
| 546 |
+
volume_per_percent = current_volume / current_ratio
|
| 547 |
+
|
| 548 |
+
monthly_volumes = []
|
| 549 |
+
dates = []
|
| 550 |
+
|
| 551 |
+
for data_point in result["data"]:
|
| 552 |
+
ratio = data_point["ratio"]
|
| 553 |
+
period_date = data_point["period"]
|
| 554 |
+
estimated_volume = int(volume_per_percent * ratio)
|
| 555 |
+
|
| 556 |
+
monthly_volumes.append(estimated_volume)
|
| 557 |
+
dates.append(period_date)
|
| 558 |
+
|
| 559 |
+
growth_rate = calculate_3year_growth_rate_improved(monthly_volumes)
|
| 560 |
+
|
| 561 |
+
monthly_data[keyword] = {
|
| 562 |
+
"monthly_volumes": monthly_volumes, # 10% 감소 적용 전
|
| 563 |
+
"dates": dates,
|
| 564 |
+
"current_volume": current_volume, # 10% 감소 적용 전
|
| 565 |
+
"growth_rate": growth_rate,
|
| 566 |
+
"volume_per_percent": volume_per_percent,
|
| 567 |
+
"current_ratio": current_ratio,
|
| 568 |
+
"actual_count": len(monthly_volumes),
|
| 569 |
+
"predicted_count": 0
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
logger.info(f"'{keyword}' 계산 완료 - 검색량 데이터 {len(monthly_data[keyword]['monthly_volumes'])}개")
|
| 573 |
+
|
| 574 |
+
# 🔥 4단계: 최종 10% 감소 적용
|
| 575 |
+
final_data = apply_final_10_percent_reduction(monthly_data)
|
| 576 |
+
|
| 577 |
+
logger.info("개선된 월별 검색량 계산 완료 (10% 감소 적용됨)")
|
| 578 |
+
return final_data
|
| 579 |
+
|
| 580 |
+
# ===== 증감율 계산 함수들 (기존 유지) =====
|
| 581 |
+
|
| 582 |
+
def calculate_future_3month_growth_rate(volumes, dates):
|
| 583 |
+
"""예상 3개월 증감율 계산"""
|
| 584 |
+
if len(volumes) < 4:
|
| 585 |
+
return 0
|
| 586 |
+
|
| 587 |
+
try:
|
| 588 |
+
completed_year, completed_month = get_complete_month()
|
| 589 |
+
|
| 590 |
+
# 기준월 데이터 찾기
|
| 591 |
+
base_month_volume = None
|
| 592 |
+
for i, date_str in enumerate(dates):
|
| 593 |
+
try:
|
| 594 |
+
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
|
| 595 |
+
if date_obj.year == completed_year and date_obj.month == completed_month:
|
| 596 |
+
base_month_volume = volumes[i]
|
| 597 |
+
break
|
| 598 |
+
except:
|
| 599 |
+
continue
|
| 600 |
+
|
| 601 |
+
if base_month_volume is None:
|
| 602 |
+
return 0
|
| 603 |
+
|
| 604 |
+
# 향후 3개월 예상 데이터 찾기
|
| 605 |
+
future_volumes = []
|
| 606 |
+
for month_offset in range(1, 4):
|
| 607 |
+
pred_year = completed_year
|
| 608 |
+
pred_month = completed_month + month_offset
|
| 609 |
+
|
| 610 |
+
while pred_month > 12:
|
| 611 |
+
pred_month -= 12
|
| 612 |
+
pred_year += 1
|
| 613 |
+
|
| 614 |
+
for i, date_str in enumerate(dates):
|
| 615 |
+
try:
|
| 616 |
+
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
|
| 617 |
+
if date_obj.year == pred_year and date_obj.month == pred_month:
|
| 618 |
+
future_volumes.append(volumes[i])
|
| 619 |
+
break
|
| 620 |
+
except:
|
| 621 |
+
continue
|
| 622 |
+
|
| 623 |
+
if len(future_volumes) < 3:
|
| 624 |
+
return 0
|
| 625 |
+
|
| 626 |
+
# 증감율 계산
|
| 627 |
+
future_average = sum(future_volumes) / len(future_volumes)
|
| 628 |
+
|
| 629 |
+
if base_month_volume > 0:
|
| 630 |
+
growth_rate = ((future_average - base_month_volume) / base_month_volume) * 100
|
| 631 |
+
return min(max(growth_rate, -50), 100)
|
| 632 |
+
|
| 633 |
+
return 0
|
| 634 |
+
|
| 635 |
+
except Exception as e:
|
| 636 |
+
logger.error(f"예상 3개월 증감율 계산 오류: {e}")
|
| 637 |
+
return 0
|
| 638 |
+
|
| 639 |
+
def calculate_3year_growth_rate_improved(volumes):
|
| 640 |
+
"""3년 증감율 계산"""
|
| 641 |
+
if len(volumes) < 24:
|
| 642 |
+
return 0
|
| 643 |
+
|
| 644 |
+
try:
|
| 645 |
+
first_year = volumes[:12]
|
| 646 |
+
last_year = volumes[-12:]
|
| 647 |
+
|
| 648 |
+
first_year_avg = sum(first_year) / len(first_year)
|
| 649 |
+
last_year_avg = sum(last_year) / len(last_year)
|
| 650 |
+
|
| 651 |
+
if first_year_avg == 0:
|
| 652 |
+
return 0
|
| 653 |
+
|
| 654 |
+
growth_rate = ((last_year_avg - first_year_avg) / first_year_avg) * 100
|
| 655 |
+
return min(max(growth_rate, -50), 200)
|
| 656 |
+
|
| 657 |
+
except Exception as e:
|
| 658 |
+
logger.error(f"3년 증감율 계산 오류: {e}")
|
| 659 |
+
return 0
|
| 660 |
+
|
| 661 |
+
def calculate_correct_growth_rate(volumes, dates):
|
| 662 |
+
"""작년 대비 증감율 계산"""
|
| 663 |
+
if len(volumes) < 13:
|
| 664 |
+
return 0
|
| 665 |
+
|
| 666 |
+
try:
|
| 667 |
+
completed_year, completed_month = get_complete_month()
|
| 668 |
+
|
| 669 |
+
this_year_volume = None
|
| 670 |
+
last_year_volume = None
|
| 671 |
+
|
| 672 |
+
for i, date_str in enumerate(dates):
|
| 673 |
+
try:
|
| 674 |
+
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
|
| 675 |
+
|
| 676 |
+
if date_obj.year == completed_year and date_obj.month == completed_month:
|
| 677 |
+
this_year_volume = volumes[i]
|
| 678 |
+
|
| 679 |
+
if date_obj.year == completed_year - 1 and date_obj.month == completed_month:
|
| 680 |
+
last_year_volume = volumes[i]
|
| 681 |
+
|
| 682 |
+
except:
|
| 683 |
+
continue
|
| 684 |
+
|
| 685 |
+
if this_year_volume is not None and last_year_volume is not None and last_year_volume > 0:
|
| 686 |
+
growth_rate = ((this_year_volume - last_year_volume) / last_year_volume) * 100
|
| 687 |
+
return min(max(growth_rate, -50), 100)
|
| 688 |
+
|
| 689 |
+
return 0
|
| 690 |
+
|
| 691 |
+
except Exception as e:
|
| 692 |
+
logger.error(f"작년 대비 증감율 계산 오류: {e}")
|
| 693 |
+
return 0
|
| 694 |
+
|
| 695 |
+
def generate_future_predictions_correct(volumes, dates, growth_rate):
|
| 696 |
+
"""미래 예측 생성 (호환성 유지)"""
|
| 697 |
+
return generate_future_from_growth_rate(volumes, dates, *get_complete_month())
|
| 698 |
+
|
| 699 |
+
# ===== 차트 생성 함수들 =====
|
| 700 |
+
|
| 701 |
+
def create_enhanced_current_chart(volume_data, keyword):
|
| 702 |
+
"""향상된 현재 검색량 정보 차트 - PC vs 모바일 비율 포함"""
|
| 703 |
+
total_vol = volume_data['총검색량']
|
| 704 |
+
pc_vol = volume_data['PC검색량']
|
| 705 |
+
mobile_vol = volume_data['모바일검색량']
|
| 706 |
+
|
| 707 |
+
# 검색량 수준 평가
|
| 708 |
+
if total_vol >= 100000:
|
| 709 |
+
level_text = "높음 🔥"
|
| 710 |
+
level_color = "#dc3545"
|
| 711 |
+
elif total_vol >= 10000:
|
| 712 |
+
level_text = "중간 📊"
|
| 713 |
+
level_color = "#ffc107"
|
| 714 |
+
elif total_vol > 0:
|
| 715 |
+
level_text = "낮음 📉"
|
| 716 |
+
level_color = "#6c757d"
|
| 717 |
+
else:
|
| 718 |
+
level_text = "데이터 없음 ⚠️"
|
| 719 |
+
level_color = "#6c757d"
|
| 720 |
+
|
| 721 |
+
# PC vs 모바일 비율
|
| 722 |
+
if total_vol > 0:
|
| 723 |
+
pc_ratio = (pc_vol / total_vol) * 100
|
| 724 |
+
mobile_ratio = (mobile_vol / total_vol) * 100
|
| 725 |
+
else:
|
| 726 |
+
pc_ratio = mobile_ratio = 0
|
| 727 |
+
|
| 728 |
+
return f"""
|
| 729 |
+
<div style="width: 100%; padding: 30px; font-family: 'Pretendard', sans-serif; background: white; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);">
|
| 730 |
+
<!-- 검색량 수준 표시 -->
|
| 731 |
+
<div style="text-align: center; margin-bottom: 25px; padding: 20px; background: #f8f9fa; border-radius: 12px;">
|
| 732 |
+
<h4 style="margin: 0 0 15px 0; color: #495057; font-size: 20px;">📊 검색량 수준</h4>
|
| 733 |
+
<span style="display: inline-block; padding: 12px 24px; background: {level_color}; color: white; border-radius: 25px; font-weight: bold; font-size: 18px;">
|
| 734 |
+
{level_text}
|
| 735 |
+
</span>
|
| 736 |
+
</div>
|
| 737 |
+
|
| 738 |
+
<!-- 검색량 상세 정보 -->
|
| 739 |
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; margin-bottom: 25px;">
|
| 740 |
+
<div style="background: white; padding: 25px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); text-align: center; border: 1px solid #e9ecef;">
|
| 741 |
+
<div style="color: #007bff; font-size: 32px; font-weight: bold; margin-bottom: 8px;">{pc_vol:,}</div>
|
| 742 |
+
<div style="color: #6c757d; font-size: 16px; margin-bottom: 8px; font-weight: 600;">PC 검색량</div>
|
| 743 |
+
<div style="color: #007bff; font-size: 14px;">({pc_ratio:.1f}%)</div>
|
| 744 |
+
</div>
|
| 745 |
+
<div style="background: white; padding: 25px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); text-align: center; border: 1px solid #e9ecef;">
|
| 746 |
+
<div style="color: #28a745; font-size: 32px; font-weight: bold; margin-bottom: 8px;">{mobile_vol:,}</div>
|
| 747 |
+
<div style="color: #6c757d; font-size: 16px; margin-bottom: 8px; font-weight: 600;">모바일 검색량</div>
|
| 748 |
+
<div style="color: #28a745; font-size: 14px;">({mobile_ratio:.1f}%)</div>
|
| 749 |
+
</div>
|
| 750 |
+
<div style="background: white; padding: 25px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); text-align: center; border: 1px solid #e9ecef;">
|
| 751 |
+
<div style="color: #dc3545; font-size: 32px; font-weight: bold; margin-bottom: 8px;">{total_vol:,}</div>
|
| 752 |
+
<div style="color: #6c757d; font-size: 16px; margin-bottom: 8px; font-weight: 600;">총 검색량</div>
|
| 753 |
+
<div style="color: #dc3545; font-size: 14px;">(100%)</div>
|
| 754 |
+
</div>
|
| 755 |
+
</div>
|
| 756 |
+
|
| 757 |
+
<!-- 비율 바 차트 -->
|
| 758 |
+
<div style="background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); border: 1px solid #e9ecef;">
|
| 759 |
+
<h5 style="margin: 0 0 20px 0; color: #495057; text-align: center; font-size: 18px;">PC vs 모바일 비율</h5>
|
| 760 |
+
<div style="display: flex; height: 25px; border-radius: 15px; overflow: hidden; background: #e9ecef;">
|
| 761 |
+
<div style="background: #007bff; width: {pc_ratio}%; display: flex; align-items: center; justify-content: center; font-size: 14px; color: white; font-weight: bold;">
|
| 762 |
+
{f'PC {pc_ratio:.1f}%' if pc_ratio > 15 else ''}
|
| 763 |
+
</div>
|
| 764 |
+
<div style="background: #28a745; width: {mobile_ratio}%; display: flex; align-items: center; justify-content: center; font-size: 14px; color: white; font-weight: bold;">
|
| 765 |
+
{f'모바일 {mobile_ratio:.1f}%' if mobile_ratio > 15 else ''}
|
| 766 |
+
</div>
|
| 767 |
+
</div>
|
| 768 |
+
</div>
|
| 769 |
+
|
| 770 |
+
<div style="margin-top: 20px; padding: 15px; background: #fff3cd; border-radius: 8px; text-align: center;">
|
| 771 |
+
<p style="margin: 0; font-size: 14px; color: #856404;">
|
| 772 |
+
📊 <strong>트렌드 분석 시스템</strong>: 네이버 데이터랩 기반 정확한 검색량 분석
|
| 773 |
+
</p>
|
| 774 |
+
</div>
|
| 775 |
+
</div>
|
| 776 |
+
"""
|
| 777 |
+
|
| 778 |
+
def create_visual_trend_chart(monthly_data_1year, monthly_data_3year):
|
| 779 |
+
"""시각적 트렌드 차트 생성"""
|
| 780 |
+
try:
|
| 781 |
+
chart_html = f"""
|
| 782 |
+
<div style="width: 100%; margin: 20px auto; font-family: 'Pretendard', sans-serif;">
|
| 783 |
+
"""
|
| 784 |
+
|
| 785 |
+
periods = [
|
| 786 |
+
{"data": monthly_data_1year, "title": "최근 1년 + 향후 3개월 예상 (정교한 역산)", "period": "1year"},
|
| 787 |
+
{"data": monthly_data_3year, "title": "최근 3년 (10% 보정 적용)", "period": "3year"}
|
| 788 |
+
]
|
| 789 |
+
|
| 790 |
+
colors = ['#FB7F0D', '#4ECDC4', '#45B7D1', '#96CEB4', '#FF6B6B']
|
| 791 |
+
|
| 792 |
+
for period_info in periods:
|
| 793 |
+
monthly_data = period_info["data"]
|
| 794 |
+
period_title = period_info["title"]
|
| 795 |
+
period_code = period_info["period"]
|
| 796 |
+
|
| 797 |
+
if not monthly_data:
|
| 798 |
+
chart_html += f"""
|
| 799 |
+
<div style="width: 100%; background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-bottom: 30px; border: 1px solid #e9ecef;">
|
| 800 |
+
<h4 style="text-align: center; color: #666; margin: 20px 0;">{period_title} - 트렌드 데이터가 없습니다.</h4>
|
| 801 |
+
</div>
|
| 802 |
+
"""
|
| 803 |
+
continue
|
| 804 |
+
|
| 805 |
+
chart_html += f"""
|
| 806 |
+
<div style="width: 100%; background: white; padding: 25px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-bottom: 30px; border: 1px solid #e9ecef;">
|
| 807 |
+
<h4 style="text-align: center; color: #333; margin-bottom: 25px; font-size: 18px; border-bottom: 2px solid #FB7F0D; padding-bottom: 10px;">
|
| 808 |
+
🚀 {period_title}
|
| 809 |
+
</h4>
|
| 810 |
+
"""
|
| 811 |
+
|
| 812 |
+
# 각 키워드별로 차트 생성
|
| 813 |
+
for i, (keyword, data) in enumerate(monthly_data.items()):
|
| 814 |
+
volumes = data["monthly_volumes"]
|
| 815 |
+
dates = data["dates"]
|
| 816 |
+
growth_rate = data["growth_rate"]
|
| 817 |
+
actual_count = data.get("actual_count", len(volumes))
|
| 818 |
+
|
| 819 |
+
if not volumes:
|
| 820 |
+
continue
|
| 821 |
+
|
| 822 |
+
# 차트 색상
|
| 823 |
+
color = colors[i % len(colors)]
|
| 824 |
+
predicted_color = f"{color}80" # 50% 투명도
|
| 825 |
+
|
| 826 |
+
# Y축을 0부터 최대값까지로 설정
|
| 827 |
+
max_volume = max(volumes) if volumes else 1
|
| 828 |
+
|
| 829 |
+
chart_html += f"""
|
| 830 |
+
<div style="width: 100%; margin-bottom: 30px; border: 1px solid #e9ecef; border-radius: 8px; overflow: hidden;">
|
| 831 |
+
<div style="padding: 20px; background: white;">
|
| 832 |
+
<h5 style="margin: 0 0 20px 0; color: #333; font-size: 16px;">
|
| 833 |
+
{keyword} ({get_growth_rate_label(period_code)}: {growth_rate:+.1f}%)
|
| 834 |
+
</h5>
|
| 835 |
+
|
| 836 |
+
<!-- 차트 영역 -->
|
| 837 |
+
<div style="position: relative; height: 350px; margin: 30px 0 60px 80px; border-left: 2px solid #333; border-bottom: 2px solid #333; padding: 10px;">
|
| 838 |
+
|
| 839 |
+
<!-- Y축 라벨 -->
|
| 840 |
+
<div style="position: absolute; left: -70px; top: -10px; width: 60px; text-align: right; font-size: 11px; color: #333; font-weight: bold;">
|
| 841 |
+
{max_volume:,}
|
| 842 |
+
</div>
|
| 843 |
+
<div style="position: absolute; left: -70px; top: 50%; transform: translateY(-50%); width: 60px; text-align: right; font-size: 10px; color: #666;">
|
| 844 |
+
{max_volume // 2:,}
|
| 845 |
+
</div>
|
| 846 |
+
<div style="position: absolute; left: -70px; bottom: -5px; width: 60px; text-align: right; font-size: 10px; color: #666;">
|
| 847 |
+
0
|
| 848 |
+
</div>
|
| 849 |
+
|
| 850 |
+
<!-- X축 그리드 라인 -->
|
| 851 |
+
<div style="position: absolute; top: 0; left: 0; right: 0; height: 1px; background: #eee;"></div>
|
| 852 |
+
<div style="position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: #eee;"></div>
|
| 853 |
+
<div style="position: absolute; bottom: 0; left: 0; right: 0; height: 1px; background: #333;"></div>
|
| 854 |
+
|
| 855 |
+
<!-- 차트 바 컨테이너 -->
|
| 856 |
+
<div style="display: flex; align-items: end; height: 100%; gap: 1px; padding: 5px 0;">
|
| 857 |
+
"""
|
| 858 |
+
|
| 859 |
+
# 데이터와 날짜를 시간순으로 정렬
|
| 860 |
+
chart_data = list(zip(dates, volumes, range(len(volumes))))
|
| 861 |
+
chart_data.sort(key=lambda x: x[0]) # 날짜순 정렬
|
| 862 |
+
|
| 863 |
+
# 막대 차트 생성
|
| 864 |
+
for date, volume, original_index in chart_data:
|
| 865 |
+
# 막대 높이 계산
|
| 866 |
+
height_percent = (volume / max_volume) * 100 if max_volume > 0 else 0
|
| 867 |
+
|
| 868 |
+
# 실제 데이터와 예상 데이터 구분
|
| 869 |
+
is_predicted = original_index >= actual_count
|
| 870 |
+
bar_color = predicted_color if is_predicted else color
|
| 871 |
+
|
| 872 |
+
# 날짜 포맷
|
| 873 |
+
try:
|
| 874 |
+
date_obj = datetime.strptime(date, "%Y-%m-%d")
|
| 875 |
+
year_short = str(date_obj.year)[-2:]
|
| 876 |
+
month_num = date_obj.month
|
| 877 |
+
|
| 878 |
+
if is_predicted:
|
| 879 |
+
date_formatted = f"{year_short}.{month_num:02d}"
|
| 880 |
+
full_date = date_obj.strftime("%Y년 %m월") + " (예상)"
|
| 881 |
+
bar_style = f"border: 2px dashed #333; background: repeating-linear-gradient(90deg, {bar_color}, {bar_color} 5px, transparent 5px, transparent 10px);"
|
| 882 |
+
else:
|
| 883 |
+
date_formatted = f"{year_short}.{month_num:02d}"
|
| 884 |
+
full_date = date_obj.strftime("%Y년 %m월")
|
| 885 |
+
bar_style = f"background: linear-gradient(to top, {bar_color}, {bar_color}dd);"
|
| 886 |
+
except:
|
| 887 |
+
date_formatted = date[-5:].replace('-', '.')
|
| 888 |
+
full_date = date
|
| 889 |
+
bar_style = f"background: linear-gradient(to top, {bar_color}, {bar_color}dd);"
|
| 890 |
+
|
| 891 |
+
# 고유 ID 생성
|
| 892 |
+
chart_id = f"bar_{period_code}_{i}_{original_index}"
|
| 893 |
+
|
| 894 |
+
chart_html += f"""
|
| 895 |
+
<div style="flex: 1; display: flex; flex-direction: column; align-items: center; position: relative; height: 100%;">
|
| 896 |
+
<!-- 막대 -->
|
| 897 |
+
<div id="{chart_id}" style="
|
| 898 |
+
{bar_style}
|
| 899 |
+
width: 100%;
|
| 900 |
+
height: {height_percent}%;
|
| 901 |
+
border-radius: 3px 3px 0 0;
|
| 902 |
+
position: relative;
|
| 903 |
+
cursor: pointer;
|
| 904 |
+
transition: all 0.3s ease;
|
| 905 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 906 |
+
min-height: 3px;
|
| 907 |
+
margin-top: auto;
|
| 908 |
+
"
|
| 909 |
+
onmouseover="
|
| 910 |
+
this.style.transform='scaleX(1.1)';
|
| 911 |
+
this.style.zIndex='10';
|
| 912 |
+
this.style.boxShadow='0 4px 8px rgba(0,0,0,0.3)';
|
| 913 |
+
document.getElementById('tooltip_{chart_id}').style.display='block';
|
| 914 |
+
"
|
| 915 |
+
onmouseout="
|
| 916 |
+
this.style.transform='scaleX(1)';
|
| 917 |
+
this.style.zIndex='1';
|
| 918 |
+
this.style.boxShadow='0 2px 4px rgba(0,0,0,0.1)';
|
| 919 |
+
document.getElementById('tooltip_{chart_id}').style.display='none';
|
| 920 |
+
">
|
| 921 |
+
<!-- 툴팁 -->
|
| 922 |
+
<div id="tooltip_{chart_id}" style="
|
| 923 |
+
display: none;
|
| 924 |
+
position: absolute;
|
| 925 |
+
bottom: calc(100% + 10px);
|
| 926 |
+
left: 50%;
|
| 927 |
+
transform: translateX(-50%);
|
| 928 |
+
background: rgba(0,0,0,0.9);
|
| 929 |
+
color: white;
|
| 930 |
+
padding: 8px 12px;
|
| 931 |
+
border-radius: 6px;
|
| 932 |
+
font-size: 11px;
|
| 933 |
+
white-space: nowrap;
|
| 934 |
+
z-index: 1000;
|
| 935 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
| 936 |
+
pointer-events: none;
|
| 937 |
+
">
|
| 938 |
+
<div style="text-align: center;">
|
| 939 |
+
<div style="font-weight: bold; color: white; margin-bottom: 2px;">{full_date}</div>
|
| 940 |
+
<div style="color: #ffd700;">검색량: {volume:,}회</div>
|
| 941 |
+
{'<div style="color: #ff6b6b; margin-top: 2px; font-size: 10px;">예상 데이터</div>' if is_predicted else '<div style="color: #90EE90; margin-top: 2px; font-size: 10px;">실제 데이터</div>'}
|
| 942 |
+
</div>
|
| 943 |
+
<!-- 화살표 -->
|
| 944 |
+
<div style="
|
| 945 |
+
position: absolute;
|
| 946 |
+
top: 100%;
|
| 947 |
+
left: 50%;
|
| 948 |
+
transform: translateX(-50%);
|
| 949 |
+
width: 0;
|
| 950 |
+
height: 0;
|
| 951 |
+
border-left: 5px solid transparent;
|
| 952 |
+
border-right: 5px solid transparent;
|
| 953 |
+
border-top: 5px solid rgba(0,0,0,0.9);
|
| 954 |
+
"></div>
|
| 955 |
+
</div>
|
| 956 |
+
</div>
|
| 957 |
+
</div>
|
| 958 |
+
"""
|
| 959 |
+
|
| 960 |
+
chart_html += f"""
|
| 961 |
+
</div>
|
| 962 |
+
|
| 963 |
+
<!-- 월 라벨 -->
|
| 964 |
+
<div style="display: flex; gap: 1px; margin-top: 10px; padding: 0 5px;">
|
| 965 |
+
"""
|
| 966 |
+
|
| 967 |
+
# 월 라벨 생성
|
| 968 |
+
for date, volume, original_index in chart_data:
|
| 969 |
+
is_predicted = original_index >= actual_count
|
| 970 |
+
|
| 971 |
+
try:
|
| 972 |
+
date_obj = datetime.strptime(date, "%Y-%m-%d")
|
| 973 |
+
year_short = str(date_obj.year)[-2:]
|
| 974 |
+
month_num = date_obj.month
|
| 975 |
+
date_formatted = f"{year_short}.{month_num:02d}"
|
| 976 |
+
except:
|
| 977 |
+
date_formatted = date[-5:].replace('-', '.')
|
| 978 |
+
|
| 979 |
+
chart_html += f"""
|
| 980 |
+
<div style="
|
| 981 |
+
flex: 1;
|
| 982 |
+
text-align: center;
|
| 983 |
+
font-size: 9px;
|
| 984 |
+
color: {'#e74c3c' if is_predicted else '#666'};
|
| 985 |
+
font-weight: {'bold' if is_predicted else 'normal'};
|
| 986 |
+
transform: rotate(-45deg);
|
| 987 |
+
transform-origin: center;
|
| 988 |
+
line-height: 1;
|
| 989 |
+
margin-top: 8px;
|
| 990 |
+
">
|
| 991 |
+
{date_formatted}
|
| 992 |
+
</div>
|
| 993 |
+
"""
|
| 994 |
+
|
| 995 |
+
# 통계 정보
|
| 996 |
+
if period_code == "1year":
|
| 997 |
+
actual_volumes = volumes[:actual_count] # 실제 데이터만
|
| 998 |
+
else:
|
| 999 |
+
actual_volumes = volumes # 3년 전체 데이터
|
| 1000 |
+
|
| 1001 |
+
avg_volume = sum(actual_volumes) // len(actual_volumes) if actual_volumes else 0
|
| 1002 |
+
max_volume_val = max(actual_volumes) if actual_volumes else 0
|
| 1003 |
+
min_volume_val = min(actual_volumes) if actual_volumes else 0
|
| 1004 |
+
|
| 1005 |
+
chart_html += f"""
|
| 1006 |
+
</div>
|
| 1007 |
+
</div>
|
| 1008 |
+
|
| 1009 |
+
<!-- 범례 -->
|
| 1010 |
+
<div style="display: flex; justify-content: center; gap: 20px; margin: 15px 0; font-size: 12px;">
|
| 1011 |
+
<div style="display: flex; align-items: center; gap: 5px;">
|
| 1012 |
+
<div style="width: 15px; height: 15px; background: {color}; border-radius: 2px;"></div>
|
| 1013 |
+
<span style="color: #333;">실제 데이터</span>
|
| 1014 |
+
</div>
|
| 1015 |
+
<div style="display: flex; align-items: center; gap: 5px;">
|
| 1016 |
+
<div style="width: 15px; height: 15px; background: repeating-linear-gradient(90deg, {predicted_color}, {predicted_color} 3px, transparent 3px, transparent 6px); border: 1px dashed #333; border-radius: 2px;"></div>
|
| 1017 |
+
<span style="color: #e74c3c;">예상 데이터</span>
|
| 1018 |
+
</div>
|
| 1019 |
+
</div>
|
| 1020 |
+
|
| 1021 |
+
<!-- 통계 정보 -->
|
| 1022 |
+
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-top: 20px;">
|
| 1023 |
+
<div style="text-align: center; padding: 12px; background: white; border-radius: 8px; box-shadow: 0 2px 6px rgba(0,0,0,0.1); border: 1px solid #e9ecef;">
|
| 1024 |
+
<div style="font-size: 16px; font-weight: bold; color: #3498db;">{min_volume_val:,}</div>
|
| 1025 |
+
<div style="font-size: 11px; color: #666;">최저검색량</div>
|
| 1026 |
+
</div>
|
| 1027 |
+
<div style="text-align: center; padding: 12px; background: white; border-radius: 8px; box-shadow: 0 2px 6px rgba(0,0,0,0.1); border: 1px solid #e9ecef;">
|
| 1028 |
+
<div style="font-size: 16px; font-weight: bold; color: #2ecc71;">{avg_volume:,}</div>
|
| 1029 |
+
<div style="font-size: 11px; color: #666;">평균검색량</div>
|
| 1030 |
+
</div>
|
| 1031 |
+
<div style="text-align: center; padding: 12px; background: white; border-radius: 8px; box-shadow: 0 2px 6px rgba(0,0,0,0.1); border: 1px solid #e9ecef;">
|
| 1032 |
+
<div style="font-size: 16px; font-weight: bold; color: #e74c3c;">{max_volume_val:,}</div>
|
| 1033 |
+
<div style="font-size: 11px; color: #666;">최고검색량</div>
|
| 1034 |
+
</div>
|
| 1035 |
+
<div style="text-align: center; padding: 12px; background: white; border-radius: 8px; box-shadow: 0 2px 6px rgba(0,0,0,0.1); border: 1px solid #e9ecef;">
|
| 1036 |
+
<div style="font-size: 16px; font-weight: bold; color: #27ae60;">{growth_rate:+.1f}%</div>
|
| 1037 |
+
<div style="font-size: 11px; color: #666;">{get_growth_rate_label(period_code)}</div>
|
| 1038 |
+
</div>
|
| 1039 |
+
</div>
|
| 1040 |
+
"""
|
| 1041 |
+
|
| 1042 |
+
# 개선 설명
|
| 1043 |
+
if period_code == "1year":
|
| 1044 |
+
chart_html += f"""
|
| 1045 |
+
<div style="margin-top: 15px; padding: 12px; background: #e8f5e8; border-radius: 8px; text-align: center;">
|
| 1046 |
+
<p style="margin: 0; font-size: 13px; color: #155724;">
|
| 1047 |
+
📊 <strong>최근 1년 + 향후 3개월 예상</strong>: 실색 막대(실제), 빗금 막대(예상)
|
| 1048 |
+
</p>
|
| 1049 |
+
</div>
|
| 1050 |
+
"""
|
| 1051 |
+
else:
|
| 1052 |
+
chart_html += f"""
|
| 1053 |
+
<div style="margin-top: 15px; padding: 12px; background: #e3f2fd; border-radius: 8px; text-align: center;">
|
| 1054 |
+
<p style="margin: 0; font-size: 13px; color: #1565c0;">
|
| 1055 |
+
📊 <strong>최근 3년 트렌드</strong>: 전체 기간 검색량 데이터
|
| 1056 |
+
</p>
|
| 1057 |
+
</div>
|
| 1058 |
+
"""
|
| 1059 |
+
|
| 1060 |
+
chart_html += """
|
| 1061 |
+
</div>
|
| 1062 |
+
</div>
|
| 1063 |
+
"""
|
| 1064 |
+
|
| 1065 |
+
chart_html += "</div>"
|
| 1066 |
+
|
| 1067 |
+
chart_html += "</div>"
|
| 1068 |
+
|
| 1069 |
+
logger.info(f"개선된 정교한 트렌드 차트 생성 완료")
|
| 1070 |
+
return chart_html
|
| 1071 |
+
|
| 1072 |
+
except Exception as e:
|
| 1073 |
+
logger.error(f"차트 생성 오류: {e}")
|
| 1074 |
+
return f"""
|
| 1075 |
+
<div style="padding: 20px; background: #f8d7da; border-radius: 8px; color: #721c24;">
|
| 1076 |
+
<h4>차트 생성 오류</h4>
|
| 1077 |
+
<p>오류: {str(e)}</p>
|
| 1078 |
+
</div>
|
| 1079 |
+
"""
|
| 1080 |
+
|
| 1081 |
+
def create_trend_chart_v7(monthly_data_1year, monthly_data_3year):
|
| 1082 |
+
"""개선된 트렌드 차트 생성"""
|
| 1083 |
+
try:
|
| 1084 |
+
chart_html = create_visual_trend_chart(monthly_data_1year, monthly_data_3year)
|
| 1085 |
+
return chart_html
|
| 1086 |
+
|
| 1087 |
+
except Exception as e:
|
| 1088 |
+
logger.error(f"차트 생성 오류: {e}")
|
| 1089 |
+
return f"""
|
| 1090 |
+
<div style="padding: 20px; background: #f8d7da; border-radius: 8px; color: #721c24;">
|
| 1091 |
+
<h4>차트 생성 오류</h4>
|
| 1092 |
+
<p>오류: {str(e)}</p>
|
| 1093 |
+
</div>
|
| 1094 |
+
"""
|
| 1095 |
+
|
| 1096 |
+
def get_growth_rate_label(period_code):
|
| 1097 |
+
"""기간에 따른 성장률 라벨 반환"""
|
| 1098 |
+
if period_code == "1year":
|
| 1099 |
+
return "예상 3개월 증감율"
|
| 1100 |
+
else: # 3year
|
| 1101 |
+
return "작년대비 증감율"
|
| 1102 |
+
|
| 1103 |
+
def create_error_chart(error_msg):
|
| 1104 |
+
"""에러 발생시 대체 차트"""
|
| 1105 |
+
return f"""
|
| 1106 |
+
<div style="padding: 20px; background: #f8d7da; border-radius: 8px; color: #721c24;">
|
| 1107 |
+
<h4>차트 생성 오류</h4>
|
| 1108 |
+
<p>오류: {error_msg}</p>
|
| 1109 |
+
</div>
|
| 1110 |
+
"""
|
| 1111 |
+
|
| 1112 |
+
# ===== 호환성 함수들 (기존 코드와의 호환성 유지) =====
|
| 1113 |
+
|
| 1114 |
+
def get_naver_trend_data_v4(keywords, period="1year", max_retries=3):
|
| 1115 |
+
"""기존 함수 호환성 유지"""
|
| 1116 |
+
return get_naver_trend_data_v5(keywords, period, max_retries)
|
| 1117 |
+
|
| 1118 |
+
def calculate_monthly_volumes_v6(keywords, current_volumes, trend_data, period="1year"):
|
| 1119 |
+
"""기존 함수 호환성 유지"""
|
| 1120 |
+
return calculate_monthly_volumes_v7(keywords, current_volumes, trend_data, period)
|
| 1121 |
+
|
| 1122 |
+
def calculate_monthly_volumes_v5(keywords, current_volumes, trend_data, period="1year"):
|
| 1123 |
+
"""기존 함수 호환성 유지"""
|
| 1124 |
+
return calculate_monthly_volumes_v7(keywords, current_volumes, trend_data, period)
|
| 1125 |
+
|
| 1126 |
+
def create_trend_chart_v6(monthly_data_1year, monthly_data_3year):
|
| 1127 |
+
"""기존 함수 호환성 유지"""
|
| 1128 |
+
return create_trend_chart_v7(monthly_data_1year, monthly_data_3year)
|