| """ |
| 트렌드 분석 모듈 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) |