|
""" |
|
트렌드 분석 모듈 v2.16 - 정교한 이중 API 호출 및 역산 로직 구현 |
|
- 이중 트렌드 API 호출: 일별 + 월별 데이터 |
|
- 일별 데이터로 전월 정확한 검색량 역산 |
|
- 전월 기준으로 3년 모든 월 검색량 역산 |
|
- 작년 동월 기반 미래 3개월 예상 |
|
- 최종 단계에서 10% 감소 조정 적용 |
|
""" |
|
|
|
import urllib.request |
|
import json |
|
import time |
|
import logging |
|
from datetime import datetime, timedelta |
|
import calendar |
|
import api_utils |
|
|
|
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
def get_complete_month(): |
|
"""완성된 마지막 월 계산 - 단순화된 로직""" |
|
current_date = datetime.now() |
|
current_day = current_date.day |
|
current_year = current_date.year |
|
current_month = current_date.month |
|
|
|
|
|
if current_day >= 3: |
|
completed_year = current_year |
|
completed_month = current_month - 1 |
|
else: |
|
completed_year = current_year |
|
completed_month = current_month - 2 |
|
|
|
|
|
while completed_month <= 0: |
|
completed_month += 12 |
|
completed_year -= 1 |
|
|
|
return completed_year, completed_month |
|
|
|
def get_daily_trend_data(keywords, max_retries=3): |
|
"""1차 호출: 일별 트렌드 데이터 (전월 정확 계산용)""" |
|
for retry_attempt in range(max_retries): |
|
try: |
|
|
|
datalab_config = api_utils.get_next_datalab_api_config() |
|
if not datalab_config: |
|
logger.warning("데이터랩 API 키가 설정되지 않았습니다.") |
|
return None |
|
|
|
client_id = datalab_config["CLIENT_ID"] |
|
client_secret = datalab_config["CLIENT_SECRET"] |
|
|
|
|
|
completed_year, completed_month = get_complete_month() |
|
|
|
|
|
current_date = datetime.now() |
|
yesterday = current_date - timedelta(days=1) |
|
|
|
start_date = f"{completed_year:04d}-{completed_month:02d}-01" |
|
end_date = yesterday.strftime("%Y-%m-%d") |
|
|
|
logger.info(f"📞 1차 호출 (일별): {start_date} ~ {end_date}") |
|
|
|
|
|
keywordGroups = [] |
|
for kw in keywords[:5]: |
|
keywordGroups.append({ |
|
'groupName': kw, |
|
'keywords': [kw] |
|
}) |
|
|
|
|
|
body_dict = { |
|
'startDate': start_date, |
|
'endDate': end_date, |
|
'timeUnit': 'date', |
|
'keywordGroups': keywordGroups |
|
} |
|
|
|
url = "https://openapi.naver.com/v1/datalab/search" |
|
body = json.dumps(body_dict, ensure_ascii=False) |
|
|
|
request = urllib.request.Request(url) |
|
request.add_header("X-Naver-Client-Id", client_id) |
|
request.add_header("X-Naver-Client-Secret", client_secret) |
|
request.add_header("Content-Type", "application/json") |
|
|
|
response = urllib.request.urlopen(request, data=body.encode("utf-8"), timeout=15) |
|
rescode = response.getcode() |
|
|
|
if rescode == 200: |
|
response_body = response.read() |
|
response_json = json.loads(response_body) |
|
logger.info(f"일별 트렌드 데이터 조회 성공") |
|
return response_json |
|
else: |
|
logger.error(f"일별 API 오류: 상태코드 {rescode}") |
|
if retry_attempt < max_retries - 1: |
|
time.sleep(2 * (retry_attempt + 1)) |
|
continue |
|
return None |
|
|
|
except Exception as e: |
|
logger.error(f"일별 트렌드 조회 오류 (시도 {retry_attempt + 1}): {e}") |
|
if retry_attempt < max_retries - 1: |
|
time.sleep(2 * (retry_attempt + 1)) |
|
continue |
|
return None |
|
|
|
return None |
|
|
|
def get_monthly_trend_data(keywords, max_retries=3): |
|
"""2차 호출: 월별 트렌드 데이터 (3년 전체 + 예상용)""" |
|
for retry_attempt in range(max_retries): |
|
try: |
|
|
|
datalab_config = api_utils.get_next_datalab_api_config() |
|
if not datalab_config: |
|
logger.warning("데이터랩 API 키가 설정되지 않았습니다.") |
|
return None |
|
|
|
client_id = datalab_config["CLIENT_ID"] |
|
client_secret = datalab_config["CLIENT_SECRET"] |
|
|
|
|
|
completed_year, completed_month = get_complete_month() |
|
|
|
|
|
start_year = completed_year - 3 |
|
start_date = f"{start_year:04d}-01-01" |
|
end_date = f"{completed_year:04d}-{completed_month:02d}-01" |
|
|
|
logger.info(f"📞 2차 호출 (월별): {start_date} ~ {end_date}") |
|
|
|
|
|
keywordGroups = [] |
|
for kw in keywords[:5]: |
|
keywordGroups.append({ |
|
'groupName': kw, |
|
'keywords': [kw] |
|
}) |
|
|
|
|
|
body_dict = { |
|
'startDate': start_date, |
|
'endDate': end_date, |
|
'timeUnit': 'month', |
|
'keywordGroups': keywordGroups |
|
} |
|
|
|
url = "https://openapi.naver.com/v1/datalab/search" |
|
body = json.dumps(body_dict, ensure_ascii=False) |
|
|
|
request = urllib.request.Request(url) |
|
request.add_header("X-Naver-Client-Id", client_id) |
|
request.add_header("X-Naver-Client-Secret", client_secret) |
|
request.add_header("Content-Type", "application/json") |
|
|
|
response = urllib.request.urlopen(request, data=body.encode("utf-8"), timeout=15) |
|
rescode = response.getcode() |
|
|
|
if rescode == 200: |
|
response_body = response.read() |
|
response_json = json.loads(response_body) |
|
logger.info(f"월별 트렌드 데이터 조회 성공") |
|
return response_json |
|
else: |
|
logger.error(f"월별 API 오류: 상태코드 {rescode}") |
|
if retry_attempt < max_retries - 1: |
|
time.sleep(2 * (retry_attempt + 1)) |
|
continue |
|
return None |
|
|
|
except Exception as e: |
|
logger.error(f"월별 트렌드 조회 오류 (시도 {retry_attempt + 1}): {e}") |
|
if retry_attempt < max_retries - 1: |
|
time.sleep(2 * (retry_attempt + 1)) |
|
continue |
|
return None |
|
|
|
return None |
|
|
|
def calculate_previous_month_from_daily(current_volume, daily_data): |
|
"""일별 트렌드로 전월 정확한 검색량 역산""" |
|
if not daily_data or "results" not in daily_data: |
|
logger.warning("일별 데이터가 없어 전월 계산을 건너뜁니다.") |
|
return current_volume |
|
|
|
try: |
|
completed_year, completed_month = get_complete_month() |
|
|
|
|
|
prev_month_days = calendar.monthrange(completed_year, completed_month)[1] |
|
|
|
for result in daily_data["results"]: |
|
keyword = result["title"] |
|
|
|
if not result["data"]: |
|
continue |
|
|
|
|
|
recent_30_ratios = [] |
|
prev_month_ratios = [] |
|
|
|
for data_point in result["data"]: |
|
try: |
|
date_obj = datetime.strptime(data_point["period"], "%Y-%m-%d") |
|
ratio = data_point["ratio"] |
|
|
|
|
|
if date_obj.year == completed_year and date_obj.month == completed_month: |
|
prev_month_ratios.append(ratio) |
|
|
|
|
|
current_date = datetime.now() |
|
if (current_date - date_obj).days <= 30: |
|
recent_30_ratios.append(ratio) |
|
|
|
except: |
|
continue |
|
|
|
if not recent_30_ratios or not prev_month_ratios: |
|
logger.warning(f"'{keyword}' 비교 데이터가 부족합니다.") |
|
continue |
|
|
|
|
|
recent_30_avg = sum(recent_30_ratios) / len(recent_30_ratios) |
|
prev_month_avg = sum(prev_month_ratios) / len(prev_month_ratios) |
|
|
|
if recent_30_avg == 0: |
|
continue |
|
|
|
|
|
|
|
|
|
prev_month_volume = int( |
|
(prev_month_avg / recent_30_avg) * current_volume * (prev_month_days / 30) |
|
) |
|
|
|
logger.info(f"'{keyword}' 전월 {completed_year}.{completed_month:02d} 역산 검색량: {prev_month_volume:,}회") |
|
logger.info(f" - 최근 30일 평균 비율: {recent_30_avg:.1f}%") |
|
logger.info(f" - 전월 평균 비율: {prev_month_avg:.1f}%") |
|
logger.info(f" - 전월 일수 보정: {prev_month_days}일") |
|
|
|
return prev_month_volume |
|
|
|
except Exception as e: |
|
logger.error(f"전월 역산 계산 오류: {e}") |
|
return current_volume |
|
|
|
return current_volume |
|
|
|
def calculate_all_months_from_previous(prev_month_volume, monthly_data, completed_year, completed_month): |
|
"""전월을 기준으로 모든 월 검색량 역산""" |
|
if not monthly_data or "results" not in monthly_data: |
|
logger.warning("월별 데이터가 없어 역산 계산을 건너뜁니다.") |
|
return [], [] |
|
|
|
monthly_volumes = [] |
|
dates = [] |
|
|
|
try: |
|
for result in monthly_data["results"]: |
|
keyword = result["title"] |
|
|
|
if not result["data"]: |
|
continue |
|
|
|
|
|
base_ratio = None |
|
for data_point in result["data"]: |
|
try: |
|
date_obj = datetime.strptime(data_point["period"], "%Y-%m-%d") |
|
if date_obj.year == completed_year and date_obj.month == completed_month: |
|
base_ratio = data_point["ratio"] |
|
break |
|
except: |
|
continue |
|
|
|
if base_ratio is None or base_ratio == 0: |
|
logger.warning(f"'{keyword}' 기준월 비율을 찾을 수 없습니다.") |
|
continue |
|
|
|
logger.info(f"'{keyword}' 기준월 {completed_year}.{completed_month:02d} 비율: {base_ratio}% (검색량: {prev_month_volume:,}회)") |
|
|
|
|
|
for data_point in result["data"]: |
|
try: |
|
date_obj = datetime.strptime(data_point["period"], "%Y-%m-%d") |
|
ratio = data_point["ratio"] |
|
|
|
|
|
month_days = calendar.monthrange(date_obj.year, date_obj.month)[1] |
|
base_month_days = calendar.monthrange(completed_year, completed_month)[1] |
|
|
|
|
|
calculated_volume = int( |
|
(ratio / base_ratio) * prev_month_volume * (month_days / base_month_days) |
|
) |
|
calculated_volume = max(calculated_volume, 0) |
|
|
|
monthly_volumes.append(calculated_volume) |
|
dates.append(data_point["period"]) |
|
|
|
except: |
|
continue |
|
|
|
logger.info(f"'{keyword}' 전체 월별 검색량 역산 완료: {len(monthly_volumes)}개월") |
|
break |
|
|
|
except Exception as e: |
|
logger.error(f"월별 역산 계산 오류: {e}") |
|
return [], [] |
|
|
|
return monthly_volumes, dates |
|
|
|
def generate_future_from_growth_rate(monthly_volumes, dates, completed_year, completed_month): |
|
"""증감율 기반 미래 3개월 예상 생성""" |
|
if len(monthly_volumes) < 12: |
|
logger.warning("미래 예측을 위한 충분한 데이터가 없습니다.") |
|
return [], [] |
|
|
|
try: |
|
|
|
this_year_volumes = [] |
|
last_year_volumes = [] |
|
|
|
for i, date_str in enumerate(dates): |
|
try: |
|
date_obj = datetime.strptime(date_str, "%Y-%m-%d") |
|
|
|
|
|
if date_obj.year == completed_year and date_obj.month <= completed_month: |
|
this_year_volumes.append(monthly_volumes[i]) |
|
|
|
|
|
if date_obj.year == completed_year - 1 and date_obj.month <= completed_month: |
|
last_year_volumes.append(monthly_volumes[i]) |
|
|
|
except: |
|
continue |
|
|
|
|
|
if len(this_year_volumes) >= 3 and len(last_year_volumes) >= 3: |
|
this_year_avg = sum(this_year_volumes) / len(this_year_volumes) |
|
last_year_avg = sum(last_year_volumes) / len(last_year_volumes) |
|
|
|
if last_year_avg > 0: |
|
growth_rate = (this_year_avg - last_year_avg) / last_year_avg |
|
|
|
growth_rate = max(-0.5, min(growth_rate, 1.0)) |
|
else: |
|
growth_rate = 0 |
|
else: |
|
growth_rate = 0 |
|
|
|
logger.info(f"계산된 증감율: {growth_rate*100:+.1f}%") |
|
|
|
|
|
predicted_volumes = [] |
|
predicted_dates = [] |
|
|
|
for month_offset in range(1, 4): |
|
pred_year = completed_year |
|
pred_month = completed_month + month_offset |
|
|
|
while pred_month > 12: |
|
pred_month -= 12 |
|
pred_year += 1 |
|
|
|
|
|
last_year_pred_year = pred_year - 1 |
|
last_year_pred_month = pred_month |
|
last_year_volume = None |
|
|
|
for i, date_str in enumerate(dates): |
|
try: |
|
date_obj = datetime.strptime(date_str, "%Y-%m-%d") |
|
if date_obj.year == last_year_pred_year and date_obj.month == last_year_pred_month: |
|
last_year_volume = monthly_volumes[i] |
|
break |
|
except: |
|
continue |
|
|
|
|
|
if last_year_volume is not None: |
|
predicted_volume = int(last_year_volume * (1 + growth_rate)) |
|
predicted_volume = max(predicted_volume, 0) |
|
|
|
predicted_volumes.append(predicted_volume) |
|
predicted_dates.append(f"{pred_year:04d}-{pred_month:02d}-01") |
|
|
|
logger.info(f"예상 {pred_year}.{pred_month:02d}: 작년 동월 {last_year_volume:,}회 → 예상 {predicted_volume:,}회") |
|
|
|
return predicted_volumes, predicted_dates |
|
|
|
except Exception as e: |
|
logger.error(f"미래 예측 생성 오류: {e}") |
|
return [], [] |
|
|
|
def apply_final_10_percent_reduction(monthly_data): |
|
"""최종 단계: 모든 결과에 10% 감소 적용""" |
|
adjusted_data = {} |
|
|
|
try: |
|
for keyword, data in monthly_data.items(): |
|
adjusted_volumes = [] |
|
|
|
for volume in data["monthly_volumes"]: |
|
if volume >= 10: |
|
adjusted_volume = int(volume * 0.9) |
|
else: |
|
adjusted_volume = volume |
|
|
|
adjusted_volumes.append(adjusted_volume) |
|
|
|
|
|
adjusted_data[keyword] = data.copy() |
|
adjusted_data[keyword]["monthly_volumes"] = adjusted_volumes |
|
|
|
|
|
if data["current_volume"] >= 10: |
|
adjusted_data[keyword]["current_volume"] = int(data["current_volume"] * 0.9) |
|
|
|
logger.info("최종 10% 감소 조정 완료") |
|
|
|
except Exception as e: |
|
logger.error(f"10% 감소 조정 오류: {e}") |
|
return monthly_data |
|
|
|
return adjusted_data |
|
|
|
|
|
|
|
def get_naver_trend_data_v5(keywords, period="1year", max_retries=3): |
|
"""개선된 네이버 데이터랩 API 호출 - 이중 호출 구현""" |
|
|
|
if period == "1year": |
|
|
|
daily_data = get_daily_trend_data(keywords, max_retries) |
|
monthly_data = get_monthly_trend_data(keywords, max_retries) |
|
|
|
|
|
return { |
|
'daily_data': daily_data, |
|
'monthly_data': monthly_data, |
|
'results': monthly_data['results'] if monthly_data else [] |
|
} |
|
else: |
|
|
|
monthly_data = get_monthly_trend_data(keywords, max_retries) |
|
return monthly_data |
|
|
|
def calculate_monthly_volumes_v7(keywords, current_volumes, trend_data, period="1year"): |
|
"""개선된 월별 검색량 계산 - 정교한 역산 로직 적용""" |
|
monthly_data = {} |
|
|
|
|
|
if isinstance(trend_data, dict) and 'daily_data' in trend_data and 'monthly_data' in trend_data: |
|
|
|
daily_data = trend_data['daily_data'] |
|
monthly_data_api = trend_data['monthly_data'] |
|
else: |
|
|
|
daily_data = None |
|
monthly_data_api = trend_data |
|
|
|
if not monthly_data_api or "results" not in monthly_data_api: |
|
logger.warning("월별 트렌드 데이터가 없어 계산을 건너뜁니다.") |
|
return monthly_data |
|
|
|
logger.info(f"개선된 월별 검색량 계산 시작: {len(monthly_data_api['results'])}개 키워드") |
|
|
|
for result in monthly_data_api["results"]: |
|
keyword = result["title"] |
|
api_keyword = keyword.replace(" ", "") |
|
|
|
|
|
volume_data = current_volumes.get(api_keyword, {"총검색량": 0}) |
|
current_volume = volume_data["총검색량"] |
|
|
|
if current_volume == 0: |
|
logger.warning(f"'{keyword}' 현재 검색량이 0이므로 계산을 건너뜁니다.") |
|
continue |
|
|
|
logger.info(f"'{keyword}' 처리 시작 - 현재 검색량: {current_volume:,}회") |
|
|
|
if period == "1year" and daily_data: |
|
|
|
completed_year, completed_month = get_complete_month() |
|
|
|
|
|
prev_month_volume = calculate_previous_month_from_daily(current_volume, daily_data) |
|
|
|
|
|
monthly_volumes, dates = calculate_all_months_from_previous( |
|
prev_month_volume, monthly_data_api, completed_year, completed_month |
|
) |
|
|
|
if not monthly_volumes: |
|
logger.warning(f"'{keyword}' 월별 검색량 계산 실패") |
|
continue |
|
|
|
|
|
predicted_volumes, predicted_dates = generate_future_from_growth_rate( |
|
monthly_volumes, dates, completed_year, completed_month |
|
) |
|
|
|
|
|
|
|
recent_12_months = monthly_volumes[-12:] if len(monthly_volumes) >= 12 else monthly_volumes |
|
recent_12_dates = dates[-12:] if len(dates) >= 12 else dates |
|
|
|
all_volumes = recent_12_months + predicted_volumes |
|
all_dates = recent_12_dates + predicted_dates |
|
|
|
|
|
growth_rate = calculate_future_3month_growth_rate(all_volumes, all_dates) |
|
|
|
monthly_data[keyword] = { |
|
"monthly_volumes": all_volumes, |
|
"dates": all_dates, |
|
"current_volume": current_volume, |
|
"growth_rate": growth_rate, |
|
"volume_per_percent": prev_month_volume / 100 if prev_month_volume > 0 else 0, |
|
"current_ratio": 100, |
|
"actual_count": len(recent_12_months), |
|
"predicted_count": len(predicted_volumes) |
|
} |
|
|
|
else: |
|
|
|
if not result["data"]: |
|
continue |
|
|
|
current_ratio = result["data"][-1]["ratio"] |
|
if current_ratio == 0: |
|
continue |
|
|
|
volume_per_percent = current_volume / current_ratio |
|
|
|
monthly_volumes = [] |
|
dates = [] |
|
|
|
for data_point in result["data"]: |
|
ratio = data_point["ratio"] |
|
period_date = data_point["period"] |
|
estimated_volume = int(volume_per_percent * ratio) |
|
|
|
monthly_volumes.append(estimated_volume) |
|
dates.append(period_date) |
|
|
|
growth_rate = calculate_3year_growth_rate_improved(monthly_volumes) |
|
|
|
monthly_data[keyword] = { |
|
"monthly_volumes": monthly_volumes, |
|
"dates": dates, |
|
"current_volume": current_volume, |
|
"growth_rate": growth_rate, |
|
"volume_per_percent": volume_per_percent, |
|
"current_ratio": current_ratio, |
|
"actual_count": len(monthly_volumes), |
|
"predicted_count": 0 |
|
} |
|
|
|
logger.info(f"'{keyword}' 계산 완료 - 검색량 데이터 {len(monthly_data[keyword]['monthly_volumes'])}개") |
|
|
|
|
|
final_data = apply_final_10_percent_reduction(monthly_data) |
|
|
|
logger.info("개선된 월별 검색량 계산 완료 (10% 감소 적용됨)") |
|
return final_data |
|
|
|
|
|
|
|
def calculate_future_3month_growth_rate(volumes, dates): |
|
"""예상 3개월 증감율 계산""" |
|
if len(volumes) < 4: |
|
return 0 |
|
|
|
try: |
|
completed_year, completed_month = get_complete_month() |
|
|
|
|
|
base_month_volume = None |
|
for i, date_str in enumerate(dates): |
|
try: |
|
date_obj = datetime.strptime(date_str, "%Y-%m-%d") |
|
if date_obj.year == completed_year and date_obj.month == completed_month: |
|
base_month_volume = volumes[i] |
|
break |
|
except: |
|
continue |
|
|
|
if base_month_volume is None: |
|
return 0 |
|
|
|
|
|
future_volumes = [] |
|
for month_offset in range(1, 4): |
|
pred_year = completed_year |
|
pred_month = completed_month + month_offset |
|
|
|
while pred_month > 12: |
|
pred_month -= 12 |
|
pred_year += 1 |
|
|
|
for i, date_str in enumerate(dates): |
|
try: |
|
date_obj = datetime.strptime(date_str, "%Y-%m-%d") |
|
if date_obj.year == pred_year and date_obj.month == pred_month: |
|
future_volumes.append(volumes[i]) |
|
break |
|
except: |
|
continue |
|
|
|
if len(future_volumes) < 3: |
|
return 0 |
|
|
|
|
|
future_average = sum(future_volumes) / len(future_volumes) |
|
|
|
if base_month_volume > 0: |
|
growth_rate = ((future_average - base_month_volume) / base_month_volume) * 100 |
|
return min(max(growth_rate, -50), 100) |
|
|
|
return 0 |
|
|
|
except Exception as e: |
|
logger.error(f"예상 3개월 증감율 계산 오류: {e}") |
|
return 0 |
|
|
|
def calculate_3year_growth_rate_improved(volumes): |
|
"""3년 증감율 계산""" |
|
if len(volumes) < 24: |
|
return 0 |
|
|
|
try: |
|
first_year = volumes[:12] |
|
last_year = volumes[-12:] |
|
|
|
first_year_avg = sum(first_year) / len(first_year) |
|
last_year_avg = sum(last_year) / len(last_year) |
|
|
|
if first_year_avg == 0: |
|
return 0 |
|
|
|
growth_rate = ((last_year_avg - first_year_avg) / first_year_avg) * 100 |
|
return min(max(growth_rate, -50), 200) |
|
|
|
except Exception as e: |
|
logger.error(f"3년 증감율 계산 오류: {e}") |
|
return 0 |
|
|
|
def calculate_correct_growth_rate(volumes, dates): |
|
"""작년 대비 증감율 계산""" |
|
if len(volumes) < 13: |
|
return 0 |
|
|
|
try: |
|
completed_year, completed_month = get_complete_month() |
|
|
|
this_year_volume = None |
|
last_year_volume = None |
|
|
|
for i, date_str in enumerate(dates): |
|
try: |
|
date_obj = datetime.strptime(date_str, "%Y-%m-%d") |
|
|
|
if date_obj.year == completed_year and date_obj.month == completed_month: |
|
this_year_volume = volumes[i] |
|
|
|
if date_obj.year == completed_year - 1 and date_obj.month == completed_month: |
|
last_year_volume = volumes[i] |
|
|
|
except: |
|
continue |
|
|
|
if this_year_volume is not None and last_year_volume is not None and last_year_volume > 0: |
|
growth_rate = ((this_year_volume - last_year_volume) / last_year_volume) * 100 |
|
return min(max(growth_rate, -50), 100) |
|
|
|
return 0 |
|
|
|
except Exception as e: |
|
logger.error(f"작년 대비 증감율 계산 오류: {e}") |
|
return 0 |
|
|
|
def generate_future_predictions_correct(volumes, dates, growth_rate): |
|
"""미래 예측 생성 (호환성 유지)""" |
|
return generate_future_from_growth_rate(volumes, dates, *get_complete_month()) |
|
|
|
|
|
|
|
def create_enhanced_current_chart(volume_data, keyword): |
|
"""향상된 현재 검색량 정보 차트 - PC vs 모바일 비율 포함""" |
|
total_vol = volume_data['총검색량'] |
|
pc_vol = volume_data['PC검색량'] |
|
mobile_vol = volume_data['모바일검색량'] |
|
|
|
|
|
if total_vol >= 100000: |
|
level_text = "높음 🔥" |
|
level_color = "#dc3545" |
|
elif total_vol >= 10000: |
|
level_text = "중간 📊" |
|
level_color = "#ffc107" |
|
elif total_vol > 0: |
|
level_text = "낮음 📉" |
|
level_color = "#6c757d" |
|
else: |
|
level_text = "데이터 없음 ⚠️" |
|
level_color = "#6c757d" |
|
|
|
|
|
if total_vol > 0: |
|
pc_ratio = (pc_vol / total_vol) * 100 |
|
mobile_ratio = (mobile_vol / total_vol) * 100 |
|
else: |
|
pc_ratio = mobile_ratio = 0 |
|
|
|
return f""" |
|
<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);"> |
|
<!-- 검색량 수준 표시 --> |
|
<div style="text-align: center; margin-bottom: 25px; padding: 20px; background: #f8f9fa; border-radius: 12px;"> |
|
<h4 style="margin: 0 0 15px 0; color: #495057; font-size: 20px;">📊 검색량 수준</h4> |
|
<span style="display: inline-block; padding: 12px 24px; background: {level_color}; color: white; border-radius: 25px; font-weight: bold; font-size: 18px;"> |
|
{level_text} |
|
</span> |
|
</div> |
|
|
|
<!-- 검색량 상세 정보 --> |
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; margin-bottom: 25px;"> |
|
<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;"> |
|
<div style="color: #007bff; font-size: 32px; font-weight: bold; margin-bottom: 8px;">{pc_vol:,}</div> |
|
<div style="color: #6c757d; font-size: 16px; margin-bottom: 8px; font-weight: 600;">PC 검색량</div> |
|
<div style="color: #007bff; font-size: 14px;">({pc_ratio:.1f}%)</div> |
|
</div> |
|
<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;"> |
|
<div style="color: #28a745; font-size: 32px; font-weight: bold; margin-bottom: 8px;">{mobile_vol:,}</div> |
|
<div style="color: #6c757d; font-size: 16px; margin-bottom: 8px; font-weight: 600;">모바일 검색량</div> |
|
<div style="color: #28a745; font-size: 14px;">({mobile_ratio:.1f}%)</div> |
|
</div> |
|
<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;"> |
|
<div style="color: #dc3545; font-size: 32px; font-weight: bold; margin-bottom: 8px;">{total_vol:,}</div> |
|
<div style="color: #6c757d; font-size: 16px; margin-bottom: 8px; font-weight: 600;">총 검색량</div> |
|
<div style="color: #dc3545; font-size: 14px;">(100%)</div> |
|
</div> |
|
</div> |
|
|
|
<!-- 비율 바 차트 --> |
|
<div style="background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); border: 1px solid #e9ecef;"> |
|
<h5 style="margin: 0 0 20px 0; color: #495057; text-align: center; font-size: 18px;">PC vs 모바일 비율</h5> |
|
<div style="display: flex; height: 25px; border-radius: 15px; overflow: hidden; background: #e9ecef;"> |
|
<div style="background: #007bff; width: {pc_ratio}%; display: flex; align-items: center; justify-content: center; font-size: 14px; color: white; font-weight: bold;"> |
|
{f'PC {pc_ratio:.1f}%' if pc_ratio > 15 else ''} |
|
</div> |
|
<div style="background: #28a745; width: {mobile_ratio}%; display: flex; align-items: center; justify-content: center; font-size: 14px; color: white; font-weight: bold;"> |
|
{f'모바일 {mobile_ratio:.1f}%' if mobile_ratio > 15 else ''} |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<div style="margin-top: 20px; padding: 15px; background: #fff3cd; border-radius: 8px; text-align: center;"> |
|
<p style="margin: 0; font-size: 14px; color: #856404;"> |
|
📊 <strong>트렌드 분석 시스템</strong>: 네이버 데이터랩 기반 정확한 검색량 분석 |
|
</p> |
|
</div> |
|
</div> |
|
""" |
|
|
|
def create_visual_trend_chart(monthly_data_1year, monthly_data_3year): |
|
"""시각적 트렌드 차트 생성""" |
|
try: |
|
chart_html = f""" |
|
<div style="width: 100%; margin: 20px auto; font-family: 'Pretendard', sans-serif;"> |
|
""" |
|
|
|
periods = [ |
|
{"data": monthly_data_1year, "title": "최근 1년 + 향후 3개월 예상 (정교한 역산)", "period": "1year"}, |
|
{"data": monthly_data_3year, "title": "최근 3년 (10% 보정 적용)", "period": "3year"} |
|
] |
|
|
|
colors = ['#FB7F0D', '#4ECDC4', '#45B7D1', '#96CEB4', '#FF6B6B'] |
|
|
|
for period_info in periods: |
|
monthly_data = period_info["data"] |
|
period_title = period_info["title"] |
|
period_code = period_info["period"] |
|
|
|
if not monthly_data: |
|
chart_html += f""" |
|
<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;"> |
|
<h4 style="text-align: center; color: #666; margin: 20px 0;">{period_title} - 트렌드 데이터가 없습니다.</h4> |
|
</div> |
|
""" |
|
continue |
|
|
|
chart_html += f""" |
|
<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;"> |
|
<h4 style="text-align: center; color: #333; margin-bottom: 25px; font-size: 18px; border-bottom: 2px solid #FB7F0D; padding-bottom: 10px;"> |
|
🚀 {period_title} |
|
</h4> |
|
""" |
|
|
|
|
|
for i, (keyword, data) in enumerate(monthly_data.items()): |
|
volumes = data["monthly_volumes"] |
|
dates = data["dates"] |
|
growth_rate = data["growth_rate"] |
|
actual_count = data.get("actual_count", len(volumes)) |
|
|
|
if not volumes: |
|
continue |
|
|
|
|
|
color = colors[i % len(colors)] |
|
predicted_color = f"{color}80" |
|
|
|
|
|
max_volume = max(volumes) if volumes else 1 |
|
|
|
chart_html += f""" |
|
<div style="width: 100%; margin-bottom: 30px; border: 1px solid #e9ecef; border-radius: 8px; overflow: hidden;"> |
|
<div style="padding: 20px; background: white;"> |
|
<h5 style="margin: 0 0 20px 0; color: #333; font-size: 16px;"> |
|
{keyword} ({get_growth_rate_label(period_code)}: {growth_rate:+.1f}%) |
|
</h5> |
|
|
|
<!-- 차트 영역 --> |
|
<div style="position: relative; height: 350px; margin: 30px 0 60px 80px; border-left: 2px solid #333; border-bottom: 2px solid #333; padding: 10px;"> |
|
|
|
<!-- Y축 라벨 --> |
|
<div style="position: absolute; left: -70px; top: -10px; width: 60px; text-align: right; font-size: 11px; color: #333; font-weight: bold;"> |
|
{max_volume:,} |
|
</div> |
|
<div style="position: absolute; left: -70px; top: 50%; transform: translateY(-50%); width: 60px; text-align: right; font-size: 10px; color: #666;"> |
|
{max_volume // 2:,} |
|
</div> |
|
<div style="position: absolute; left: -70px; bottom: -5px; width: 60px; text-align: right; font-size: 10px; color: #666;"> |
|
0 |
|
</div> |
|
|
|
<!-- X축 그리드 라인 --> |
|
<div style="position: absolute; top: 0; left: 0; right: 0; height: 1px; background: #eee;"></div> |
|
<div style="position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: #eee;"></div> |
|
<div style="position: absolute; bottom: 0; left: 0; right: 0; height: 1px; background: #333;"></div> |
|
|
|
<!-- 차트 바 컨테이너 --> |
|
<div style="display: flex; align-items: end; height: 100%; gap: 1px; padding: 5px 0;"> |
|
""" |
|
|
|
|
|
chart_data = list(zip(dates, volumes, range(len(volumes)))) |
|
chart_data.sort(key=lambda x: x[0]) |
|
|
|
|
|
for date, volume, original_index in chart_data: |
|
|
|
height_percent = (volume / max_volume) * 100 if max_volume > 0 else 0 |
|
|
|
|
|
is_predicted = original_index >= actual_count |
|
bar_color = predicted_color if is_predicted else color |
|
|
|
|
|
try: |
|
date_obj = datetime.strptime(date, "%Y-%m-%d") |
|
year_short = str(date_obj.year)[-2:] |
|
month_num = date_obj.month |
|
|
|
if is_predicted: |
|
date_formatted = f"{year_short}.{month_num:02d}" |
|
full_date = date_obj.strftime("%Y년 %m월") + " (예상)" |
|
bar_style = f"border: 2px dashed #333; background: repeating-linear-gradient(90deg, {bar_color}, {bar_color} 5px, transparent 5px, transparent 10px);" |
|
else: |
|
date_formatted = f"{year_short}.{month_num:02d}" |
|
full_date = date_obj.strftime("%Y년 %m월") |
|
bar_style = f"background: linear-gradient(to top, {bar_color}, {bar_color}dd);" |
|
except: |
|
date_formatted = date[-5:].replace('-', '.') |
|
full_date = date |
|
bar_style = f"background: linear-gradient(to top, {bar_color}, {bar_color}dd);" |
|
|
|
|
|
chart_id = f"bar_{period_code}_{i}_{original_index}" |
|
|
|
chart_html += f""" |
|
<div style="flex: 1; display: flex; flex-direction: column; align-items: center; position: relative; height: 100%;"> |
|
<!-- 막대 --> |
|
<div id="{chart_id}" style=" |
|
{bar_style} |
|
width: 100%; |
|
height: {height_percent}%; |
|
border-radius: 3px 3px 0 0; |
|
position: relative; |
|
cursor: pointer; |
|
transition: all 0.3s ease; |
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1); |
|
min-height: 3px; |
|
margin-top: auto; |
|
" |
|
onmouseover=" |
|
this.style.transform='scaleX(1.1)'; |
|
this.style.zIndex='10'; |
|
this.style.boxShadow='0 4px 8px rgba(0,0,0,0.3)'; |
|
document.getElementById('tooltip_{chart_id}').style.display='block'; |
|
" |
|
onmouseout=" |
|
this.style.transform='scaleX(1)'; |
|
this.style.zIndex='1'; |
|
this.style.boxShadow='0 2px 4px rgba(0,0,0,0.1)'; |
|
document.getElementById('tooltip_{chart_id}').style.display='none'; |
|
"> |
|
<!-- 툴팁 --> |
|
<div id="tooltip_{chart_id}" style=" |
|
display: none; |
|
position: absolute; |
|
bottom: calc(100% + 10px); |
|
left: 50%; |
|
transform: translateX(-50%); |
|
background: rgba(0,0,0,0.9); |
|
color: white; |
|
padding: 8px 12px; |
|
border-radius: 6px; |
|
font-size: 11px; |
|
white-space: nowrap; |
|
z-index: 1000; |
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3); |
|
pointer-events: none; |
|
"> |
|
<div style="text-align: center;"> |
|
<div style="font-weight: bold; color: white; margin-bottom: 2px;">{full_date}</div> |
|
<div style="color: #ffd700;">검색량: {volume:,}회</div> |
|
{'<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>'} |
|
</div> |
|
<!-- 화살표 --> |
|
<div style=" |
|
position: absolute; |
|
top: 100%; |
|
left: 50%; |
|
transform: translateX(-50%); |
|
width: 0; |
|
height: 0; |
|
border-left: 5px solid transparent; |
|
border-right: 5px solid transparent; |
|
border-top: 5px solid rgba(0,0,0,0.9); |
|
"></div> |
|
</div> |
|
</div> |
|
</div> |
|
""" |
|
|
|
chart_html += f""" |
|
</div> |
|
|
|
<!-- 월 라벨 --> |
|
<div style="display: flex; gap: 1px; margin-top: 10px; padding: 0 5px;"> |
|
""" |
|
|
|
|
|
for date, volume, original_index in chart_data: |
|
is_predicted = original_index >= actual_count |
|
|
|
try: |
|
date_obj = datetime.strptime(date, "%Y-%m-%d") |
|
year_short = str(date_obj.year)[-2:] |
|
month_num = date_obj.month |
|
date_formatted = f"{year_short}.{month_num:02d}" |
|
except: |
|
date_formatted = date[-5:].replace('-', '.') |
|
|
|
chart_html += f""" |
|
<div style=" |
|
flex: 1; |
|
text-align: center; |
|
font-size: 9px; |
|
color: {'#e74c3c' if is_predicted else '#666'}; |
|
font-weight: {'bold' if is_predicted else 'normal'}; |
|
transform: rotate(-45deg); |
|
transform-origin: center; |
|
line-height: 1; |
|
margin-top: 8px; |
|
"> |
|
{date_formatted} |
|
</div> |
|
""" |
|
|
|
|
|
if period_code == "1year": |
|
actual_volumes = volumes[:actual_count] |
|
else: |
|
actual_volumes = volumes |
|
|
|
avg_volume = sum(actual_volumes) // len(actual_volumes) if actual_volumes else 0 |
|
max_volume_val = max(actual_volumes) if actual_volumes else 0 |
|
min_volume_val = min(actual_volumes) if actual_volumes else 0 |
|
|
|
chart_html += f""" |
|
</div> |
|
</div> |
|
|
|
<!-- 범례 --> |
|
<div style="display: flex; justify-content: center; gap: 20px; margin: 15px 0; font-size: 12px;"> |
|
<div style="display: flex; align-items: center; gap: 5px;"> |
|
<div style="width: 15px; height: 15px; background: {color}; border-radius: 2px;"></div> |
|
<span style="color: #333;">실제 데이터</span> |
|
</div> |
|
<div style="display: flex; align-items: center; gap: 5px;"> |
|
<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> |
|
<span style="color: #e74c3c;">예상 데이터</span> |
|
</div> |
|
</div> |
|
|
|
<!-- 통계 정보 --> |
|
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-top: 20px;"> |
|
<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;"> |
|
<div style="font-size: 16px; font-weight: bold; color: #3498db;">{min_volume_val:,}</div> |
|
<div style="font-size: 11px; color: #666;">최저검색량</div> |
|
</div> |
|
<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;"> |
|
<div style="font-size: 16px; font-weight: bold; color: #2ecc71;">{avg_volume:,}</div> |
|
<div style="font-size: 11px; color: #666;">평균검색량</div> |
|
</div> |
|
<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;"> |
|
<div style="font-size: 16px; font-weight: bold; color: #e74c3c;">{max_volume_val:,}</div> |
|
<div style="font-size: 11px; color: #666;">최고검색량</div> |
|
</div> |
|
<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;"> |
|
<div style="font-size: 16px; font-weight: bold; color: #27ae60;">{growth_rate:+.1f}%</div> |
|
<div style="font-size: 11px; color: #666;">{get_growth_rate_label(period_code)}</div> |
|
</div> |
|
</div> |
|
""" |
|
|
|
|
|
if period_code == "1year": |
|
chart_html += f""" |
|
<div style="margin-top: 15px; padding: 12px; background: #e8f5e8; border-radius: 8px; text-align: center;"> |
|
<p style="margin: 0; font-size: 13px; color: #155724;"> |
|
📊 <strong>최근 1년 + 향후 3개월 예상</strong>: 실색 막대(실제), 빗금 막대(예상) |
|
</p> |
|
</div> |
|
""" |
|
else: |
|
chart_html += f""" |
|
<div style="margin-top: 15px; padding: 12px; background: #e3f2fd; border-radius: 8px; text-align: center;"> |
|
<p style="margin: 0; font-size: 13px; color: #1565c0;"> |
|
📊 <strong>최근 3년 트렌드</strong>: 전체 기간 검색량 데이터 |
|
</p> |
|
</div> |
|
""" |
|
|
|
chart_html += """ |
|
</div> |
|
</div> |
|
""" |
|
|
|
chart_html += "</div>" |
|
|
|
chart_html += "</div>" |
|
|
|
logger.info(f"개선된 정교한 트렌드 차트 생성 완료") |
|
return chart_html |
|
|
|
except Exception as e: |
|
logger.error(f"차트 생성 오류: {e}") |
|
return f""" |
|
<div style="padding: 20px; background: #f8d7da; border-radius: 8px; color: #721c24;"> |
|
<h4>차트 생성 오류</h4> |
|
<p>오류: {str(e)}</p> |
|
</div> |
|
""" |
|
|
|
def create_trend_chart_v7(monthly_data_1year, monthly_data_3year): |
|
"""개선된 트렌드 차트 생성""" |
|
try: |
|
chart_html = create_visual_trend_chart(monthly_data_1year, monthly_data_3year) |
|
return chart_html |
|
|
|
except Exception as e: |
|
logger.error(f"차트 생성 오류: {e}") |
|
return f""" |
|
<div style="padding: 20px; background: #f8d7da; border-radius: 8px; color: #721c24;"> |
|
<h4>차트 생성 오류</h4> |
|
<p>오류: {str(e)}</p> |
|
</div> |
|
""" |
|
|
|
def get_growth_rate_label(period_code): |
|
"""기간에 따른 성장률 라벨 반환""" |
|
if period_code == "1year": |
|
return "예상 3개월 증감율" |
|
else: |
|
return "작년대비 증감율" |
|
|
|
def create_error_chart(error_msg): |
|
"""에러 발생시 대체 차트""" |
|
return f""" |
|
<div style="padding: 20px; background: #f8d7da; border-radius: 8px; color: #721c24;"> |
|
<h4>차트 생성 오류</h4> |
|
<p>오류: {error_msg}</p> |
|
</div> |
|
""" |
|
|
|
|
|
|
|
def get_naver_trend_data_v4(keywords, period="1year", max_retries=3): |
|
"""기존 함수 호환성 유지""" |
|
return get_naver_trend_data_v5(keywords, period, max_retries) |
|
|
|
def calculate_monthly_volumes_v6(keywords, current_volumes, trend_data, period="1year"): |
|
"""기존 함수 호환성 유지""" |
|
return calculate_monthly_volumes_v7(keywords, current_volumes, trend_data, period) |
|
|
|
def calculate_monthly_volumes_v5(keywords, current_volumes, trend_data, period="1year"): |
|
"""기존 함수 호환성 유지""" |
|
return calculate_monthly_volumes_v7(keywords, current_volumes, trend_data, period) |
|
|
|
def create_trend_chart_v6(monthly_data_1year, monthly_data_3year): |
|
"""기존 함수 호환성 유지""" |
|
return create_trend_chart_v7(monthly_data_1year, monthly_data_3year) |