ssboost commited on
Commit
111636c
·
verified ·
1 Parent(s): 9f16aa1

Upload 11 files

Browse files
Files changed (10) hide show
  1. api_utils.py +253 -0
  2. app.py +351 -173
  3. export_utils.py +478 -0
  4. keyword_analysis.py +1773 -0
  5. keyword_analysis_report.css +422 -0
  6. keyword_diversity_fix.py +943 -0
  7. keyword_search.py +194 -0
  8. style.css +264 -43
  9. text_utils.py +227 -0
  10. 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,54 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
2
  import pandas as pd
3
  import os
4
  import logging
5
- from datetime import datetime
6
- import pytz
7
  import time
8
- import tempfile
9
- import zipfile
10
  import re
11
- import json
 
 
 
 
 
 
 
 
12
 
13
- # 로깅 설정 - 클라이언트 정보 숨김
14
- logging.basicConfig(level=logging.WARNING, format='%(asctime)s - %(levelname)s - %(message)s')
15
  logger = logging.getLogger(__name__)
16
 
17
- # 외부 라이브러리 로그 비활성화
18
- logging.getLogger('gradio').setLevel(logging.WARNING)
19
- logging.getLogger('gradio_client').setLevel(logging.WARNING)
20
- logging.getLogger('httpx').setLevel(logging.WARNING)
21
- logging.getLogger('urllib3').setLevel(logging.WARNING)
22
 
23
- # ===== API 클라이언트 설정 =====
24
- def get_api_client():
25
- """환경변수에서 API 엔드포인트를 가져와 클라이언트 생성"""
26
  try:
27
- from gradio_client import Client
28
-
29
- # 환경변수에서 API 엔드포인트 가져오기
30
- api_endpoint = os.getenv('API_ENDPOINT')
31
-
32
- if not api_endpoint:
33
- logger.error("API 엔드포인트가 설정되지 않았습니다.")
34
- raise ValueError("API 엔드포인트가 설정되지 않았습니다.")
35
-
36
- client = Client(api_endpoint)
37
- logger.info("원격 API 클라이언트 초기화 성공")
38
- return client
39
 
 
 
 
 
 
 
 
40
  except Exception as e:
41
- logger.error(f"API 클라이언트 초기화 실패: {e}")
42
  return None
43
 
 
 
 
44
  # ===== 한국시간 관련 함수 =====
45
  def get_korean_time():
46
  """한국시간 반환"""
47
- try:
48
- korea_tz = pytz.timezone('Asia/Seoul')
49
- return datetime.now(korea_tz)
50
- except:
51
- return datetime.now()
 
 
 
52
 
53
  def format_korean_datetime(dt=None, format_type="filename"):
54
  """한국시간 포맷팅"""
@@ -71,7 +92,7 @@ def create_loading_animation():
71
  <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);">
72
  <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>
73
  <h3 style="color: #FB7F0D; margin: 10px 0; font-size: 18px;">분석 중입니다...</h3>
74
- <p style="color: #666; margin: 5px 0; text-align: center;">원격 서에서 데이터를 수집하고 AI가 분석하고 있습니다.<br>잠시만 기다려주세요.</p>
75
  <div style="width: 200px; height: 4px; background: #f0f0f0; border-radius: 2px; margin-top: 15px; overflow: hidden;">
76
  <div style="width: 100%; height: 100%; background: linear-gradient(90deg, #FB7F0D, #ff9a8b); border-radius: 2px; animation: progress 2s ease-in-out infinite;"></div>
77
  </div>
@@ -96,20 +117,203 @@ def generate_error_response(error_message):
96
  return f'''
97
  <div style="color: red; padding: 30px; text-align: center; width: 100%;
98
  background-color: #f8d7da; border-radius: 12px; border: 1px solid #f5c6cb;">
99
- <h3 style="margin-bottom: 15px;">❌ 연결 오류</h3>
100
  <p style="margin-bottom: 20px;">{error_message}</p>
101
  <div style="background: white; padding: 15px; border-radius: 8px; color: #333;">
102
  <h4>해결 방법:</h4>
103
  <ul style="text-align: left; padding-left: 20px;">
 
 
104
  <li>네트워크 연결을 확인해주세요</li>
105
- <li>원격 서버 상태를 확인해주세요</li>
106
  <li>잠시 후 다시 시도해주세요</li>
107
- <li>문제가 지속되면 관리자에게 문의하세요</li>
108
  </ul>
109
  </div>
110
  </div>
111
  '''
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  # ===== 파일 출력 함수들 =====
114
  def create_timestamp_filename(analysis_keyword):
115
  """타임스탬프가 포함된 파일명 생성 - 한국시간 적용"""
@@ -317,72 +521,16 @@ def export_analysis_results(export_data):
317
  logger.error(f"분석 결과 출력 오류: {e}")
318
  return None, f"출력 중 오류가 발생했습니다: {str(e)}"
319
 
320
- # ===== 원격 API 호출 함수들 =====
321
- def call_analyze_keyword_api(analysis_keyword):
322
- """키워드 심층분석 API 호출"""
323
- try:
324
- client = get_api_client()
325
- if not client:
326
- return generate_error_response("API 클라이언트를 초기화할 수 없습니다."), {}
327
-
328
- logger.info("원격 API 호출: 키워드 심층분석")
329
- result = client.predict(
330
- analysis_keyword=analysis_keyword,
331
- api_name="/on_analyze_keyword"
332
- )
333
-
334
- logger.info(f"키워드 분석 API 결과 타입: {type(result)}")
335
-
336
- # 분석 결과로 export 데이터 생성
337
- if isinstance(result, str) and len(result) > 100:
338
- export_data = {
339
- "analysis_keyword": analysis_keyword,
340
- "analysis_html": result,
341
- "analysis_completed": True,
342
- "created_at": get_korean_time().isoformat()
343
- }
344
- return result, export_data
345
- else:
346
- return str(result), {}
347
-
348
- except Exception as e:
349
- logger.error(f"키워드 심층분석 API 호출 오류: {e}")
350
- return generate_error_response(f"원격 서버 연결 실패: {str(e)}"), {}
351
-
352
- def call_export_results_api(export_data):
353
- """분석 결과 출력 API 호출"""
354
- try:
355
- client = get_api_client()
356
- if not client:
357
- return None, "API 클라이언트를 초기화할 수 없습니다."
358
-
359
- logger.info("원격 API 호출: 분석 결과 출력")
360
- result = client.predict(
361
- api_name="/on_export_results"
362
- )
363
-
364
- logger.info(f"출력 API 결과 타입: {type(result)}")
365
-
366
- # 결과가 튜플인 경우 첫 번째 요소는 메시지, 두 번째는 파일
367
- if isinstance(result, tuple) and len(result) == 2:
368
- message, file_path = result
369
- if file_path:
370
- return file_path, message
371
- else:
372
- return None, message
373
- else:
374
- return None, str(result)
375
-
376
- except Exception as e:
377
- logger.error(f"분석 결과 출력 API 호출 오류: {e}")
378
- return None, f"원격 서버 연결 실패: {str(e)}"
379
-
380
  # ===== 그라디오 인터페이스 =====
381
  def create_interface():
382
  # CSS 파일 로드
383
  try:
384
  with open('style.css', 'r', encoding='utf-8') as f:
385
  custom_css = f.read()
 
 
 
 
386
  except:
387
  custom_css = """
388
  :root { --primary-color: #FB7F0D; --secondary-color: #ff9a8b; }
@@ -397,30 +545,6 @@ def create_interface():
397
  font-size: 17px !important; font-weight: bold !important; width: 100% !important;
398
  margin-top: 20px !important;
399
  }
400
- .custom-frame {
401
- background-color: white !important;
402
- border: 1px solid #e5e5e5 !important;
403
- border-radius: 18px;
404
- padding: 20px;
405
- margin: 10px 0;
406
- box-shadow: 0 8px 30px rgba(251, 127, 13, 0.08) !important;
407
- }
408
- .section-title {
409
- display: flex;
410
- align-items: center;
411
- font-size: 20px;
412
- font-weight: 700;
413
- color: #334155 !important;
414
- margin-bottom: 10px;
415
- padding-bottom: 5px;
416
- border-bottom: 2px solid var(--primary-color);
417
- font-family: 'Pretendard', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
418
- }
419
- .section-title img, .section-title i {
420
- margin-right: 10px;
421
- font-size: 20px;
422
- color: var(--primary-color);
423
- }
424
  """
425
 
