import gradio as gr import pandas as pd import os import time import threading import tempfile import logging import random import uuid import shutil import glob from datetime import datetime from gradio_client import Client # 로깅 설정 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.StreamHandler(), logging.FileHandler('control_tower_app.log', mode='a') ] ) logger = logging.getLogger(__name__) # API 클라이언트 설정 def get_client(): # 환경변수에서 API 엔드포인트 읽기 endpoint = os.environ.get('API_ENDPOINT', '') # 환경변수에 파일 내용이나 불필요한 텍스트가 들어온 경우 정리 if endpoint: # 줄바꿈으로 분리해서 첫 번째 유효한 라인 찾기 lines = endpoint.split('\n') for line in lines: line = line.strip() # 주석이나 비어있는 라인 제외 if line and not line.startswith('#') and '/' in line: # API_ENDPOINT= 같은 키값 제거 if '=' in line: line = line.split('=', 1)[1].strip() # 따옴표 제거 line = line.strip('"\'') if line and '/' in line and len(line) < 50: return Client(line) raise ValueError("올바른 API_ENDPOINT를 설정해주세요 (예: username/repo-name)") # 세션별 임시 파일 관리를 위한 딕셔너리 session_temp_files = {} session_data = {} def cleanup_huggingface_temp_folders(): """허깅페이스 임시 폴더 초기 정리""" try: temp_dirs = [ tempfile.gettempdir(), "/tmp", "/var/tmp", os.path.join(os.getcwd(), "temp"), os.path.join(os.getcwd(), "tmp"), "/gradio_cached_examples", "/flagged" ] cleanup_count = 0 for temp_dir in temp_dirs: if os.path.exists(temp_dir): try: session_files = glob.glob(os.path.join(temp_dir, "session_*.xlsx")) session_files.extend(glob.glob(os.path.join(temp_dir, "session_*.csv"))) session_files.extend(glob.glob(os.path.join(temp_dir, "*keyword*.xlsx"))) session_files.extend(glob.glob(os.path.join(temp_dir, "*keyword*.csv"))) session_files.extend(glob.glob(os.path.join(temp_dir, "tmp*.xlsx"))) session_files.extend(glob.glob(os.path.join(temp_dir, "tmp*.csv"))) for file_path in session_files: try: if os.path.getmtime(file_path) < time.time() - 3600: os.remove(file_path) cleanup_count += 1 logger.info(f"초기 정리: 오래된 임시 파일 삭제 - {file_path}") except Exception as e: logger.warning(f"파일 삭제 실패 (무시됨): {file_path} - {e}") except Exception as e: logger.warning(f"임시 디렉토리 정리 실패 (무시됨): {temp_dir} - {e}") logger.info(f"✅ 허깅페이스 임시 폴더 초기 정리 완료 - {cleanup_count}개 파일 삭제") try: gradio_temp_dir = os.path.join(os.getcwd(), "gradio_cached_examples") if os.path.exists(gradio_temp_dir): shutil.rmtree(gradio_temp_dir, ignore_errors=True) logger.info("Gradio 캐시 폴더 정리 완료") except Exception as e: logger.warning(f"Gradio 캐시 폴더 정리 실패 (무시됨): {e}") except Exception as e: logger.error(f"초기 임시 폴더 정리 중 오류 (계속 진행): {e}") def setup_clean_temp_environment(): """깨끗한 임시 환경 설정""" try: cleanup_huggingface_temp_folders() app_temp_dir = os.path.join(tempfile.gettempdir(), "keyword_app") if os.path.exists(app_temp_dir): shutil.rmtree(app_temp_dir, ignore_errors=True) os.makedirs(app_temp_dir, exist_ok=True) os.environ['KEYWORD_APP_TEMP'] = app_temp_dir logger.info(f"✅ 애플리케이션 전용 임시 디렉토리 설정: {app_temp_dir}") return app_temp_dir except Exception as e: logger.error(f"임시 환경 설정 실패: {e}") return tempfile.gettempdir() def get_app_temp_dir(): """애플리케이션 전용 임시 디렉토리 반환""" return os.environ.get('KEYWORD_APP_TEMP', tempfile.gettempdir()) def get_session_id(): """세션 ID 생성""" return str(uuid.uuid4()) def cleanup_session_files(session_id, delay=300): """세션별 임시 파일 정리 함수""" def cleanup(): time.sleep(delay) if session_id in session_temp_files: files_to_remove = session_temp_files[session_id].copy() del session_temp_files[session_id] for file_path in files_to_remove: try: if os.path.exists(file_path): os.remove(file_path) logger.info(f"세션 {session_id[:8]}... 임시 파일 삭제: {file_path}") except Exception as e: logger.error(f"세션 {session_id[:8]}... 파일 삭제 오류: {e}") threading.Thread(target=cleanup, daemon=True).start() def register_session_file(session_id, file_path): """세션별 파일 등록""" if session_id not in session_temp_files: session_temp_files[session_id] = [] session_temp_files[session_id].append(file_path) def cleanup_old_sessions(): """오래된 세션 데이터 정리""" current_time = time.time() sessions_to_remove = [] for session_id, data in session_data.items(): if current_time - data.get('last_activity', 0) > 3600: sessions_to_remove.append(session_id) for session_id in sessions_to_remove: if session_id in session_temp_files: for file_path in session_temp_files[session_id]: try: if os.path.exists(file_path): os.remove(file_path) logger.info(f"오래된 세션 {session_id[:8]}... 파일 삭제: {file_path}") except Exception as e: logger.error(f"오래된 세션 파일 삭제 오류: {e}") del session_temp_files[session_id] if session_id in session_data: del session_data[session_id] logger.info(f"오래된 세션 데이터 삭제: {session_id[:8]}...") def update_session_activity(session_id): """세션 활동 시간 업데이트""" if session_id not in session_data: session_data[session_id] = {} session_data[session_id]['last_activity'] = time.time() def create_session_temp_file(session_id, suffix='.xlsx'): """세션별 임시 파일 생성 (전용 디렉토리 사용)""" timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") random_suffix = str(random.randint(1000, 9999)) temp_dir = get_app_temp_dir() filename = f"session_{session_id[:8]}_{timestamp}_{random_suffix}{suffix}" temp_file_path = os.path.join(temp_dir, filename) with open(temp_file_path, 'w') as f: pass register_session_file(session_id, temp_file_path) return temp_file_path def wrapper_modified(keyword, korean_only, apply_main_keyword_option, exclude_zero_volume, session_id): """키워드 검색 및 처리 래퍼 함수 (API 사용)""" update_session_activity(session_id) try: client = get_client() result = client.predict( keyword=keyword, korean_only=korean_only, apply_main_keyword=apply_main_keyword_option, exclude_zero_volume=exclude_zero_volume, api_name="/process_search_results" ) # API 응답 확인 및 처리 logger.info(f"API 응답 타입: {type(result)}, 길이: {len(result) if isinstance(result, (list, tuple)) else 'N/A'}") if isinstance(result, (list, tuple)) and len(result) >= 5: table_html, cat_choices, vol_choices, selected_cat, download_file = result[:5] else: # 응답이 예상과 다른 경우 기본값 사용 logger.warning(f"예상과 다른 API 응답: {result}") table_html = "

