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 # 디버깅(로그)용 함수 def debug_log(message: str): print(f"[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) 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 태그를 찾을 수 없습니다.") return "본문 iframe을 찾을 수 없습니다." iframe_src = iframe.get("src") if not iframe_src: debug_log("iframe src가 존재하지 않습니다.") return "본문 iframe의 src를 찾을 수 없습니다." 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) 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_div = iframe_soup.select_one('.se-module.se-module-text.se-title-text') title = title_div.get_text(strip=True) if title_div else "제목을 찾을 수 없습니다." debug_log(f"추출된 제목: {title}") content_div = iframe_soup.select_one('.se-main-container') if content_div: content = content_div.get_text("\n", strip=True) else: content = "본문을 찾을 수 없습니다." debug_log("본문 추출 완료") result = f"[제목]\n{title}\n\n[본문]\n{content}" debug_log("제목과 본문 합침 완료") return result 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 관련 (참조코드-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 } def fetch_related_keywords(keyword): debug_log(f"fetch_related_keywords 호출, 키워드: {keyword}") API_KEY = os.environ["NAVER_API_KEY"] SECRET_KEY = os.environ["NAVER_SECRET_KEY"] CUSTOMER_ID = os.environ["NAVER_CUSTOMER_ID"] BASE_URL = "https://api.naver.com" uri = "/keywordstool" method = "GET" headers = get_header(method, uri, API_KEY, SECRET_KEY, CUSTOMER_ID) params = { "hintKeywords": [keyword], "showDetail": "1" } response = requests.get(BASE_URL + uri, params=params, headers=headers) data = response.json() if "keywordList" not in data: return pd.DataFrame() df = pd.DataFrame(data["keywordList"]) if len(df) > 100: df = df.head(100) def parse_count(x): try: return int(str(x).replace(",", "")) except: 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) result_df = df[["정보키워드", "PC월검색량", "모바일월검색량", "토탈월검색량"]] debug_log("fetch_related_keywords 완료") return result_df def fetch_blog_count(keyword): debug_log(f"fetch_blog_count 호출, 키워드: {keyword}") client_id = os.environ["NAVER_SEARCH_CLIENT_ID"] client_secret = os.environ["NAVER_SEARCH_CLIENT_SECRET"] 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} response = requests.get(url, headers=headers, params=params) if response.status_code == 200: data = response.json() debug_log(f"fetch_blog_count 결과: {data.get('total', 0)}") return data.get("total", 0) else: debug_log(f"fetch_blog_count 오류, 상태코드: {response.status_code}") 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()] result_dfs = [] for idx, kw in enumerate(input_keywords): df_kw = fetch_related_keywords(kw) if df_kw.empty: continue row_kw = df_kw[df_kw["정보키워드"] == kw] if not row_kw.empty: result_dfs.append(row_kw) else: result_dfs.append(df_kw.head(1)) if include_related and idx == 0: df_related = df_kw[df_kw["정보키워드"] != kw] if not df_related.empty: result_dfs.append(df_related) if result_dfs: result_df = pd.concat(result_dfs, ignore_index=True) result_df.drop_duplicates(subset=["정보키워드"], inplace=True) else: result_df = pd.DataFrame(columns=["정보키워드", "PC월검색량", "모바일월검색량", "토탈월검색량"]) result_df["블로그문서수"] = result_df["정보키워드"].apply(fetch_blog_count) 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 = "\n".join(df_freq["단어"].tolist()) debug_log(f"분석된 키워드: {keywords}") df_keyword_info, _ = process_keyword(keywords, include_related=False) 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) df_direct, _ = process_keyword(dk, include_related=False) if (not df_direct.empty) and (dk in df_direct["정보키워드"].values): row = df_direct[df_direct["정보키워드"] == dk].iloc[0] pc = row.get("PC월검색량", None) mobile = row.get("모바일월검색량", None) total = row.get("토탈월검색량", None) blog_count = row.get("블로그문서수", None) else: pc = mobile = total = blog_count = None 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, direct_keyword_input: str, direct_keyword_only: bool): debug_log("analysis_handler 함수 시작") if direct_keyword_only: # 직접 키워드 분석만 수행 return direct_keyword_analysis(blog_text, direct_keyword_input) else: # 통합 분석 (형태소 분석 + 직접 키워드 분석) return combined_analysis(blog_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 인터페이스 구성 --- custom_css = """ .gradio-container { max-width: 960px; margin: auto; } .centered-button-row { justify-content: center; } """ with gr.Blocks(title="네이버 블로그 형태소 분석 스페이스", css=custom_css) as demo: gr.Markdown("# 네이버 블로그 형태소 분석 스페이스") # 블로그 링크와 스크래핑 실행 버튼을 한 그룹 내에 배치 (버튼은 가운데 정렬) with gr.Group(): blog_url_input = gr.Textbox(label="네이버 블로그 링크", placeholder="예: https://blog.naver.com/ssboost/222983068507", lines=1) with gr.Row(elem_classes="centered-button-row"): scrape_button = gr.Button("스크래핑 실행") with gr.Row(): blog_content_box = gr.Textbox(label="블로그 내용 (수정 가능)", lines=10, placeholder="스크래핑된 블로그 내용이 여기에 표시됩니다.") with gr.Row(): remove_freq_checkbox = gr.Checkbox(label="빈도수1 제거", value=True) # "빈도수1 제거" 아래에 "직접 키워드 입력만 분석" 선택 항목 추가 (기본 미선택) with gr.Row(): direct_keyword_only_checkbox = gr.Checkbox(label="직접 키워드 입력만 분석", value=False) with gr.Row(): direct_keyword_box = gr.Textbox(label="직접 키워드 입력 (엔터 또는 ','로 구분)", lines=2, placeholder="예: 키워드1, 키워드2\n키워드3") with gr.Row(): analyze_button = gr.Button("분석 실행") # 결과 테이블은 화면 전체 폭을 사용하고, Excel 다운로드 버튼은 그 아래 별도 행에 배치 with gr.Row(): result_df = gr.Dataframe(label="통합 분석 결과 (단어, 빈도수, 검색량, 블로그문서수, 직접입력)", interactive=True) with gr.Row(): excel_file = 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, excel_file]) if __name__ == "__main__": debug_log("Gradio 앱 실행 시작") demo.launch() debug_log("Gradio 앱 실행 종료")