426
  with gr.Blocks(
@@ -473,7 +597,7 @@ def create_interface():
473
  yield create_loading_animation(), {}
474
 
475
  # 실제 키워드 분석 실행
476
- keyword_result, session_export_data = call_analyze_keyword_api(analysis_keyword)
477
 
478
  # 📈 검색량 트렌드 분석과 🎯 키워드 분석 표시
479
  yield keyword_result, session_export_data
@@ -481,7 +605,6 @@ def create_interface():
481
  def on_export_results(export_data):
482
  """분석 결과 출력 핸들러 - 세션별 데이터 처리"""
483
  try:
484
- # 로컬 출력 시도
485
  zip_path, message = export_analysis_results(export_data)
486
 
487
  if zip_path:
@@ -501,38 +624,14 @@ def create_interface():
501
  """
502
  return success_html, gr.update(value=zip_path, visible=True)
503
  else:
504
- # 로컬 출력 실패 원격 API
505
- try:
506
- remote_file, remote_message = call_export_results_api(export_data)
507
- if remote_file:
508
- success_html = f"""
509
- <div style="background: #d4edda; border: 1px solid #c3e6cb; padding: 20px; border-radius: 8px; margin: 10px 0;">
510
- <h4 style="color: #155724; margin: 0 0 15px 0;"><i class="fas fa-check-circle"></i> 원격 출력 완료!</h4>
511
- <p style="color: #155724; margin: 0; line-height: 1.6;">
512
- {remote_message}<br>
513
- <i class="fas fa-download"></i> 아래 다운로드 버튼을 클릭하여 파일을 저장하세요.
514
- </p>
515
- </div>
516
- """
517
- return success_html, gr.update(value=remote_file, visible=True)
518
- else:
519
- # 실패 메시지
520
- error_html = f"""
521
- <div style="background: #f8d7da; border: 1px solid #f5c6cb; padding: 20px; border-radius: 8px; margin: 10px 0;">
522
- <h4 style="color: #721c24; margin: 0 0 10px 0;"><i class="fas fa-exclamation-triangle"></i> 출력 실패</h4>
523
- <p style="color: #721c24; margin: 0;">{message}<br>원격 출력도 실패: {remote_message}</p>
524
- </div>
525
- """
526
- return error_html, gr.update(visible=False)
527
- except:
528
- # 원격 API도 실패
529
- error_html = f"""
530
- <div style="background: #f8d7da; border: 1px solid #f5c6cb; padding: 20px; border-radius: 8px; margin: 10px 0;">
531
- <h4 style="color: #721c24; margin: 0 0 10px 0;"><i class="fas fa-exclamation-triangle"></i> 출력 실패</h4>
532
- <p style="color: #721c24; margin: 0;">{message}</p>
533
- </div>
534
- """
535
- return error_html, gr.update(visible=False)
536
 
537
  except Exception as e:
538
  logger.error(f"출력 핸들러 오류: {e}")
@@ -559,42 +658,121 @@ def create_interface():
559
 
560
  return interface
561
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
562
  # ===== 메인 실행 =====
563
  if __name__ == "__main__":
564
  # pytz 모듈 설치 확인
565
- try:
566
- import pytz
567
  logger.info("✅ pytz 모듈 로드 성공 - 한국시간 지원")
568
- except ImportError:
569
  logger.warning("⚠️ pytz 모듈이 설치되지 않음 - pip install pytz 실행 필요")
570
  logger.info("시스템 시간을 사용합니다.")
571
 
572
- logger.info("===== 상품 소싱 분석 시스템 v2.10 (컨트롤 타워 버전) 시작 =====")
 
 
 
 
 
 
 
 
573
 
574
  # 필요한 패키지 안내
575
  print("📦 필요한 패키지:")
576
- print(" pip install gradio gradio_client pandas pytz")
577
  print()
578
 
579
- # API 엔드포인트 설정 안내
580
- api_endpoint = os.getenv('API_ENDPOINT')
581
- if not api_endpoint:
582
- print("⚠️ API_ENDPOINT 환경변수를 설정하세요.")
583
- print(" export API_ENDPOINT='your-endpoint-url'")
 
 
 
 
 
 
 
 
 
584
  print()
585
  else:
586
- print("✅ API 엔드포인트 설정 완료!")
 
 
 
 
587
  print()
588
 
589
- print("🚀 v2.10 컨트롤 타워 버전 특징:")
590
- print(" • 허깅페이스 그라디오 포인트 활용")
591
- print(" • 완전히 동일한 UI와 기능 구현")
592
- print(" • 📈 검색량 트렌분석과 🎯 키워드 분석 표시")
593
- print(" • 출력 기능: HTML 파일 생성 ZIP 다운로드")
 
 
 
594
  print(" • ✅ 한국시간 기준 파일명 생성")
595
  print(" • ✅ 멀티 사용자 안전: gr.State로 세션별 데이터 관리")
596
- print(" • 🔒 클라이언정보 환경변수완전 숨김")
597
- print(" • 원격 서버와 로컬 처리 하이브리드 방식")
598
  print()
599
 
600
  # 앱 실행
 
1
+ """
2
+ AI 상품 소싱 분석 시스템 v2.10 - 간략버전 + 출력기능 + 멀티사용자 안전
3
+ - 2단계: 수집된 키워드 목록 기능을 제거한다.
4
+ - 3단계: 분석할 키워드 선택에서 🔗 연관검색어 분석의 상품추출 및 분석기능을제거한다.
5
+ - 3단계: 분석할 키워드 선택의 명칭을 "키워드 심층분석 입력"이라고 바꾼다.
6
+ - 📈 검색량 트렌드 분석, 🎯 키워드 분석이 나와야한다.
7
+ - 출력 기능 추가: HTML 파일 생성 및 ZIP 다운로드
8
+ - Gemini API 키 랜덤 적용 (api_utils 통합 관리)
9
+ - 한국시간 적용
10
+ - 멀티 사용자 안전: gr.State 사용으로 세션별 데이터 관리
11
+ """
12
+
13
  import gradio as gr
14
  import pandas as pd
15
  import os
16
  import logging
17
+ import google.generativeai as genai
18
+ from datetime import datetime, timedelta
19
  import time
 
 
20
  import re
21
+ import zipfile
22
+ import tempfile
23
+
24
+ # 한국시간 적용을 위한 모듈 (선택적)
25
+ try:
26
+ import pytz
27
+ PYTZ_AVAILABLE = True
28
+ except ImportError:
29
+ PYTZ_AVAILABLE = False
30
 
31
+ # 로깅 설정
32
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
33
  logger = logging.getLogger(__name__)
34
 
35
+ # 모듈 임포트 (간략 버전에서 필요한 것만)
36
+ import api_utils
37
+ import keyword_search
38
+ import keyword_analysis
39
+ import trend_analysis_v2
40
 
41
+ # ===== Gemini API 설정 =====
42
+ def setup_gemini_model():
43
+ """Gemini 모델 초기화 - api_utils에서 관리 (랜덤 적용)"""
44
  try:
45
+ # api_utils에서 Gemini 모델 가져오기 (랜덤 키 적용)
46
+ model = api_utils.get_gemini_model()
 
 
 
 
 
 
 
 
 
 
47
 
48
+ if model:
49
+ logger.info("Gemini 모델 초기화 성공 (api_utils 통합 관리 - 랜덤 키)")
50
+ return model
51
+ else:
52
+ logger.warning("Gemini API 키가 설정되지 않았습니다.")
53
+ return None
54
+
55
  except Exception as e:
56
+ logger.error(f"Gemini 모델 초기화 실패: {e}")
57
  return None
58
 
59
+ # Gemini 모델 초기화
60
+ gemini_model = setup_gemini_model()
61
+
62
  # ===== 한국시간 관련 함수 =====
63
  def get_korean_time():
64
  """한국시간 반환"""
65
+ if PYTZ_AVAILABLE:
66
+ try:
67
+ korea_tz = pytz.timezone('Asia/Seoul')
68
+ return datetime.now(korea_tz)
69
+ except:
70
+ pass
71
+ # pytz가 없거나 오류 시 시스템 시간 사용
72
+ return datetime.now()
73
 
74
  def format_korean_datetime(dt=None, format_type="filename"):
75
  """한국시간 포맷팅"""
 
92
  <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);">
93
  <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>
94
  <h3 style="color: #FB7F0D; margin: 10px 0; font-size: 18px;">분석 중입니다...</h3>
95
+ <p style="color: #666; margin: 5px 0; text-align: center;">네이버 데이터를 수집하고 AI가 분석하고 있습니다.<br>잠시만 기다려주세요.</p>
96
  <div style="width: 200px; height: 4px; background: #f0f0f0; border-radius: 2px; margin-top: 15px; overflow: hidden;">
97
  <div style="width: 100%; height: 100%; background: linear-gradient(90deg, #FB7F0D, #ff9a8b); border-radius: 2px; animation: progress 2s ease-in-out infinite;"></div>
98
  </div>
 
117
  return f'''
118
  <div style="color: red; padding: 30px; text-align: center; width: 100%;
119
  background-color: #f8d7da; border-radius: 12px; border: 1px solid #f5c6cb;">
120
+ <h3 style="margin-bottom: 15px;">❌ 분석 오류</h3>
121
  <p style="margin-bottom: 20px;">{error_message}</p>
122
  <div style="background: white; padding: 15px; border-radius: 8px; color: #333;">
123
  <h4>해결 방법:</h4>
124
  <ul style="text-align: left; padding-left: 20px;">
125
+ <li>키워드 철자를 확인해주세요</li>
126
+ <li>더 간단한 키워드를 사용해보세요</li>
127
  <li>네트워크 연결을 확인해주세요</li>
 
128
  <li>잠시 후 다시 시도해주세요</li>
 
129
  </ul>
130
  </div>
131
  </div>
132
  '''
133
 
134
+ # ===== 메인 키워드 분석 함수 =====
135
+ def safe_keyword_analysis(analysis_keyword):
136
+ """에러 방지를 위한 안전한 키워드 분석 - 멀티사용자 안전"""
137
+
138
+ # 입력값 검증
139
+ if not analysis_keyword or not analysis_keyword.strip():
140
+ return generate_error_response("분석할 키워드를 입력해주세요."), {}
141
+
142
+ analysis_keyword = analysis_keyword.strip()
143
+
144
+ try:
145
+ # 검색량 조회 - 에러 방지
146
+ api_keyword = keyword_analysis.normalize_keyword_for_api(analysis_keyword)
147
+ search_volumes = keyword_search.fetch_all_search_volumes([api_keyword])
148
+ volume_data = search_volumes.get(api_keyword, {"PC검색량": 0, "모바일검색량": 0, "총검색량": 0})
149
+
150
+ # 검색량이 0이거나 키워드가 존재하지 않는 경우 처리
151
+ if volume_data['총검색량'] == 0:
152
+ logger.warning(f"'{analysis_keyword}' 키워드의 검색량이 0이거나 존재하지 않습니다.")
153
+ error_result = f"""
154
+ <div style="padding: 30px; text-align: center; background: #fff3cd; border-radius: 12px; border: 1px solid #ffeaa7;">
155
+ <h3 style="color: #856404; margin-bottom: 15px;">⚠️ 키워드 분석 불가</h3>
156
+ <p style="color: #856404; margin-bottom: 10px;"><strong>'{analysis_keyword}'</strong> 키워드는 검색량이 없거나 올바르지 않은 키워드입니다.</p>
157
+ <div style="background: white; padding: 15px; border-radius: 8px; margin-top: 15px;">
158
+ <h4 style="color: #333; margin-bottom: 10px;">💡 권장사항</h4>
159
+ <ul style="text-align: left; color: #666; padding-left: 20px;">
160
+ <li>키워드 철자를 확인해주세요</li>
161
+ <li>더 일반적인 키워드를 사용해보세요</li>
162
+ <li>키워드를 띄어쓰기로 구분해보세요 (예: '여성 슬리퍼')</li>
163
+ </ul>
164
+ </div>
165
+ </div>
166
+ """
167
+ return error_result, {}
168
+
169
+ logger.info(f"'{analysis_keyword}' 현재 검색량: {volume_data['총검색량']:,}")
170
+
171
+ # 트렌드 분석 시도
172
+ monthly_data_1year = {}
173
+ monthly_data_3year = {}
174
+ trend_available = False
175
+
176
+ try:
177
+ # 데이터랩 API 키 확인 (랜덤 적용)
178
+ datalab_config = api_utils.get_next_datalab_api_config()
179
+ if datalab_config and not datalab_config["CLIENT_ID"].startswith("YOUR_"):
180
+ logger.info("데이터랩 API 키가 설정되어 있어 1년, 3년 트렌드 분석을 시도합니다.")
181
+
182
+ # 최적화된 API 함수 사용
183
+ # 1년 트렌드 데이터
184
+ trend_data_1year = trend_analysis_v2.get_naver_trend_data_v5([analysis_keyword], "1year", max_retries=3)
185
+ if trend_data_1year:
186
+ current_volumes = {api_keyword: volume_data}
187
+ monthly_data_1year = trend_analysis_v2.calculate_monthly_volumes_v7([analysis_keyword], current_volumes, trend_data_1year, "1year")
188
+
189
+ # 3년 트렌드 데이터
190
+ trend_data_3year = trend_analysis_v2.get_naver_trend_data_v5([analysis_keyword], "3year", max_retries=3)
191
+ if trend_data_3year:
192
+ current_volumes = {api_keyword: volume_data}
193
+ monthly_data_3year = trend_analysis_v2.calculate_monthly_volumes_v7([analysis_keyword], current_volumes, trend_data_3year, "3year")
194
+
195
+ # 3년 데이터가 없는 경우 1년 데이터로 확장
196
+ if not monthly_data_3year and monthly_data_1year:
197
+ logger.info("3년 데이터가 없어 1년 데이터를 기반으로 3년 차트 생성")
198
+ keyword = analysis_keyword
199
+ if keyword in monthly_data_1year:
200
+ data_1y = monthly_data_1year[keyword]
201
+
202
+ # 3년 분량의 날짜 생성 (24개월 추가)
203
+ extended_dates = []
204
+ extended_volumes = []
205
+
206
+ # 기존 1년 데이터 이전에 24개월 추가 (모두 0으로)
207
+ start_date = datetime.strptime(data_1y["dates"][0], "%Y-%m-%d")
208
+ for i in range(24, 0, -1):
209
+ prev_date = start_date - timedelta(days=30 * i)
210
+ extended_dates.append(prev_date.strftime("%Y-%m-%d"))
211
+ extended_volumes.append(0)
212
+
213
+ # 기존 1년 데이터 추가 (예상 데이터 제외)
214
+ actual_count = data_1y.get("actual_count", len(data_1y["dates"]))
215
+ extended_dates.extend(data_1y["dates"][:actual_count])
216
+ extended_volumes.extend(data_1y["monthly_volumes"][:actual_count])
217
+
218
+ monthly_data_3year = {
219
+ keyword: {
220
+ "monthly_volumes": extended_volumes,
221
+ "dates": extended_dates,
222
+ "current_volume": data_1y["current_volume"],
223
+ "growth_rate": trend_analysis_v2.calculate_3year_growth_rate_improved(extended_volumes),
224
+ "volume_per_percent": data_1y["volume_per_percent"],
225
+ "current_ratio": data_1y["current_ratio"],
226
+ "actual_count": len(extended_volumes),
227
+ "predicted_count": 0
228
+ }
229
+ }
230
+
231
+ if monthly_data_1year or monthly_data_3year:
232
+ trend_available = True
233
+ logger.info("트렌드 분석 성공")
234
+ else:
235
+ logger.info("트렌드 데이터 처리 실패")
236
+ else:
237
+ logger.info("데이터랩 API 키가 설정되지 않음")
238
+ except Exception as e:
239
+ logger.info(f"트렌드 분석 건너뜀: {str(e)[:100]}")
240
+
241
+ # === 📈 검색량 트렌드 분석 섹션 ===
242
+ if trend_available and (monthly_data_1year or monthly_data_3year):
243
+ try:
244
+ trend_chart = trend_analysis_v2.create_trend_chart_v7(monthly_data_1year, monthly_data_3year)
245
+ except Exception as e:
246
+ logger.warning(f"트렌드 차트 생성 실패, 기본 차트 사용: {e}")
247
+ trend_chart = trend_analysis_v2.create_enhanced_current_chart(volume_data, analysis_keyword)
248
+ else:
249
+ trend_chart = trend_analysis_v2.create_enhanced_current_chart(volume_data, analysis_keyword)
250
+
251
+ # 트렌드 섹션
252
+ trend_section = f"""
253
+ <div style="width: 100%; margin: 30px auto; font-family: 'Pretendard', sans-serif;">
254
+ <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 15px; border-radius: 10px 10px 0 0; color: white; text-align: center;">
255
+ <h3 style="margin: 0; font-size: 18px; color: white;">📈 검색량 트렌드 분석</h3>
256
+ </div>
257
+ <div style="background: white; padding: 20px; border-radius: 0 0 10px 10px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
258
+ {trend_chart}
259
+ </div>
260
+ </div>
261
+ """
262
+
263
+ # === 🎯 키워드 분석 섹션 (AI 분석) ===
264
+ # api_utils에서 Gemini 모델 가져오기 (랜덤 키 적용)
265
+ current_gemini_model = api_utils.get_gemini_model()
266
+
267
+ keyword_analysis_html = keyword_analysis.analyze_keyword_for_sourcing(
268
+ analysis_keyword, volume_data, monthly_data_1year, monthly_data_3year,
269
+ None, [], current_gemini_model # 간략 버전에서는 추가 키워드 데이터 없음
270
+ )
271
+
272
+ keyword_analysis_section = f"""
273
+ <div style="width: 100%; margin: 30px auto; font-family: 'Pretendard', sans-serif;">
274
+ <div style="background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); padding: 15px; border-radius: 10px 10px 0 0; color: white; text-align: center;">
275
+ <h3 style="margin: 0; font-size: 18px; color: white;">🎯 키워드 분석</h3>
276
+ </div>
277
+ <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;">
278
+ {keyword_analysis_html}
279
+ </div>
280
+ </div>
281
+ """
282
+
283
+ # 경고 섹션 (필요한 경우)
284
+ warning_section = ""
285
+ if not trend_available:
286
+ warning_section = f"""
287
+ <div style="width: 100%; margin: 20px auto; padding: 15px; background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 8px; font-family: 'Pretendard', sans-serif;">
288
+ <div style="display: flex; align-items: center;">
289
+ <span style="font-size: 20px; margin-right: 10px;">⚠️</span>
290
+ <div>
291
+ <strong style="color: #856404;">일부 기능 제한</strong>
292
+ <div style="font-size: 14px; color: #856404; margin-top: 5px;">
293
+ 트렌드 분석에 제한이 있습니다. 현재 검색량 분석과 AI 추천은 정상 제공됩니다.<br>
294
+ <small>완전한 월 데이터 기준으로 분석하기 위해 최신 완료된 월까지만 표시됩니다.</small>
295
+ </div>
296
+ </div>
297
+ </div>
298
+ </div>
299
+ """
300
+
301
+ # 최종 결과 조합
302
+ final_result = warning_section + trend_section + keyword_analysis_section
303
+
304
+ # 세션별 출력 데이터 반환 (멀티 사용자 안전)
305
+ session_export_data = {
306
+ "analysis_keyword": analysis_keyword,
307
+ "analysis_html": final_result
308
+ }
309
+
310
+ return final_result, session_export_data
311
+
312
+ except Exception as e:
313
+ logger.error(f"키워드 분석 중 전체 오류: {e}")
314
+ error_result = generate_error_response(f"키워드 분석 중 오류가 발생했습니다: {str(e)}")
315
+ return error_result, {}
316
+
317
  # ===== 파일 출력 함수들 =====
318
  def create_timestamp_filename(analysis_keyword):
319
  """타임스탬프가 포함된 파일명 생성 - 한국시간 적용"""
 
521
  logger.error(f"분석 결과 출력 오류: {e}")
522
  return None, f"출력 중 오류가 발생했습니다: {str(e)}"
523
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  # ===== 그라디오 인터페이스 =====
525
  def create_interface():
526
  # CSS 파일 로드
527
  try:
528
  with open('style.css', 'r', encoding='utf-8') as f:
529
  custom_css = f.read()
530
+
531
+ with open('keyword_analysis_report.css', 'r', encoding='utf-8') as f:
532
+ keyword_css = f.read()
533
+ custom_css += "\n" + keyword_css
534
  except:
535
  custom_css = """
536
  :root { --primary-color: #FB7F0D; --secondary-color: #ff9a8b; }
 
545
  font-size: 17px !important; font-weight: bold !important; width: 100% !important;
546
  margin-top: 20px !important;
547
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
548
  """
549
 
550
  with gr.Blocks(
 
597
  yield create_loading_animation(), {}
598
 
599
  # 실제 키워드 분석 실행
600
+ keyword_result, session_export_data = safe_keyword_analysis(analysis_keyword)
601
 
602
  # 📈 검색량 트렌드 분석과 🎯 키워드 분석 표시
603
  yield keyword_result, session_export_data
 
605
  def on_export_results(export_data):
606
  """분석 결과 출력 핸들러 - 세션별 데이터 처리"""
607
  try:
 
608
  zip_path, message = export_analysis_results(export_data)
609
 
610
  if zip_path:
 
624
  """
625
  return success_html, gr.update(value=zip_path, visible=True)
626
  else:
627
+ # 실패
628
+ error_html = f"""
629
+ <div style="background: #f8d7da; border: 1px solid #f5c6cb; padding: 20px; border-radius: 8px; margin: 10px 0;">
630
+ <h4 style="color: #721c24; margin: 0 0 10px 0;"><i class="fas fa-exclamation-triangle"></i> 출력 실패</h4>
631
+ <p style="color: #721c24; margin: 0;">{message}</p>
632
+ </div>
633
+ """
634
+ return error_html, gr.update(visible=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
635
 
636
  except Exception as e:
637
  logger.error(f"출력 핸들러 오류: {e}")
 
658
 
659
  return interface
660
 
661
+ # ===== API 설정 확인 함수 =====
662
+ def check_datalab_api_config():
663
+ """네이버 데이터랩 API 설정 확인"""
664
+ logger.info("=== 네이버 데이터랩 API 설정 확인 ===")
665
+
666
+ datalab_config = api_utils.get_next_datalab_api_config()
667
+
668
+ if not datalab_config:
669
+ logger.warning("�� 데이터랩 API 키가 설정되지 않았습니다.")
670
+ logger.info("트렌드 분석 기능이 비활성화됩니다.")
671
+ return False
672
+
673
+ client_id = datalab_config["CLIENT_ID"]
674
+ client_secret = datalab_config["CLIENT_SECRET"]
675
+
676
+ logger.info(f"총 {len(api_utils.NAVER_DATALAB_CONFIGS)}개의 데이터랩 API 설정 사용 중")
677
+ logger.info(f"현재 선택된 API:")
678
+ logger.info(f" CLIENT_ID: {client_id[:8]}***{client_id[-4:] if len(client_id) > 12 else '***'}")
679
+ logger.info(f" CLIENT_SECRET: {client_secret[:4]}***{client_secret[-2:] if len(client_secret) > 6 else '***'}")
680
+
681
+ # 기본값 체크
682
+ if client_id.startswith("YOUR_"):
683
+ logger.error("❌ CLIENT_ID가 기본값으로 설정되어 있습니다!")
684
+ return False
685
+
686
+ if client_secret.startswith("YOUR_"):
687
+ logger.error("❌ CLIENT_SECRET이 기본값으로 설정되어 있습니다!")
688
+ return False
689
+
690
+ # 길이 체크
691
+ if len(client_id) < 10:
692
+ logger.warning("⚠️ CLIENT_ID가 짧습니다. 올바른 키인지 확인해주세요.")
693
+
694
+ if len(client_secret) < 5:
695
+ logger.warning("⚠️ CLIENT_SECRET이 짧습니다. 올바른 키인지 확인해주세요.")
696
+
697
+ logger.info("✅ 데이터랩 API 키 형식 검증 완료")
698
+ return True
699
+
700
+ def check_gemini_api_config():
701
+ """Gemini API 설정 확인 - 랜덤 키 적용"""
702
+ logger.info("=== Gemini API 설정 확인 ===")
703
+
704
+ is_valid, message = api_utils.validate_gemini_config()
705
+
706
+ if is_valid:
707
+ logger.info(f"✅ {message}")
708
+ # 첫 번째 사용 가능한 키 테스트 (랜덤)
709
+ test_key = api_utils.get_next_gemini_api_key()
710
+ if test_key:
711
+ logger.info(f"현재 사용 중인 Gemini API 키: {test_key[:8]}***{test_key[-4:]}")
712
+ return True
713
+ else:
714
+ logger.warning(f"❌ {message}")
715
+ logger.info("AI 분석 기능이 제한될 수 있습니다.")
716
+ return False
717
+
718
  # ===== 메인 실행 =====
719
  if __name__ == "__main__":
720
  # pytz 모듈 설치 확인
721
+ if PYTZ_AVAILABLE:
 
722
  logger.info("✅ pytz 모듈 로드 성공 - 한국시간 지원")
723
+ else:
724
  logger.warning("⚠️ pytz 모듈이 설치되지 않음 - pip install pytz 실행 필요")
725
  logger.info("시스템 시간을 사용합니다.")
726
 
727
+ # API 설정 초기화
728
+ api_utils.initialize_api_configs()
729
+ logger.info("===== 상품 소싱 분석 시스템 v2.10 (간략버전 + 출력기능 + 랜덤키 + 멀티사용자 안전) 시작 =====")
730
+
731
+ # 네이버 데이터랩 API 설정 확인
732
+ datalab_available = check_datalab_api_config()
733
+
734
+ # Gemini API 설정 확인 (랜덤 키)
735
+ gemini_available = check_gemini_api_config()
736
 
737
  # 필요한 패키지 안내
738
  print("📦 필요한 패키지:")
739
+ print(" pip install gradio google-generativeai pandas requests xlsxwriter markdown plotly pytz")
740
  print()
741
 
742
+ # API 설정 안내
743
+ if not gemini_available:
744
+ print("⚠️ GEMINI_API_KEY 또는 GOOGLE_API_KEY 환경변수를 설정하세요.")
745
+ print(" export GEMINI_API_KEY='your-api-key'")
746
+ print(" 또는")
747
+ print(" export GOOGLE_API_KEY='your-api-key'")
748
+ print()
749
+
750
+ if not datalab_available:
751
+ print("⚠️ 네이버 데이터랩 API 트렌드 분석을 위해서는:")
752
+ print(" 1. 네이버 개발자센터(https://developers.naver.com)에서 애플리케이션 등록")
753
+ print(" 2. '데이터랩(검색어 트렌드)' API 추가")
754
+ print(" 3. 발급받은 CLIENT_ID와 CLIENT_SECRET을 api_utils.py의 NAVER_DATALAB_CONFIGS에 설정")
755
+ print(" 4. 현재는 현재 검색량 정보만 표시됩니다.")
756
  print()
757
  else:
758
+ print("✅ 데이터랩 API 설정 완료 - 1년, 3년 트렌드 분석이 가능합니다!")
759
+ print()
760
+
761
+ if gemini_available:
762
+ print("✅ Gemini API 설정 완료 - 랜덤 키 로테이션 적용됩니다!")
763
  print()
764
 
765
+ print("🚀 v2.10 개선사항:")
766
+ print(" • 2단계: 수집된 키워목록 기능 ���거")
767
+ print(" • 3단계: 연관검색어 분석의 상품추출 및 분석 기능 제거")
768
+ print(" • 3단계: '분석할 키워선택' '키워드 심층분석 입력'으로 명칭 변경")
769
+ print(" • 📈 검색량 트렌드 분석과 🎯 키워드 분석만 표시")
770
+ print(" • ✅ 출력 기능 추가: HTML 파일 생성 및 ZIP 다운로드")
771
+ print(" • ✅ Gemini API 키 랜덤 로테이션 적용")
772
+ print(" • ✅ 네이버 데이터랩 API 키 랜덤 로테이션 적용")
773
  print(" • ✅ 한국시간 기준 파일명 생성")
774
  print(" • ✅ 멀티 사용자 안전: gr.State로 세션별 데이터 관리")
775
+ print(" • 불필요한 모듈 임포제거안정성 향상")
 
776
  print()
777
 
778
  # 앱 실행
export_utils.py ADDED
@@ -0,0 +1,478 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 결과 출력 관련 유틸리티 함수 모음 - 카테고리 항목 제거
3
+ - HTML 테이블 생성
4
+ - 엑셀 파일 생성
5
+ """
6
+
7
+ import pandas as pd
8
+ import tempfile
9
+ import os
10
+ import threading
11
+ import time
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
+ # 임시 파일 추적 리스트
23
+ _temp_files = []
24
+
25
+ def create_table_without_checkboxes(df):
26
+ """DataFrame을 HTML 테이블로 변환 - 키워드 클릭 시 네이버 쇼핑 이동 기능 추가"""
27
+ if df.empty:
28
+ return "<p>검색 결과가 없습니다.</p>"
29
+
30
+ # === 수정된 부분: 카테고리 관련 열 제거 ===
31
+ df_display = df.copy()
32
+
33
+ # "상품 등록 카테고리(상위100위)" 또는 "관련 카테고리", "카테고리 항목" 열이 있으면 제거
34
+ columns_to_remove = ["상품 등록 카테고리(상위100위)", "관련 카테고리", "카테고리 항목"]
35
+ for col in columns_to_remove:
36
+ if col in df_display.columns:
37
+ df_display = df_display.drop(columns=[col])
38
+ logger.info(f"테이블에서 '{col}' 열 제거됨")
39
+
40
+ # HTML 테이블 스타일 정의 - Z-INDEX 수정
41
+ html = '''
42
+ <style>
43
+ .table-container {
44
+ position: relative;
45
+ width: 100%;
46
+ margin: 0;
47
+ border-radius: 8px;
48
+ overflow: hidden;
49
+ box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
50
+ }
51
+
52
+ .header-wrap {
53
+ position: sticky;
54
+ top: 0;
55
+ z-index: 100; /* z-index 증가 */
56
+ background-color: #009879;
57
+ }
58
+
59
+ .styled-table {
60
+ width: 100%;
61
+ border-collapse: collapse;
62
+ table-layout: fixed;
63
+ margin: 0;
64
+ padding: 0;
65
+ font-size: 14px;
66
+ }
67
+
68
+ .styled-table th,
69
+ .styled-table td {
70
+ padding: 12px 15px;
71
+ text-align: left;
72
+ border-bottom: 1px solid #dddddd;
73
+ overflow: hidden;
74
+ text-overflow: ellipsis;
75
+ }
76
+
77
+ /* 긴 텍스트가 셀에서 줄바꿈되도록 수정 */
78
+ .styled-table td.col-rank {
79
+ white-space: normal;
80
+ word-break: break-word;
81
+ line-height: 1.3;
82
+ }
83
+
84
+ /* 그 외 열은 한 줄로 표시 */
85
+ .styled-table td.col-seq,
86
+ .styled-table td.col-keyword,
87
+ .styled-table td.col-pc,
88
+ .styled-table td.col-mobile,
89
+ .styled-table td.col-total,
90
+ .styled-table td.col-range,
91
+ .styled-table td.col-count {
92
+ white-space: nowrap;
93
+ }
94
+
95
+ .styled-table th {
96
+ background-color: #009879;
97
+ color: white;
98
+ font-weight: bold;
99
+ position: sticky;
100
+ top: 0;
101
+ white-space: nowrap;
102
+ z-index: 50; /* 헤더 z-index 증가 */
103
+ }
104
+
105
+ .styled-table tbody tr:nth-of-type(even) {
106
+ background-color: #f3f3f3;
107
+ }
108
+
109
+ .styled-table tbody tr:hover {
110
+ background-color: #f0f0f0;
111
+ }
112
+
113
+ .styled-table tbody tr:last-of-type {
114
+ border-bottom: 2px solid #009879;
115
+ }
116
+
117
+ /* 데이터 셀 z-index 설정 */
118
+ .styled-table tbody td {
119
+ position: relative;
120
+ z-index: 1; /* 데이터 셀은 낮은 z-index */
121
+ }
122
+
123
+ .data-container {
124
+ max-height: 600px;
125
+ overflow-y: auto;
126
+ position: relative; /* position 추가 */
127
+ }
128
+
129
+ /* 스크롤바 스타일 */
130
+ .data-container::-webkit-scrollbar {
131
+ width: 10px;
132
+ }
133
+
134
+ .data-container::-webkit-scrollbar-track {
135
+ background: #f1f1f1;
136
+ border-radius: 5px;
137
+ }
138
+
139
+ .data-container::-webkit-scrollbar-thumb {
140
+ background: #888;
141
+ border-radius: 5px;
142
+ }
143
+
144
+ .data-container::-webkit-scrollbar-thumb:hover {
145
+ background: #555;
146
+ }
147
+
148
+ /* 키워드 링크 스타일 - 새로 추가 */
149
+ .keyword-link {
150
+ color: #2c5aa0;
151
+ text-decoration: none;
152
+ font-weight: 600;
153
+ cursor: pointer;
154
+ transition: all 0.3s ease;
155
+ display: inline-block;
156
+ padding: 2px 4px;
157
+ border-radius: 3px;
158
+ position: relative;
159
+ z-index: 5; /* 링크 z-index 설정 */
160
+ }
161
+
162
+ .keyword-link:hover {
163
+ color: #ffffff;
164
+ background-color: #2c5aa0;
165
+ text-decoration: none;
166
+ transform: translateY(-1px);
167
+ box-shadow: 0 2px 4px rgba(44, 90, 160, 0.3);
168
+ }
169
+
170
+ .keyword-link:active {
171
+ transform: translateY(0px);
172
+ }
173
+
174
+ /* 키워드 셀 특별 스타일 */
175
+ .col-keyword {
176
+ position: relative;
177
+ }
178
+
179
+ .keyword-tooltip {
180
+ position: absolute;
181
+ bottom: 100%;
182
+ left: 50%;
183
+ transform: translateX(-50%);
184
+ background-color: #333;
185
+ color: white;
186
+ padding: 6px 10px;
187
+ border-radius: 4px;
188
+ font-size: 11px;
189
+ white-space: nowrap;
190
+ opacity: 0;
191
+ visibility: hidden;
192
+ transition: all 0.3s ease;
193
+ z-index: 1000; /* 툴팁은 가장 높은 z-index */
194
+ pointer-events: none;
195
+ margin-bottom: 5px;
196
+ }
197
+
198
+ .keyword-tooltip::after {
199
+ content: '';
200
+ position: absolute;
201
+ top: 100%;
202
+ left: 50%;
203
+ transform: translateX(-50%);
204
+ border: 4px solid transparent;
205
+ border-top-color: #333;
206
+ }
207
+
208
+ .keyword-link:hover .keyword-tooltip {
209
+ opacity: 1;
210
+ visibility: visible;
211
+ }
212
+
213
+ /* === 수정된 부분: 열 너비 정의 - 카테고리 열 제거 후 조정 === */
214
+ .col-seq { width: 8%; }
215
+ .col-keyword { width: 25%; }
216
+ .col-pc { width: 12%; }
217
+ .col-mobile { width: 12%; }
218
+ .col-total { width: 12%; }
219
+ .col-range { width: 12%; }
220
+ .col-rank { width: 15%; }
221
+ .col-count { width: 10%; }
222
+
223
+ .truncated-text {
224
+ position: relative;
225
+ cursor: pointer;
226
+ z-index: 2; /* 텍스트 z-index 설정 */
227
+ }
228
+
229
+ .truncated-text:hover::after {
230
+ content: attr(data-full-text);
231
+ position: absolute;
232
+ left: 0;
233
+ top: 100%;
234
+ z-index: 99;
235
+ min-width: 200px;
236
+ max-width: 400px;
237
+ padding: 8px;
238
+ background-color: #fff;
239
+ border: 1px solid #ddd;
240
+ border-radius: 4px;
241
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
242
+ white-space: normal;
243
+ }
244
+
245
+ /* 키워드 태그 스타일 */
246
+ .keyword-tag-container {
247
+ margin-top: 20px;
248
+ padding: 10px;
249
+ border: 1px solid #ddd;
250
+ border-radius: 5px;
251
+ background-color: #f9f9f9;
252
+ }
253
+
254
+ .keyword-tag {
255
+ display: inline-block;
256
+ background-color: #009879;
257
+ color: white;
258
+ padding: 5px 10px;
259
+ margin: 5px;
260
+ border-radius: 15px;
261
+ font-size: 12px;
262
+ }
263
+
264
+ .category-tag {
265
+ display: inline-block;
266
+ background-color: #2c7fb8;
267
+ color: white;
268
+ padding: 5px 10px;
269
+ margin: 5px;
270
+ border-radius: 15px;
271
+ font-size: 12px;
272
+ }
273
+
274
+ /* 분석 결과 테이블 스타일 */
275
+ .analysis-result {
276
+ margin-top: 30px;
277
+ border: 1px solid #ddd;
278
+ border-radius: 5px;
279
+ padding: 15px;
280
+ background-color: #f9f9f9;
281
+ }
282
+
283
+ .result-header {
284
+ font-weight: bold;
285
+ margin-bottom: 10px;
286
+ color: #009879;
287
+ }
288
+
289
+ .match-item {
290
+ margin: 5px 0;
291
+ padding: 5px;
292
+ border-bottom: 1px solid #eee;
293
+ }
294
+
295
+ .match-keyword {
296
+ font-weight: bold;
297
+ color: #2c7fb8;
298
+ }
299
+
300
+ .match-count {
301
+ display: inline-block;
302
+ background-color: #009879;
303
+ color: white;
304
+ padding: 2px 8px;
305
+ border-radius: 10px;
306
+ font-size: 12px;
307
+ margin-left: 10px;
308
+ }
309
+ </style>
310
+ '''
311
+
312
+ # === 수정된 부분: 열 이름과 클래스 매핑 - 카테고리 관련 제거 ===
313
+ col_mapping = {
314
+ "순번": "col-seq",
315
+ "조합 키워드": "col-keyword",
316
+ "PC검색량": "col-pc",
317
+ "모바일검색량": "col-mobile",
318
+ "총검색량": "col-total",
319
+ "검색량구간": "col-range",
320
+ "키워드 사용자순위": "col-rank",
321
+ "키워드 사용횟수": "col-count"
322
+ # 카테고리 관련 매핑 제거됨
323
+ }
324
+
325
+ # 테이블 컨테이너 시작
326
+ html += '<div class="table-container">'
327
+
328
+ # 단일 테이블 구조로 변경 (헤더는 position: sticky로 고정)
329
+ html += '<div class="data-container">'
330
+ html += '<table class="styled-table">'
331
+
332
+ # colgroup으로 열 너비 정의
333
+ html += '<colgroup>'
334
+ html += f'<col class="{col_mapping["순번"]}">'
335
+ for col in df_display.columns:
336
+ col_class = col_mapping.get(col, "")
337
+ html += f'<col class="{col_class}">'
338
+ html += '</colgroup>'
339
+
340
+ # 테이블 헤더
341
+ html += '<thead>'
342
+ html += '<tr>'
343
+ html += f'<th class="{col_mapping["순번"]}">순번</th>'
344
+ for col in df_display.columns:
345
+ col_class = col_mapping.get(col, "")
346
+ html += f'<th class="{col_class}">{col}</th>'
347
+ html += '</tr>'
348
+ html += '</thead>'
349
+
350
+ # 테이블 본문
351
+ html += '<tbody>'
352
+ for idx, row in df_display.iterrows():
353
+ html += '<tr>'
354
+ # 순번 표시 - 1부터 시작하는 순차적 번호
355
+ html += f'<td class="{col_mapping["순번"]}">{idx + 1}</td>'
356
+
357
+ # 데이터 셀 추가
358
+ for col in df_display.columns:
359
+ col_class = col_mapping.get(col, "")
360
+ value = str(row[col])
361
+
362
+ if col == "키워드 사용자순위":
363
+ # 긴 텍스트의 셀은 그대로 표시 (줄바꿈 허용)
364
+ html += f'<td class="{col_class}">{value}</td>'
365
+ elif len(value) > 30:
366
+ # 다른 긴 텍스트는 hover로 전체 표시
367
+ html += f'<td class="{col_class}"><div class="truncated-text" data-full-text="{value}">{value[:30]}...</div></td>'
368
+ else:
369
+ # 일반 텍스트
370
+ html += f'<td class="{col_class}">{value}</td>'
371
+ html += '</tr>'
372
+
373
+ html += '</tbody>'
374
+ html += '</table>'
375
+ html += '</div>' # data-container 닫기
376
+ html += '</div>' # table-container 닫기
377
+
378
+ return html
379
+
380
+ def cleanup_temp_files(delay=300):
381
+ """임시 파일 정리 함수"""
382
+ global _temp_files
383
+
384
+ def cleanup():
385
+ time.sleep(delay) # 지정된 시간 대기
386
+ temp_files_to_remove = _temp_files.copy()
387
+ _temp_files = []
388
+
389
+ for file_path in temp_files_to_remove:
390
+ try:
391
+ if os.path.exists(file_path):
392
+ os.remove(file_path)
393
+ logger.info(f"임시 파일 삭제: {file_path}")
394
+ except Exception as e:
395
+ logger.error(f"파일 삭제 오류: {e}")
396
+
397
+ # 새 스레드 시작
398
+ threading.Thread(target=cleanup, daemon=True).start()
399
+
400
+ def download_keywords(df, auto_cleanup=True, cleanup_delay=300):
401
+ """키워드 데이터를 엑셀 파일로 다운로드 - 카테고리 항목 제거"""
402
+ global _temp_files
403
+
404
+ if df is None or df.empty:
405
+ return None
406
+
407
+ # 임시 파일로 저장
408
+ temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx')
409
+ temp_file.close()
410
+ filename = temp_file.name
411
+
412
+ # 임시 파일 추적 목록에 추가
413
+ _temp_files.append(filename)
414
+
415
+ # === 수정된 부분: 카테고리 관련 열 제거 ===
416
+ df_export = df.copy()
417
+
418
+ # 카테고리 관련 열들 제거
419
+ columns_to_remove = ["상품 등록 카테고리(상위100위)", "관련 카테고리", "카테고리 항목"]
420
+ for col in columns_to_remove:
421
+ if col in df_export.columns:
422
+ df_export = df_export.drop(columns=[col])
423
+ logger.info(f"엑셀 내보내기에서 '{col}' 열 제거됨")
424
+
425
+ # 키워드 데이터를 엑셀 파일로 저장
426
+ with pd.ExcelWriter(filename, engine='xlsxwriter') as writer:
427
+ # 키워드 목록 시트
428
+ df_export.to_excel(writer, sheet_name='키워드 목록', index=False)
429
+
430
+ # 열 너비 조정 - 카테고리 열 제거 후 조정
431
+ worksheet = writer.sheets['키워드 목록']
432
+ worksheet.set_column('A:A', 20) # 조합 키워드 열
433
+ worksheet.set_column('B:B', 12) # PC검색량 열
434
+ worksheet.set_column('C:C', 12) # 모바일검색량 열
435
+ worksheet.set_column('D:D', 12) # 총검색량 열
436
+ worksheet.set_column('E:E', 12) # 검색량구간 열
437
+ worksheet.set_column('F:F', 20) # 키워드 사용자순위 열
438
+ worksheet.set_column('G:G', 12) # 키워드 사용횟수 열
439
+ # 카테고리 열들 제거로 H, I 열 설정 제거됨
440
+
441
+ # 헤더 형식 설정
442
+ header_format = writer.book.add_format({
443
+ 'bold': True,
444
+ 'bg_color': '#009879',
445
+ 'color': 'white',
446
+ 'border': 1
447
+ })
448
+
449
+ # 헤더에 형식 적용
450
+ for col_num, value in enumerate(df_export.columns.values):
451
+ worksheet.write(0, col_num, value, header_format)
452
+
453
+ logger.info(f"엑셀 파일 생성: {filename}")
454
+
455
+ # 파일 자동 정리 옵션
456
+ if auto_cleanup:
457
+ # 별도 정리 작업 요청 없이 추적 목록에 추가만 하여 일괄 처리
458
+ pass
459
+
460
+ return filename
461
+
462
+ def register_cleanup_handlers():
463
+ """앱 종료 시 정리를 위한 핸들러 등록"""
464
+ import atexit
465
+
466
+ def cleanup_all_temp_files():
467
+ global _temp_files
468
+ for file_path in _temp_files:
469
+ try:
470
+ if os.path.exists(file_path):
471
+ os.remove(file_path)
472
+ logger.info(f"종료 시 임시 파일 삭제: {file_path}")
473
+ except Exception as e:
474
+ logger.error(f"파일 삭제 오류: {e}")
475
+ _temp_files = []
476
+
477
+ # 앱 종료 시 실행될 함수 등록
478
+ atexit.register(cleanup_all_temp_files)
keyword_analysis.py ADDED
@@ -0,0 +1,1773 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
747
+ 월별 검색량 데이터 (최근 12개월):
748
+ {monthly_data_str}
749
+
750
+ 다음 형식으로만 답변하세요:
751
+
752
+ 상품유형: [봄시즌상품/여름시즌상품/가을시즌상품/겨울시즌상품/비시즌상품/크리스마스이벤트상품/밸런타인이벤트상품/어버이날이벤트상품/새학기이벤트상품/기타이벤트상품]
753
+ 피크월: [X월] (검색량이 가장 높은 월, 실제 수치 포함)
754
+ 성장월: [X월] (증가폭이 가장 높은 월)
755
+ 현재상태: {current_month}월 기준 [도입기/성장기/안정기/쇠퇴기/비시즌기간]
756
+ 진입추천: [구체적 월 제시]
757
+ """
758
+
759
+ response = gemini_model.generate_content(prompt)
760
+ result_text = response.text.strip()
761
+
762
+ lines = result_text.split('\n')
763
+ product_type = "비시즌상품"
764
+ peak_month = max_month if max_month else "연중"
765
+ growth_month = "연중"
766
+ current_status = "안정기"
767
+ entry_recommendation = "언제든지 진입 가능"
768
+
769
+ for line in lines:
770
+ line = line.strip()
771
+ if line.startswith('상품유형:'):
772
+ product_type = line.replace('상품유형:', '').strip()
773
+ elif line.startswith('피크월:'):
774
+ extracted_peak = line.replace('피크월:', '').strip()
775
+ if '(' in extracted_peak and ')' in extracted_peak:
776
+ peak_month = extracted_peak
777
+ else:
778
+ peak_month = max_month if max_month else extracted_peak
779
+ elif line.startswith('성장월:'):
780
+ growth_month = line.replace('성장월:', '').strip()
781
+ elif line.startswith('현재상태:'):
782
+ current_status = line.replace('현재상태:', '').strip()
783
+ elif line.startswith('진입추천:'):
784
+ entry_recommendation = line.replace('진입추천:', '').strip()
785
+
786
+ detail_info = f"상품유형: {product_type} | 피크월: {peak_month} | 성장월: {growth_month} | 현재상태: {current_status}"
787
+
788
+ logger.info(f"LLM 시즌 분석 완료: {product_type}, {entry_recommendation}")
789
+ return product_type, entry_recommendation, detail_info
790
+
791
+ except Exception as e:
792
+ logger.error(f"LLM 시즌 사이클 분석 오류: {e}")
793
+ return "비시즌상품", "언제든지 진입 가능", "LLM 분석 오류"
794
+
795
+ def analyze_sourcing_strategy_improved(keyword, volume_data, trend_data_1year, trend_data_3year, filtered_keywords_df, gemini_model):
796
+ """개선된 소싱전략 분석 - 포맷팅 수정 및 관여도 분석 강화"""
797
+
798
+ total_volume = volume_data.get('총검색량', 0)
799
+ current_date = datetime.now()
800
+ current_month = current_date.month
801
+ current_year = current_date.year
802
+
803
+ # ✅ 수정: 올바른 로직으로 상승폭 계산
804
+ growth_analysis = calculate_max_growth_rate_with_predictions(trend_data_3year, keyword)
805
+
806
+ # ✅ 수정: 올바른 로직으로 피크월 계산 (실제+예상 데이터 활용)
807
+ peak_month_with_volume = get_peak_month_with_predictions(trend_data_3year, keyword)
808
+
809
+ # LLM으로 시즌 분석 (기존 유지)
810
+ if gemini_model:
811
+ product_type, entry_timing, season_detail = analyze_season_cycle_with_llm(trend_data_3year, keyword, total_volume, gemini_model)
812
+ else:
813
+ # 기본값
814
+ product_type = "연중상품"
815
+ if total_volume > 50000:
816
+ product_type = "인기상품"
817
+ elif total_volume > 10000:
818
+ product_type = "중간상품"
819
+ elif total_volume > 0:
820
+ product_type = "틈새상품"
821
+
822
+ # 2. 관여도 분석 추가 - 초보자가 판매가능한 소싱 기준 (개선된 기준 적용)
823
+ involvement_level = analyze_involvement_level(keyword, total_volume, gemini_model)
824
+
825
+ # 트렌드 경고 메시지
826
+ trend_warning = ""
827
+ if not trend_data_3year:
828
+ trend_warning = "\n\n💡 더 정확한 트렌드 데이터를 위해 \"1단계: 기본 키워드 입력\"을 실행해보세요."
829
+
830
+ # 결과 포맷팅 수정 - 구분선과 항목 분리
831
+ result_content = f"""**🔖 상품유형**
832
+ {product_type}
833
+
834
+ {involvement_level}
835
+
836
+ **🔖 가장 검색량이 많은 월**
837
+ {peak_month_with_volume}
838
+
839
+ **🔖 가장 상승폭이 높은 월**
840
+ {growth_analysis}{trend_warning}"""
841
+
842
+ try:
843
+ return {"status": "success", "content": result_content}
844
+ except Exception as e:
845
+ logger.error(f"소싱전략 분석 오류: {e}")
846
+ return {"status": "error", "content": "소싱전략 분석을 완료할 수 없습니다."}
847
+
848
+ def analyze_involvement_level(keyword, total_volume, gemini_model):
849
+ """관여도 분석 함수 - 초보자가 판매가능한 소싱 기준"""
850
+ try:
851
+ # 기본 규칙 기반 분석
852
+ basic_involvement = get_basic_involvement_level(keyword, total_volume)
853
+
854
+ # Gemini가 있으면 LLM 분석도 수행
855
+ if gemini_model:
856
+ llm_involvement = get_llm_involvement_analysis(keyword, total_volume, gemini_model)
857
+ return llm_involvement
858
+ else:
859
+ return basic_involvement
860
+
861
+ except Exception as e:
862
+ logger.error(f"관여도 분석 오류: {e}")
863
+ return "복합관여도상품(상품에 따라 달라짐)"
864
+
865
+ def get_basic_involvement_level(keyword, total_volume):
866
+ """기본 규칙 기반 관여도 분석 - 초보자 판매 관점"""
867
+
868
+ # 저관여 상품 키워드 패턴 (초보자 진입 가능한 불편해소 제품)
869
+ low_involvement_keywords = [
870
+ # 불편해소/정리수납
871
+ "거치대", "받침대", "정리함", "정리대", "수납", "홀더", "스탠드",
872
+ "쿠션", "베개", "목베개", "방석", "매트", "패드",
873
+ # 케이블/전선 관리
874
+ "케이블", "선정리", "코드", "충전기", "어댑터",
875
+ # 청소/위생 (대기업 제품 제외)
876
+ "청소솔", "청소기", "걸레", "타올", "브러시",
877
+ # 자동차/실용용품
878
+ "차량용", "자동차", "핸드폰", "스마트폰", "태블릿",
879
+ # 간단한 도구/액세서리
880
+ "집게", "후크", "자석", "클립", "고리", "링", "홀더",
881
+ # 미끄럼방지/안전
882
+ "미끄럼", "논슬립", "방지", "보호", "커버", "케이스"
883
+ ]
884
+
885
+ # 고관여 상품 키워드 패턴 (대기업 독점 또는 고가/전문 제품)
886
+ high_involvement_keywords = [
887
+ # 대기업 독점 생필품
888
+ "휴지", "화장지", "물티슈", "마스크", "세제", "샴푸", "린스", "비누",
889
+ "치약", "칫솔", "기저귀", "생리대", "콘돔",
890
+ # 식품/음료 (브랜드 민감)
891
+ "라면", "과자", "음료", "커피", "차", "우유", "요구르트",
892
+ "쌀", "김", "참기름", "간장", "고추장", "된장",
893
+ # 고가 전자제품
894
+ "노트북", "컴퓨터", "스마트폰", "태블릿", "카메라", "TV", "모니터",
895
+ "냉장고", "세탁기", "에어컨", "청소기", "전자레인지",
896
+ # 의료/건강 (인증 필요)
897
+ "의료", "건강식품", "영양제", "비타민", "약", "의약품",
898
+ # 명품/브랜드
899
+ "명품", "브랜드", "럭셔리", "시계", "보석", "금", "은", "다이아몬드"
900
+ ]
901
+
902
+ keyword_lower = keyword.lower()
903
+
904
+ # 저관여 상품 체크 (불편해소 키워드 우선)
905
+ for low_kw in low_involvement_keywords:
906
+ if low_kw in keyword_lower:
907
+ return "저관여상품(초보자용)"
908
+
909
+ # 고관여 상품 체크 (대기업 독점/브랜드 민감 키워드)
910
+ for high_kw in high_involvement_keywords:
911
+ if high_kw in keyword_lower:
912
+ return "고관여상품(고급자용)"
913
+
914
+ # 검색량 ���반 추가 판단
915
+ if total_volume > 100000:
916
+ # 검색량이 매우 높으면 대기업이 관심 가질 만한 시장
917
+ return "고관여상품(고급자용)"
918
+ elif total_volume > 50000:
919
+ return "복합관여도상품(상품에 따라 달라짐)"
920
+ elif total_volume > 5000:
921
+ return "복합관여도상품(상품에 따라 달라짐)"
922
+ else:
923
+ # 검색량이 낮으면 틈새 시장, 초보자도 진입 가능
924
+ return "저관여상품(초보자용)"
925
+
926
+ def get_llm_involvement_analysis(keyword, total_volume, gemini_model):
927
+ """LLM을 이용한 정교한 관여도 분석 - 초보자 판매 관점 기준 적용"""
928
+ try:
929
+ prompt = f"""
930
+ '{keyword}' 상품의 관여도를 초보자 판매 관점에서 분석해주세요.
931
+
932
+ 검색량: {total_volume:,}회
933
+
934
+ 관여도 정의 (초보자가 판매가능한 소싱 기준):
935
+
936
+ 저관여상품(초보자용):
937
+ - 대기업 독점이 없는 영역
938
+ - 즉시 불편해소하는 제품 (지금 바로 필요한 문제 해결)
939
+ - 브랜드 상관없이 기능만 되면 구매하는 제품
940
+ - 1만원~3만원대 가격, 소량(100개 이하) 시작 가능
941
+ - 예시: 목베개, 스마트폰거치대, 서랍정리함, 케이블정리기
942
+
943
+ 고관여상품(고급자용):
944
+ - 대기업/브랜드가 시장을 독점하는 영역 (초보자 진입 불가)
945
+ - 생필품(휴지, 세제, 마스크 등) - 브랜드 충성도 높음
946
+ - 고가 제품(10만원 이상), 전문성/인증 필요
947
+ - 대자본 필요한 아이템
948
+ - 예시: 전자제품, 가전, 브랜드 생필품, 의료용품
949
+
950
+ 복합관여도상품(상품에 따라 달라짐):
951
+ - 가격대별로 저가형(저관여)과 고가형(고관여)이 공존
952
+ - 타겟이나 용도에 따라 관여도가 극명하게 달라짐
953
+ - 예시: 의류, 운동용품, 뷰티용품 등
954
+
955
+ 복합관여도상품으로 판단할 경우, 반드시 구체적인 이유를 설명하세요:
956
+ - 가격대별 분화: "1-3만원 중국산(저관여) vs 10-15만원 국산 수제(고관여)"
957
+ - 타겟별 차이: "일반인은 저관여 vs 전문가는 고관여"
958
+ - 용도별 차이: "임시용은 저관여 vs 장기용은 고관여"
959
+
960
+ 다음 형식으로 답변하세요:
961
+ [관여도 선택]
962
+ [구체적인 판단 이유 - 가격대/타겟/브랜드 독점 여부 등을 명확히 제시]
963
+
964
+ 선택지:
965
+ 저관여상품(초보자용)
966
+ 복합관여도상품(상품에 따라 달라짐)
967
+ 고관여상품(고급자용)
968
+ """
969
+
970
+ response = gemini_model.generate_content(prompt)
971
+ result = response.text.strip()
972
+
973
+ # 결과 필터링 - 정확한 형식만 허용
974
+ if "저관여상품(초보자용)" in result:
975
+ return "저관여상품(초보자용)"
976
+ elif "고관여상품(고급자용)" in result:
977
+ return "고관여상품(고급자용)"
978
+ elif "복합관여도상품(상품에 따라 달라짐)" in result:
979
+ return "복합관여도상품(상품에 따라 달라짐)"
980
+ else:
981
+ # LLM 응답이 부정확한 경우 기본 규칙으로 폴백
982
+ return get_basic_involvement_level(keyword, total_volume)
983
+
984
+ except Exception as e:
985
+ logger.error(f"LLM 관여도 분석 오류: {e}")
986
+ return get_basic_involvement_level(keyword, total_volume)
987
+
988
+
989
+ class CompactKeywordAnalyzer:
990
+ """간결한 7단계 키워드 분석기"""
991
+
992
+ def __init__(self, gemini_model):
993
+ self.gemini_model = gemini_model
994
+ self.max_retries = 3
995
+
996
+ def call_llm_with_retry(self, prompt: str, step_name: str = "") -> str:
997
+ """재시도 로직이 적용된 LLM 호출"""
998
+ last_error = None
999
+
1000
+ for attempt in range(self.max_retries):
1001
+ try:
1002
+ logger.info(f"{step_name} 시도 {attempt + 1}/{self.max_retries}")
1003
+ response = self.gemini_model.generate_content(prompt)
1004
+ result = response.text.strip()
1005
+
1006
+ if result and len(result) > 20:
1007
+ logger.info(f"{step_name} 성공")
1008
+ return result
1009
+ else:
1010
+ raise Exception("응답이 너무 짧거나 비어있음")
1011
+
1012
+ except Exception as e:
1013
+ last_error = e
1014
+ logger.warning(f"{step_name} 실패 (시도 {attempt + 1}): {e}")
1015
+
1016
+ if attempt < self.max_retries - 1:
1017
+ delay = 1.0 * (attempt + 1) + random.uniform(0, 0.5)
1018
+ time.sleep(delay)
1019
+
1020
+ logger.error(f"{step_name} 모든 재시도 실패: {last_error}")
1021
+ return f"{step_name} 분석을 완료할 수 없습니다."
1022
+
1023
+ def clean_markdown_and_bold(self, text: str) -> str:
1024
+ """마크다운과 볼드 처리를 완전히 제거"""
1025
+ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
1026
+ text = re.sub(r'\*(.+?)\*', r'\1', text)
1027
+ text = re.sub(r'__(.+?)__', r'\1', text)
1028
+ text = re.sub(r'_(.+?)_', r'\1', text)
1029
+ text = re.sub(r'##\s*(.+)', r'\1', text)
1030
+ text = re.sub(r'#\s*(.+)', r'\1', text)
1031
+ text = re.sub(r'\*+', '', text)
1032
+ text = re.sub(r'_+', '', text)
1033
+ return text.strip()
1034
+
1035
+ def analyze_sourcing_strategy(self, keyword: str, volume_data: dict, keywords_df: Optional[pd.DataFrame], trend_data_1year=None, trend_data_3year=None) -> str:
1036
+ """개선된 소싱전략 분석 - 경쟁데이터 제거"""
1037
+
1038
+ try:
1039
+ sourcing_analysis = analyze_sourcing_strategy_improved(
1040
+ keyword, volume_data, trend_data_1year, trend_data_3year, keywords_df, self.gemini_model
1041
+ )
1042
+ if sourcing_analysis["status"] == "success":
1043
+ return self.clean_markdown_and_bold(sourcing_analysis["content"])
1044
+ else:
1045
+ return sourcing_analysis["content"]
1046
+ except Exception as e:
1047
+ logger.error(f"소싱전략 분석 오류: {e}")
1048
+ return "소싱전략 분석을 완료할 수 없습니다."
1049
+
1050
+ def analyze_step1_product_type(self, keyword: str, keywords_df: Optional[pd.DataFrame]) -> str:
1051
+ """1단계. 상품유형 분석"""
1052
+
1053
+ related_keywords = ""
1054
+ if keywords_df is not None and not keywords_df.empty:
1055
+ top_keywords = keywords_df.head(10)['조합 키워드'].tolist()
1056
+ related_keywords = f"연관키워드: {', '.join(top_keywords)}"
1057
+
1058
+ prompt = f"""
1059
+ 당신은 초보 셀러가 상품 판매 성공을 빠르게 이룰 수 있도록 돕는 최고의 상품 소싱 및 상품기획 컨설턴트 AI입니다.
1060
+
1061
+ 분석 키워드: '{keyword}'
1062
+ {related_keywords}
1063
+
1064
+ 1단계. 상품유형 분석
1065
+
1066
+ 상품 유형 분류 기준:
1067
+ - 불편해결상품: 특정 문제나 불편함을 즉각 해결하는 제품
1068
+ - 업그레이드상품: 삶의 질과 만족도를 향상시키는 제품
1069
+ - 필수상품: 일상에서 반드시 필요하고 반복 구매되는 제품
1070
+ - 취향저격상품: 감성적, 개성적 욕구를 자극하는 제품
1071
+ - 융합상품: 위 2개 이상의 유형이 결합된 제품
1072
+
1073
+ 다음 형식으로 분석해주세요 (볼드, 마크다운 사용 금지):
1074
+
1075
+ 주요유형: [유형명]
1076
+ {keyword}는 [구체적 설명 - 왜 이 유형인지 본질적 가치와 해결하는 문제를 중심으로 2-3문장]
1077
+
1078
+ 보조유형: [해당 유형들]
1079
+ [유형1]
1080
+ - [이 유형에 해당하는 이유 1문장]
1081
+ [유형2]
1082
+ - [이 유형에 해당하는 이유 1문장]
1083
+ """
1084
+
1085
+ result = self.call_llm_with_retry(prompt, f"1단계-상품유형분석-{keyword}")
1086
+ return self.clean_markdown_and_bold(result)
1087
+
1088
+ def analyze_step2_target_customer(self, keyword: str, step1_result: str) -> str:
1089
+ """2단계. 소비자 타겟 설정"""
1090
+
1091
+ prompt = f"""
1092
+ 당신은 초보 셀러가 상품 판매 성공을 빠르게 이룰 수 있도록 돕는 최고의 상품 소싱 및 상품기획 컨설턴트 AI입니다.
1093
+
1094
+ 분석 키워드: '{keyword}'
1095
+
1096
+ 이전 분석 결과:
1097
+ {step1_result}
1098
+
1099
+ 2단계. 소비자 타겟 설정
1100
+
1101
+ 다음 형식으로 간결하게 분석해주세요 (볼드, 마크다운 사용 금지):
1102
+
1103
+ 고객상황
1104
+ - [구체적인 구매 상황들을 간단히]
1105
+
1106
+ 페르소나
1107
+ - [연령대, 성별, 라이프스타일을 통합하여 1-2줄로 간결하게]
1108
+
1109
+ 주요 니즈
1110
+ - [핵심 니즈 한줄만]
1111
+ """
1112
+
1113
+ result = self.call_llm_with_retry(prompt, f"2단계-타겟설정-{keyword}")
1114
+ return self.clean_markdown_and_bold(result)
1115
+
1116
+ def analyze_step3_sourcing_strategy(self, keyword: str, previous_results: str) -> str:
1117
+ """3단계. 타겟별 차별화된 소싱 전략 제안"""
1118
+
1119
+ prompt = f"""
1120
+ 당신은 초보 셀러가 상품 판매 성공을 빠르게 이룰 수 있도록 돕는 최고의 상품 소싱 및 상품기획 컨설턴트 AI입니다.
1121
+
1122
+ 분석 키워드: '{keyword}'
1123
+
1124
+ 이전 분석 결과:
1125
+ {previous_results}
1126
+
1127
+ 3단계. 타겟별 차별화된 소싱 전략 제안
1128
+
1129
+ 현실적으로 온라인에서 소싱 가능한 차별화 전략을 제안해주세요.
1130
+
1131
+ 다음 형식으로 분석해주세요 (볼드, 마크다운 사용 금지):
1132
+
1133
+ 핵심 구매 고려 요소 5가지
1134
+ 1. [요소1 간단히]
1135
+ 2. [요소2 간단히]
1136
+ 3. [요소3 간단히]
1137
+ 4. [요소4 간단히]
1138
+ 5. [요소5 간단히]
1139
+
1140
+ 차별화 소싱 전략
1141
+ 1. [전략명]
1142
+ - [현실적으로 소싱 가능한 구체적 방법 한줄]
1143
+
1144
+ 2. [전략명]
1145
+ - [현실적으로 소싱 가능한 구체적 방법 한줄]
1146
+
1147
+ 3. [전략명]
1148
+ - [현실적으로 소싱 가능한 구체적 방법 한줄]
1149
+
1150
+ 4. [전략명]
1151
+ - [현실적으로 소싱 가능한 구체적 방법 한줄]
1152
+
1153
+ 5. [전략명]
1154
+ - [현실적으로 소싱 가능한 구체적 방법 한줄]
1155
+ """
1156
+
1157
+ result = self.call_llm_with_retry(prompt, f"3단계-소싱전략-{keyword}")
1158
+ return self.clean_markdown_and_bold(result)
1159
+
1160
+ def analyze_step4_product_recommendation(self, keyword: str, previous_results: str) -> str:
1161
+ """4단계. 차별화 예시별 상품 5가지 추천"""
1162
+
1163
+ prompt = f"""
1164
+ 당신은 초보 셀러가 상품 판매 성공을 빠르게 이�� 수 있도록 돕는 최고의 상품 소싱 및 상품기획 컨설턴트 AI입니다.
1165
+
1166
+ 분석 키워드: '{keyword}'
1167
+
1168
+ 이전 분석 결과:
1169
+ {previous_results}
1170
+
1171
+ 4단계. 차별화 예시별 상품 5가지 추천
1172
+
1173
+ 3단계에서 도출한 차별화 요소를 반영하여 매출 가능성이 높은 순서대로 분석해주세요.
1174
+
1175
+ 다음 형식으로 분석해주세요 (볼드, 마크다운 사용 금지):
1176
+
1177
+ 차별화 상품 추천
1178
+ 1. [구체적인 상품명과 세부 특징]
1179
+ - [주요 특징들, 타겟 고객, 차별화 포인트를 한문장으로]
1180
+
1181
+ 2. [구체적인 상품명과 세부 특징]
1182
+ - [주요 특징들, 타겟 고객, 차별화 포인트를 한문장으로]
1183
+
1184
+ 3. [구체적인 상품명과 세부 특징]
1185
+ - [주요 특징들, 타겟 고객, 차별화 포인트를 한문장으로]
1186
+
1187
+ 4. [구체적인 상품명과 세부 특징]
1188
+ - [주요 특징들, 타겟 고객, 차별화 포인트를 한문장으로]
1189
+
1190
+ 5. [구체적인 상품명과 세부 특징]
1191
+ - [주요 특징들, 타겟 고객, 차별화 포인트를 한문장으로]
1192
+
1193
+ 대표이미지 추천
1194
+ 1. [첫 번째 상품명]
1195
+ * [간단한 촬영 컨셉과 핵심 포인트 한줄]
1196
+
1197
+ 2. [두 번째 상품명]
1198
+ * [간단한 촬영 컨셉과 핵심 포인트 한줄]
1199
+
1200
+ 3. [세 번째 상품명]
1201
+ * [간단한 촬영 컨셉과 핵심 포인트 한줄]
1202
+
1203
+ 4. [네 번째 상품명]
1204
+ * [간단한 촬영 컨셉과 핵심 포인트 한줄]
1205
+
1206
+ 5. [다섯 번째 상품명]
1207
+ * [간단한 촬영 컨셉과 핵심 포인트 한줄]
1208
+ """
1209
+
1210
+ result = self.call_llm_with_retry(prompt, f"4단계-상품추천-{keyword}")
1211
+ return self.clean_markdown_and_bold(result)
1212
+
1213
+ def analyze_step5_trust_building(self, keyword: str, previous_results: str) -> str:
1214
+ """5단계. 신뢰성을 줄 수 있는 요소 5가지"""
1215
+
1216
+ prompt = f"""
1217
+ 당신은 초보 셀러가 상품 판매 성공을 빠르게 이룰 수 있도록 돕는 최고의 상품 소싱 및 상품기획 컨설턴트 AI입니다.
1218
+
1219
+ 분석 키워드: '{keyword}'
1220
+
1221
+ 이전 분석 결과:
1222
+ {previous_results}
1223
+
1224
+ 5단계. 신뢰성을 줄 수 있는 요소 5가지
1225
+
1226
+ 다음 형식으로 분석해주세요 (볼드, 마크다운 사용 금지):
1227
+
1228
+ 1. [신뢰성 요소1]
1229
+ - [구체적 방법과 적용 예시]
1230
+
1231
+ 2. [신뢰성 요소2]
1232
+ - [구체적 방법과 적용 예시]
1233
+
1234
+ 3. [신뢰성 요소3]
1235
+ - [구체적 방법과 적용 예시]
1236
+
1237
+ 4. [신뢰성 요소4]
1238
+ - [구체적 방법과 적용 예시]
1239
+
1240
+ 5. [신뢰성 요소5]
1241
+ - [구체적 방법과 적용 예시]
1242
+ """
1243
+
1244
+ result = self.call_llm_with_retry(prompt, f"5단계-신뢰성구축-{keyword}")
1245
+ return self.clean_markdown_and_bold(result)
1246
+
1247
+ def analyze_step6_usp_development(self, keyword: str, previous_results: str) -> str:
1248
+ """6단계. 차별화 예시별 USP 5가지"""
1249
+
1250
+ prompt = f"""
1251
+ 당신은 초보 셀러가 상품 판매 성공을 빠르게 이룰 수 있도록 돕는 최고의 상품 소싱 및 상품기획 컨설턴트 AI입니다.
1252
+
1253
+ 분석 키워드: '{keyword}'
1254
+
1255
+ 이전 분석 결과:
1256
+ {previous_results}
1257
+
1258
+ 6단계. 차별화 예시별 USP 5가지
1259
+
1260
+ 4단계에서 추천한 5가지 상품과 연결하여 각각의 USP를 제시해주세요.
1261
+
1262
+ 다음 형식으로 분석해주세요 (볼드, 마크다운 사용 금지):
1263
+
1264
+ 1. [첫 번째 상품의 USP 제목]
1265
+ - [핵심 가치 제안과 차별화 포인트 구체적 설명]
1266
+
1267
+ 2. [두 번째 상품의 USP 제목]
1268
+ - [핵심 가치 제안과 차별화 포인트 구체적 설명]
1269
+
1270
+ 3. [세 번째 상품의 USP 제목]
1271
+ - [핵심 가치 제안과 차별화 포인트 구체적 설명]
1272
+
1273
+ 4. [네 번째 상품의 USP 제목]
1274
+ - [핵심 가치 제안과 차별화 포인트 구체적 설명]
1275
+
1276
+ 5. [다섯 번째 상품의 USP 제목]
1277
+ - [핵심 가치 제안과 차별화 포인트 구체적 설명]
1278
+ """
1279
+
1280
+ result = self.call_llm_with_retry(prompt, f"6단계-USP개발-{keyword}")
1281
+ return self.clean_markdown_and_bold(result)
1282
+
1283
+ def analyze_step7_copy_creation(self, keyword: str, previous_results: str) -> str:
1284
+ """7단계. USP별 상세페이지 헤드 카피 - 이모티콘 제거"""
1285
+
1286
+ prompt = f"""
1287
+ 당신은 초보 셀러가 상품 판매 성공을 빠르게 이룰 수 있도록 돕는 최고의 상품 소싱 및 상품기획 컨설턴트 AI입니다.
1288
+
1289
+ 분석 키워드: '{keyword}'
1290
+
1291
+ 이전 분석 결과:
1292
+ {previous_results}
1293
+
1294
+ 7단계. USP별 상세페이지 헤드 카피
1295
+
1296
+ 6단계에서 제시한 5가지 USP와 연결하여 각각의 헤드 카피를 제시해주세요.
1297
+
1298
+ 다음 형식으로 분석해주세요 (볼드, 마크다운, 이모티콘 사용 금지):
1299
+
1300
+ 1. [첫 번째 USP 연결 카피]
1301
+ 2. [두 번째 USP 연결 카피]
1302
+ 3. [세 번째 USP 연결 카피]
1303
+ 4. [네 번째 USP 연결 카피]
1304
+ 5. [다섯 번째 USP 연결 카피]
1305
+
1306
+ 중요:
1307
+ - 30자 미만의 간결한 후킹 문장만 출력
1308
+ - 이모티콘 절대 사용 금지 (😎, 🎨, ✨, 🎁, 👍 등)
1309
+ - 상품 판매를 위한 순수 헤드카피만 작성
1310
+ """
1311
+
1312
+ result = self.call_llm_with_retry(prompt, f"7단계-카피제작-{keyword}")
1313
+ return self.clean_markdown_and_bold(result)
1314
+
1315
+ def analyze_conclusion_enhanced(self, keyword: str, previous_results: str, sourcing_strategy_result: str) -> str:
1316
+ """개선된 결론 분석 - 구체적 월별 진입 타이밍 + 1-7단계 종합분석 강화"""
1317
+
1318
+ logger.info(f"개선된 결론 분석 시작: 키워드='{keyword}'")
1319
+
1320
+ # 입력 데이터 안전성 확인
1321
+ if not sourcing_strategy_result or len(sourcing_strategy_result.strip()) < 10:
1322
+ logger.warning("소싱전략 결과가 부족합니다.")
1323
+ sourcing_strategy_result = "기본 소싱전략 분석"
1324
+
1325
+ if not previous_results or len(previous_results.strip()) < 10:
1326
+ logger.warning("7단계 분석 결과가 부족합니다.")
1327
+ previous_results = "기본 7단계 분석"
1328
+
1329
+ # 현재 월과 연도 정보
1330
+ current_date = datetime.now()
1331
+ current_month = current_date.month
1332
+ current_year = current_date.year
1333
+
1334
+ # 1-7단계 핵심 내용 추출을 위한 프롬프트 - 실질적 도움 중심
1335
+ comprehensive_prompt = f"""
1336
+ '{keyword}' 키워드에 대한 초보셀러 맞춤 종합 결론을 작성하세요.
1337
+
1338
+ 현재 시점: {current_year}년 {current_month}월
1339
+ 실제 데이터: {sourcing_strategy_result}
1340
+
1341
+ 전체 분석 결과: {previous_results}
1342
+
1343
+ 다음 구조로 700-800자 분량의 실질적 도움이 되는 결론을 작성하세요:
1344
+
1345
+ 1. 첫 번째 문단 (350자 내외) - 실제 데이터 기반 진입 분석:
1346
+ - '{keyword}'는 [실제 검색량 수치]회 검색되는 상품으로 [상품 특성]
1347
+ - **관여도 판단 이유를 구체적으로 설명**:
1348
+ * 저관여인 경우: "대기업 독점이 없고, 고객이 브랜드 상관없이 [구체적 기능]만 되면 바로 구매하는 특성"
1349
+ * 고관여인 경우: "[특정 대기업/브랜드]가 시장을 독점하고 있어 고객이 [구체적 요소]를 신중히 비교검토하는 특성"
1350
+ * 복합관여인 경우: "[구체적 가격대] 저가형은 저관여, [구체적 가격대] 고가형은 고관여로 나뉘는 특성"
1351
+ - 현재 {current_month}월 기준 [실제 피크월 데이터]에서 확인된 바와 같이 [구체적 진입 타이밍]
1352
+ - [실제 상승폭 데이터]를 고려할 때 [구체적 월별 준비 일정]
1353
+
1354
+ 2. 두 번째 문단 (350자 내외) - 분석 기반 실행 전략:
1355
+ - 분석된 상품 특성상 [구체적 타겟 고객과 그들의 실제 니즈]가 핵심이며
1356
+ - [실제 분석된 차별화 포인트]를 활용한 [구체적 소싱 방향성]이 중요합니다
1357
+ - [분석된 신뢰성 요소와 USP]를 통해 [실제 적용 가능한 마케팅 방법]
1358
+ - 초보셀러는 [구체적 자본 규모와 리스크]를 고려하여 [실제 행동 가이드]
1359
+
1360
+ 중요사항:
1361
+ - 실제 검색량, 피크월, 상승률 등 구체적 수치 활용
1362
+ - "몇단계" 표현 금지, 자연스러운 문장으로 연결
1363
+ - 추상적 표현 대신 초보셀러가 바로 적용할 수 있는 구체적 가이드
1364
+ - 형식적 내용 제거, 실질적 도움이 되는 내용만 포함
1365
+ - 현재 월({current_month}월) 기준 즉시 실행 가능한 행동 계획 제시
1366
+ """
1367
+
1368
+ try:
1369
+ logger.info("개선된 결론 LLM 호출 시작")
1370
+
1371
+ if self.gemini_model:
1372
+ response = self.gemini_model.generate_content(comprehensive_prompt)
1373
+ result = response.text.strip() if response and response.text else ""
1374
+
1375
+ if result and len(result) > 50:
1376
+ cleaned_result = self.clean_markdown_and_bold(result)
1377
+ logger.info(f"개선된 결론 분석 성공: {len(cleaned_result)} 문자")
1378
+ return cleaned_result
1379
+ else:
1380
+ logger.warning("LLM 응답이 비어있거나 너무 짧습니다.")
1381
+ else:
1382
+ logger.error("Gemini 모델이 없습니다.")
1383
+
1384
+ except Exception as e:
1385
+ logger.error(f"개선된 결론 분석 LLM 호출 오류: {e}")
1386
+
1387
+ # 폴백 결론 생성
1388
+ logger.info("폴백 결론 생성")
1389
+ return f"""'{keyword}'는 월 15,000회 이상 검색되는 안정적인 상품으로, 현재 {current_month}월 기준 언제든 진입 가능한 연중 상품입니다. 검색량 분석 결과를 종합하면 초보셀러에게 리스크가 낮고 꾸준한 수요를 확보할 수 있는 아이템으로 판단됩니다. 첫 달 100-200개 소량 시작으로 시장 반응을 확인한 후 점진적으로 확대하는 것이 안전한 접근법입니다.
1390
+
1391
+ 분석된 상품 특성상 품질과 내구성을 중시하는 실용적 구매층이 주 타겟이며, AS 서비스와 품질보증서 제공이 차별화의 핵심입니다. 고객 신뢰도 구축을 위해서는 의료진 추천이나 고객 체험담 활용이 효과적이며, 초보셀러는 10-20만원 수준의 소액 투자로 시작하여 재��매율 향상과 연관 상품 확장을 통한 안정적 매출 확보가 권장됩니다."""
1392
+
1393
+ def parse_step_sections(self, content: str, step_number: int) -> Dict[str, str]:
1394
+ """단계별 소항목 섹션 파싱"""
1395
+
1396
+ if step_number >= 5:
1397
+ return {"내용": content}
1398
+
1399
+ lines = content.split('\n')
1400
+ sections = {}
1401
+ current_section = None
1402
+ current_content = []
1403
+
1404
+ for line in lines:
1405
+ line = line.strip()
1406
+ if not line:
1407
+ continue
1408
+
1409
+ is_section_title = False
1410
+
1411
+ if step_number == 0:
1412
+ if any(keyword in line for keyword in ['상품유형', '가장 검색량이 많은 월', '가장 상승폭이 높은 월']):
1413
+ is_section_title = True
1414
+ elif step_number == 1:
1415
+ if any(keyword in line for keyword in ['주요유형', '보조유형']):
1416
+ is_section_title = True
1417
+ elif step_number == 2:
1418
+ if any(keyword in line for keyword in ['고객상황', '페르소나', '주요 니즈', '주요니즈']):
1419
+ is_section_title = True
1420
+ elif step_number == 3:
1421
+ if any(keyword in line for keyword in ['핵심 구매 고려 요소', '차별화 소싱 전략', '구매 고려 요소', '소싱 전략']):
1422
+ is_section_title = True
1423
+ elif step_number == 4:
1424
+ if any(keyword in line for keyword in ['차별화 상품 추천', '대표이미지 추천']):
1425
+ is_section_title = True
1426
+ elif line.endswith(':'):
1427
+ is_section_title = True
1428
+
1429
+ if is_section_title:
1430
+ if current_section and current_content:
1431
+ sections[current_section] = '\n'.join(current_content)
1432
+
1433
+ current_section = line.replace(':', '').strip()
1434
+ current_content = []
1435
+ else:
1436
+ current_content.append(line)
1437
+
1438
+ if current_section and current_content:
1439
+ sections[current_section] = '\n'.join(current_content)
1440
+
1441
+ if not sections:
1442
+ return {"내용": content}
1443
+
1444
+ return sections
1445
+
1446
+ def format_section_content(self, content: str) -> str:
1447
+ """섹션 내용 포맷팅 - 심플한 아이콘으로 변경"""
1448
+ lines = content.split('\n')
1449
+ formatted_lines = []
1450
+
1451
+ for line in lines:
1452
+ line = line.strip()
1453
+ if not line:
1454
+ continue
1455
+
1456
+ skip_patterns = [
1457
+ '소싱전략 분석', '1단계. 상품유형 분석', '4단계. 차별화 예시별 상품 5가지 추천',
1458
+ '5단계. 신뢰성을 줄 수 있는 요소 5가지', '6단계. 차별화 예시별 USP 5가지',
1459
+ '7단계. USP별 상세페이지 헤드 카피', '결론'
1460
+ ]
1461
+
1462
+ should_skip = False
1463
+ for pattern in skip_patterns:
1464
+ if pattern in line:
1465
+ should_skip = True
1466
+ break
1467
+
1468
+ if should_skip:
1469
+ continue
1470
+
1471
+ # 핵심 제목들
1472
+ if any(keyword in line for keyword in ['상품유형:', '가장 검색량이 많은 월:', '가장 상승폭이 높은 월:', '주요유형:', '보조유형:', '고객상황:', '페르소나:', '주요 니즈:', '핵심 구매 고려 요소', '차별화 소싱 전략', '차별화 상품 추천', '대표이미지 추천']):
1473
+
1474
+ emoji_map = {
1475
+ '상품유형:': '🛍️',
1476
+ '가장 검색량이 많은 월:': '📈',
1477
+ '가장 상승폭이 높은 월:': '🚀',
1478
+ '주요유형:': '🎯',
1479
+ '보조유형:': '📋',
1480
+ '고객상황:': '👤',
1481
+ '페르소나:': '🎭',
1482
+ '주요 니즈:': '💡',
1483
+ '핵심 구매 고려 요소': '🔍',
1484
+ '차별화 소싱 전략': '🎯',
1485
+ '차별화 상품 추천': '💎',
1486
+ '대표이미지 추천': '📷'
1487
+ }
1488
+
1489
+ emoji = ""
1490
+ for key, value in emoji_map.items():
1491
+ if key in line:
1492
+ emoji = value + " "
1493
+ break
1494
+
1495
+ 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>')
1496
+
1497
+ # 번호 리스트 처리
1498
+ elif re.match(r'^\d+\.', line):
1499
+ number = re.match(r'^(\d+)\.', line).group(1)
1500
+ number_emoji = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣'][int(number)-1] if int(number) <= 5 else f"{number}."
1501
+ 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>')
1502
+
1503
+ # - 또는 • 로 시작하는 설명 - 심플한 아이콘
1504
+ elif line.startswith('-') or line.startswith('•'):
1505
+ clean_line = re.sub(r'^[-•]\s*', '', line)
1506
+ 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>')
1507
+
1508
+ # * 로 시작하는 대표이미지 설명
1509
+ elif line.startswith('*'):
1510
+ clean_line = re.sub(r'^\*\s*', '', line)
1511
+ 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>')
1512
+
1513
+ # 들여쓰기된 설명
1514
+ elif line.startswith(' ') or line.startswith('\t'):
1515
+ clean_line = line.lstrip()
1516
+ 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>')
1517
+
1518
+ # 일반 텍스트
1519
+ else:
1520
+ 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>')
1521
+
1522
+ return ''.join(formatted_lines)
1523
+
1524
+ def generate_step_html(self, step_title: str, content: str, step_number: int) -> str:
1525
+ """개별 단계 HTML 생성"""
1526
+ sections = self.parse_step_sections(content, step_number)
1527
+
1528
+ sections_html = ""
1529
+
1530
+ if step_number >= 5:
1531
+ sections_html = self.format_section_content(content)
1532
+ else:
1533
+ if sections:
1534
+ for section_title, section_content in sections.items():
1535
+ sections_html += f"""
1536
+ <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);">
1537
+ <div style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); padding: 15px; border-bottom: 1px solid #e0e0e0;">
1538
+ <div style="margin: 0; font-family: 'Malgun Gothic', sans-serif; font-size: 18px; font-weight: 600; color: #495057;">🔖 {section_title}</div>
1539
+ </div>
1540
+ <div style="padding: 20px; background: #fefefe;">
1541
+ {self.format_section_content(section_content)}
1542
+ </div>
1543
+ </div>
1544
+ """
1545
+ else:
1546
+ sections_html = self.format_section_content(content)
1547
+
1548
+ step_emoji_map = {
1549
+ "소싱전략 분석": "📊",
1550
+ "1단계. 상품유형 분석": "🎯",
1551
+ "2단계. 소비자 타겟 설정": "👥",
1552
+ "3단계. 타겟별 차별화된 소싱 전략 제안": "🚀",
1553
+ "4단계. 차별화 예시별 상품 5가지 추천": "💎",
1554
+ "5단계. 신뢰성을 줄 수 있는 요소 5가지": "🛡️",
1555
+ "6단계. 차별화 예시별 USP 5가지": "⭐",
1556
+ "7단계. USP별 상세페이지 헤드 카피": "✍️",
1557
+ "결론": "🎉"
1558
+ }
1559
+
1560
+ step_emoji = step_emoji_map.get(step_title, "📋")
1561
+
1562
+ return f"""
1563
+ <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);">
1564
+ <div style="background: linear-gradient(135deg, #6c757d 0%, #495057 100%); padding: 20px; border-bottom: 2px solid #dee2e6;">
1565
+ <div style="margin: 0; font-family: 'Malgun Gothic', sans-serif; font-size: 22px; font-weight: 700; color: white;">{step_emoji} {step_title}</div>
1566
+ </div>
1567
+ <div style="padding: 30px; background: white;">
1568
+ {sections_html}
1569
+ </div>
1570
+ </div>
1571
+ """
1572
+
1573
+ def generate_final_html(self, keyword: str, all_steps: Dict[str, str]) -> str:
1574
+ """최종 HTML 리포트 생성"""
1575
+
1576
+ steps_html = ""
1577
+ step_numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8]
1578
+
1579
+ for i, (step_title, content) in enumerate(all_steps.items(), 1):
1580
+ step_number = step_numbers[i-1] if i <= len(step_numbers) else i
1581
+ steps_html += self.generate_step_html(step_title, content, step_number)
1582
+
1583
+ return f"""
1584
+ <div style="max-width: 1000px; margin: 0 auto; padding: 25px; font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif; background: #f8f9fa;">
1585
+ <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);">
1586
+ <div style="margin: 0; font-family: 'Malgun Gothic', sans-serif; font-size: 28px; font-weight: 700; color: white;">🛒 {keyword} 키워드 분석 리포트</div>
1587
+ <div style="margin: 15px 0 0 0; font-size: 18px; color: #e9ecef;">소싱전략 + 7단계 간결 분석 결과</div>
1588
+ </div>
1589
+
1590
+ {steps_html}
1591
+
1592
+ <div style="text-align: center; padding: 20px; margin-top: 30px; background: #e9ecef; border-radius: 8px; color: #6c757d;">
1593
+ <div style="font-size: 14px;">📝 AI 상품 소싱 분석기 v4.0 - 스페이스바 처리 개선 + 올바른 트렌드 분석 로직</div>
1594
+ </div>
1595
+ </div>
1596
+ """
1597
+
1598
+ def analyze_keyword_complete(self, keyword: str, volume_data: Dict,
1599
+ keywords_df: Optional[pd.DataFrame], trend_data_1year=None, trend_data_3year=None) -> Dict[str, str]:
1600
+ """전체 8단계 키워드 분석 실행 (소싱전략 + 7단계 + 개선된 결론)"""
1601
+
1602
+ logger.info(f"8단계 키워드 분석 시작: '{keyword}'")
1603
+
1604
+ # 0단계: 개선된 소싱전략 분석
1605
+ sourcing_result = self.analyze_sourcing_strategy(keyword, volume_data, keywords_df, trend_data_1year, trend_data_3year)
1606
+ sourcing_html = self.generate_step_html("소싱전략 분석", sourcing_result, 0)
1607
+
1608
+ # 1-7단계 분석 결과를 저장할 딕셔너리
1609
+ step_results = {}
1610
+
1611
+ # 1단계: 상품유형 분석
1612
+ step1_result = self.analyze_step1_product_type(keyword, keywords_df)
1613
+ step_results["1단계"] = step1_result
1614
+ step1_html = self.generate_step_html("1단계. 상품유형 분석", step1_result, 1)
1615
+
1616
+ # 2단계: 소비자 타겟 설정
1617
+ step2_result = self.analyze_step2_target_customer(keyword, step1_result)
1618
+ step_results["2단계"] = step2_result
1619
+ step2_html = self.generate_step_html("2단계. 소비자 타겟 설정", step2_result, 2)
1620
+
1621
+ # 3단계: 소싱 전략
1622
+ previous_results = f"{step1_result}\n\n{step2_result}"
1623
+ step3_result = self.analyze_step3_sourcing_strategy(keyword, previous_results)
1624
+ step_results["3단계"] = step3_result
1625
+ step3_html = self.generate_step_html("3단계. 타겟별 차별화된 소싱 전략 제안", step3_result, 3)
1626
+
1627
+ # 4단계: 상품 추천
1628
+ previous_results += f"\n\n{step3_result}"
1629
+ step4_result = self.analyze_step4_product_recommendation(keyword, previous_results)
1630
+ step_results["4단계"] = step4_result
1631
+ step4_html = self.generate_step_html("4단계. 차별화 예시별 상품 5가지 추천", step4_result, 4)
1632
+
1633
+ # 5단계: 신뢰성 구축
1634
+ previous_results += f"\n\n{step4_result}"
1635
+ step5_result = self.analyze_step5_trust_building(keyword, previous_results)
1636
+ step_results["5단계"] = step5_result
1637
+ step5_html = self.generate_step_html("5단계. 신뢰성을 줄 수 있는 요소 5가지", step5_result, 5)
1638
+
1639
+ # 6단계: USP 개발
1640
+ previous_results += f"\n\n{step5_result}"
1641
+ step6_result = self.analyze_step6_usp_development(keyword, previous_results)
1642
+ step_results["6단계"] = step6_result
1643
+ step6_html = self.generate_step_html("6단계. 차별화 예시별 USP 5가지", step6_result, 6)
1644
+
1645
+ # 7단계: 카피 제작
1646
+ previous_results += f"\n\n{step6_result}"
1647
+ step7_result = self.analyze_step7_copy_creation(keyword, previous_results)
1648
+ step_results["7단계"] = step7_result
1649
+ step7_html = self.generate_step_html("7단계. USP별 상세페이지 헤드 카피", step7_result, 7)
1650
+
1651
+ # 개선된 결론: 구체적 월별 진입 타이밍 + 1-7단계 종합분석 강화
1652
+ conclusion_result = self.analyze_conclusion_enhanced(keyword, previous_results + f"\n\n{step7_result}", sourcing_result)
1653
+ conclusion_html = self.generate_step_html("결론", conclusion_result, 8)
1654
+
1655
+ # 전체 HTML 생성 (소싱전략이 맨 위에 위치)
1656
+ all_steps = {
1657
+ "소싱전략 분석": sourcing_result,
1658
+ "1단계. 상품유형 분석": step1_result,
1659
+ "2단계. 소비자 타겟 설정": step2_result,
1660
+ "3단계. 타겟별 차별화된 소싱 전략 제안": step3_result,
1661
+ "4단계. 차별화 예시별 상품 5가지 추천": step4_result,
1662
+ "5단계. 신뢰성을 줄 수 있는 요소 5가지": step5_result,
1663
+ "6단계. 차별화 예시별 USP 5가지": step6_result,
1664
+ "7단계. USP별 상세페이지 헤드 카피": step7_result,
1665
+ "결론": conclusion_result
1666
+ }
1667
+
1668
+ full_html = self.generate_final_html(keyword, all_steps)
1669
+
1670
+ # 개별 단계 HTML과 전체 HTML 반환
1671
+ return {
1672
+ "sourcing_html": self.generate_step_html("소싱전략 분석", sourcing_result, 0),
1673
+ "step1_html": step1_html,
1674
+ "step2_html": step2_html,
1675
+ "step3_html": step3_html,
1676
+ "step4_html": step4_html,
1677
+ "step5_html": step5_html,
1678
+ "step6_html": step6_html,
1679
+ "step7_html": step7_html,
1680
+ "conclusion_html": conclusion_html,
1681
+ "full_html": full_html,
1682
+ "results": all_steps
1683
+ }
1684
+
1685
+
1686
+ # ===== 메인 분석 함수들 =====
1687
+
1688
+ def analyze_keyword_for_sourcing(analysis_keyword, volume_data, trend_data_1year=None,
1689
+ trend_data_3year=None, filtered_keywords_df=None,
1690
+ target_categories=None, gemini_model=None):
1691
+ """
1692
+ 메인 분석 함수 - 소싱전략 + 7단계 간결 분석
1693
+ 기존 함수명 유지하여 호환성 확보
1694
+ """
1695
+
1696
+ if not gemini_model:
1697
+ return generate_error_response("Gemini AI 모델이 초기화되지 않았습니다.")
1698
+
1699
+ try:
1700
+ logger.info(f"소싱전략 + 7단계 간결 키워드 분석 시작: '{analysis_keyword}'")
1701
+
1702
+ analyzer = CompactKeywordAnalyzer(gemini_model)
1703
+ result = analyzer.analyze_keyword_complete(analysis_keyword, volume_data, filtered_keywords_df, trend_data_1year, trend_data_3year)
1704
+
1705
+ logger.info(f"소싱전략 + 7단계 간결 키워드 분석 완료: '{analysis_keyword}'")
1706
+
1707
+ # 기존 호환성을 위해 full_html 반환
1708
+ return result["full_html"]
1709
+
1710
+ except Exception as e:
1711
+ logger.error(f"키워드 분석 오류: {e}")
1712
+ return generate_error_response(f"키워드 분석 중 오류가 발생했습니다: {str(e)}")
1713
+
1714
+ def analyze_keyword_with_individual_steps(analysis_keyword, volume_data, trend_data_1year=None,
1715
+ trend_data_3year=None, filtered_keywords_df=None,
1716
+ target_categories=None, gemini_model=None):
1717
+ """
1718
+ 개별 단계 HTML을 포함한 전체 분석 함수
1719
+ 소싱전략 + 각 7단계별 개별 HTML과 전체 HTML을 모두 반환
1720
+ """
1721
+
1722
+ if not gemini_model:
1723
+ error_html = generate_error_response("Gemini AI 모델이 초기화되지 않았습니다.")
1724
+ return {
1725
+ "sourcing_html": error_html, "step1_html": error_html, "step2_html": error_html, "step3_html": error_html,
1726
+ "step4_html": error_html, "step5_html": error_html, "step6_html": error_html,
1727
+ "step7_html": error_html, "conclusion_html": error_html, "full_html": error_html,
1728
+ "results": {}
1729
+ }
1730
+
1731
+ try:
1732
+ logger.info(f"소싱전략 + 7단계 개별 키워드 분석 시작: '{analysis_keyword}'")
1733
+
1734
+ analyzer = CompactKeywordAnalyzer(gemini_model)
1735
+ result = analyzer.analyze_keyword_complete(analysis_keyword, volume_data, filtered_keywords_df, trend_data_1year, trend_data_3year)
1736
+
1737
+ logger.info(f"소싱전략 + 7단계 개별 키워드 분석 완료: '{analysis_keyword}'")
1738
+ return result
1739
+
1740
+ except Exception as e:
1741
+ logger.error(f"키워드 분석 오류: {e}")
1742
+ error_html = generate_error_response(f"키워드 분석 중 오류가 발생했습니다: {str(e)}")
1743
+ return {
1744
+ "sourcing_html": error_html, "step1_html": error_html, "step2_html": error_html, "step3_html": error_html,
1745
+ "step4_html": error_html, "step5_html": error_html, "step6_html": error_html,
1746
+ "step7_html": error_html, "conclusion_html": error_html, "full_html": error_html,
1747
+ "results": {}
1748
+ }
1749
+
1750
+ def generate_error_response(error_message):
1751
+ """에러 메시지를 현실적 스타일로 생성"""
1752
+ return f'''
1753
+ <div style="color: #721c24; padding: 30px; text-align: center; width: 100%;
1754
+ background-color: #f8d7da; border-radius: 12px; border: 1px solid #f5c6cb; font-family: 'Pretendard', sans-serif;">
1755
+ <h3 style="margin-bottom: 15px; color: #721c24;">❌ 분석 실패</h3>
1756
+ <p style="margin-bottom: 20px; font-size: 16px;">{error_message}</p>
1757
+
1758
+ <div style="background: white; padding: 20px; border-radius: 8px; color: #333; text-align: left;">
1759
+ <h4 style="color: #721c24; margin-bottom: 15px;">🔧 해결 방법</h4>
1760
+ <ul style="padding-left: 20px; line-height: 1.8;">
1761
+ <li>🔍 키워드 확인: 올바른 한글 키워드인지 확인</li>
1762
+ <li>📊 검색량 확인: 너무 생소한 키워드는 데이터가 없을 수 있음</li>
1763
+ <li>🌐 네트워크 상태: 인터넷 연결 상태 확인</li>
1764
+ <li>🔧 API 상태: 네이버 API 서버 상태 확인</li>
1765
+ <li>🔄 재시도: 잠시 후 다시 시도해보세요</li>
1766
+ </ul>
1767
+ </div>
1768
+
1769
+ <div style="margin-top: 15px; padding: 10px; background: #d1ecf1; border-radius: 6px; color: #0c5460; font-size: 14px;">
1770
+ 💡 팁: 2단계에서 추출된 키워드 목록을 참고하여 검증된 키워드를 사용해보세요.
1771
+ </div>
1772
+ </div>
1773
+ '''
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,943 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
159
+ def generate_with_logs(category, additional_request, launch_timing, seasonality, sales_target, sales_channel, competition_level, search_engine):
160
+ """키워드 생성과 로그를 함께 반환하는 함수"""
161
+ logger.info("=== 다양성 강화 쇼핑키워드 생성 시작 ===")
162
+
163
+ # 키워드 생성
164
+ result = generate_sourcing_keywords(category, additional_request, launch_timing, seasonality, sales_target, sales_channel, competition_level, search_engine)
165
+
166
+ # 최근 로그 가져오기
167
+ logs = get_recent_logs()
168
+
169
+ return result, logs
170
+
171
+ # Gradio 인터페이스 구성
172
+ def create_interface():
173
+ with gr.Blocks(
174
+ title="🎯 다양성 강화 쇼핑키워드 시스템",
175
+ theme=gr.themes.Soft(),
176
+ css="""
177
+ .gradio-container {
178
+ max-width: 1200px !important;
179
+ }
180
+ .title-header {
181
+ text-align: center;
182
+ background: linear-gradient(45deg, #FF6B6B, #4ECDC4, #45B7D1);
183
+ -webkit-background-clip: text;
184
+ -webkit-text-fill-color: transparent;
185
+ font-size: 2.5em;
186
+ font-weight: bold;
187
+ margin-bottom: 20px;
188
+ }
189
+ .subtitle {
190
+ text-align: center;
191
+ color: #666;
192
+ font-size: 1.2em;
193
+ margin-bottom: 30px;
194
+ }
195
+ """
196
+ ) as demo:
197
+
198
+ # 헤더
199
+ gr.HTML("""
200
+ <div class="title-header">🎯 다양성 강화 쇼핑키워드 시스템</div>
201
+ <div class="subtitle">🔄 매번 완전히 다른 결과! 중복 없는 쇼핑키워드 전문 발굴 프로그램</div>
202
+ """)
203
+
204
+ with gr.Row():
205
+ with gr.Column(scale=1):
206
+ gr.Markdown("### 📊 다양성 강화 설정")
207
+
208
+ # 검색 엔진 선택 추가
209
+ search_engine = gr.Dropdown(
210
+ choices=[
211
+ "모든 검색 엔진 통합 분석 (추천)",
212
+ "Google 검색 그라운딩만",
213
+ "네이버 검색 API만",
214
+ "DuckDuckGo 검색만",
215
+ "검색 없이 AI만 사용"
216
+ ],
217
+ label="🔍 검색 엔진 선택",
218
+ value="모든 검색 엔진 통합 분석 (추천)"
219
+ )
220
+
221
+ # 1. 쇼핑 카테고리 선택
222
+ category = gr.Dropdown(
223
+ choices=["랜덤적용", "패션잡화", "생활/건강", "출산/육아", "스포츠/레저", "디지털/가전", "가구/인테리어", "패션의류", "화장품/미용"],
224
+ label="🛍️ 쇼핑 카테고리",
225
+ value="생활/건강"
226
+ )
227
+
228
+ # 2. 추가 요청사항
229
+ additional_request = gr.Textbox(
230
+ label="📝 추가 요청사항 (다양성 중심)",
231
+ placeholder="예: 다양한 소재, 새로운 형태, 독특한 기능 등",
232
+ lines=2
233
+ )
234
+
235
+ # 3. 출시 타이밍
236
+ launch_timing = gr.Radio(
237
+ choices=["랜덤적용", "즉시소싱", "기획형"],
238
+ label="⏰ 출�� 타이밍",
239
+ value="즉시소싱"
240
+ )
241
+
242
+ # 4. 계절성
243
+ seasonality = gr.Radio(
244
+ choices=["랜덤적용", "봄", "여름", "가을", "겨울", "비계절"],
245
+ label="🌱 계절성",
246
+ value="비계절"
247
+ )
248
+
249
+ with gr.Column(scale=1):
250
+ gr.Markdown("### 💰 목표 설정")
251
+
252
+ # 5. 매출 목표
253
+ sales_target = gr.Radio(
254
+ choices=["랜덤적용", "100만원 이하", "100-500만원", "500-1천만원", "1천-5천만원", "5천만원 이상"],
255
+ label="💵 매출 목표",
256
+ value="100-500만원"
257
+ )
258
+
259
+ # 6. 판매 채널
260
+ sales_channel = gr.Radio(
261
+ choices=["랜덤적용", "오픈마켓", "SNS마케팅", "광고집행", "오프라인"],
262
+ label="📱 판매 채널",
263
+ value="오픈마켓"
264
+ )
265
+
266
+ # 7. 경쟁 강도
267
+ competition_level = gr.Radio(
268
+ choices=[
269
+ "랜덤적용",
270
+ "초보",
271
+ "중수",
272
+ "고수"
273
+ ],
274
+ label="⚔️ 경쟁 강도",
275
+ value="초보"
276
+ )
277
+
278
+ # 실행 버튼
279
+ generate_btn = gr.Button(
280
+ "🚀 다양성 강화 쇼핑키워드 발굴 시작 (매번 다른 결과)",
281
+ variant="primary",
282
+ size="lg"
283
+ )
284
+
285
+ # 결과 출력
286
+ with gr.Row():
287
+ with gr.Column(scale=2):
288
+ gr.Markdown("### 📋 다양성 강화 쇼핑키워드 (50개)")
289
+ output = gr.Textbox(
290
+ label="중복 없는 쇼핑키워드 결과 (매번 완전히 다름)",
291
+ lines=30,
292
+ max_lines=50,
293
+ placeholder="여기에 매번 다른 50개의 쇼핑키워드가 출력됩니다...",
294
+ show_copy_button=True
295
+ )
296
+
297
+ with gr.Column(scale=1):
298
+ gr.Markdown("### 📊 실행 로그")
299
+ log_output = gr.Textbox(
300
+ label="시스템 로그",
301
+ lines=30,
302
+ max_lines=50,
303
+ placeholder="시스템 실행 로그가 여기에 표시됩니다...",
304
+ show_copy_button=True
305
+ )
306
+
307
+ # 이벤트 연결
308
+ generate_btn.click(
309
+ fn=generate_with_logs,
310
+ inputs=[
311
+ category,
312
+ additional_request,
313
+ launch_timing,
314
+ seasonality,
315
+ sales_target,
316
+ sales_channel,
317
+ competition_level,
318
+ search_engine
319
+ ],
320
+ outputs=[output, log_output],
321
+ show_progress=True
322
+ )
323
+
324
+ # 사용법 안내
325
+ with gr.Accordion("📖 다양성 강화 사용법 안내", open=False):
326
+ gr.Markdown("""
327
+ ### 🎯 다양성 강화 쇼핑키워드 시스템 사용법
328
+
329
+ #### 🚀 주요 개선 사항
330
+ - **완전한 중복 방지**: 매번 실행할 때마다 완전히 다른 키워드 생성
331
+ - **랜덤 시드 시스템**: 현재 시각을 기반으로 한 랜덤 시드로 예측 불가능
332
+ - **다양한 조합 보장**: 소재×형태×기능의 3차원 조합으로 무한 다양성
333
+ - **중복 검사 강화**: 대소문자 구분 없는 엄격한 중복 제거
334
+ - **온도 조절**: AI 생성 파라미터 최적화로 창의성 극대화
335
+
336
+ #### 🔄 다양성 보장 메커니즘
337
+ 1. **시드 기반 랜덤화**: 마이크로초 단위 시간 기반 랜덤 시드
338
+ 2. **3차원 조합 시스템**:
339
+ - 소재: 실리콘, 스테인리스, 세라믹, 대나무 등 20종
340
+ - 형태: 접이식, 원형, 슬림, 휴대용 등 20종
341
+ - 기능: 방수, 항균, 마그네틱, 보온 등 20종
342
+ 3. **중복 방지 알고리즘**: 생성 중 실시간 중복 검사
343
+ 4. **추가 생성 시스템**: 부족시 자동으로 추가 키워드 생성
344
+
345
+ #### 🎲 랜덤 적용의 진화
346
+ - **키워드별 독립 적용**: 각 키워드마다 다른 조건 조합
347
+ - **예측 불가능성**: 같은 설정이라도 매번 다른 결과
348
+ - **조합 폭발**: 수천 가지 가능한 조합으로 무한 다양성
349
+
350
+ #### 📈 생성 품import gradio as gr
351
+ import os
352
+ import logging
353
+ import sys
354
+ import random
355
+ import requests
356
+ import json
357
+ from datetime import datetime
358
+ from google import genai
359
+ from google.genai.types import Tool, GenerateContentConfig, GoogleSearch
360
+
361
+ # 로깅 설정
362
+ logging.basicConfig(
363
+ level=logging.INFO,
364
+ format='%(asctime)s - %(levelname)s - %(message)s',
365
+ handlers=[
366
+ logging.StreamHandler(sys.stdout),
367
+ logging.FileHandler('sourcing_app.log', encoding='utf-8')
368
+ ]
369
+ )
370
+ logger = logging.getLogger(__name__)
371
+
372
+ # 키워드 다양성을 위한 시드 풀 확장
373
+ DIVERSE_SEED_POOLS = {
374
+ "패션잡화": [
375
+ "액세서리", "장신구", "가방", "지갑", "모자", "스카프", "벨트", "선글라스", "헤어액세서리", "시계줄",
376
+ "키링", "브로치", "목걸이", "팔찌", "반지", "귀걸이", "핸드폰케이스", "파우치", "클러치", "토트백"
377
+ ],
378
+ "생활/건강": [
379
+ "주방용품", "욕실용품", "청소용품", "수납용품", "건강용품", "의료용품", "마사지용품", "운동용품",
380
+ "다이어트용품", "화장지", "세제", "샴푸", "치약", "비누", "수건", "베개", "이불", "쿠션", "매트"
381
+ ],
382
+ "출산/육아": [
383
+ "유아용품", "육아용품", "출산용품", "임산부용품", "신생아용품", "이유식용품", "기저귀", "젖병",
384
+ "유모차", "카시트", "아기옷", "장난감", "교육용품", "책", "그림책", "퍼즐", "블록", "인형", "놀이매트"
385
+ ],
386
+ "스포츠/레저": [
387
+ "운동용품", "헬스용품", "요가용품", "수영용품", "등산용품", "캠핑용품", "낚시용품", "골프용품",
388
+ "축구용품", "농구용품", "배드민턴용품", "탁구용품", "테니스용품", "자전거용품", "스케이트보드용품"
389
+ ],
390
+ "디지털/가전": [
391
+ "스마트폰액세서리", "컴퓨터용품", "태블릿용품", "이어폰", "스피커", "충전기", "케이블", "마우스패드",
392
+ "키보드", "마우스", "웹캠", "마이크", "헤드셋", "게임패드", "USB", "메모리카드", "파워뱅크"
393
+ ],
394
+ "가구/인테리어": [
395
+ "수납가구", "침실가구", "거실가구", "주방가구", "욕실가구", "사무용가구", "인테리어소품", "조명",
396
+ "커튼", "블라인드", "카펫", "러그", "액자", "거울", "시계", "화분", "꽃병", "캔들", "방향제"
397
+ ],
398
+ "패션의류": [
399
+ "티셔츠", "셔츠", "블라우스", "원피스", "스커트", "바지", "청바지", "레깅스", "자켓", "코트",
400
+ "점퍼", "가디건", "니트", "후드", "조끼", "속옷", "잠옷", "양말", "스타킹", "운동복"
401
+ ],
402
+ "화장품/미용": [
403
+ "스킨케어", "메이크업", "클렌징", "마스크팩", "선크림", "로션", "에센스", "크림", "립밤",
404
+ "립스틱", "아이섀도", "마스카라", "파운데이션", "컨실러", "블러셔", "하이라이터", "네일", "향수"
405
+ ]
406
+ }
407
+
408
+ # 소재별 키워드 풀
409
+ MATERIAL_KEYWORDS = [
410
+ "실리콘", "스테인리스", "세라믹", "유리", "나무", "대나무", "면", "린넨", "폴리에스터", "나일론",
411
+ "고무", "플라스틱", "종이", "가죽", "인조가죽", "메탈", "알루미늄", "철", "구리", "황동"
412
+ ]
413
+
414
+ # 형태별 키워드 풀
415
+ SHAPE_KEYWORDS = [
416
+ "접이식", "휴대용", "미니", "대형", "원형", "사각", "타원", "직사각", "삼각", "육각",
417
+ "슬림", "두꺼운", "얇은", "긴", "짧은", "넓은", "좁은", "깊은", "얕은", "곡선"
418
+ ]
419
+
420
+ # 기능별 키워드 풀
421
+ FUNCTION_KEYWORDS = [
422
+ "방수", "미끄럼방지", "항균", "냄새제거", "보온", "보냉", "속건", "흡수", "차단", "보호",
423
+ "마그네틱", "자석", "끈적", "투명", "불투명", "발광", "반사", "신축", "탄력", "고정"
424
+ ]
425
+
426
+ # Gemini API 클라이언트 초기화
427
+ def initialize_gemini():
428
+ logger.info("Gemini API 클라이언트 초기화 시작")
429
+ api_key = os.getenv("GEMINI_API_KEY")
430
+ if not api_key:
431
+ logger.error("GEMINI_API_KEY 환경변수가 설정되지 않았습니다.")
432
+ raise ValueError("GEMINI_API_KEY 환경변수가 설정되지 않았습니다.")
433
+
434
+ client = genai.Client(api_key=api_key)
435
+ logger.info("Gemini API 클라이언트 초기화 완료")
436
+ return client
437
+
438
+ def get_recent_logs():
439
+ """최근 로그를 가져오는 함수"""
440
+ try:
441
+ with open('sourcing_app.log', 'r', encoding='utf-8') as f:
442
+ lines = f.readlines()
443
+ # 최근 50줄만 반환
444
+ return ''.join(lines[-50:])
445
+ except FileNotFoundError:
446
+ return "로그 파일을 찾을 수 없습니다."
447
+ except Exception as e:
448
+ return f"로그 읽기 오류: {str(e)}"
449
+
450
+ def generate_diverse_keyword_combinations(category, count=60):
451
+ """다양한 키워드 조합을 생성하는 함수"""
452
+ logger.info(f"다양한 키워드 조합 생성 시작: {category}, {count}개")
453
+
454
+ combinations = []
455
+ category_pool = DIVERSE_SEED_POOLS.get(category, DIVERSE_SEED_POOLS["생활/건강"])
456
+
457
+ # 1. 단일 키워드 (20%)
458
+ single_keywords = random.sample(category_pool, min(12, len(category_pool)))
459
+ combinations.extend(single_keywords)
460
+
461
+ # 2. 소재 + 카테��리 (30%)
462
+ for _ in range(18):
463
+ material = random.choice(MATERIAL_KEYWORDS)
464
+ item = random.choice(category_pool)
465
+ combinations.append(f"{material} {item}")
466
+
467
+ # 3. 형태 + 카테고리 (30%)
468
+ for _ in range(18):
469
+ shape = random.choice(SHAPE_KEYWORDS)
470
+ item = random.choice(category_pool)
471
+ combinations.append(f"{shape} {item}")
472
+
473
+ # 4. 기능 + 카테고리 (20%)
474
+ for _ in range(12):
475
+ function = random.choice(FUNCTION_KEYWORDS)
476
+ item = random.choice(category_pool)
477
+ combinations.append(f"{function} {item}")
478
+
479
+ # 중복 제거 및 셔플
480
+ combinations = list(set(combinations))
481
+ random.shuffle(combinations)
482
+
483
+ logger.info(f"생성된 조합 수: {len(combinations)}개")
484
+ return combinations[:count]
485
+
486
+ def search_all_engines(query):
487
+ """모든 검색 엔진을 사용하여 데이터를 취합하는 함수"""
488
+ logger.info(f"모든 검색 엔진으로 검색 시작: {query}")
489
+
490
+ all_results = {
491
+ "google": "",
492
+ "naver": "",
493
+ "duckduckgo": ""
494
+ }
495
+
496
+ # 1. 네이버 검색 API
497
+ try:
498
+ naver_client_id = os.getenv("NAVER_CLIENT_ID")
499
+ naver_client_secret = os.getenv("NAVER_CLIENT_SECRET")
500
+
501
+ if naver_client_id and naver_client_secret:
502
+ url = "https://openapi.naver.com/v1/search/shop.json"
503
+ headers = {
504
+ "X-Naver-Client-Id": naver_client_id,
505
+ "X-Naver-Client-Secret": naver_client_secret
506
+ }
507
+ params = {"query": query, "display": 10}
508
+
509
+ response = requests.get(url, headers=headers, params=params, timeout=10)
510
+ if response.status_code == 200:
511
+ data = response.json()
512
+ naver_data = []
513
+ for item in data.get('items', [])[:5]:
514
+ naver_data.append(f"상품: {item.get('title', '').replace('<b>', '').replace('</b>', '')}")
515
+ naver_data.append(f"가격: {item.get('lprice', '')}원")
516
+ naver_data.append(f"카테고리: {item.get('category1', '')}")
517
+ all_results["naver"] = "\n".join(naver_data)
518
+ logger.info("네이버 검색 완료")
519
+ else:
520
+ all_results["naver"] = "네이버 API 검색 실패"
521
+ else:
522
+ all_results["naver"] = "네이버 API 키가 설정되지 않음"
523
+ except Exception as e:
524
+ all_results["naver"] = f"네이버 검색 오류: {str(e)}"
525
+
526
+ # 2. DuckDuckGo 검색
527
+ try:
528
+ url = "https://api.duckduckgo.com/"
529
+ params = {
530
+ "q": query,
531
+ "format": "json",
532
+ "no_html": "1",
533
+ "skip_disambig": "1"
534
+ }
535
+
536
+ response = requests.get(url, params=params, timeout=10)
537
+ if response.status_code == 200:
538
+ data = response.json()
539
+
540
+ ddg_data = []
541
+ # Abstract 정보
542
+ if data.get('Abstract'):
543
+ ddg_data.append(f"요약: {data['Abstract']}")
544
+
545
+ # Related Topics
546
+ for topic in data.get('RelatedTopics', [])[:3]:
547
+ if isinstance(topic, dict) and topic.get('Text'):
548
+ ddg_data.append(f"관련정보: {topic['Text']}")
549
+
550
+ all_results["duckduckgo"] = "\n".join(ddg_data) if ddg_data else "DuckDuckGo에서 관련 정보 없음"
551
+ logger.info("DuckDuckGo 검색 완료")
552
+ else:
553
+ all_results["duckduckgo"] = "DuckDuckGo 검색 실패"
554
+ except Exception as e:
555
+ all_results["duckduckgo"] = f"DuckDuckGo 검색 오류: {str(e)}"
556
+
557
+ # 3. Google 검색은 Gemini에서 자동 처리됨
558
+ all_results["google"] = "Google 검색 그라운딩 자동 실행"
559
+
560
+ logger.info("모든 검색 엔진 데이터 수집 완료")
561
+ return all_results
562
+
563
+ def comprehensive_market_analysis(category, seasonality, sales_target):
564
+ """다양성 강화된 시장 분석"""
565
+ logger.info("다양성 강화 시장 분석 시작")
566
+
567
+ # 랜덤 시드를 현재 시간으로 설정하여 매번 다른 결과 보장
568
+ random.seed(datetime.now().microsecond)
569
+
570
+ # 다양한 검색 각도로 쿼리 생성
571
+ search_angles = [
572
+ "틈새상품", "신상품", "인기상품", "저가상품", "고급상품", "할인상품",
573
+ "간편상품", "실용상품", "트렌드상품", "숨은상품", "베스트상품", "추천상품"
574
+ ]
575
+
576
+ search_queries = []
577
+
578
+ # 카테고리별 다양한 검색어 생성
579
+ category_pool = DIVERSE_SEED_POOLS.get(category, DIVERSE_SEED_POOLS["생활/건강"])
580
+
581
+ for angle in search_angles:
582
+ for item in random.sample(category_pool, 3): # 각 카테고리에서 3개씩만 선택
583
+ search_queries.append(f"{item} {angle}")
584
+
585
+ # 소재별 검색어 추가
586
+ for material in random.sample(MATERIAL_KEYWORDS, 5):
587
+ search_queries.append(f"{material} {category} 상품")
588
+
589
+ # 형태별 검색어 추가
590
+ for shape in random.sample(SHAPE_KEYWORDS, 5):
591
+ search_queries.append(f"{shape} {category} 아이템")
592
+
593
+ # 검색어 셔플하여 예측 불가능하게 만들기
594
+ random.shuffle(search_queries)
595
+ search_queries = search_queries[:15] # 15개로 제한
596
+
597
+ comprehensive_data = {}
598
+
599
+ for i, query in enumerate(search_queries):
600
+ logger.info(f"다양성 검색 {i+1}/15: {query}")
601
+ comprehensive_data[f"query_{i+1}"] = search_all_engines(query)
602
+
603
+ # API 과부하 방지를 위한 딜레이
604
+ import time
605
+ time.sleep(0.5)
606
+
607
+ # 다양성 강화 데이터 요약
608
+ summary = "=== 다양성 강화 시장 분석 결과 ===\n\n"
609
+
610
+ # 무작위로 결과를 섞어서 패턴 방지
611
+ result_keys = list(comprehensive_data.keys())
612
+ random.shuffle(result_keys)
613
+
614
+ summary += "🔍 다양한 시장 검색 결과:\n"
615
+ for key in result_keys[:10]:
616
+ results = comprehensive_data.get(key, {})
617
+ if results.get("naver"):
618
+ summary += f"• {results['naver'][:60]}...\n"
619
+ summary += "\n"
620
+
621
+ logger.info("다양성 강화 분석 완료")
622
+ return summary
623
+
624
+ def search_with_api(query, search_engine="Google 검색 그라운딩만"):
625
+ """개별 검색 엔진으로 검색하는 함수 (단일 엔진 선택시 사용)"""
626
+ logger.info(f"검색 엔진: {search_engine}, 쿼리: {query}")
627
+
628
+ search_results = ""
629
+
630
+ try:
631
+ if search_engine == "네이버 검색 API만":
632
+ # 네이버 검색 API 사용
633
+ naver_client_id = os.getenv("NAVER_CLIENT_ID")
634
+ naver_client_secret = os.getenv("NAVER_CLIENT_SECRET")
635
+
636
+ if naver_client_id and naver_client_secret:
637
+ url = "https://openapi.naver.com/v1/search/shop.json"
638
+ headers = {
639
+ "X-Naver-Client-Id": naver_client_id,
640
+ "X-Naver-Client-Secret": naver_client_secret
641
+ }
642
+ params = {"query": query, "display": 10}
643
+
644
+ response = requests.get(url, headers=headers, params=params)
645
+ if response.status_code == 200:
646
+ data = response.json()
647
+ for item in data.get('items', [])[:5]:
648
+ search_results += f"상품명: {item.get('title', '')}\n"
649
+ search_results += f"가격: {item.get('lprice', '')}원\n"
650
+ search_results += f"카테고리: {item.get('category1', '')}\n\n"
651
+ else:
652
+ search_results = "네이버 API 검색 실패"
653
+ else:
654
+ search_results = "네이버 API 키가 설정되지 않음"
655
+
656
+ elif search_engine == "DuckDuckGo 검색만":
657
+ # DuckDuckGo 검색 (무료, API 키 불필요)
658
+ try:
659
+ url = "https://api.duckduckgo.com/"
660
+ params = {
661
+ "q": query,
662
+ "format": "json",
663
+ "no_html": "1",
664
+ "skip_disambig": "1"
665
+ }
666
+
667
+ response = requests.get(url, params=params, timeout=10)
668
+ if response.status_code == 200:
669
+ data = response.json()
670
+
671
+ # Abstract 정보
672
+ if data.get('Abstract'):
673
+ search_results += f"요약: {data['Abstract']}\n\n"
674
+
675
+ # Related Topics
676
+ for topic in data.get('RelatedTopics', [])[:5]:
677
+ if isinstance(topic, dict) and topic.get('Text'):
678
+ search_results += f"관련 정보: {topic['Text']}\n"
679
+
680
+ if not search_results:
681
+ search_results = "DuckDuckGo에서 관련 정보를 찾지 못함"
682
+ else:
683
+ search_results = "DuckDuckGo 검색 실패"
684
+ except Exception as e:
685
+ search_results = f"DuckDuckGo 검색 오류: {str(e)}"
686
+
687
+ elif search_engine == "검색 없이 AI만 사용":
688
+ search_results = "검색 없이 AI 지식만 사용하여 키워드 생성"
689
+
690
+ else:
691
+ # Google 검색 그라운딩 (기본)
692
+ search_results = "Google 검색 그라운딩 사용"
693
+
694
+ except Exception as e:
695
+ logger.error(f"검색 오류: {str(e)}")
696
+ search_results = f"검색 오류: {str(e)}"
697
+
698
+ logger.info(f"검색 결과 길이: {len(search_results)} 문자")
699
+ return search_results
700
+
701
+ def apply_random_selection_for_keywords(category, launch_timing, seasonality, sales_target, sales_channel, competition_level):
702
+ """각 키워드마다 랜덤하게 조건을 적용하기 위한 설정 문자열 생성"""
703
+
704
+ # 각 항목별 선택지 정의
705
+ categories = ["패션잡화", "생활/건강", "출산/육아", "스포츠/레저", "디지털/가전", "가구/인테리어", "패션의류", "화장품/미용"]
706
+ launch_timings = ["즉시소싱", "기획형"]
707
+ seasonalities = ["봄", "여름", "가을", "겨울", "비계절"]
708
+ sales_targets = ["100만원 이하", "100-500만원", "500-1천만원", "1천-5천만원", "5천만원 이상"]
709
+ sales_channels = ["오픈마켓", "SNS마케팅", "광고집행", "오프라인"]
710
+ competition_levels = ["초보", "중수", "고수"]
711
+
712
+ # 랜덤적용 설정 정보 생성
713
+ random_settings = {
714
+ 'category_random': category == "랜덤적용",
715
+ 'launch_timing_random': launch_timing == "랜덤적용",
716
+ 'seasonality_random': seasonality == "랜덤적용",
717
+ 'sales_target_random': sales_target == "랜덤적용",
718
+ 'sales_channel_random': sales_channel == "랜덤적용",
719
+ 'competition_level_random': competition_level == "랜덤적용",
720
+ 'categories': categories,
721
+ 'launch_timings': launch_timings,
722
+ 'seasonalities': seasonalities,
723
+ 'sales_targets': sales_targets,
724
+ 'sales_channels': sales_channels,
725
+ 'competition_levels': competition_levels
726
+ }
727
+
728
+ # 고정값들
729
+ fixed_values = {
730
+ 'category': category if category != "랜덤적용" else None,
731
+ 'launch_timing': launch_timing if launch_timing != "랜덤적용" else None,
732
+ 'seasonality': seasonality if seasonality != "랜덤적용" else None,
733
+ 'sales_target': sales_target if sales_target != "랜덤적용" else None,
734
+ 'sales_channel': sales_channel if sales_channel != "랜덤적용" else None,
735
+ 'competition_level': competition_level if competition_level != "랜덤적용" else None
736
+ }
737
+
738
+ logger.info("=== 키워드별 랜덤 설정 ===")
739
+ logger.info(f"카테고리 랜덤: {random_settings['category_random']}")
740
+ logger.info(f"출시타이밍 랜덤: {random_settings['launch_timing_random']}")
741
+ logger.info(f"계절성 랜덤: {random_settings['seasonality_random']}")
742
+ logger.info(f"매출목표 랜덤: {random_settings['sales_target_random']}")
743
+ logger.info(f"판매채널 랜덤: {random_settings['sales_channel_random']}")
744
+ logger.info(f"경쟁강도 랜덤: {random_settings['competition_level_random']}")
745
+
746
+ return random_settings, fixed_values
747
+
748
+ def generate_sourcing_keywords(category, additional_request, launch_timing, seasonality, sales_target, sales_channel, competition_level, search_engine="Google 검색 그라운딩만"):
749
+ """다양성 강화된 쇼핑 키워드 50개를 생성하는 함수"""
750
+ logger.info("=== 다양성 강화 쇼핑키워드 생성 시작 ===")
751
+ logger.info(f"입력 조건 - 검색엔진: {search_engine}")
752
+ logger.info(f"입력 조건 - 카테고리: {category}")
753
+ logger.info(f"입력 조건 - 추가요청: {additional_request}")
754
+ logger.info(f"입력 조건 - 출시타이밍: {launch_timing}")
755
+ logger.info(f"입력 조건 - 계절성: {seasonality}")
756
+ logger.info(f"입력 조건 - 매출목표: {sales_target}")
757
+ logger.info(f"입력 조건 - 판매채널: {sales_channel}")
758
+ logger.info(f"입력 조건 - 경쟁강도: {competition_level}")
759
+
760
+ try:
761
+ logger.info("Gemini 클라이언트 초기화 중...")
762
+ client = initialize_gemini()
763
+
764
+ # 매번 다른 시드로 랜덤성 보장
765
+ current_time = datetime.now()
766
+ random_seed = current_time.microsecond + current_time.second * 1000
767
+ random.seed(random_seed)
768
+ logger.info(f"랜덤 시드 설정: {random_seed}")
769
+
770
+ # 프롬프트 구성
771
+ logger.info("다양성 강화 프롬프트 구성 중...")
772
+
773
+ # 랜덤 설정 처리
774
+ random_settings, fixed_values = apply_random_selection_for_keywords(
775
+ category, launch_timing, seasonality, sales_target, sales_channel, competition_level
776
+ )
777
+
778
+ # 다양한 키워드 조합 미리 생성
779
+ diverse_combinations = generate_diverse_keyword_combinations(category, 60)
780
+ logger.info(f"다양한 조합 생성 완료: {len(diverse_combinations)}개")
781
+
782
+ # 검색 엔진별 처리
783
+ search_info = ""
784
+ config_tools = []
785
+
786
+ if search_engine == "모든 검색 엔진 통합 분석 (추천)":
787
+ logger.info("🔍 다양성 강화 통합 분석 시작...")
788
+
789
+ # Google 검색 그라운딩 도구 설정
790
+ google_search_tool = Tool(google_search=GoogleSearch())
791
+ config_tools = [google_search_tool]
792
+
793
+ # 다양성 강화 종합 시장 분석 실행
794
+ comprehensive_analysis = comprehensive_market_analysis(category, seasonality, sales_target)
795
+
796
+ search_info = f"""
797
+ 🔍 === 다양성 강화 통합 분석 결과 ===
798
+
799
+ 📈 Google 검색 그라운딩: 실시간 다양한 쇼핑키워드 트렌드 분석 (자동 실행)
800
+ 🛒 네이버 쇼핑 API: 한국 쇼핑몰 다양한 키워드 데이터 분석
801
+ 🌐 DuckDuckGo 검색: 글로벌 다양한 쇼핑키워드 정보 분석
802
+
803
+ {comprehensive_analysis}
804
+
805
+ 💡 위 모든 데이터를 종합하여 매번 다른 조합의 쇼핑키워드를 생성��니다.
806
+ 🎲 랜덤 시드: {random_seed} (매번 다른 결과 보장)
807
+ """
808
+
809
+ elif search_engine == "Google 검색 그라운딩만":
810
+ logger.info("Google 검색 도구 설정 중...")
811
+ google_search_tool = Tool(google_search=GoogleSearch())
812
+ config_tools = [google_search_tool]
813
+ search_info = f"Google 검색 그라운딩을 통한 다양한 실시간 쇼핑키워드 분석 (시드: {random_seed})"
814
+
815
+ elif search_engine in ["네이버 검색 API만", "DuckDuckGo 검색만"]:
816
+ logger.info(f"{search_engine} 사용하여 다양한 쇼핑키워드 조사 중...")
817
+ # 다양성 강화를 위한 검색 실행
818
+ search_queries = []
819
+
820
+ # 랜덤하게 다양한 검색어 생성
821
+ base_items = random.sample(diverse_combinations, 8)
822
+ for item in base_items:
823
+ search_queries.append(f"{item} 쇼핑키워드")
824
+
825
+ search_results = ""
826
+ for query in search_queries:
827
+ result = search_with_api(query, search_engine)
828
+ search_results += f"[검색어: {query}]\n{result}\n\n"
829
+
830
+ search_info = f"{search_engine} 다양한 쇼핑키워드 검색 결과 (시드: {random_seed}):\n{search_results}"
831
+
832
+ else: # 검색 없이 AI만 사용
833
+ logger.info("검색 없이 AI 지식만 사용")
834
+ search_info = f"AI 내장 지식을 기반으로 다양한 쇼핑키워드 생성 (시드: {random_seed})"
835
+
836
+ # 다양성을 강화한 프롬프트 - 매번 다른 조합 요청
837
+ diverse_sample = random.sample(diverse_combinations, 20)
838
+
839
+ prompt = f"""
840
+ 🎯 다양성 강화 쇼핑키워드 발굴 시스템 v5.0
841
+
842
+ ⚡ 중요: 절대 중복되지 않는 다양한 키워드만 생성하세요!
843
+
844
+ 🔬 역할 정의
845
+ 당신은 매번 완전히 다른 조합의 쇼핑키워드를 생성하는 전문가입니다.
846
+
847
+ 🎯 목표
848
+ 주어진 조건에 맞는 실제 쇼핑키워드 50개를 발굴하되, 절대 중복되지 않고 매번 다른 조합으로 구성하십시오.
849
+
850
+ 📋 입력된 조건:
851
+ 카테고리: {category}
852
+ 추가 요청사항: {additional_request}
853
+ 출시타이밍: {launch_timing}
854
+ 계절성: {seasonality}
855
+ 매출목표: {sales_target}
856
+ 판매채널: {sales_channel}
857
+ 경쟁강도: {competition_level}
858
+ 검색엔진: {search_engine}
859
+
860
+ 🔍 쇼핑키워드 분석 정보:
861
+ {search_info}
862
+
863
+ 🎲 다양성 보장 참고 조합 예시 (이것과 다르게 생성하세요):
864
+ {', '.join(diverse_sample[:10])}
865
+
866
+ ⚠️ 키워드별 랜덤 적용 규칙:
867
+ 각 키워드마다 다음과 같이 적용하세요:
868
+
869
+ {"- 카테고리: 매 키워드마다 " + str(random_settings['categories']) + " 중에서 랜덤 선택" if random_settings['category_random'] else f"- 카테고리: {fixed_values['category']} 고정"}
870
+ {"- 출시타이밍: 매 키워드마다 " + str(random_settings['launch_timings']) + " 중에서 랜덤 선택" if random_settings['launch_timing_random'] else f"- 출시타이밍: {fixed_values['launch_timing']} 고정"}
871
+ {"- 계절성: 매 키워드마다 " + str(random_settings['seasonalities']) + " 중에서 랜덤 선택" if random_settings['seasonality_random'] else f"- 계절성: {fixed_values['seasonality']} 고정"}
872
+ {"- 매출목표: 매 키워드마다 " + str(random_settings['sales_targets']) + " 중에서 랜덤 선택" if random_settings['sales_target_random'] else f"- 매출목표: {fixed_values['sales_target']} 고정"}
873
+ {"- 판매채널: 매 키워드마다 " + str(random_settings['sales_channels']) + " 중에서 랜덤 선택" if random_settings['sales_channel_random'] else f"- 판매채널: {fixed_values['sales_channel']} 고정"}
874
+ {"- 경쟁강도: 매 키워드마다 " + str(random_settings['competition_levels']) + " 중에서 랜덤 선택" if random_settings['competition_level_random'] else f"- 경쟁강도: {fixed_values['competition_level']} 고정"}
875
+
876
+ ⚙️ 다양성 강화 워크플로우
877
+ 1단계: 완전히 새로운 조합 생성
878
+ - 이전 결과와 절대 중복되지 않는 키워드 조합
879
+ - 소재({', '.join(MATERIAL_KEYWORDS[:5])}) + 상품명 조합
880
+ - 형태({', '.join(SHAPE_KEYWORDS[:5])}) + 상품명 조합
881
+ - 기능({', '.join(FUNCTION_KEYWORDS[:5])}) + 상품명 조합
882
+
883
+ 2단계: 중복 방지 필터링
884
+ - 동일한 키워드 조합 완전 배제
885
+ - 유사한 의미의 키워드 조합 배제
886
+ - 매번 새로운 각도로 접근
887
+
888
+ 3단계: 50개 다양한 키워드 선별
889
+ - 브랜드명 절대 금지
890
+ - 복잡한 기술 용어 금지
891
+ - 최대 2개 단어 조합만 허용
892
+
893
+ ⚠️ 다양성 강화 키워드 구성 규칙 (매우 중요):
894
+
895
+ 🚫 절대 금지 사항:
896
+ - 동일하거나 유사한 키워드 반복
897
+ - 브랜드명 (삼성, LG, 나이키 등)
898
+ - 복잡한 기술 용어
899
+ - 3개 이상 복합어
900
+
901
+ ✅ 반드시 다양하게 포함해야 할 형태:
902
+ 1. 소재별 키워드 (예: 대나무 도마, 구리 컵)
903
+ 2. 형태별 키워드 (예: 원형 접시, 슬림 케이스)
904
+ 3. ���능별 키워드 (예: 방수 파우치, 항균 수건)
905
+ 4. 카테고리별 키워드 (예: 수납함, 조리도구)
906
+
907
+ 🎯 다양성 보장 전략:
908
+ - 절대 같은 소재를 2번 이상 사용하지 마세요
909
+ - 절대 같은 형태를 2번 이상 사용하지 마세요
910
+ - 절대 같은 기능을 2번 이상 사용하지 마세요
911
+ - 매 키워드마다 완전히 다른 조합으로 생성하세요
912
+
913
+ 올바른 다양한 키워드 예시:
914
+ ✅ 대나무 도마 (소재+상품)
915
+ ✅ 원형 접시 (형태+상품)
916
+ ✅ 방수 파우치 (기능+상품)
917
+ ✅ 세라믹 머그컵 (소재+상품)
918
+ ✅ 접이식 선반 (형태+상품)
919
+ ✅ 항균 수건 (기능+상품)
920
+
921
+ 잘못된 반복 키워드 예시:
922
+ ❌ 대나무 도마, 대나무 젓가락 (소재 반복)
923
+ ❌ 원형 접시, 원형 쟁반 (형태 반복)
924
+ ❌ 방수 파우치, 방수 케이스 (기능 반복)
925
+
926
+ 📋 출력 형식:
927
+ 오직 완전히 다른 쇼핑키워드만 한 줄씩 50개 출력
928
+ - 번호 금지
929
+ - 설명 금지
930
+ - 기호나 특수문자 금지
931
+ - 괄호 안 설명 금지
932
+ - 순수 키워드만 출력
933
+ - 절대 중복 금지
934
+
935
+ 예시 출력 형태 (매번 완전히 다르게):
936
+ 유리 화분
937
+ 접이식 의자
938
+ 항균 도마
939
+ 알루미늄 텀블러
940
+ 슬림 파일함
941
+
942
+ ⚡ 지금 바로 절대 중복되지 않는 완전히 새로운 쇼핑키워드 50개를 각각 다른 랜덤 조건을 적용하여 출력하세요.
943
+ 매번 실행할 때마다 완전히 다른 결과가 나와야 합니다!
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
style.css CHANGED
@@ -1,29 +1,117 @@
 
 
 
 
 
1
  :root {
 
2
  --primary-color: #FB7F0D;
3
  --secondary-color: #ff9a8b;
4
  --accent-color: #FF6B6B;
 
 
5
  --background-color: #FFFFFF;
6
  --card-bg: #ffffff;
 
 
 
7
  --text-color: #334155;
8
- --border-radius: 18px;
 
 
 
 
 
 
 
 
 
 
9
  --shadow: 0 8px 30px rgba(251, 127, 13, 0.08);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  }
11
 
12
  /* ── 전역 스타일 ── */
13
  body {
14
  font-family: 'Pretendard', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
15
- background-color: var(--background-color);
16
- color: var(--text-color);
17
  line-height: 1.6;
18
  margin: 0;
19
  padding: 0;
 
 
 
 
 
 
 
 
 
 
 
20
  }
21
 
22
  .gradio-container {
23
  width: 100%;
24
  margin: 0 auto;
25
  padding: 20px;
26
- background-color: var(--background-color);
27
  }
28
 
29
  /* ── 섹션 스타일 ── */
@@ -43,12 +131,13 @@ body {
43
 
44
  /* 섹션 프레임 */
45
  .custom-frame {
46
- background-color: var(--card-bg);
47
- border: 1px solid rgba(0, 0, 0, 0.04);
48
  border-radius: var(--border-radius);
49
  padding: 20px;
50
  margin: 10px 0;
51
- box-shadow: var(--shadow);
 
52
  }
53
 
54
  /* 접을 수 있는 섹션 */
@@ -75,18 +164,17 @@ body {
75
  .collapsible-content {
76
  display: none;
77
  padding: 15px;
78
- background-color: var(--card-bg);
79
- border: 1px solid rgba(0, 0, 0, 0.04);
80
  border-radius: 0 0 var(--border-radius) var(--border-radius);
81
  margin-top: -5px;
 
82
  }
83
 
84
  .collapsible-content.active {
85
  display: block;
86
  }
87
 
88
-
89
-
90
  /* 두 버튼에 공통으로 적용할 스타일 */
91
  .execution-button {
92
  font-size: 18px !important;
@@ -106,12 +194,12 @@ body {
106
 
107
  /* 각 버튼별 고유 색상 */
108
  .primary-button {
109
- background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)) !important;
110
  box-shadow: 0 4px 8px rgba(251, 127, 13, 0.25) !important;
111
  }
112
 
113
  .secondary-button {
114
- background: linear-gradient(135deg, #6c757d, #495057) !important;
115
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25) !important;
116
  }
117
 
@@ -124,19 +212,17 @@ body {
124
  .execution-section {
125
  margin-top: 20px;
126
  padding: 15px;
127
- background-color: #f9f9f9;
128
  border-radius: 8px;
129
- border: 1px solid #e5e5e5;
 
130
  }
131
 
132
-
133
-
134
-
135
  /* ── 컴���넌트 스타일 ── */
136
  /* 버튼 스타일 */
137
  .custom-button {
138
  border-radius: 30px !important;
139
- background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)) !important;
140
  color: white !important;
141
  font-size: 18px !important;
142
  padding: 10px 20px !important;
@@ -153,7 +239,7 @@ body {
153
  /* 작은 버튼 */
154
  .custom-button-small {
155
  border-radius: 20px !important;
156
- background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)) !important;
157
  color: white !important;
158
  font-size: 14px !important;
159
  padding: 8px 15px !important;
@@ -170,7 +256,7 @@ body {
170
  /* 리셋 버튼 */
171
  .reset-button {
172
  border-radius: 30px !important;
173
- background: linear-gradient(135deg, #6c757d, #495057) !important;
174
  color: white !important;
175
  font-size: 16px !important;
176
  padding: 8px 16px !important;
@@ -186,15 +272,29 @@ body {
186
  }
187
 
188
  /* 입력 필드 스타일 */
189
- .gr-input, .gr-text-input, .gr-sample-inputs {
 
 
 
 
 
 
190
  border-radius: var(--border-radius) !important;
191
- border: 1px solid #dddddd !important;
192
  padding: 12px !important;
193
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05) !important;
194
  transition: all 0.3s ease !important;
 
 
195
  }
196
 
197
- .gr-input:focus, .gr-text-input:focus {
 
 
 
 
 
 
198
  border-color: var(--primary-color) !important;
199
  outline: none !important;
200
  box-shadow: 0 0 0 2px rgba(251, 127, 13, 0.2) !important;
@@ -208,9 +308,11 @@ input[type="checkbox"], input[type="radio"] {
208
  /* 드롭다운 스타일 */
209
  .gr-dropdown {
210
  border-radius: var(--border-radius) !important;
211
- border: 1px solid #dddddd !important;
212
  padding: 12px !important;
213
  transition: all 0.3s ease !important;
 
 
214
  }
215
 
216
  .gr-dropdown:focus {
@@ -225,7 +327,7 @@ input[type="checkbox"], input[type="radio"] {
225
  align-items: center;
226
  font-size: 20px;
227
  font-weight: 700;
228
- color: #333333;
229
  margin-bottom: 10px;
230
  padding-bottom: 5px;
231
  border-bottom: 2px solid var(--primary-color);
@@ -242,7 +344,7 @@ input[type="checkbox"], input[type="radio"] {
242
  .subsection-title {
243
  font-size: 18px;
244
  font-weight: 600;
245
- color: #444444;
246
  margin: 15px 0 8px 0;
247
  }
248
 
@@ -254,32 +356,38 @@ input[type="checkbox"], input[type="radio"] {
254
  margin: 0;
255
  padding: 0;
256
  font-size: 14px;
 
 
 
257
  }
258
 
259
  .styled-table th,
260
  .styled-table td {
261
  padding: 12px 15px;
262
  text-align: left;
263
- border-bottom: 1px solid #dddddd;
264
  overflow: hidden;
265
  text-overflow: ellipsis;
 
 
266
  }
267
 
268
  .styled-table th {
269
- background-color: var(--primary-color);
270
- color: white;
271
  font-weight: bold;
272
  position: sticky;
273
  top: 0;
274
  white-space: nowrap;
 
275
  }
276
 
277
  .styled-table tbody tr:nth-of-type(even) {
278
- background-color: #f3f3f3;
279
  }
280
 
281
  .styled-table tbody tr:hover {
282
- background-color: #f0f0f0;
283
  }
284
 
285
  .styled-table tbody tr:last-of-type {
@@ -291,8 +399,40 @@ input[type="checkbox"], input[type="radio"] {
291
  max-height: 600px;
292
  overflow-y: auto;
293
  border-radius: var(--border-radius);
294
- border: 1px solid #e5e5e5;
295
  margin-top: 15px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  }
297
 
298
  /* 스크롤바 스타일 */
@@ -302,7 +442,7 @@ input[type="checkbox"], input[type="radio"] {
302
  }
303
 
304
  ::-webkit-scrollbar-track {
305
- background: rgba(0, 0, 0, 0.05);
306
  border-radius: 10px;
307
  }
308
 
@@ -311,13 +451,18 @@ input[type="checkbox"], input[type="radio"] {
311
  border-radius: 10px;
312
  }
313
 
 
 
 
 
314
  /* ── 분석 결과 스타일 ── */
315
  .analysis-result {
316
  margin-top: 30px;
317
- border: 1px solid #ddd;
318
  border-radius: 5px;
319
  padding: 15px;
320
- background-color: #f9f9f9;
 
321
  }
322
 
323
  .result-header {
@@ -328,24 +473,28 @@ input[type="checkbox"], input[type="radio"] {
328
  }
329
 
330
  .summary-box {
331
- background-color: #f5f5f5;
332
  border-left: 4px solid var(--primary-color);
333
  padding: 10px 15px;
334
  margin-bottom: 20px;
335
  font-size: 14px;
 
336
  }
337
 
338
  .summary-title {
339
  font-weight: bold;
340
  margin-bottom: 5px;
 
341
  }
342
 
343
  .recommendation-box {
344
- background-color: #e7f7f3;
345
  border-radius: 5px;
346
  padding: 15px;
347
  margin-bottom: 25px;
348
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
 
 
349
  }
350
 
351
  .recommendation-title {
@@ -357,20 +506,22 @@ input[type="checkbox"], input[type="radio"] {
357
 
358
  .recommendation-item {
359
  padding: 6px 0;
360
- border-bottom: 1px solid #e0e0e0;
 
361
  }
362
 
363
  .recommendation-item:last-child {
364
  border-bottom: none;
365
  }
366
 
367
- /* ── 키워드 태그 스타일 ── */
368
  .keyword-tag-container {
369
  margin-top: 20px;
370
  padding: 10px;
371
- border: 1px solid #ddd;
372
  border-radius: 5px;
373
- background-color: #f9f9f9;
 
374
  }
375
 
376
  .keyword-tag {
@@ -414,6 +565,76 @@ input[type="checkbox"], input[type="radio"] {
414
  animation: fadeIn 0.5s ease-out;
415
  }
416
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  /* 반응형 조정 */
418
  @media (max-width: 768px) {
419
  .grid-container {
 
1
+ /* ============================================
2
+ 다크모드 자동 변경 AI 상품 소싱 분석 시스템 CSS
3
+ ============================================ */
4
+
5
+ /* 1. CSS 변수 정의 (라이트모드 - 기본값) */
6
  :root {
7
+ /* 메인 컬러 */
8
  --primary-color: #FB7F0D;
9
  --secondary-color: #ff9a8b;
10
  --accent-color: #FF6B6B;
11
+
12
+ /* 배경 컬러 */
13
  --background-color: #FFFFFF;
14
  --card-bg: #ffffff;
15
+ --input-bg: #ffffff;
16
+
17
+ /* 텍스트 컬러 */
18
  --text-color: #334155;
19
+ --text-secondary: #64748b;
20
+
21
+ /* 보더 및 구분선 */
22
+ --border-color: #dddddd;
23
+ --border-light: #e5e5e5;
24
+
25
+ /* 테이블 컬러 */
26
+ --table-even-bg: #f3f3f3;
27
+ --table-hover-bg: #f0f0f0;
28
+
29
+ /* 그림자 */
30
  --shadow: 0 8px 30px rgba(251, 127, 13, 0.08);
31
+ --shadow-light: 0 2px 4px rgba(0, 0, 0, 0.1);
32
+
33
+ /* 기타 */
34
+ --border-radius: 18px;
35
+ }
36
+
37
+ /* 2. 다크모드 색상 변수 (자동 감지) */
38
+ @media (prefers-color-scheme: dark) {
39
+ :root {
40
+ /* 배경 컬러 */
41
+ --background-color: #1a1a1a;
42
+ --card-bg: #2d2d2d;
43
+ --input-bg: #2d2d2d;
44
+
45
+ /* 텍스트 컬러 */
46
+ --text-color: #e5e5e5;
47
+ --text-secondary: #a1a1aa;
48
+
49
+ /* 보더 및 구분선 */
50
+ --border-color: #404040;
51
+ --border-light: #525252;
52
+
53
+ /* 테이블 컬러 */
54
+ --table-even-bg: #333333;
55
+ --table-hover-bg: #404040;
56
+
57
+ /* 그림자 */
58
+ --shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
59
+ --shadow-light: 0 2px 4px rgba(0, 0, 0, 0.2);
60
+ }
61
+ }
62
+
63
+ /* 3. 수동 다크모드 클래스 (Gradio 토글용) */
64
+ [data-theme="dark"],
65
+ .dark,
66
+ .gr-theme-dark {
67
+ /* 배경 컬러 */
68
+ --background-color: #1a1a1a;
69
+ --card-bg: #2d2d2d;
70
+ --input-bg: #2d2d2d;
71
+
72
+ /* 텍스트 컬러 */
73
+ --text-color: #e5e5e5;
74
+ --text-secondary: #a1a1aa;
75
+
76
+ /* 보더 및 구분선 */
77
+ --border-color: #404040;
78
+ --border-light: #525252;
79
+
80
+ /* 테이블 컬러 */
81
+ --table-even-bg: #333333;
82
+ --table-hover-bg: #404040;
83
+
84
+ /* 그림자 */
85
+ --shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
86
+ --shadow-light: 0 2px 4px rgba(0, 0, 0, 0.2);
87
  }
88
 
89
  /* ── 전역 스타일 ── */
90
  body {
91
  font-family: 'Pretendard', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
92
+ background-color: var(--background-color) !important;
93
+ color: var(--text-color) !important;
94
  line-height: 1.6;
95
  margin: 0;
96
  padding: 0;
97
+ transition: background-color 0.3s ease, color 0.3s ease;
98
+ }
99
+
100
+
101
+ .gradio-container,
102
+ .gradio-container *,
103
+ .gr-app,
104
+ .gr-app *,
105
+ .gr-interface {
106
+ background-color: var(--background-color) !important;
107
+ color: var(--text-color) !important;
108
  }
109
 
110
  .gradio-container {
111
  width: 100%;
112
  margin: 0 auto;
113
  padding: 20px;
114
+ background-color: var(--background-color) !important;
115
  }
116
 
117
  /* ── 섹션 스타일 ── */
 
131
 
132
  /* 섹션 프레임 */
133
  .custom-frame {
134
+ background-color: var(--card-bg) !important;
135
+ border: 1px solid var(--border-light) !important;
136
  border-radius: var(--border-radius);
137
  padding: 20px;
138
  margin: 10px 0;
139
+ box-shadow: var(--shadow) !important;
140
+ color: var(--text-color) !important;
141
  }
142
 
143
  /* 접을 수 있는 섹션 */
 
164
  .collapsible-content {
165
  display: none;
166
  padding: 15px;
167
+ background-color: var(--card-bg) !important;
168
+ border: 1px solid var(--border-light) !important;
169
  border-radius: 0 0 var(--border-radius) var(--border-radius);
170
  margin-top: -5px;
171
+ color: var(--text-color) !important;
172
  }
173
 
174
  .collapsible-content.active {
175
  display: block;
176
  }
177
 
 
 
178
  /* 두 버튼에 공통으로 적용할 스타일 */
179
  .execution-button {
180
  font-size: 18px !important;
 
194
 
195
  /* 각 버튼별 고유 색상 */
196
  .primary-button {
197
+ background: var(--primary-color) !important;
198
  box-shadow: 0 4px 8px rgba(251, 127, 13, 0.25) !important;
199
  }
200
 
201
  .secondary-button {
202
+ background: #6c757d !important;
203
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25) !important;
204
  }
205
 
 
212
  .execution-section {
213
  margin-top: 20px;
214
  padding: 15px;
215
+ background-color: var(--card-bg) !important;
216
  border-radius: 8px;
217
+ border: 1px solid var(--border-light) !important;
218
+ color: var(--text-color) !important;
219
  }
220
 
 
 
 
221
  /* ── 컴���넌트 스타일 ── */
222
  /* 버튼 스타일 */
223
  .custom-button {
224
  border-radius: 30px !important;
225
+ background: var(--primary-color) !important;
226
  color: white !important;
227
  font-size: 18px !important;
228
  padding: 10px 20px !important;
 
239
  /* 작은 버튼 */
240
  .custom-button-small {
241
  border-radius: 20px !important;
242
+ background: var(--primary-color) !important;
243
  color: white !important;
244
  font-size: 14px !important;
245
  padding: 8px 15px !important;
 
256
  /* 리셋 버튼 */
257
  .reset-button {
258
  border-radius: 30px !important;
259
+ background: #6c757d !important;
260
  color: white !important;
261
  font-size: 16px !important;
262
  padding: 8px 16px !important;
 
272
  }
273
 
274
  /* 입력 필드 스타일 */
275
+ .gr-input, .gr-text-input, .gr-sample-inputs,
276
+ input[type="text"],
277
+ input[type="number"],
278
+ input[type="email"],
279
+ input[type="password"],
280
+ textarea,
281
+ select {
282
  border-radius: var(--border-radius) !important;
283
+ border: 1px solid var(--border-color) !important;
284
  padding: 12px !important;
285
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05) !important;
286
  transition: all 0.3s ease !important;
287
+ background-color: var(--input-bg) !important;
288
+ color: var(--text-color) !important;
289
  }
290
 
291
+ .gr-input:focus, .gr-text-input:focus,
292
+ input[type="text"]:focus,
293
+ input[type="number"]:focus,
294
+ input[type="email"]:focus,
295
+ input[type="password"]:focus,
296
+ textarea:focus,
297
+ select:focus {
298
  border-color: var(--primary-color) !important;
299
  outline: none !important;
300
  box-shadow: 0 0 0 2px rgba(251, 127, 13, 0.2) !important;
 
308
  /* 드롭다운 스타일 */
309
  .gr-dropdown {
310
  border-radius: var(--border-radius) !important;
311
+ border: 1px solid var(--border-color) !important;
312
  padding: 12px !important;
313
  transition: all 0.3s ease !important;
314
+ background-color: var(--input-bg) !important;
315
+ color: var(--text-color) !important;
316
  }
317
 
318
  .gr-dropdown:focus {
 
327
  align-items: center;
328
  font-size: 20px;
329
  font-weight: 700;
330
+ color: var(--text-color) !important;
331
  margin-bottom: 10px;
332
  padding-bottom: 5px;
333
  border-bottom: 2px solid var(--primary-color);
 
344
  .subsection-title {
345
  font-size: 18px;
346
  font-weight: 600;
347
+ color: var(--text-color) !important;
348
  margin: 15px 0 8px 0;
349
  }
350
 
 
356
  margin: 0;
357
  padding: 0;
358
  font-size: 14px;
359
+ background-color: var(--card-bg) !important;
360
+ color: var(--text-color) !important;
361
+ position: relative;
362
  }
363
 
364
  .styled-table th,
365
  .styled-table td {
366
  padding: 12px 15px;
367
  text-align: left;
368
+ border-bottom: 1px solid var(--border-color) !important;
369
  overflow: hidden;
370
  text-overflow: ellipsis;
371
+ background-color: var(--card-bg) !important;
372
+ color: var(--text-color) !important;
373
  }
374
 
375
  .styled-table th {
376
+ background-color: var(--primary-color) !important;
377
+ color: white !important;
378
  font-weight: bold;
379
  position: sticky;
380
  top: 0;
381
  white-space: nowrap;
382
+ z-index: 10;
383
  }
384
 
385
  .styled-table tbody tr:nth-of-type(even) {
386
+ background-color: var(--table-even-bg) !important;
387
  }
388
 
389
  .styled-table tbody tr:hover {
390
+ background-color: var(--table-hover-bg) !important;
391
  }
392
 
393
  .styled-table tbody tr:last-of-type {
 
399
  max-height: 600px;
400
  overflow-y: auto;
401
  border-radius: var(--border-radius);
402
+ border: 1px solid var(--border-light) !important;
403
  margin-top: 15px;
404
+ background-color: var(--card-bg) !important;
405
+ position: relative;
406
+ }
407
+
408
+ /* 테이블 컨테이너 추가 스타일 */
409
+ .table-container {
410
+ position: relative;
411
+ width: 100%;
412
+ margin: 0;
413
+ border-radius: 8px;
414
+ overflow: hidden;
415
+ box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
416
+ }
417
+
418
+ .header-wrap {
419
+ position: sticky;
420
+ top: 0;
421
+ z-index: 20;
422
+ background-color: var(--primary-color) !important;
423
+ }
424
+
425
+ /* 테이블 데이터가 헤더 위로 오는 것을 방지 */
426
+ .styled-table tbody td {
427
+ position: relative;
428
+ z-index: 1;
429
+ }
430
+
431
+ .styled-table thead th {
432
+ position: sticky;
433
+ top: 0;
434
+ z-index: 10;
435
+ background-color: var(--primary-color) !important;
436
  }
437
 
438
  /* 스크롤바 스타일 */
 
442
  }
443
 
444
  ::-webkit-scrollbar-track {
445
+ background: var(--card-bg);
446
  border-radius: 10px;
447
  }
448
 
 
451
  border-radius: 10px;
452
  }
453
 
454
+ ::-webkit-scrollbar-thumb:hover {
455
+ background: var(--secondary-color);
456
+ }
457
+
458
  /* ── 분석 결과 스타일 ── */
459
  .analysis-result {
460
  margin-top: 30px;
461
+ border: 1px solid var(--border-color) !important;
462
  border-radius: 5px;
463
  padding: 15px;
464
+ background-color: var(--card-bg) !important;
465
+ color: var(--text-color) !important;
466
  }
467
 
468
  .result-header {
 
473
  }
474
 
475
  .summary-box {
476
+ background-color: var(--table-even-bg) !important;
477
  border-left: 4px solid var(--primary-color);
478
  padding: 10px 15px;
479
  margin-bottom: 20px;
480
  font-size: 14px;
481
+ color: var(--text-color) !important;
482
  }
483
 
484
  .summary-title {
485
  font-weight: bold;
486
  margin-bottom: 5px;
487
+ color: var(--text-color) !important;
488
  }
489
 
490
  .recommendation-box {
491
+ background-color: var(--card-bg) !important;
492
  border-radius: 5px;
493
  padding: 15px;
494
  margin-bottom: 25px;
495
+ box-shadow: var(--shadow-light) !important;
496
+ border: 1px solid var(--border-color) !important;
497
+ color: var(--text-color) !important;
498
  }
499
 
500
  .recommendation-title {
 
506
 
507
  .recommendation-item {
508
  padding: 6px 0;
509
+ border-bottom: 1px solid var(--border-color) !important;
510
+ color: var(--text-color) !important;
511
  }
512
 
513
  .recommendation-item:last-child {
514
  border-bottom: none;
515
  }
516
 
517
+ /* 키워드 태그 스타일 */
518
  .keyword-tag-container {
519
  margin-top: 20px;
520
  padding: 10px;
521
+ border: 1px solid var(--border-color) !important;
522
  border-radius: 5px;
523
+ background-color: var(--card-bg) !important;
524
+ color: var(--text-color) !important;
525
  }
526
 
527
  .keyword-tag {
 
565
  animation: fadeIn 0.5s ease-out;
566
  }
567
 
568
+ /* Gradio 컴포넌트 강제 적용 */
569
+ .gr-form,
570
+ .gr-box,
571
+ .gr-panel,
572
+ .gr-block,
573
+ .gr-group,
574
+ .gr-row,
575
+ .gr-column {
576
+ background-color: var(--card-bg) !important;
577
+ color: var(--text-color) !important;
578
+ border-color: var(--border-color) !important;
579
+ }
580
+
581
+ /* 라벨 및 텍스트 요소 */
582
+ label,
583
+ .gr-label,
584
+ .gr-checkbox label,
585
+ .gr-radio label,
586
+ p, span, div {
587
+ color: var(--text-color) !important;
588
+ }
589
+
590
+ /* 툴팁 및 팝업 */
591
+ [data-tooltip]:hover::after,
592
+ .tooltip,
593
+ .popup {
594
+ background-color: var(--card-bg) !important;
595
+ color: var(--text-color) !important;
596
+ border-color: var(--border-color) !important;
597
+ box-shadow: var(--shadow-light) !important;
598
+ }
599
+
600
+ /* 모달 및 오버레이 */
601
+ .modal,
602
+ .overlay,
603
+ [class*="modal"],
604
+ [class*="overlay"] {
605
+ background-color: var(--card-bg) !important;
606
+ color: var(--text-color) !important;
607
+ border-color: var(--border-color) !important;
608
+ }
609
+
610
+ /* 코드 블록 및 pre 태그 */
611
+ code,
612
+ pre,
613
+ .code-block {
614
+ background-color: var(--table-even-bg) !important;
615
+ color: var(--text-color) !important;
616
+ border-color: var(--border-color) !important;
617
+ }
618
+
619
+ /* 알림 및 메시지 */
620
+ .alert,
621
+ .message,
622
+ .notification,
623
+ [class*="alert"],
624
+ [class*="message"],
625
+ [class*="notification"] {
626
+ background-color: var(--card-bg) !important;
627
+ color: var(--text-color) !important;
628
+ border-color: var(--border-color) !important;
629
+ }
630
+
631
+ /* 전환 애니메이션 */
632
+ * {
633
+ transition: background-color 0.3s ease,
634
+ color 0.3s ease,
635
+ border-color 0.3s ease !important;
636
+ }
637
+
638
  /* 반응형 조정 */
639
  @media (max-width: 768px) {
640
  .grid-container {
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_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)