검색 결과를 처리하는 중 오류가 발생했습니다.

" cat_choices = ["전체 보기"] vol_choices = ["전체"] selected_cat = "전체 보기" download_file = None local_file = None if download_file: local_file = create_session_temp_file(session_id, '.xlsx') shutil.copy(download_file, local_file) return ( gr.update(value=table_html), gr.update(choices=cat_choices), gr.update(choices=vol_choices), None, gr.update(choices=cat_choices, value=selected_cat), local_file, gr.update(visible=True), gr.update(visible=True), keyword ) except Exception as e: logger.error(f"API 호출 오류: {e}") return ( gr.update(value="

검색 결과가 없습니다. 다른 키워드로 시도해보세요.

"), gr.update(choices=["전체 보기"]), gr.update(choices=["전체"]), None, gr.update(choices=["전체 보기"], value="전체 보기"), None, gr.update(visible=False), gr.update(visible=False), keyword ) def analyze_with_auto_download(analysis_keywords, selected_category, state_df, session_id): """카테고리 일치 분석 실행 및 자동 다운로드 (API 사용)""" update_session_activity(session_id) try: client = get_client() result = client.predict( analysis_keywords=analysis_keywords, selected_category=selected_category, api_name="/process_analyze_results" ) # API 응답 확인 및 처리 logger.info(f"분석 API 응답 타입: {type(result)}, 길이: {len(result) if isinstance(result, (list, tuple)) else 'N/A'}") if isinstance(result, (list, tuple)) and len(result) >= 2: analysis_result, download_file = result[:2] elif isinstance(result, str): analysis_result = result download_file = None else: logger.warning(f"예상과 다른 분석 API 응답: {result}") analysis_result = "분석 결과를 처리하는 중 오류가 발생했습니다." download_file = None local_file = None if download_file: local_file = create_session_temp_file(session_id, '.xlsx') shutil.copy(download_file, local_file) return analysis_result, local_file, gr.update(visible=True) except Exception as e: logger.error(f"분석 API 호출 오류: {e}") return "분석 중 오류가 발생했습니다. 다시 시도해주세요.", None, gr.update(visible=False) def filter_and_sort_table(df, selected_cat, keyword_sort, total_volume_sort, usage_count_sort, selected_volume_range, exclude_zero_volume, session_id): """테이블 필터링 및 정렬 함수 (API 사용)""" update_session_activity(session_id) try: client = get_client() result = client.predict( selected_cat=selected_cat, keyword_sort=keyword_sort, total_volume_sort=total_volume_sort, usage_count_sort=usage_count_sort, selected_volume_range=selected_volume_range, exclude_zero_volume=exclude_zero_volume, api_name="/filter_and_sort_table" ) return result except Exception as e: logger.error(f"필터링 API 호출 오류: {e}") return "" def update_category_selection(selected_cat, session_id): """카테고리 필터 선택 시 분석할 카테고리도 같은 값으로 업데이트""" update_session_activity(session_id) try: client = get_client() result = client.predict( selected_cat=selected_cat, api_name="/update_category_selection" ) return gr.update(value=result) except Exception as e: logger.error(f"카테고리 선택 API 호출 오류: {e}") return gr.update(value=selected_cat) def reset_interface(session_id): """인터페이스 리셋 함수 - 세션별 데이터 초기화""" update_session_activity(session_id) if session_id in session_temp_files: for file_path in session_temp_files[session_id]: try: if os.path.exists(file_path): os.remove(file_path) logger.info(f"세션 {session_id[:8]}... 리셋 시 파일 삭제: {file_path}") except Exception as e: logger.error(f"세션 {session_id[:8]}... 리셋 시 파일 삭제 오류: {e}") session_temp_files[session_id] = [] try: client = get_client() result = client.predict(api_name="/reset_interface") return result except Exception as e: logger.error(f"리셋 API 호출 오류: {e}") return ( "", True, False, "메인키워드 적용", "", ["전체 보기"], "전체 보기", ["전체"], "전체", "정렬 없음", "정렬 없음", None, ["전체 보기"], "전체 보기", "", "", None, gr.update(visible=False), gr.update(visible=False), "" ) def search_with_loading(keyword, korean_only, apply_main_keyword, exclude_zero_volume, session_id): update_session_activity(session_id) return (gr.update(visible=True), gr.update(visible=False)) def process_search_results(keyword, korean_only, apply_main_keyword, exclude_zero_volume, session_id): update_session_activity(session_id) result = wrapper_modified(keyword, korean_only, apply_main_keyword, exclude_zero_volume, session_id) table_html, cat_choices, vol_choices, df, selected_cat, excel, keyword_section_vis, cat_section_vis, new_keyword_state = result if excel is not None: empty_placeholder_vis = False keyword_section_visibility = True execution_section_visibility = True else: empty_placeholder_vis = True keyword_section_visibility = False execution_section_visibility = False return ( table_html, cat_choices, vol_choices, df, selected_cat, excel, gr.update(visible=keyword_section_visibility), gr.update(visible=cat_section_vis), gr.update(visible=False), gr.update(visible=empty_placeholder_vis), gr.update(visible=execution_section_visibility), new_keyword_state ) def analyze_with_loading(analysis_keywords, selected_category, state_df, session_id): update_session_activity(session_id) return gr.update(visible=True) def process_analyze_results(analysis_keywords, selected_category, state_df, session_id): update_session_activity(session_id) results = analyze_with_auto_download(analysis_keywords, selected_category, state_df, session_id) return results + (gr.update(visible=False),) def start_session_cleanup_scheduler(): """세션 정리 스케줄러 시작""" def cleanup_scheduler(): while True: time.sleep(600) cleanup_old_sessions() cleanup_huggingface_temp_folders() threading.Thread(target=cleanup_scheduler, daemon=True).start() def cleanup_on_startup(): """애플리케이션 시작 시 전체 정리""" logger.info("🧹 애플리케이션 시작 - 초기 정리 작업 시작...") cleanup_huggingface_temp_folders() app_temp_dir = setup_clean_temp_environment() global session_temp_files, session_data session_temp_files.clear() session_data.clear() logger.info(f"✅ 초기 정리 작업 완료 - 앱 전용 디렉토리: {app_temp_dir}") return app_temp_dir def create_app(): fontawesome_html = """ """ try: with open('style.css', 'r', encoding='utf-8') as f: custom_css = f.read() except: custom_css = "" with gr.Blocks(css=custom_css, theme=gr.themes.Default( primary_hue="orange", secondary_hue="orange", font=[gr.themes.GoogleFont("Noto Sans KR"), "ui-sans-serif", "system-ui"] )) as demo: gr.HTML(fontawesome_html) session_id = gr.State(get_session_id) keyword_state = gr.State("") with gr.Column(elem_classes="custom-frame fade-in"): gr.HTML('
검색 입력
') with gr.Row(): with gr.Column(scale=1): keyword = gr.Textbox(label="메인 키워드", placeholder="예: 오징어") with gr.Column(scale=1): search_btn = gr.Button("메인키워드 분석", elem_classes="custom-button") with gr.Accordion("옵션 설정", open=False): with gr.Row(): with gr.Column(scale=1): korean_only = gr.Checkbox(label="한글만 추출", value=True) with gr.Column(scale=1): exclude_zero_volume = gr.Checkbox(label="검색량 0 키워드 제외", value=False) with gr.Row(): with gr.Column(scale=1): apply_main_keyword = gr.Radio( ["메인키워드 적용", "메인키워드 미적용"], label="조합 방식", value="메인키워드 적용" ) with gr.Column(scale=1): gr.HTML("") with gr.Column(elem_classes="custom-frame fade-in", visible=False) as progress_section: gr.HTML('
분석 진행 상태
') progress_html = gr.HTML("""
키워드 데이터를 분석중입니다. 잠시만 기다려주세요...
""") with gr.Column(elem_classes="custom-frame fade-in") as main_keyword_section: gr.HTML('
메인키워드 분석 결과
') empty_table_html = gr.HTML("""
순번 조합 키워드 PC검색량 모바일검색량 총검색량 검색량구간 키워드 사용자순위 키워드 사용횟수 상품 등록 카테고리
검색을 실행하면 여기에 결과가 표시됩니다
""") with gr.Column(visible=False) as keyword_analysis_section: with gr.Row(): with gr.Column(scale=1): category_filter = gr.Dropdown( choices=["전체 보기"], label="카테고리 필터", value="전체 보기", interactive=True ) with gr.Column(scale=1): total_volume_sort = gr.Dropdown( choices=["정렬 없음", "오름차순", "내림차순"], label="총검색량 정렬", value="정렬 없음", interactive=True ) with gr.Row(): with gr.Column(scale=1): search_volume_filter = gr.Dropdown( choices=["전체"], label="검색량 구간 필터", value="전체", interactive=True ) with gr.Column(scale=1): usage_count_sort = gr.Dropdown( choices=["정렬 없음", "오름차순", "내림차순"], label="키워드 사용횟수 정렬", value="정렬 없음", interactive=True ) gr.HTML("
") table_output = gr.HTML(elem_classes="fade-in") with gr.Column(elem_classes="custom-frame fade-in", visible=False) as category_analysis_section: gr.HTML('
키워드 분석
') with gr.Row(): with gr.Column(scale=1): analysis_keywords = gr.Textbox( label="키워드 입력 (최대 20개, 쉼표 또는 엔터로 구분)", placeholder="예: 오징어볶음, 오징어 손질, 오징어 요리...", lines=5 ) with gr.Column(scale=1): selected_category = gr.Dropdown( label="분석할 카테고리(분석 전 반드시 선택해주세요)", choices=["전체 보기"], value="전체 보기", interactive=True ) with gr.Column(elem_classes="execution-section", visible=False) as execution_section: gr.HTML('
실행
') with gr.Row(): with gr.Column(scale=1): analyze_btn = gr.Button( "카테고리 일치 분석", elem_classes=["execution-button", "primary-button"] ) with gr.Column(scale=1): reset_btn = gr.Button( "모든 입력 초기화", elem_classes=["execution-button", "secondary-button"] ) with gr.Column(elem_classes="custom-frame fade-in", visible=False) as analysis_output_section: gr.HTML('
분석 결과 요약
') analysis_result = gr.HTML(elem_classes="fade-in") with gr.Row(): download_output = gr.File(label="키워드 목록 다운로드", visible=True) state_df = gr.State() search_btn.click( fn=search_with_loading, inputs=[keyword, korean_only, apply_main_keyword, exclude_zero_volume, session_id], outputs=[progress_section, empty_table_html] ).then( fn=process_search_results, inputs=[keyword, korean_only, apply_main_keyword, exclude_zero_volume, session_id], outputs=[ table_output, category_filter, search_volume_filter, state_df, selected_category, download_output, keyword_analysis_section, category_analysis_section, progress_section, empty_table_html, execution_section, keyword_state ] ) category_filter.change( fn=filter_and_sort_table, inputs=[ state_df, category_filter, gr.Textbox(value="정렬 없음", visible=False), total_volume_sort, usage_count_sort, search_volume_filter, exclude_zero_volume, session_id ], outputs=[table_output] ) category_filter.change( fn=update_category_selection, inputs=[category_filter, session_id], outputs=[selected_category] ) total_volume_sort.change( fn=filter_and_sort_table, inputs=[ state_df, category_filter, gr.Textbox(value="정렬 없음", visible=False), total_volume_sort, usage_count_sort, search_volume_filter, exclude_zero_volume, session_id ], outputs=[table_output] ) usage_count_sort.change( fn=filter_and_sort_table, inputs=[ state_df, category_filter, gr.Textbox(value="정렬 없음", visible=False), total_volume_sort, usage_count_sort, search_volume_filter, exclude_zero_volume, session_id ], outputs=[table_output] ) search_volume_filter.change( fn=filter_and_sort_table, inputs=[ state_df, category_filter, gr.Textbox(value="정렬 없음", visible=False), total_volume_sort, usage_count_sort, search_volume_filter, exclude_zero_volume, session_id ], outputs=[table_output] ) exclude_zero_volume.change( fn=filter_and_sort_table, inputs=[ state_df, category_filter, gr.Textbox(value="정렬 없음", visible=False), total_volume_sort, usage_count_sort, search_volume_filter, exclude_zero_volume, session_id ], outputs=[table_output] ) analyze_btn.click( fn=analyze_with_loading, inputs=[analysis_keywords, selected_category, state_df, session_id], outputs=[progress_section] ).then( fn=process_analyze_results, inputs=[analysis_keywords, selected_category, state_df, session_id], outputs=[analysis_result, download_output, analysis_output_section, progress_section] ) reset_btn.click( fn=reset_interface, inputs=[session_id], outputs=[ keyword, korean_only, exclude_zero_volume, apply_main_keyword, table_output, category_filter, category_filter, search_volume_filter, search_volume_filter, total_volume_sort, usage_count_sort, state_df, selected_category, selected_category, analysis_keywords, analysis_result, download_output, keyword_analysis_section, analysis_output_section, keyword_state ] ) return demo if __name__ == "__main__": logger.info("🚀 메인키워드 분석 애플리케이션 시작...") app_temp_dir = cleanup_on_startup() start_session_cleanup_scheduler() logger.info("===== 멀티유저 메인키워드 분석 Application Startup at %s =====", time.strftime("%Y-%m-%d %H:%M:%S")) logger.info(f"📁 임시 파일 저장 위치: {app_temp_dir}") try: app = create_app() app.launch( share=False, server_name="0.0.0.0", server_port=7860, max_threads=40, auth=None, show_error=True, quiet=False, favicon_path=None, ssl_verify=False ) except Exception as e: logger.error(f"애플리케이션 실행 실패: {e}") raise finally: logger.info("🧹 애플리케이션 종료 - 최종 정리 작업...") try: cleanup_huggingface_temp_folders() if os.path.exists(app_temp_dir): shutil.rmtree(app_temp_dir, ignore_errors=True) logger.info("✅ 최종 정리 완료") except Exception as e: logger.error(f"최종 정리 중 오류: {e}")