import gradio as gr import requests from bs4 import BeautifulSoup import urllib.parse # iframe 경로 보정을 위한 모듈 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 from concurrent.futures import ThreadPoolExecutor, as_completed # --- 병렬 처리 설정 --- # API 호출 제한에 맞춰 적절히 조절하세요. # 너무 높은 값은 API 제한에 걸릴 수 있습니다. MAX_WORKERS_RELATED_KEYWORDS = 5 # fetch_related_keywords 병렬 작업자 수 MAX_WORKERS_BLOG_COUNT = 10 # fetch_blog_count 병렬 작업자 수 # 디버깅(로그)용 함수 def debug_log(message: str): print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [DEBUG] {message}") # --- 네이버 블로그 스크래핑 --- def scrape_naver_blog(url: str) -> str: debug_log("scrape_naver_blog 함수 시작") debug_log(f"요청받은 URL: {url}") headers = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/96.0.4664.110 Safari/537.36" ) } try: response = requests.get(url, headers=headers, timeout=10) debug_log("HTTP GET 요청(메인 페이지) 완료") if response.status_code != 200: debug_log(f"요청 실패, 상태코드: {response.status_code}") return f"오류가 발생했습니다. 상태코드: {response.status_code}" soup = BeautifulSoup(response.text, "html.parser") debug_log("HTML 파싱(메인 페이지) 완료") iframe = soup.select_one("iframe#mainFrame") if not iframe: debug_log("iframe#mainFrame 태그를 찾을 수 없습니다.") # 일부 블로그는 mainFrame이 없을 수 있음. 본문 직접 시도 content_div_direct = soup.select_one('.se-main-container') if content_div_direct: title_div_direct = soup.select_one('.se-module.se-module-text.se-title-text') title = title_div_direct.get_text(strip=True) if title_div_direct else "제목을 찾을 수 없습니다." content = content_div_direct.get_text("\n", strip=True) debug_log("iframe 없이 본문 직접 추출 완료") return f"[제목]\n{title}\n\n[본문]\n{content}" return "본문 iframe을 찾을 수 없습니다. (본문 직접 추출 실패)" iframe_src = iframe.get("src") if not iframe_src: debug_log("iframe src가 존재하지 않습니다.") return "본문 iframe의 src를 찾을 수 없습니다." # iframe_src가 절대 URL이 아닌 경우를 대비 if iframe_src.startswith("//"): parsed_iframe_url = "https:" + iframe_src elif iframe_src.startswith("/"): parsed_main_url = urllib.parse.urlparse(url) parsed_iframe_url = urllib.parse.urlunparse( (parsed_main_url.scheme, parsed_main_url.netloc, iframe_src, None, None, None) ) else: parsed_iframe_url = urllib.parse.urljoin(url, iframe_src) debug_log(f"iframe 페이지 요청 URL: {parsed_iframe_url}") iframe_response = requests.get(parsed_iframe_url, headers=headers, timeout=10) debug_log("HTTP GET 요청(iframe 페이지) 완료") if iframe_response.status_code != 200: debug_log(f"iframe 요청 실패, 상태코드: {iframe_response.status_code}") return f"iframe에서 오류가 발생했습니다. 상태코드: {iframe_response.status_code}" iframe_soup = BeautifulSoup(iframe_response.text, "html.parser") debug_log("HTML 파싱(iframe 페이지) 완료") # 제목 추출 (다양한 구조 시도) title_selectors = [ '.se-module.se-module-text.se-title-text', # 일반적인 스마트에디터 ONE '.title_text', # 구버전 에디터 또는 다른 구조 'div[class*="title"] h3', 'h1', 'h2', 'h3' # 일반적인 제목 태그 ] title = "제목을 찾을 수 없습니다." for selector in title_selectors: title_div = iframe_soup.select_one(selector) if title_div: title = title_div.get_text(strip=True) break debug_log(f"추출된 제목: {title}") # 본문 추출 (다양한 구조 시도) content_selectors = [ '.se-main-container', # 스마트에디터 ONE 'div#content', # 구버전 에디터 'div.post_ct', # 일부 블로그 구조 'article', 'main' # 시맨틱 태그 ] content = "본문을 찾을 수 없습니다." for selector in content_selectors: content_div = iframe_soup.select_one(selector) if content_div: # 불필요한 스크립트, 스타일 태그 제거 for s in content_div(['script', 'style']): s.decompose() content = content_div.get_text("\n", strip=True) break debug_log("본문 추출 완료") result = f"[제목]\n{title}\n\n[본문]\n{content}" debug_log("제목과 본문 합침 완료") return result except requests.exceptions.Timeout: debug_log(f"요청 시간 초과: {url}") return f"스크래핑 중 시간 초과가 발생했습니다: {url}" except Exception as e: debug_log(f"스크래핑 에러 발생: {str(e)}") return f"스크래핑 중 오류가 발생했습니다: {str(e)}" # --- 형태소 분석 (참조코드-1) --- def analyze_text(text: str): logging.basicConfig(level=logging.INFO) # INFO 레벨로 변경하여 너무 많은 로그 방지 logger = logging.getLogger(__name__) # logger.debug("원본 텍스트: %s", text) # 너무 길 수 있으므로 주석 처리 filtered_text = re.sub(r'[^가-힣a-zA-Z0-9\s]', '', text) # 영어, 숫자, 공백 포함 # logger.debug("필터링된 텍스트: %s", filtered_text) if not filtered_text.strip(): logger.info("유효한 텍스트가 없음 (필터링 후).") return pd.DataFrame(columns=["단어", "빈도수"]), "" try: mecab_instance = mecab.MeCab() tokens = mecab_instance.pos(filtered_text) except Exception as e: logger.error(f"MeCab 형태소 분석 중 오류: {e}") return pd.DataFrame(columns=["단어", "빈도수"]), "" # logger.debug("형태소 분석 결과: %s", tokens) freq = {} for word, pos in tokens: # 일반명사(NNG), 고유명사(NNP), 외국어(SL), 숫자(SN) 등 포함, 한 글자 단어는 제외 (선택 사항) if word and word.strip() and (pos.startswith("NN") or pos in ["SL", "SH"]) and len(word) > 1 : 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.info(f"형태소 분석 DataFrame 생성됨, shape: {df.shape}") temp_file_path = "" if not df.empty: try: with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx", mode='w+b') as temp_file: df.to_excel(temp_file.name, index=False, engine='openpyxl') temp_file_path = temp_file.name logger.info(f"Excel 파일 생성됨: {temp_file_path}") except Exception as e: logger.error(f"Excel 파일 저장 중 오류: {e}") temp_file_path = "" # 오류 발생 시 경로 초기화 return df, temp_file_path # --- 네이버 검색 및 광고 API 관련 (참조코드-2) --- 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 } # API 키 환경 변수 확인 함수 def get_env_variable(var_name): value = os.environ.get(var_name) if value is None: debug_log(f"환경 변수 '{var_name}'가 설정되지 않았습니다. API 호출이 실패할 수 있습니다.") # 필요시 여기서 raise Exception 또는 기본값 반환 return value def fetch_related_keywords(keyword): debug_log(f"fetch_related_keywords 호출 시작, 키워드: {keyword}") API_KEY = get_env_variable("NAVER_API_KEY") SECRET_KEY = get_env_variable("NAVER_SECRET_KEY") CUSTOMER_ID = get_env_variable("NAVER_CUSTOMER_ID") if not all([API_KEY, SECRET_KEY, CUSTOMER_ID]): debug_log(f"네이버 광고 API 키 정보 부족으로 '{keyword}' 연관 키워드 조회를 건너<0xEB><0xB5>니다.") return pd.DataFrame() BASE_URL = "https://api.naver.com" uri = "/keywordstool" method = "GET" try: headers = get_header(method, uri, API_KEY, SECRET_KEY, CUSTOMER_ID) params = { "hintKeywords": keyword, # 단일 키워드 문자열로 전달 "showDetail": "1" } # hintKeywords는 리스트로 받을 수 있으나, 여기서는 단일 키워드 처리를 가정하고 문자열로 전달 # 만약 API가 hintKeywords를 리스트로만 받는다면 [keyword]로 수정 필요 response = requests.get(BASE_URL + uri, params=params, headers=headers, timeout=10) response.raise_for_status() # 오류 발생 시 예외 발생 data = response.json() if "keywordList" not in data or not data["keywordList"]: debug_log(f"'{keyword}'에 대한 연관 키워드 결과 없음.") return pd.DataFrame() # 빈 DataFrame 반환 df = pd.DataFrame(data["keywordList"]) # API 응답에 해당 컬럼이 없을 경우를 대비 df["monthlyPcQcCnt"] = df.get("monthlyPcQcCnt", 0) df["monthlyMobileQcCnt"] = df.get("monthlyMobileQcCnt", 0) def parse_count(x): if pd.isna(x) or str(x).lower() == '< 10': # 네이버 API는 10 미만일 때 "< 10"으로 반환 return 5 # 또는 0, 또는 다른 대표값 (예: 5) try: return int(str(x).replace(",", "")) except ValueError: return 0 df["PC월검색량"] = df["monthlyPcQcCnt"].apply(parse_count) df["모바일월검색량"] = df["monthlyMobileQcCnt"].apply(parse_count) df["토탈월검색량"] = df["PC월검색량"] + df["모바일월검색량"] df.rename(columns={"relKeyword": "정보키워드"}, inplace=True) # 필요한 컬럼만 선택, 없는 경우 대비 required_cols = ["정보키워드", "PC월검색량", "모바일월검색량", "토탈월검색량"] result_df = pd.DataFrame(columns=required_cols) for col in required_cols: if col in df.columns: result_df[col] = df[col] else: # 해당 컬럼이 API 응답에 없을 경우 기본값으로 채움 if col == "정보키워드": # 정보키워드는 필수 debug_log(f"API 응답에 'relKeyword'가 없습니다. '{keyword}' 처리 중단.") return pd.DataFrame() result_df[col] = 0 debug_log(f"fetch_related_keywords '{keyword}' 완료, 결과 {len(result_df)}개") return result_df.head(100) # 최대 100개로 제한 except requests.exceptions.HTTPError as http_err: debug_log(f"HTTP 오류 발생 (fetch_related_keywords for '{keyword}'): {http_err} - 응답: {response.text if 'response' in locals() else 'N/A'}") except requests.exceptions.RequestException as req_err: debug_log(f"요청 오류 발생 (fetch_related_keywords for '{keyword}'): {req_err}") except Exception as e: debug_log(f"알 수 없는 오류 발생 (fetch_related_keywords for '{keyword}'): {e}") return pd.DataFrame() # 오류 발생 시 빈 DataFrame 반환 def fetch_blog_count(keyword): debug_log(f"fetch_blog_count 호출, 키워드: {keyword}") client_id = get_env_variable("NAVER_SEARCH_CLIENT_ID") client_secret = get_env_variable("NAVER_SEARCH_CLIENT_SECRET") if not client_id or not client_secret: debug_log(f"네이버 검색 API 키 정보 부족으로 '{keyword}' 블로그 수 조회를 건너<0xEB><0xB5>니다.") return 0 url = "https://openapi.naver.com/v1/search/blog.json" headers = { "X-Naver-Client-Id": client_id, "X-Naver-Client-Secret": client_secret } params = {"query": keyword, "display": 1} # display=1로 설정하여 total 값만 빠르게 확인 try: response = requests.get(url, headers=headers, params=params, timeout=5) response.raise_for_status() # HTTP 오류 발생 시 예외 발생 data = response.json() total_count = data.get("total", 0) debug_log(f"fetch_blog_count 결과: {total_count} for '{keyword}'") return total_count except requests.exceptions.HTTPError as http_err: debug_log(f"HTTP 오류 발생 (fetch_blog_count for '{keyword}'): {http_err} - 응답: {response.text}") except requests.exceptions.RequestException as req_err: # Timeout, ConnectionError 등 debug_log(f"요청 오류 발생 (fetch_blog_count for '{keyword}'): {req_err}") except Exception as e: # JSONDecodeError 등 기타 예외 debug_log(f"알 수 없는 오류 발생 (fetch_blog_count for '{keyword}'): {e}") return 0 # 오류 발생 시 0 반환 def create_excel_file(df): if df.empty: debug_log("빈 DataFrame으로 Excel 파일을 생성하지 않습니다.") # 빈 파일을 생성하거나, None을 반환하여 Gradio에서 처리하도록 할 수 있음 # 여기서는 빈 임시 파일을 생성하여 반환 (Gradio File 컴포넌트가 경로를 기대하므로) with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp: excel_path = tmp.name # 빈 엑셀 파일에 헤더만이라도 써주려면 # pd.DataFrame(columns=df.columns).to_excel(excel_path, index=False) # 아니면 그냥 빈 파일을 반환 return excel_path try: with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False, mode='w+b') as tmp: excel_path = tmp.name df.to_excel(excel_path, index=False, engine='openpyxl') debug_log(f"Excel 파일 생성됨: {excel_path}") return excel_path except Exception as e: debug_log(f"Excel 파일 생성 중 오류: {e}") # 오류 발생 시 빈 파일 경로라도 반환 (Gradio 호환성) with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp: return tmp.name def process_keyword(keywords: str, include_related: bool): debug_log(f"process_keyword 호출 시작, 키워드들: '{keywords[:100]}...', 연관검색어 포함: {include_related}") input_keywords_orig = [k.strip() for k in keywords.splitlines() if k.strip()] if not input_keywords_orig: debug_log("입력된 키워드가 없습니다.") return pd.DataFrame(columns=["정보키워드", "PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수"]), "" all_related_keywords_dfs = [] # 1. fetch_related_keywords 병렬 처리 debug_log(f"연관 키워드 조회 병렬 처리 시작 (최대 작업자 수: {MAX_WORKERS_RELATED_KEYWORDS})") with ThreadPoolExecutor(max_workers=MAX_WORKERS_RELATED_KEYWORDS) as executor: future_to_keyword_related = { executor.submit(fetch_related_keywords, kw): kw for kw in input_keywords_orig } for i, future in enumerate(as_completed(future_to_keyword_related)): kw = future_to_keyword_related[future] try: df_kw_related = future.result() # DataFrame 반환 if not df_kw_related.empty: # 원본 키워드가 결과에 포함되어 있는지 확인하고, 없으면 추가 시도 (API가 항상 relKeyword로 자신을 주진 않음) # 하지만 fetch_related_keywords에서 이미 hintKeyword를 기반으로 검색하므로, # 일반적으로는 해당 키워드 정보가 있거나, 연관 키워드만 나옴. # 여기서는 API 응답을 그대로 활용. # 첫 번째 입력 키워드이고, 연관 키워드 포함 옵션이 켜져 있으면 모든 연관 키워드를 추가 # 그 외의 경우에는 해당 키워드 자체의 정보만 (있다면) 사용하거나, 최상단 키워드 사용 if include_related and kw == input_keywords_orig[0]: all_related_keywords_dfs.append(df_kw_related) debug_log(f"첫 번째 키워드 '{kw}'의 모든 연관 키워드 ({len(df_kw_related)}개) 추가됨.") else: # 해당 키워드와 일치하는 행을 찾거나, 없으면 API가 반환한 첫번째 행을 사용 row_kw = df_kw_related[df_kw_related["정보키워드"] == kw] if not row_kw.empty: all_related_keywords_dfs.append(row_kw) debug_log(f"키워드 '{kw}'의 직접 정보 추가됨.") elif not df_kw_related.empty : # 직접 정보는 없지만 연관 키워드는 있을 때 all_related_keywords_dfs.append(df_kw_related.head(1)) # 가장 연관성 높은 키워드 추가 debug_log(f"키워드 '{kw}'의 직접 정보는 없으나, 가장 연관성 높은 키워드 1개 추가됨.") # else: 키워드 정보도, 연관 정보도 없을 때 (df_kw_related가 비어있음) debug_log(f"'{kw}' 연관 키워드 처리 완료 ({i+1}/{len(input_keywords_orig)})") except Exception as e: debug_log(f"'{kw}' 연관 키워드 조회 중 병렬 작업 오류: {e}") if not all_related_keywords_dfs: debug_log("연관 키워드 조회 결과가 모두 비어있습니다.") # 빈 DataFrame에 블로그 문서수 컬럼 추가 empty_df = pd.DataFrame(columns=["정보키워드", "PC월검색량", "모바일월검색량", "토탈월검색량"]) empty_df["블로그문서수"] = None return empty_df, create_excel_file(empty_df) result_df = pd.concat(all_related_keywords_dfs, ignore_index=True) result_df.drop_duplicates(subset=["정보키워드"], inplace=True) # 중복 제거 debug_log(f"연관 키워드 병렬 처리 완료. 통합된 DataFrame shape: {result_df.shape}") # 2. fetch_blog_count 병렬 처리 keywords_for_blog_count = result_df["정보키워드"].dropna().unique().tolist() blog_counts_map = {} if keywords_for_blog_count: debug_log(f"블로그 문서 수 조회 병렬 처리 시작 (키워드 {len(keywords_for_blog_count)}개, 최대 작업자 수: {MAX_WORKERS_BLOG_COUNT})") with ThreadPoolExecutor(max_workers=MAX_WORKERS_BLOG_COUNT) as executor: future_to_keyword_blog = { executor.submit(fetch_blog_count, kw): kw for kw in keywords_for_blog_count } for i, future in enumerate(as_completed(future_to_keyword_blog)): kw = future_to_keyword_blog[future] try: count = future.result() # 숫자 반환 blog_counts_map[kw] = count if (i+1) % 50 == 0: # 너무 많은 로그 방지 debug_log(f"블로그 수 조회 진행 중... ({i+1}/{len(keywords_for_blog_count)})") except Exception as e: debug_log(f"'{kw}' 블로그 수 조회 중 병렬 작업 오류: {e}") blog_counts_map[kw] = 0 # 오류 시 0으로 처리 result_df["블로그문서수"] = result_df["정보키워드"].map(blog_counts_map).fillna(0).astype(int) debug_log("블로그 문서 수 병렬 처리 완료.") else: result_df["블로그문서수"] = 0 # 조회할 키워드가 없으면 0으로 채움 result_df.sort_values(by="토탈월검색량", ascending=False, inplace=True) debug_log(f"process_keyword 최종 완료. DataFrame shape: {result_df.shape}") # 최종 컬럼 순서 및 존재 여부 확인 final_columns = ["정보키워드", "PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수"] for col in final_columns: if col not in result_df.columns: result_df[col] = 0 if col != "정보키워드" else "" # 없는 컬럼은 기본값으로 채움 result_df = result_df[final_columns] # 컬럼 순서 고정 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 pd.DataFrame(columns=["단어", "빈도수", "PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수"]), "" if remove_freq1: before_count = len(df_freq) df_freq = df_freq[df_freq["빈도수"] > 1].copy() # .copy() 추가 debug_log(f"빈도수 1 제거 적용됨. {before_count} -> {len(df_freq)}") if df_freq.empty: debug_log("빈도수 1 제거 후 데이터가 없습니다.") return pd.DataFrame(columns=["단어", "빈도수", "PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수"]), "" keywords_from_morph = "\n".join(df_freq["단어"].tolist()) debug_log(f"형태소 분석 기반 키워드 ({len(df_freq['단어'])}개)에 대한 정보 조회 시작") # process_keyword는 연관 키워드를 포함하지 않도록 호출 (include_related=False) df_keyword_info, _ = process_keyword(keywords_from_morph, include_related=False) debug_log("형태소 분석 키워드에 대한 검색량 및 블로그문서수 조회 완료") if df_keyword_info.empty: debug_log("형태소 분석 키워드에 대한 API 정보 조회 결과가 없습니다.") # df_freq에 빈 컬럼들 추가 for col in ["PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수"]: df_freq[col] = None merged_df = df_freq else: merged_df = pd.merge(df_freq, df_keyword_info, left_on="단어", right_on="정보키워드", how="left") if "정보키워드" in merged_df.columns: # merge 후 정보키워드 컬럼이 생겼다면 삭제 merged_df.drop(columns=["정보키워드"], inplace=True, errors='ignore') # 누락된 컬럼 기본값으로 채우기 expected_cols = ["단어", "빈도수", "PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수"] for col in expected_cols: if col not in merged_df.columns: merged_df[col] = None if col not in ["빈도수"] else 0 merged_df = merged_df[expected_cols] # 컬럼 순서 고정 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 함수 시작") direct_keywords_list = [kw.strip() for kw in re.split(r'[\n,]+', keyword_input) if kw.strip()] debug_log(f"입력된 직접 키워드 목록: {direct_keywords_list}") if not direct_keywords_list: debug_log("직접 입력된 키워드가 없습니다.") return pd.DataFrame(columns=["키워드", "빈도수"]), "" # 1. 본문 내 빈도수 계산 results_freq = [] for kw in direct_keywords_list: count = text.count(kw) # 대소문자 구분, 정확한 문자열 카운트 results_freq.append({"키워드": kw, "빈도수": count}) debug_log(f"직접 키워드 '{kw}'의 본문 내 빈도수: {count}") df_direct_freq = pd.DataFrame(results_freq) # 2. API를 통해 검색량 및 블로그 수 조회 (병렬 처리된 process_keyword 사용) # 여기서는 각 직접 키워드에 대한 정보만 필요하므로 include_related=False keywords_for_api = "\n".join(direct_keywords_list) df_direct_api_info, _ = process_keyword(keywords_for_api, include_related=False) # 3. 빈도수 결과와 API 결과 병합 if not df_direct_api_info.empty: # API 결과의 '정보키워드'를 '키워드'로 변경하여 병합 기준 통일 df_direct_api_info.rename(columns={"정보키워드": "키워드"}, inplace=True) merged_df = pd.merge(df_direct_freq, df_direct_api_info, on="키워드", how="left") else: merged_df = df_direct_freq for col in ["PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수"]: merged_df[col] = None # API 정보가 없을 경우 빈 컬럼 추가 # 컬럼 순서 및 기본값 정리 final_cols = ["키워드", "빈도수", "PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수"] for col in final_cols: if col not in merged_df.columns: merged_df[col] = 0 if col != "키워드" else "" merged_df = merged_df[final_cols] excel_path = create_excel_file(merged_df) debug_log("direct_keyword_analysis 함수 완료") return merged_df, excel_path # --- 통합 분석 (형태소 분석 + 직접 키워드 분석) --- def combined_analysis(blog_text: str, remove_freq1: bool, direct_keyword_input: str): debug_log("combined_analysis 함수 시작") # 1. 형태소 분석 기반 결과 (API 정보 포함) df_morph, _ = morphological_analysis_and_enrich(blog_text, remove_freq1) # df_morph 컬럼: "단어", "빈도수", "PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수" # 2. 직접 입력 키워드 처리 direct_keywords_list = [kw.strip() for kw in re.split(r'[\n,]+', direct_keyword_input) if kw.strip()] debug_log(f"통합 분석 - 입력된 직접 키워드: {direct_keywords_list}") if not direct_keywords_list: # 직접 입력 키워드가 없으면 형태소 분석 결과만 반환 if "직접입력" not in df_morph.columns and not df_morph.empty: df_morph["직접입력"] = "" # 직접입력 컬럼 추가 # 컬럼 순서 조정 cols = ["단어", "빈도수", "PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수", "직접입력"] for col in cols: if col not in df_morph.columns: df_morph[col] = "" if col == "직접입력" else (0 if col != "단어" else "") df_morph = df_morph[cols] return df_morph, create_excel_file(df_morph) # 직접 입력 키워드에 대한 정보 (빈도수, API 정보) 가져오기 # direct_keyword_analysis는 "키워드" 컬럼을 사용하므로, df_morph의 "단어"와 통일 필요 df_direct_raw, _ = direct_keyword_analysis(blog_text, direct_keyword_input) # df_direct_raw 컬럼: "키워드", "빈도수", "PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수" df_direct_raw.rename(columns={"키워드": "단어"}, inplace=True) # 컬럼명 통일 # 형태소 분석 결과에 '직접입력' 표기 if not df_morph.empty: df_morph["직접입력"] = df_morph["단어"].apply(lambda x: "직접입력" if x in direct_keywords_list else "") else: # 형태소 분석 결과가 비어있을 수 있음 df_morph = pd.DataFrame(columns=["단어", "빈도수", "PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수", "직접입력"]) # 직접 입력된 키워드 중 형태소 분석 결과에 없는 것들을 추가 # df_direct_raw에는 모든 직접 입력 키워드의 정보가 있음 # df_morph와 df_direct_raw를 합치되, '단어' 기준으로 중복 처리 # 먼저 df_direct_raw에 '직접입력' 컬럼을 추가하고 "직접입력"으로 채움 df_direct_raw["직접입력"] = "직접입력" # df_morph에 있는 단어는 df_morph 정보를 우선 사용 (직접입력 플래그만 업데이트) # df_direct_raw에서 df_morph에 없는 단어만 골라서 추가 # 합치기: df_morph를 기준으로 df_direct_raw의 정보를 추가/업데이트 # Pandas 0.25.0 이상에서는 combine_first의 overwrite 동작이 약간 다를 수 있으므로 merge 사용 고려 # 1. df_morph의 단어들에 대해 df_direct_raw의 정보로 업데이트 (API 정보 등) # 단, 빈도수는 각자 계산한 것을 유지할지, 아니면 한쪽을 택할지 결정 필요. # 여기서는 df_morph의 빈도수(형태소분석 기반)와 df_direct_raw의 빈도수(단순 count)가 다를 수 있음. # 일단은 df_morph 기준으로 하고, 없는 직접 키워드만 df_direct_raw에서 추가하는 방식. # df_morph의 '직접입력' 컬럼은 이미 위에서 처리됨. # 이제 df_direct_raw에만 있는 키워드를 df_morph에 추가 # df_morph에 있는 단어 목록 morph_words = df_morph['단어'].tolist() if not df_morph.empty else [] rows_to_add = [] for idx, row in df_direct_raw.iterrows(): if row['단어'] not in morph_words: rows_to_add.append(row) if rows_to_add: df_to_add = pd.DataFrame(rows_to_add) combined_df = pd.concat([df_morph, df_to_add], ignore_index=True) else: combined_df = df_morph.copy() # df_morph가 비어있을 수도 있음 # 최종 컬럼 정리 및 순서 final_cols_combined = ["단어", "빈도수", "PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수", "직접입력"] for col in final_cols_combined: if col not in combined_df.columns: # 기본값 설정: '직접입력'은 "", 나머지는 0 또는 None (API 값은 None 허용) if col == "직접입력": combined_df[col] = "" elif col == "빈도수": combined_df[col] = 0 elif col == "단어": combined_df[col] = "" else: # API 관련 컬럼 combined_df[col] = None # pd.NA도 가능 # NA 값들을 적절히 처리 (예: 0으로 채우거나 그대로 두기) # API 값들은 숫자가 아닐 수 있으므로 (예: "< 10"), process_keyword에서 처리됨. 여기서는 int형 변환 전이므로 그대로 둠. # Gradio Dataframe은 None을 잘 표시함. # 빈도수는 정수형이어야 함 if "빈도수" in combined_df.columns: combined_df["빈도수"] = combined_df["빈도수"].fillna(0).astype(int) combined_df = combined_df[final_cols_combined].drop_duplicates(subset=['단어'], keep='first') # 만약을 위한 중복 제거 combined_df.sort_values(by=["직접입력", "빈도수"], ascending=[False, False], inplace=True, na_position='last') # 직접입력 우선, 그 다음 빈도수 combined_df.reset_index(drop=True, inplace=True) combined_excel = create_excel_file(combined_df) debug_log("combined_analysis 함수 완료") return combined_df, combined_excel # --- 분석 핸들러 --- def analysis_handler(blog_text: str, remove_freq1: bool, direct_keyword_input: str, direct_keyword_only: bool): debug_log(f"analysis_handler 함수 시작. 직접 키워드만 분석: {direct_keyword_only}") start_time = time.time() if not blog_text or blog_text.strip() == "스크래핑된 블로그 내용이 여기에 표시됩니다." or blog_text.strip() == "": debug_log("분석할 블로그 내용이 없습니다.") # 빈 결과를 반환하기 위한 DataFrame 구조 명시 empty_cols_direct = ["키워드", "빈도수", "PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수"] empty_cols_combined = ["단어", "빈도수", "PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수", "직접입력"] df_empty = pd.DataFrame(columns=empty_cols_direct if direct_keyword_only else empty_cols_combined) return df_empty, create_excel_file(df_empty) if direct_keyword_only: # "직접 키워드 입력만 분석" 선택 시 단독 분석 수행 if not direct_keyword_input or not direct_keyword_input.strip(): debug_log("직접 키워드만 분석 선택되었으나, 입력된 직접 키워드가 없습니다.") empty_cols_direct = ["키워드", "빈도수", "PC월검색량", "모바일월검색량", "토탈월검색량", "블로그문서수"] df_empty = pd.DataFrame(columns=empty_cols_direct) return df_empty, create_excel_file(df_empty) result_df, excel_path = direct_keyword_analysis(blog_text, direct_keyword_input) else: # 기본 통합 분석 수행 result_df, excel_path = combined_analysis(blog_text, remove_freq1, direct_keyword_input) end_time = time.time() debug_log(f"analysis_handler 총 실행 시간: {end_time - start_time:.2f} 초") return result_df, excel_path # --- 스크래핑 실행 --- def fetch_blog_content(url: str): debug_log("fetch_blog_content 함수 시작") if not url or not url.strip(): return "블로그 URL을 입력해주세요." if not url.startswith("http://") and not url.startswith("https://"): return "유효한 URL 형식(http:// 또는 https://)으로 입력해주세요." start_time = time.time() content = scrape_naver_blog(url) end_time = time.time() debug_log(f"fetch_blog_content 총 실행 시간: {end_time - start_time:.2f} 초. 내용 길이: {len(content)}") return content # --- Custom CSS --- custom_css = """ /* 전체 컨테이너 스타일 */ .gradio-container { max-width: 1080px; /* 너비 확장 */ margin: auto; font-family: 'Helvetica Neue', Arial, sans-serif; background: #f5f7fa; padding: 2rem; } /* 헤더 스타일 */ .custom-header { text-align: center; font-size: 2.5rem; font-weight: bold; margin-bottom: 1.5rem; color: #333; } /* 그룹 박스 스타일 */ .custom-group { background: #ffffff; border-radius: 8px; padding: 1.5rem; box-shadow: 0 2px 8px rgba(0,0,0,0.1); margin-bottom: 1.5rem; } /* 버튼 스타일 */ .custom-button { background-color: #007bff; color: #fff; border: none; border-radius: 4px; padding: 0.6rem 1.2rem; font-size: 1rem; cursor: pointer; min-width: 150px; /* 버튼 최소 너비 */ } .custom-button:hover { background-color: #0056b3; } /* 체크박스 스타일 */ .custom-checkbox { margin-right: 1rem; } /* 결과 테이블 및 다운로드 버튼 */ .custom-result { margin-top: 1.5rem; } /* 가운데 정렬 */ .centered { display: flex; justify-content: center; align-items: center; } """ # --- Gradio 인터페이스 구성 --- with gr.Blocks(title="네이버 블로그 키워드 분석 서비스", css=custom_css) as demo: gr.HTML("
네이버 블로그 키워드 분석 서비스
") with gr.Row(): with gr.Column(scale=2): # 왼쪽 컬럼 (입력 영역) with gr.Group(elem_classes="custom-group"): blog_url_input = gr.Textbox( label="네이버 블로그 링크", placeholder="예: https://blog.naver.com/아이디/글번호", lines=1, info="분석할 네이버 블로그 게시물 URL을 입력하세요." ) with gr.Row(elem_classes="centered"): scrape_button = gr.Button("블로그 내용 가져오기", elem_classes="custom-button", variant="primary") with gr.Group(elem_classes="custom-group"): blog_content_box = gr.Textbox( label="블로그 내용 (수정 가능)", lines=10, placeholder="스크래핑된 블로그 내용이 여기에 표시됩니다. 직접 수정하거나 붙여넣을 수 있습니다." ) with gr.Group(elem_classes="custom-group"): gr.Markdown("### 분석 옵션 설정") with gr.Row(): remove_freq_checkbox = gr.Checkbox( label="빈도수 1인 단어 제거 (형태소 분석 시)", value=True, elem_classes="custom-checkbox", info="형태소 분석 결과에서 빈도수가 1인 단어를 제외합니다." ) with gr.Row(): direct_keyword_only_checkbox = gr.Checkbox( label="직접 키워드만 분석", value=False, elem_classes="custom-checkbox", info="이 옵션을 선택하면 아래 입력한 직접 키워드에 대해서만 분석을 수행합니다 (형태소 분석 생략)." ) with gr.Row(): direct_keyword_box = gr.Textbox( label="직접 키워드 입력 (엔터 또는 ','로 구분)", lines=3, placeholder="예: 키워드1, 키워드2\n키워드3\n...\n(형태소 분석 결과와 별도로 분석하거나, 통합 분석에 추가할 키워드)", info="분석에 포함하거나 단독으로 분석할 키워드를 직접 입력합니다." ) with gr.Group(elem_classes="custom-group"): with gr.Row(elem_classes="centered"): analyze_button = gr.Button("키워드 분석 실행", elem_classes="custom-button", variant="primary") with gr.Column(scale=3): # 오른쪽 컬럼 (결과 영역) with gr.Group(elem_classes="custom-group custom-result"): gr.Markdown("### 분석 결과") result_df_display = gr.Dataframe( label="통합 분석 결과 (단어, 빈도수, 검색량, 블로그문서수, 직접입력 여부)", interactive=False, # 사용자가 직접 수정 불가 height=600, # 높이 조절 wrap=True # 긴 텍스트 줄바꿈 ) with gr.Group(elem_classes="custom-group"): gr.Markdown("### 결과 다운로드") excel_file_display = gr.File(label="분석 결과 Excel 파일 다운로드") # 이벤트 연결 scrape_button.click(fn=fetch_blog_content, inputs=blog_url_input, outputs=blog_content_box) analyze_button.click( fn=analysis_handler, inputs=[blog_content_box, remove_freq_checkbox, direct_keyword_box, direct_keyword_only_checkbox], outputs=[result_df_display, excel_file_display] ) if __name__ == "__main__": # 환경 변수 설정 예시 (실제 실행 시에는 시스템 환경 변수로 설정하거나, .env 파일 등을 사용) # os.environ["NAVER_API_KEY"] = "YOUR_NAVER_API_KEY" # os.environ["NAVER_SECRET_KEY"] = "YOUR_NAVER_SECRET_KEY" # os.environ["NAVER_CUSTOMER_ID"] = "YOUR_NAVER_CUSTOMER_ID" # os.environ["NAVER_SEARCH_CLIENT_ID"] = "YOUR_NAVER_SEARCH_CLIENT_ID" # os.environ["NAVER_SEARCH_CLIENT_SECRET"] = "YOUR_NAVER_SEARCH_CLIENT_SECRET" # 환경 변수 설정 확인 required_env_vars = [ "NAVER_API_KEY", "NAVER_SECRET_KEY", "NAVER_CUSTOMER_ID", "NAVER_SEARCH_CLIENT_ID", "NAVER_SEARCH_CLIENT_SECRET" ] missing_vars = [var for var in required_env_vars if not os.environ.get(var)] if missing_vars: debug_log(f"경고: 다음 필수 환경 변수가 설정되지 않았습니다 - {', '.join(missing_vars)}") debug_log("API 호출 기능이 정상적으로 동작하지 않을 수 있습니다.") debug_log("스크립트 실행 전에 해당 환경 변수를 설정해주세요.") # Gradio 앱은 실행하되, API 호출 시 오류가 발생할 수 있음을 사용자에게 알림. debug_log("Gradio 앱 실행 시작") demo.launch(debug=True) # 개발 중에는 debug=True로 설정하여 오류 확인 용이 debug_log("Gradio 앱 실행 종료")