import gradio as gr import requests from urllib.parse import quote_plus, urlparse import re import logging import tempfile import pandas as pd import mecab # python‑mecab‑ko 라이브러리 사용 import os import time import hmac import hashlib import base64 import random from concurrent.futures import ThreadPoolExecutor, as_completed # API 설정 API_BASE_URL = os.getenv("API_BASE_UR", "") API_KEY = os.getenv("API_KEY", "") HEADERS = { "x-api-key": API_KEY, "content-type": "application/json" } # 환경변수에서 API 설정 로드 def get_api_configs(): """환경변수에서 API 설정을 로드""" api_configs_str = os.getenv('API_CONFIGS', '') if not api_configs_str: debug_log("API_CONFIGS 환경변수가 설정되지 않았습니다.") return [], [] try: # 환경변수 값을 exec로 실행하여 설정 로드 local_vars = {} exec(api_configs_str, {}, local_vars) return ( local_vars.get('NAVER_API_KEYS_LIST', []), local_vars.get('NAVER_SEARCH_API_KEYS_LIST', []) ) except Exception as e: debug_log(f"환경변수 파싱 오류: {e}") return [], [] # API 설정 로드 NAVER_API_KEYS_LIST, NAVER_SEARCH_API_KEYS_LIST = get_api_configs() # API 키 관리를 위한 전역 변수 naver_api_manager = { 'keys': [], 'current_index': -1, 'failed_keys': set(), # 실패한 키들을 추적 'is_initialized': False } naver_search_api_manager = { 'keys': [], 'current_index': -1, 'failed_keys': set(), # 실패한 키들을 추적 'is_initialized': False } # 디버깅(로그)용 함수 def debug_log(message: str): print(f"[DEBUG] {message}") def load_naver_api_keys(): """네이버 광고 API 키를 로드합니다.""" if not NAVER_API_KEYS_LIST: debug_log("네이버 광고 API 키가 환경변수에 설정되지 않았습니다.") return [] # 빈 키나 플레이스홀더 제거 api_keys = [] for config in NAVER_API_KEYS_LIST: if (config.get("API_KEY") and config.get("SECRET_KEY") and config.get("CUSTOMER_ID") and not config["API_KEY"].startswith("YOUR_") and not config["SECRET_KEY"].startswith("YOUR_") and not config["CUSTOMER_ID"].startswith("YOUR_") and config["API_KEY"].strip() and config["SECRET_KEY"].strip() and config["CUSTOMER_ID"].strip()): api_keys.append(config) if not api_keys: debug_log("유효한 네이버 광고 API 키가 없습니다.") return [] debug_log(f"총 {len(api_keys)}개의 네이버 광고 API 키가 로드되었습니다.") return api_keys def load_naver_search_api_keys(): """네이버 검색 API 키를 로드합니다.""" if not NAVER_SEARCH_API_KEYS_LIST: debug_log("네이버 검색 API 키가 환경변수에 설정되지 않았습니다.") return [] # 빈 키나 플레이스홀더 제거 api_keys = [] for config in NAVER_SEARCH_API_KEYS_LIST: if (config.get("CLIENT_ID") and config.get("CLIENT_SECRET") and not config["CLIENT_ID"].startswith("YOUR_") and not config["CLIENT_SECRET"].startswith("YOUR_") and config["CLIENT_ID"].strip() and config["CLIENT_SECRET"].strip()): api_keys.append(config) if not api_keys: debug_log("유효한 네이버 검색 API 키가 없습니다.") return [] debug_log(f"총 {len(api_keys)}개의 네이버 검색 API 키가 로드되었습니다.") return api_keys def initialize_api_configs(): """API 설정을 초기화하고 랜덤하게 정렬""" global naver_api_manager, naver_search_api_manager try: # API 키 로드 및 랜덤 섞기 naver_keys = load_naver_api_keys() search_keys = load_naver_search_api_keys() if naver_keys: random.shuffle(naver_keys) if search_keys: random.shuffle(search_keys) # API 관리자 초기화 naver_api_manager['keys'] = naver_keys naver_api_manager['is_initialized'] = True naver_search_api_manager['keys'] = search_keys naver_search_api_manager['is_initialized'] = True debug_log("API 설정 초기화 완료:") debug_log(f" - 네이버 광고 API: {len(naver_keys)}개") debug_log(f" - 네이버 검색 API: {len(search_keys)}개") except Exception as e: debug_log(f"API 설정 초기화 실패: {str(e)}") def initialize_naver_api_keys(): """네이버 광고 API 키 관리자를 초기화합니다.""" global naver_api_manager if naver_api_manager['is_initialized']: return initialize_api_configs() def initialize_naver_search_api_keys(): """네이버 검색 API 키 관리자를 초기화합니다.""" global naver_search_api_manager if naver_search_api_manager['is_initialized']: return initialize_api_configs() def get_next_naver_api_key(): """다음 사용할 네이버 광고 API 키를 반환합니다. 실패한 키는 건너뜁니다.""" global naver_api_manager # 초기화 확인 if not naver_api_manager['is_initialized']: initialize_naver_api_keys() if not naver_api_manager['keys']: debug_log("사용 가능한 네이버 광고 API 키가 없습니다.") return None available_keys = [ key for i, key in enumerate(naver_api_manager['keys']) if i not in naver_api_manager['failed_keys'] ] if not available_keys: # 모든 키가 실패했으면 실패 목록을 초기화하고 다시 시도 debug_log("모든 네이버 광고 API 키가 실패했습니다. 실패 목록을 초기화하고 다시 시도합니다.") naver_api_manager['failed_keys'].clear() available_keys = naver_api_manager['keys'] # 첫 번째 사용은 랜덤으로 선택 if naver_api_manager['current_index'] == -1: available_indices = [ i for i, key in enumerate(naver_api_manager['keys']) if i not in naver_api_manager['failed_keys'] ] naver_api_manager['current_index'] = random.choice(available_indices) debug_log(f"첫 번째 네이버 광고 API 키 선택: 랜덤 인덱스 {naver_api_manager['current_index'] + 1}") else: # 이후 사용은 순차적으로 다음 키 선택 (실패한 키는 건너뜀) for _ in range(len(naver_api_manager['keys'])): naver_api_manager['current_index'] = (naver_api_manager['current_index'] + 1) % len(naver_api_manager['keys']) if naver_api_manager['current_index'] not in naver_api_manager['failed_keys']: break if naver_api_manager['current_index'] in naver_api_manager['failed_keys']: # 모든 키를 시도했지만 사용할 수 있는 키가 없음 debug_log("사용 가능한 네이버 광고 API 키가 없습니다. 실패 목록을 초기화합니다.") naver_api_manager['failed_keys'].clear() naver_api_manager['current_index'] = 0 debug_log(f"다음 네이버 광고 API 키 선택: 인덱스 {naver_api_manager['current_index'] + 1}") return naver_api_manager['keys'][naver_api_manager['current_index']] def get_next_naver_search_api_key(): """다음 사용할 네이버 검색 API 키를 반환합니다. 실패한 키는 건너뜁니다.""" global naver_search_api_manager # 초기화 확인 if not naver_search_api_manager['is_initialized']: initialize_naver_search_api_keys() if not naver_search_api_manager['keys']: debug_log("사용 가능한 네이버 검색 API 키가 없습니다.") return None available_keys = [ key for i, key in enumerate(naver_search_api_manager['keys']) if i not in naver_search_api_manager['failed_keys'] ] if not available_keys: # 모든 키가 실패했으면 실패 목록을 초기화하고 다시 시도 debug_log("모든 네이버 검색 API 키가 실패했습니다. 실패 목록을 초기화하고 다시 시도합니다.") naver_search_api_manager['failed_keys'].clear() available_keys = naver_search_api_manager['keys'] # 첫 번째 사용은 랜덤으로 선택 if naver_search_api_manager['current_index'] == -1: available_indices = [ i for i, key in enumerate(naver_search_api_manager['keys']) if i not in naver_search_api_manager['failed_keys'] ] naver_search_api_manager['current_index'] = random.choice(available_indices) debug_log(f"첫 번째 네이버 검색 API 키 선택: 랜덤 인덱스 {naver_search_api_manager['current_index'] + 1}") else: # 이후 사용은 순차적으로 다음 키 선택 (실패한 키는 건너뜀) for _ in range(len(naver_search_api_manager['keys'])): naver_search_api_manager['current_index'] = (naver_search_api_manager['current_index'] + 1) % len(naver_search_api_manager['keys']) if naver_search_api_manager['current_index'] not in naver_search_api_manager['failed_keys']: break if naver_search_api_manager['current_index'] in naver_search_api_manager['failed_keys']: # 모든 키를 시도했지만 사용할 수 있는 키가 없음 debug_log("사용 가능한 네이버 검색 API 키가 없습니다. 실패 목록을 초기화합니다.") naver_search_api_manager['failed_keys'].clear() naver_search_api_manager['current_index'] = 0 debug_log(f"다음 네이버 검색 API 키 선택: 인덱스 {naver_search_api_manager['current_index'] + 1}") return naver_search_api_manager['keys'][naver_search_api_manager['current_index']] def mark_naver_api_key_failed(api_config): """네이버 광고 API 키를 실패 목록에 추가합니다.""" global naver_api_manager try: key_index = naver_api_manager['keys'].index(api_config) naver_api_manager['failed_keys'].add(key_index) debug_log(f"네이버 광고 API 키 인덱스 {key_index + 1}를 실패 목록에 추가했습니다.") except ValueError: debug_log("실패한 네이버 광고 API 키를 목록에서 찾을 수 없습니다.") def mark_naver_search_api_key_failed(api_config): """네이버 검색 API 키를 실패 목록에 추가합니다.""" global naver_search_api_manager try: key_index = naver_search_api_manager['keys'].index(api_config) naver_search_api_manager['failed_keys'].add(key_index) debug_log(f"네이버 검색 API 키 인덱스 {key_index + 1}를 실패 목록에 추가했습니다.") except ValueError: debug_log("실패한 네이버 검색 API 키를 목록에서 찾을 수 없습니다.") # --- 제목 제거 함수 --- def remove_title_from_content(content: str) -> str: """블로그 내용에서 제목 부분을 제거하는 함수""" debug_log("제목 제거 함수 시작") # "[제목]\n제목내용\n\n[본문]\n본문내용" 형태의 텍스트에서 제목 부분만 제거 if content.startswith("[제목]"): try: # "[본문]" 또는 첫 번째 double newline의 위치 찾기 parts = content.split("\n\n", 1) if len(parts) > 1: # 본문 부분만 반환 body_parts = parts[1].split("\n", 1) if len(body_parts) > 1 and body_parts[0] == "[본문]": result = body_parts[1] else: result = parts[1] debug_log("제목 제거 완료") return result except Exception as e: debug_log(f"제목 제거 중 오류: {str(e)}") # 제목 형태가 아니면 그대로 반환 return content # --- 네이버 블로그 스크래핑 (API 방식) --- def scrape_naver_blog(url: str) -> str: debug_log("scrape_naver_blog 함수 시작 (API 방식)") debug_log(f"요청받은 URL: {url}") try: if not url: return "URL이 필요합니다." # URL 인코딩 encoded_url = quote_plus(url.strip(), encoding='utf-8') api_url = f"{API_BASE_URL}/blog?url={encoded_url}" debug_log(f"API URL: {api_url}") # API 요청 response = requests.get(api_url, headers=HEADERS) debug_log(f"API 요청 완료, 상태코드: {response.status_code}") if response.ok: data = response.json() if 'content' in data: content = data['content'] debug_log("콘텐츠 추출 완료") # 콘텐츠가 이미 적절한 형태로 포맷되어 있다고 가정 return content else: debug_log("콘텐츠를 찾을 수 없음") return "콘텐츠를 찾을 수 없습니다." else: debug_log(f"API 오류 응답: {response.text}") return f"API 오류: {response.text}" except Exception as e: debug_log(f"에러 발생: {str(e)}") return f"스크래핑 중 오류가 발생했습니다: {str(e)}" # --- 형태소 분석 (참조코드-1) --- def analyze_text(text: str): logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) logger.debug("원본 텍스트: %s", text) filtered_text = re.sub(r'[^가-힣]', '', text) logger.debug("필터링된 텍스트: %s", filtered_text) if not filtered_text: logger.debug("유효한 한국어 텍스트가 없음.") return pd.DataFrame(columns=["단어", "빈도수"]), "" mecab_instance = mecab.MeCab() tokens = mecab_instance.pos(filtered_text) logger.debug("형태소 분석 결과: %s", tokens) freq = {} for word, pos in tokens: if word and word.strip() and pos.startswith("NN"): freq[word] = freq.get(word, 0) + 1 logger.debug("단어: %s, 품사: %s, 빈도: %d", word, pos, freq[word]) sorted_freq = sorted(freq.items(), key=lambda x: x[1], reverse=True) logger.debug("정렬된 단어 빈도: %s", sorted_freq) df = pd.DataFrame(sorted_freq, columns=["단어", "빈도수"]) logger.debug("형태소 분석 DataFrame 생성됨, shape: %s", df.shape) temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") df.to_excel(temp_file.name, index=False, engine='openpyxl') temp_file.close() logger.debug("Excel 파일 생성됨: %s", temp_file.name) return df, temp_file.name # --- 네이버 검색 및 광고 API 관련 (병렬 처리 적용) --- def generate_signature(timestamp, method, uri, secret_key): message = f"{timestamp}.{method}.{uri}" digest = hmac.new(secret_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).digest() return base64.b64encode(digest).decode() def get_header(method, uri, api_key, secret_key, customer_id): timestamp = str(round(time.time() * 1000)) signature = generate_signature(timestamp, method, uri, secret_key) return { "Content-Type": "application/json; charset=UTF-8", "X-Timestamp": timestamp, "X-API-KEY": api_key, "X-Customer": str(customer_id), "X-Signature": signature } def fetch_search_volume_batch(keywords_batch): """키워드 배치에 대한 네이버 검색량 조회""" result = {} # 순차적으로 API 설정 가져오기 (배치마다 한 번만 호출) api_config = get_next_naver_api_key() if not api_config: debug_log("❌ 사용 가능한 네이버 광고 API 키가 없습니다.") return result API_KEY = api_config["API_KEY"] SECRET_KEY = api_config["SECRET_KEY"] CUSTOMER_ID_STR = api_config["CUSTOMER_ID"] debug_log(f"=== 환경 변수 체크 (API 계정 {naver_api_manager['current_index'] + 1}) ===") debug_log(f"API_KEY: {'있음' if API_KEY else '없음'}") debug_log(f"SECRET_KEY: {'있음' if SECRET_KEY else '없음'}") debug_log(f"CUSTOMER_ID: {'있음' if CUSTOMER_ID_STR else '없음'}") debug_log(f"배치 크기: {len(keywords_batch)}개 키워드") # CUSTOMER_ID를 정수로 변환 try: CUSTOMER_ID = int(CUSTOMER_ID_STR) except ValueError: debug_log(f"❌ CUSTOMER_ID 변환 오류: '{CUSTOMER_ID_STR}'는 유효한 숫자가 아닙니다.") mark_naver_api_key_failed(api_config) return result try: BASE_URL = "https://api.naver.com" uri = "/keywordstool" method = "GET" headers = get_header(method, uri, API_KEY, SECRET_KEY, CUSTOMER_ID) # 키워드 배치를 한 번에 API로 전송 params = { "hintKeywords": keywords_batch, "showDetail": "1" } debug_log(f"요청 파라미터: {len(keywords_batch)}개 키워드") # API 호출 response = requests.get(BASE_URL + uri, params=params, headers=headers) debug_log(f"응답 상태 코드: {response.status_code}") if response.status_code != 200: debug_log(f"❌ API 오류 응답:") debug_log(f" 본문: {response.text}") # API 키 문제인 경우 실패 목록에 추가 if response.status_code in [401, 403]: mark_naver_api_key_failed(api_config) return result # 응답 데이터 파싱 result_data = response.json() if isinstance(result_data, dict) and "keywordList" in result_data: debug_log(f" keywordList 길이: {len(result_data['keywordList'])}") # 배치 내 각 키워드와 매칭 for keyword in keywords_batch: found = False for item in result_data["keywordList"]: rel_keyword = item.get("relKeyword", "") if rel_keyword == keyword: pc_count = item.get("monthlyPcQcCnt", 0) mobile_count = item.get("monthlyMobileQcCnt", 0) # 숫자 변환 try: if isinstance(pc_count, str): pc_count_converted = int(pc_count.replace(",", "")) else: pc_count_converted = int(pc_count) except: pc_count_converted = 0 try: if isinstance(mobile_count, str): mobile_count_converted = int(mobile_count.replace(",", "")) else: mobile_count_converted = int(mobile_count) except: mobile_count_converted = 0 total_count = pc_count_converted + mobile_count_converted result[keyword] = { "PC월검색량": pc_count_converted, "모바일월검색량": mobile_count_converted, "토탈월검색량": total_count } debug_log(f"✅ '{keyword}': PC={pc_count_converted}, Mobile={mobile_count_converted}, Total={total_count}") found = True break if not found: debug_log(f"❌ '{keyword}': 매칭되는 데이터를 찾을 수 없음") # 검색량이 없는 경우 0으로 설정 result[keyword] = { "PC월검색량": 0, "모바일월검색량": 0, "토탈월검색량": 0 } else: debug_log(f"❌ keywordList가 없음") except Exception as e: debug_log(f"❌ 배치 처리 중 오류: {str(e)}") # API 키 문제인 경우 실패 목록에 추가 if "api" in str(e).lower() or "key" in str(e).lower() or "auth" in str(e).lower(): mark_naver_api_key_failed(api_config) import traceback traceback.print_exc() debug_log(f"\n=== 배치 처리 완료 ===") debug_log(f"성공적으로 처리된 키워드 수: {len(result)}") return result def fetch_all_search_volumes(keywords, batch_size=5): """키워드 리스트에 대한 네이버 검색량 병렬 조회""" results = {} batches = [] # 키워드를 5개씩 묶어서 배치 생성 for i in range(0, len(keywords), batch_size): batch = keywords[i:i + batch_size] batches.append(batch) debug_log(f"총 {len(batches)}개 배치로 {len(keywords)}개 키워드 처리 중…") debug_log(f"배치 크기: {batch_size}, 병렬 워커: 10개, API 계정: {len(naver_api_manager['keys'])}개 순차 사용") with ThreadPoolExecutor(max_workers=10) as executor: futures = {executor.submit(fetch_search_volume_batch, batch): batch for batch in batches} for future in as_completed(futures): batch = futures[future] try: batch_results = future.result() results.update(batch_results) debug_log(f"배치 처리 완료: {len(batch)}개 키워드 (성공: {len(batch_results)}개)") except Exception as e: debug_log(f"배치 처리 오류: {e}") time.sleep(0.5) # API 레이트 리밋 방지 debug_log(f"검색량 조회 완료: {len(results)}개 키워드") return results def fetch_blog_count_batch(keywords_batch): """키워드 배치에 대한 네이버 블로그 발생수 조회""" result = {} # 순차적으로 검색 API 설정 가져오기 api_config = get_next_naver_search_api_key() if not api_config: debug_log("❌ 사용 가능한 네이버 검색 API 키가 없습니다.") return result client_id = api_config["CLIENT_ID"] client_secret = api_config["CLIENT_SECRET"] debug_log(f"=== 블로그 검색 API 체크 (계정 {naver_search_api_manager['current_index'] + 1}) ===") debug_log(f"CLIENT_ID: {'있음' if client_id else '없음'}") debug_log(f"CLIENT_SECRET: {'있음' if client_secret else '없음'}") debug_log(f"배치 크기: {len(keywords_batch)}개 키워드") url = "https://openapi.naver.com/v1/search/blog.json" headers = { "X-Naver-Client-Id": client_id, "X-Naver-Client-Secret": client_secret } for keyword in keywords_batch: try: params = {"query": keyword, "display": 1} response = requests.get(url, headers=headers, params=params) if response.status_code == 200: data = response.json() blog_count = data.get("total", 0) result[keyword] = blog_count debug_log(f"✅ '{keyword}': 블로그 문서수 = {blog_count}") else: debug_log(f"❌ '{keyword}': API 오류 (상태코드: {response.status_code})") # API 키 문제인 경우 실패 목록에 추가 if response.status_code in [401, 403]: mark_naver_search_api_key_failed(api_config) result[keyword] = 0 except Exception as e: debug_log(f"❌ '{keyword}': 처리 중 오류 = {str(e)}") # API 키 문제인 경우 실패 목록에 추가 if "api" in str(e).lower() or "key" in str(e).lower() or "auth" in str(e).lower(): mark_naver_search_api_key_failed(api_config) result[keyword] = 0 return result def fetch_all_blog_counts(keywords, batch_size=10): """키워드 리스트에 대한 네이버 블로그 발생수 병렬 조회""" results = {} batches = [] # 키워드를 10개씩 묶어서 배치 생성 for i in range(0, len(keywords), batch_size): batch = keywords[i:i + batch_size] batches.append(batch) debug_log(f"총 {len(batches)}개 배치로 {len(keywords)}개 키워드의 블로그 발생수 처리 중…") debug_log(f"배치 크기: {batch_size}, 병렬 워커: 5개, API 계정: {len(naver_search_api_manager['keys'])}개 순차 사용") with ThreadPoolExecutor(max_workers=5) as executor: futures = {executor.submit(fetch_blog_count_batch, batch): batch for batch in batches} for future in as_completed(futures): batch = futures[future] try: batch_results = future.result() results.update(batch_results) debug_log(f"배치 처리 완료: {len(batch)}개 키워드 (성공: {len(batch_results)}개)") except Exception as e: debug_log(f"배치 처리 오류: {e}") time.sleep(0.3) # API 레이트 리밋 방지 debug_log(f"블로그 발생수 조회 완료: {len(results)}개 키워드") return results def fetch_related_keywords(keyword): """연관 키워드 조회 (기존 로직은 그대로 유지)""" debug_log(f"fetch_related_keywords 호출, 키워드: {keyword}") # 단일 키워드만 있으면 배치 처리 keywords_list = [keyword] search_volumes = fetch_all_search_volumes(keywords_list) # 결과가 있으면 데이터프레임으로 변환 if search_volumes: data = [] for kw, vol in search_volumes.items(): data.append({ "정보키워드": kw, "PC월검색량": vol["PC월검색량"], "모바일월검색량": vol["모바일월검색량"], "토탈월검색량": vol["토탈월검색량"] }) df = pd.DataFrame(data) debug_log("fetch_related_keywords 완료") return df else: return pd.DataFrame() def fetch_blog_count(keyword): """단일 키워드의 블로그 발생수 조회""" debug_log(f"fetch_blog_count 호출, 키워드: {keyword}") # 단일 키워드만 있으므로 배치 처리 keywords_list = [keyword] blog_counts = fetch_all_blog_counts(keywords_list) # 결과 반환 if keyword in blog_counts: count = blog_counts[keyword] debug_log(f"fetch_blog_count 결과: {count}") return count else: debug_log(f"fetch_blog_count 오류") return 0 def create_excel_file(df): with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp: excel_path = tmp.name df.to_excel(excel_path, index=False) debug_log(f"Excel 파일 생성됨: {excel_path}") return excel_path def process_keyword(keywords: str, include_related: bool): debug_log(f"process_keyword 호출, 키워드들: {keywords}, 연관검색어 포함: {include_related}") input_keywords = [k.strip() for k in keywords.splitlines() if k.strip()] # 모든 키워드를 한 번에 검색량 조회 search_volumes = fetch_all_search_volumes(input_keywords) # 결과를 DataFrame으로 구성 result_data = [] for kw in input_keywords: if kw in search_volumes: vol = search_volumes[kw] result_data.append({ "정보키워드": kw, "PC월검색량": vol["PC월검색량"], "모바일월검색량": vol["모바일월검색량"], "토탈월검색량": vol["토탈월검색량"] }) if result_data: result_df = pd.DataFrame(result_data) else: result_df = pd.DataFrame(columns=["정보키워드", "PC월검색량", "모바일월검색량", "토탈월검색량"]) # 블로그 문서수 조회 (병렬 처리) blog_counts = fetch_all_blog_counts(input_keywords) result_df["블로그문서수"] = result_df["정보키워드"].map(blog_counts).fillna(0).astype(int) result_df.sort_values(by="토탈월검색량", ascending=False, inplace=True) debug_log("process_keyword 완료") return result_df, create_excel_file(result_df) # --- 형태소 분석과 검색량/블로그문서수 병합 --- def morphological_analysis_and_enrich(text: str, remove_freq1: bool): debug_log("morphological_analysis_and_enrich 함수 시작") df_freq, _ = analyze_text(text) if df_freq.empty: debug_log("형태소 분석 결과가 빈 데이터프레임입니다.") return df_freq, "" if remove_freq1: before_shape = df_freq.shape df_freq = df_freq[df_freq["빈도수"] != 1] debug_log(f"빈도수 1 제거 적용됨. {before_shape} -> {df_freq.shape}") # 모든 키워드를 한 번에 검색량 조회 (병렬 처리) keywords_list = df_freq["단어"].tolist() search_volumes = fetch_all_search_volumes(keywords_list) blog_counts = fetch_all_blog_counts(keywords_list) # 검색량 정보를 데이터프레임에 매핑 df_keyword_info = [] for kw in keywords_list: if kw in search_volumes: vol = search_volumes[kw] df_keyword_info.append({ "정보키워드": kw, "PC월검색량": vol["PC월검색량"], "모바일월검색량": vol["모바일월검색량"], "토탈월검색량": vol["토탈월검색량"], "블로그문서수": blog_counts.get(kw, 0) }) if df_keyword_info: df_keyword_info = pd.DataFrame(df_keyword_info) else: df_keyword_info = pd.DataFrame(columns=["정보키워드", "PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수"]) debug_log("검색량 및 블로그문서수 조회 완료") merged_df = pd.merge(df_freq, df_keyword_info, left_on="단어", right_on="정보키워드", how="left") merged_df.drop(columns=["정보키워드"], inplace=True) merged_excel_path = create_excel_file(merged_df) debug_log("morphological_analysis_and_enrich 함수 완료") return merged_df, merged_excel_path # --- 직접 키워드 분석 (단독 분석) --- def direct_keyword_analysis(text: str, keyword_input: str): debug_log("direct_keyword_analysis 함수 시작") keywords = re.split(r'[\n,]+', keyword_input) keywords = [kw.strip() for kw in keywords if kw.strip()] debug_log(f"입력된 키워드 목록: {keywords}") results = [] for kw in keywords: count = text.count(kw) results.append((kw, count)) debug_log(f"키워드 '{kw}'의 빈도수: {count}") df = pd.DataFrame(results, columns=["키워드", "빈도수"]) excel_path = create_excel_file(df) debug_log("direct_keyword_analysis 함수 완료") return df, excel_path # --- 통합 분석 (형태소 분석 + 직접 키워드 분석) --- def combined_analysis(blog_text: str, remove_freq1: bool, direct_keyword_input: str): debug_log("combined_analysis 함수 시작") merged_df, _ = morphological_analysis_and_enrich(blog_text, remove_freq1) if "직접입력" not in merged_df.columns: merged_df["직접입력"] = "" direct_keywords = re.split(r'[\n,]+', direct_keyword_input) direct_keywords = [kw.strip() for kw in direct_keywords if kw.strip()] debug_log(f"입력된 직접 키워드: {direct_keywords}") # 직접 입력된 키워드들을 병렬로 처리 for dk in direct_keywords: if dk in merged_df["단어"].values: merged_df.loc[merged_df["단어"] == dk, "직접입력"] = "직접입력" else: freq = blog_text.count(dk) # 병렬 처리로 검색량 및 블로그 문서수 조회 search_volumes = fetch_all_search_volumes([dk]) blog_counts = fetch_all_blog_counts([dk]) if dk in search_volumes: vol = search_volumes[dk] pc = vol["PC월검색량"] mobile = vol["모바일월검색량"] total = vol["토탈월검색량"] else: pc = mobile = total = 0 blog_count = blog_counts.get(dk, 0) new_row = { "단어": dk, "빈도수": freq, "PC월검색량": pc, "모바일월검색량": mobile, "토탈월검색량": total, "블로그문서수": blog_count, "직접입력": "직접입력" } merged_df = pd.concat([merged_df, pd.DataFrame([new_row])], ignore_index=True) merged_df = merged_df.sort_values(by="빈도수", ascending=False).reset_index(drop=True) combined_excel = create_excel_file(merged_df) debug_log("combined_analysis 함수 완료") return merged_df, combined_excel # --- 분석 핸들러 --- def analysis_handler(blog_text: str, remove_freq1: bool, include_title: bool, direct_keyword_input: str, direct_keyword_only: bool): debug_log("analysis_handler 함수 시작") # 제목 포함/제외 처리 if not include_title: debug_log("제목 제거 로직 적용") analysis_text = remove_title_from_content(blog_text) else: debug_log("제목 포함하여 분석") analysis_text = blog_text if direct_keyword_only: # "직접 키워드 입력만 분석" 선택 시 단독 분석 수행 return direct_keyword_analysis(analysis_text, direct_keyword_input) else: # 기본 통합 분석 수행 return combined_analysis(analysis_text, remove_freq1, direct_keyword_input) # --- 스크래핑 실행 --- def fetch_blog_content(url: str): debug_log("fetch_blog_content 함수 시작") content = scrape_naver_blog(url) debug_log("fetch_blog_content 함수 완료") return content # --- Gradio 인터페이스 구성 --- def create_interface(): css = """ /* ============================================ 다크모드 자동 변경 템플릿 CSS 다른 프로젝트에 복사해서 사용 가능 ============================================ */ /* 1. CSS 변수 정의 (라이트모드 - 기본값) */ :root { /* 메인 컬러 */ --primary-color: #FB7F0D; --secondary-color: #ff9a8b; --accent-color: #FF6B6B; /* 배경 컬러 */ --background-color: #FFFFFF; --card-bg: #ffffff; --input-bg: #ffffff; /* 텍스트 컬러 */ --text-color: #334155; --text-secondary: #64748b; /* 보더 및 구분선 */ --border-color: #dddddd; --border-light: #e5e5e5; /* 테이블 컬러 */ --table-even-bg: #f3f3f3; --table-hover-bg: #f0f0f0; /* 그림자 */ --shadow: 0 8px 30px rgba(251, 127, 13, 0.08); --shadow-light: 0 2px 4px rgba(0, 0, 0, 0.1); /* 기타 */ --border-radius: 18px; } /* 2. 다크모드 색상 변수 (자동 감지) */ @media (prefers-color-scheme: dark) { :root { /* 배경 컬러 */ --background-color: #1a1a1a; --card-bg: #2d2d2d; --input-bg: #2d2d2d; /* 텍스트 컬러 */ --text-color: #e5e5e5; --text-secondary: #a1a1aa; /* 보더 및 구분선 */ --border-color: #404040; --border-light: #525252; /* 테이블 컬러 */ --table-even-bg: #333333; --table-hover-bg: #404040; /* 그림자 */ --shadow: 0 8px 30px rgba(0, 0, 0, 0.3); --shadow-light: 0 2px 4px rgba(0, 0, 0, 0.2); } } /* 3. 수동 다크모드 클래스 (Gradio 토글용) */ [data-theme="dark"], .dark, .gr-theme-dark { /* 배경 컬러 */ --background-color: #1a1a1a; --card-bg: #2d2d2d; --input-bg: #2d2d2d; /* 텍스트 컬러 */ --text-color: #e5e5e5; --text-secondary: #a1a1aa; /* 보더 및 구분선 */ --border-color: #404040; --border-light: #525252; /* 테이블 컬러 */ --table-even-bg: #333333; --table-hover-bg: #404040; /* 그림자 */ --shadow: 0 8px 30px rgba(0, 0, 0, 0.3); --shadow-light: 0 2px 4px rgba(0, 0, 0, 0.2); } /* 4. 기본 요소 다크모드 적용 */ body { font-family: 'Pretendard', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif; background-color: var(--background-color) !important; color: var(--text-color) !important; line-height: 1.6; transition: background-color 0.3s ease, color 0.3s ease; } footer { visibility: hidden; } /* 5. Gradio 컨테이너 강제 적용 */ .gradio-container, .gradio-container *, .gr-app, .gr-app *, .gr-interface { background-color: var(--background-color) !important; color: var(--text-color) !important; } /* 6. 카드 및 패널 스타일 */ .gr-form, .gr-box, .gr-panel, .custom-frame, [class*="frame"], [class*="card"], [class*="panel"] { background-color: var(--card-bg) !important; border-color: var(--border-color) !important; color: var(--text-color) !important; box-shadow: var(--shadow) !important; } /* 7. 입력 필드 스타일 */ input[type="text"], input[type="number"], input[type="email"], input[type="password"], textarea, select, .gr-input, .gr-text-input, .gr-textarea, .gr-dropdown { background-color: var(--input-bg) !important; color: var(--text-color) !important; border-color: var(--border-color) !important; } input[type="text"]:focus, input[type="number"]:focus, input[type="email"]:focus, input[type="password"]:focus, textarea:focus, select:focus, .gr-input:focus, .gr-text-input:focus, .gr-textarea:focus, .gr-dropdown:focus { border-color: var(--primary-color) !important; box-shadow: 0 0 0 2px rgba(251, 127, 13, 0.2) !important; } /* 8. 라벨 및 텍스트 요소 */ label, .gr-label, .gr-checkbox label, .gr-radio label, p, span, div { color: var(--text-color) !important; } /* 폼 라벨 텍스트 크기 대폭 증가 */ .gr-form label, .gr-textbox label, .gr-checkbox label, .gr-radio label { font-size: 20px !important; font-weight: 600 !important; color: var(--text-color) !important; } /* 설명 텍스트 크기 대폭 증가 */ .gr-form .gr-form-label, .gr-textbox .gr-form-label, .gr-checkbox .gr-form-label, .gr-radio .gr-form-label, .gr-info { font-size: 18px !important; color: var(--text-secondary) !important; line-height: 1.4 !important; } /* 9. 테이블 스타일 */ table { background-color: var(--card-bg) !important; color: var(--text-color) !important; border-color: var(--border-color) !important; } table th { background-color: var(--primary-color) !important; color: white !important; border-color: var(--border-color) !important; } table td { background-color: var(--card-bg) !important; color: var(--text-color) !important; border-color: var(--border-color) !important; } table tbody tr:nth-child(even) { background-color: var(--table-even-bg) !important; } table tbody tr:hover { background-color: var(--table-hover-bg) !important; } /* 10. 체크박스 및 라디오 버튼 스타일 개선 */ input[type="checkbox"], input[type="radio"] { accent-color: var(--primary-color) !important; width: 24px !important; height: 24px !important; cursor: pointer !important; } /* 체크박스 커스텀 스타일 - 더 강력한 선택자 사용 */ .gradio-container input[type="checkbox"], .gr-checkbox input[type="checkbox"], input[type="checkbox"] { -webkit-appearance: none !important; -moz-appearance: none !important; appearance: none !important; width: 24px !important; height: 24px !important; border: 2px solid var(--border-color) !important; border-radius: 6px !important; background-color: var(--card-bg) !important; cursor: pointer !important; position: relative !important; transition: all 0.3s ease !important; margin-right: 12px !important; flex-shrink: 0 !important; } .gradio-container input[type="checkbox"]:checked, .gr-checkbox input[type="checkbox"]:checked, input[type="checkbox"]:checked { background-color: var(--primary-color) !important; border-color: var(--primary-color) !important; } .gradio-container input[type="checkbox"]:checked::before, .gr-checkbox input[type="checkbox"]:checked::before, input[type="checkbox"]:checked::before { content: "✓" !important; position: absolute !important; top: 50% !important; left: 50% !important; transform: translate(-50%, -50%) !important; color: white !important; font-size: 16px !important; font-weight: bold !important; line-height: 1 !important; } .gradio-container input[type="checkbox"]:hover, .gr-checkbox input[type="checkbox"]:hover, input[type="checkbox"]:hover { border-color: var(--primary-color) !important; box-shadow: 0 0 0 2px rgba(251, 127, 13, 0.2) !important; } /* 체크박스 라벨 텍스트 크기 대폭 증가 */ .gradio-container .gr-checkbox label, .gr-checkbox label, label[for] { font-size: 20px !important; font-weight: 600 !important; color: var(--text-color) !important; cursor: pointer !important; margin-left: 8px !important; display: flex !important; align-items: center !important; } /* 체크박스 컨테이너 정렬 */ .gr-checkbox { display: flex !important; align-items: center !important; gap: 8px !important; } /* 11. 스크롤바 스타일 */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { background: var(--card-bg); border-radius: 10px; } ::-webkit-scrollbar-thumb { background: var(--primary-color); border-radius: 10px; } ::-webkit-scrollbar-thumb:hover { background: var(--secondary-color); } /* 12. 아코디언 및 드롭다운 */ details { background-color: var(--card-bg) !important; border-color: var(--border-color) !important; color: var(--text-color) !important; } details summary { background-color: var(--card-bg) !important; color: var(--text-color) !important; } /* 13. 툴팁 및 팝업 */ [data-tooltip]:hover::after, .tooltip, .popup { background-color: var(--card-bg) !important; color: var(--text-color) !important; border-color: var(--border-color) !important; box-shadow: var(--shadow-light) !important; } /* 14. 모달 및 오버레이 */ .modal, .overlay, [class*="modal"], [class*="overlay"] { background-color: var(--card-bg) !important; color: var(--text-color) !important; border-color: var(--border-color) !important; } /* 15. 추가 Gradio 컴포넌트들 */ .gr-block, .gr-group, .gr-row, .gr-column { background-color: var(--background-color) !important; color: var(--text-color) !important; } /* 16. 버튼은 기존 스타일 유지 (primary-color 사용) */ button:not([class*="custom"]):not([class*="primary"]):not([class*="secondary"]) { background-color: var(--card-bg) !important; color: var(--text-color) !important; border-color: var(--border-color) !important; } /* 17. 코드 블록 및 pre 태그 */ code, pre, .code-block { background-color: var(--table-even-bg) !important; color: var(--text-color) !important; border-color: var(--border-color) !important; } /* 18. 알림 및 메시지 */ .alert, .message, .notification, [class*="alert"], [class*="message"], [class*="notification"] { background-color: var(--card-bg) !important; color: var(--text-color) !important; border-color: var(--border-color) !important; } /* 19. 전환 애니메이션 */ * { transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease !important; } /* 20. 기존 스타일 유지 */ .container { max-width: 1200px; margin: 0 auto; } .header { background: linear-gradient(135deg, #FB7F0D, #FF9A5B); padding: 2rem; border-radius: 15px; margin-bottom: 20px; box-shadow: var(--shadow); text-align: center; color: white; } .header h1 { margin: 0; font-size: 2.5rem; font-weight: 700; } .header p { margin: 10px 0 0; font-size: 1.2rem; opacity: 0.9; } .card { background-color: var(--card-bg); border-radius: var(--border-radius); padding: 20px; margin: 10px 0; box-shadow: var(--shadow); border: 1px solid var(--border-color); } .button-primary { border-radius: 30px !important; background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)) !important; color: white !important; font-size: 18px !important; padding: 10px 20px !important; border: none; box-shadow: 0 4px 8px rgba(251, 127, 13, 0.25); transition: transform 0.3s ease; text-align: center; font-weight: 600; } .button-primary:hover { transform: translateY(-2px); box-shadow: 0 6px 12px rgba(251, 127, 13, 0.3); } .section-title { display: flex; align-items: center; font-size: 20px; font-weight: 700; color: var(--text-color); margin-bottom: 15px; padding-bottom: 8px; border-bottom: 2px solid var(--primary-color); } .section-title i { margin-right: 10px; color: var(--primary-color); } .guide-container { background-color: var(--card-bg); border-radius: var(--border-radius); padding: 1.5rem; margin-bottom: 1.5rem; border: 1px solid var(--border-color); } .guide-title { font-size: 1.3rem; font-weight: 700; color: var(--primary-color); margin-bottom: 1rem; display: flex; align-items: center; } .guide-title i { margin-right: 0.8rem; font-size: 1.3rem; } .guide-item { display: flex; margin-bottom: 0.8rem; align-items: flex-start; } .guide-number { background-color: var(--primary-color); color: white; width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; margin-right: 10px; flex-shrink: 0; font-size: 14px; } .guide-text { flex: 1; line-height: 1.6; color: var(--text-color); } /* 그라디오 요소 스타일 커스터마이징 */ .gr-input, .gr-text-input, .gr-textarea { border-radius: var(--border-radius) !important; border: 1px solid var(--border-color) !important; padding: 12px !important; transition: all 0.3s ease !important; } .gr-input:focus, .gr-text-input:focus, .gr-textarea:focus { border-color: var(--primary-color) !important; outline: none !important; box-shadow: 0 0 0 2px rgba(251, 127, 13, 0.2) !important; } """ # FontAwesome 아이콘 추가 fontawesome = """ """ with gr.Blocks(css=css, theme=gr.themes.Soft( primary_hue=gr.themes.Color( c50="#FFF7ED", c100="#FFEDD5", c200="#FED7AA", c300="#FDBA74", c400="#FB923C", c500="#F97316", c600="#EA580C", c700="#C2410C", c800="#9A3412", c900="#7C2D12", c950="#431407", ), secondary_hue="zinc", neutral_hue="zinc", font=("Pretendard", "sans-serif") )) as demo: gr.HTML(fontawesome) # 메인 컨텐츠 with gr.Row(): # 왼쪽 입력 영역 with gr.Column(scale=1, elem_classes="card"): # 블로그 링크 입력 gr.HTML('