Download export_utils.py from ssboost/3gghdf5: direct link, hf CLI and curl.
- Browser
- Download file 16 kB
-
https://huggingface.co/spaces/ssboost/3gghdf5/resolve/main/export_utils.py
- Command line
-
hf download hf://spaces/ssboost/3gghdf5/export_utils.py
-
curl -L -o export_utils.py https://huggingface.co/spaces/ssboost/3gghdf5/resolve/main/export_utils.py
16 kB
| """ | |
| 결과 출력 관련 유틸리티 함수 모음 - 카테고리 항목 제거 + 키워드 클릭 시 네이버 쇼핑 이동 기능 추가 | |
| - HTML 테이블 생성 | |
| - 엑셀 파일 생성 | |
| - 키워드 클릭 시 네이버 쇼핑 링크 기능 | |
| """ | |
| import pandas as pd | |
| import tempfile | |
| import os | |
| import threading | |
| import time | |
| import logging | |
| import urllib.parse # URL 인코딩을 위해 추가 | |
| # 로깅 설정 | |
| logger = logging.getLogger(__name__) | |
| logger.setLevel(logging.INFO) | |
| formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') | |
| handler = logging.StreamHandler() | |
| handler.setFormatter(formatter) | |
| logger.addHandler(handler) | |
| # 임시 파일 추적 리스트 | |
| _temp_files = [] | |
| def create_table_without_checkboxes(df): | |
| """DataFrame을 HTML 테이블로 변환 - 키워드 클릭 시 네이버 쇼핑 이동 기능 추가""" | |
| if df.empty: | |
| return "<p>검색 결과가 없습니다.</p>" | |
| # === 수정된 부분: 카테고리 관련 열 제거 === | |
| df_display = df.copy() | |
| # "상품 등록 카테고리(상위100위)" 또는 "관련 카테고리", "카테고리 항목" 열이 있으면 제거 | |
| columns_to_remove = ["상품 등록 카테고리(상위100위)", "관련 카테고리", "카테고리 항목"] | |
| for col in columns_to_remove: | |
| if col in df_display.columns: | |
| df_display = df_display.drop(columns=[col]) | |
| logger.info(f"테이블에서 '{col}' 열 제거됨") | |
| # HTML 테이블 스타일 정의 - Z-INDEX 수정 | |
| html = ''' | |
| <style> | |
| .table-container { | |
| position: relative; | |
| width: 100%; | |
| margin: 0; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); | |
| } | |
| .header-wrap { | |
| position: sticky; | |
| top: 0; | |
| z-index: 100; /* z-index 증가 */ | |
| background-color: #009879; | |
| } | |
| .styled-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| table-layout: fixed; | |
| margin: 0; | |
| padding: 0; | |
| font-size: 14px; | |
| } | |
| .styled-table th, | |
| .styled-table td { | |
| padding: 12px 15px; | |
| text-align: left; | |
| border-bottom: 1px solid #dddddd; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| /* 긴 텍스트가 셀에서 줄바꿈되도록 수정 */ | |
| .styled-table td.col-rank { | |
| white-space: normal; | |
| word-break: break-word; | |
| line-height: 1.3; | |
| } | |
| /* 그 외 열은 한 줄로 표시 */ | |
| .styled-table td.col-seq, | |
| .styled-table td.col-keyword, | |
| .styled-table td.col-pc, | |
| .styled-table td.col-mobile, | |
| .styled-table td.col-total, | |
| .styled-table td.col-range, | |
| .styled-table td.col-count { | |
| white-space: nowrap; | |
| } | |
| .styled-table th { | |
| background-color: #009879; | |
| color: white; | |
| font-weight: bold; | |
| position: sticky; | |
| top: 0; | |
| white-space: nowrap; | |
| z-index: 50; /* 헤더 z-index 증가 */ | |
| } | |
| .styled-table tbody tr:nth-of-type(even) { | |
| background-color: #f3f3f3; | |
| } | |
| .styled-table tbody tr:hover { | |
| background-color: #f0f0f0; | |
| } | |
| .styled-table tbody tr:last-of-type { | |
| border-bottom: 2px solid #009879; | |
| } | |
| /* 데이터 셀 z-index 설정 */ | |
| .styled-table tbody td { | |
| position: relative; | |
| z-index: 1; /* 데이터 셀은 낮은 z-index */ | |
| } | |
| .data-container { | |
| max-height: 600px; | |
| overflow-y: auto; | |
| position: relative; /* position 추가 */ | |
| } | |
| /* 스크롤바 스타일 */ | |
| .data-container::-webkit-scrollbar { | |
| width: 10px; | |
| } | |
| .data-container::-webkit-scrollbar-track { | |
| background: #f1f1f1; | |
| border-radius: 5px; | |
| } | |
| .data-container::-webkit-scrollbar-thumb { | |
| background: #888; | |
| border-radius: 5px; | |
| } | |
| .data-container::-webkit-scrollbar-thumb:hover { | |
| background: #555; | |
| } | |
| /* 키워드 링크 스타일 - 새로 추가 */ | |
| .keyword-link { | |
| color: #2c5aa0; | |
| text-decoration: none; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| display: inline-block; | |
| padding: 2px 4px; | |
| border-radius: 3px; | |
| position: relative; | |
| z-index: 5; /* 링크 z-index 설정 */ | |
| } | |
| .keyword-link:hover { | |
| color: #ffffff; | |
| background-color: #2c5aa0; | |
| text-decoration: none; | |
| transform: translateY(-1px); | |
| box-shadow: 0 2px 4px rgba(44, 90, 160, 0.3); | |
| } | |
| .keyword-link:active { | |
| transform: translateY(0px); | |
| } | |
| /* 키워드 셀 특별 스타일 */ | |
| .col-keyword { | |
| position: relative; | |
| } | |
| .keyword-tooltip { | |
| position: absolute; | |
| bottom: 100%; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background-color: #333; | |
| color: white; | |
| padding: 6px 10px; | |
| border-radius: 4px; | |
| font-size: 11px; | |
| white-space: nowrap; | |
| opacity: 0; | |
| visibility: hidden; | |
| transition: all 0.3s ease; | |
| z-index: 1000; /* 툴팁은 가장 높은 z-index */ | |
| pointer-events: none; | |
| margin-bottom: 5px; | |
| } | |
| .keyword-tooltip::after { | |
| content: ''; | |
| position: absolute; | |
| top: 100%; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| border: 4px solid transparent; | |
| border-top-color: #333; | |
| } | |
| .keyword-link:hover .keyword-tooltip { | |
| opacity: 1; | |
| visibility: visible; | |
| } | |
| /* === 수정된 부분: 열 너비 정의 - 카테고리 열 제거 후 조정 === */ | |
| .col-seq { width: 8%; } | |
| .col-keyword { width: 25%; } | |
| .col-pc { width: 12%; } | |
| .col-mobile { width: 12%; } | |
| .col-total { width: 12%; } | |
| .col-range { width: 12%; } | |
| .col-rank { width: 15%; } | |
| .col-count { width: 10%; } | |
| .truncated-text { | |
| position: relative; | |
| cursor: pointer; | |
| z-index: 2; /* 텍스트 z-index 설정 */ | |
| } | |
| .truncated-text:hover::after { | |
| content: attr(data-full-text); | |
| position: absolute; | |
| left: 0; | |
| top: 100%; | |
| z-index: 99; | |
| min-width: 200px; | |
| max-width: 400px; | |
| padding: 8px; | |
| background-color: #fff; | |
| border: 1px solid #ddd; | |
| border-radius: 4px; | |
| box-shadow: 0 2px 5px rgba(0,0,0,0.2); | |
| white-space: normal; | |
| } | |
| /* 키워드 태그 스타일 */ | |
| .keyword-tag-container { | |
| margin-top: 20px; | |
| padding: 10px; | |
| border: 1px solid #ddd; | |
| border-radius: 5px; | |
| background-color: #f9f9f9; | |
| } | |
| .keyword-tag { | |
| display: inline-block; | |
| background-color: #009879; | |
| color: white; | |
| padding: 5px 10px; | |
| margin: 5px; | |
| border-radius: 15px; | |
| font-size: 12px; | |
| } | |
| .category-tag { | |
| display: inline-block; | |
| background-color: #2c7fb8; | |
| color: white; | |
| padding: 5px 10px; | |
| margin: 5px; | |
| border-radius: 15px; | |
| font-size: 12px; | |
| } | |
| /* 분석 결과 테이블 스타일 */ | |
| .analysis-result { | |
| margin-top: 30px; | |
| border: 1px solid #ddd; | |
| border-radius: 5px; | |
| padding: 15px; | |
| background-color: #f9f9f9; | |
| } | |
| .result-header { | |
| font-weight: bold; | |
| margin-bottom: 10px; | |
| color: #009879; | |
| } | |
| .match-item { | |
| margin: 5px 0; | |
| padding: 5px; | |
| border-bottom: 1px solid #eee; | |
| } | |
| .match-keyword { | |
| font-weight: bold; | |
| color: #2c7fb8; | |
| } | |
| .match-count { | |
| display: inline-block; | |
| background-color: #009879; | |
| color: white; | |
| padding: 2px 8px; | |
| border-radius: 10px; | |
| font-size: 12px; | |
| margin-left: 10px; | |
| } | |
| </style> | |
| ''' | |
| # === 수정된 부분: 열 이름과 클래스 매핑 - 카테고리 관련 제거 === | |
| col_mapping = { | |
| "순번": "col-seq", | |
| "조합 키워드": "col-keyword", | |
| "연관 키워드": "col-keyword", # 연관검색어 분석용 추가 | |
| "키워드": "col-keyword", # 일반 키워드용 추가 | |
| "PC검색량": "col-pc", | |
| "모바일검색량": "col-mobile", | |
| "총검색량": "col-total", | |
| "검색량구간": "col-range", | |
| "키워드 사용자순위": "col-rank", | |
| "키워드 사용횟수": "col-count" | |
| # 카테고리 관련 매핑 제거됨 | |
| } | |
| # 네이버 쇼핑 링크 생성 함수 | |
| def create_naver_shopping_link(keyword): | |
| """키워드를 네이버 쇼핑 링크로 변환""" | |
| # URL 인코딩 (한글 키워드 처리) | |
| encoded_keyword = urllib.parse.quote(keyword.strip()) | |
| naver_shopping_url = f"https://search.shopping.naver.com/search/all?where=all&frm=NVSCTAB&query={encoded_keyword}" | |
| # 링크가 포함된 HTML 반환 | |
| return f'''<a href="{naver_shopping_url}" target="_blank" class="keyword-link" title="네이버 쇼핑에서 '{keyword}' 검색하기"> | |
| {keyword} | |
| <span class="keyword-tooltip">클릭하면 네이버 쇼핑으로 이동</span> | |
| </a>''' | |
| # 테이블 컨테이너 시작 | |
| html += '<div class="table-container">' | |
| # 단일 테이블 구조로 변경 (헤더는 position: sticky로 고정) | |
| html += '<div class="data-container">' | |
| html += '<table class="styled-table">' | |
| # colgroup으로 열 너비 정의 | |
| html += '<colgroup>' | |
| html += f'<col class="{col_mapping["순번"]}">' | |
| for col in df_display.columns: | |
| col_class = col_mapping.get(col, "") | |
| html += f'<col class="{col_class}">' | |
| html += '</colgroup>' | |
| # 테이블 헤더 | |
| html += '<thead>' | |
| html += '<tr>' | |
| html += f'<th class="{col_mapping["순번"]}">순번</th>' | |
| for col in df_display.columns: | |
| col_class = col_mapping.get(col, "") | |
| html += f'<th class="{col_class}">{col}</th>' | |
| html += '</tr>' | |
| html += '</thead>' | |
| # 테이블 본문 | |
| html += '<tbody>' | |
| for idx, row in df_display.iterrows(): | |
| html += '<tr>' | |
| # 순번 표시 - 1부터 시작하는 순차적 번호 | |
| html += f'<td class="{col_mapping["순번"]}">{idx + 1}</td>' | |
| # 데이터 셀 추가 | |
| for col in df_display.columns: | |
| col_class = col_mapping.get(col, "") | |
| value = str(row[col]) | |
| # === 새로 추가: 키워드 열에 링크 적용 === | |
| if col in ["조합 키워드", "연관 키워드", "키워드"]: | |
| # 키워드 셀에 네이버 쇼핑 링크 적용 | |
| keyword_with_link = create_naver_shopping_link(value) | |
| html += f'<td class="{col_class}">{keyword_with_link}</td>' | |
| elif col == "키워드 사용자순위": | |
| # 긴 텍스트의 셀은 그대로 표시 (줄바꿈 허용) | |
| html += f'<td class="{col_class}">{value}</td>' | |
| elif len(value) > 30: | |
| # 다른 긴 텍스트는 hover로 전체 표시 | |
| html += f'<td class="{col_class}"><div class="truncated-text" data-full-text="{value}">{value[:30]}...</div></td>' | |
| else: | |
| # 일반 텍스트 | |
| html += f'<td class="{col_class}">{value}</td>' | |
| html += '</tr>' | |
| html += '</tbody>' | |
| html += '</table>' | |
| html += '</div>' # data-container 닫기 | |
| html += '</div>' # table-container 닫기 | |
| # 사용법 안내 추가 | |
| html += ''' | |
| <div style="margin-top: 15px; padding: 12px; background: #e8f5e8; border-radius: 8px; border-left: 4px solid #009879;"> | |
| <div style="font-weight: bold; color: #155724; margin-bottom: 5px;">💡 사용팁</div> | |
| <div style="font-size: 14px; color: #155724;"> | |
| 키워드를 클릭하면 네이버 쇼핑에서 해당 키워드로 검색한 결과를 새 창에서 확인할 수 있습니다. | |
| </div> | |
| </div> | |
| ''' | |
| return html | |
| def cleanup_temp_files(delay=300): | |
| """임시 파일 정리 함수""" | |
| global _temp_files | |
| def cleanup(): | |
| time.sleep(delay) # 지정된 시간 대기 | |
| temp_files_to_remove = _temp_files.copy() | |
| _temp_files = [] | |
| for file_path in temp_files_to_remove: | |
| try: | |
| if os.path.exists(file_path): | |
| os.remove(file_path) | |
| logger.info(f"임시 파일 삭제: {file_path}") | |
| except Exception as e: | |
| logger.error(f"파일 삭제 오류: {e}") | |
| # 새 스레드 시작 | |
| threading.Thread(target=cleanup, daemon=True).start() | |
| def download_keywords(df, auto_cleanup=True, cleanup_delay=300): | |
| """키워드 데이터를 엑셀 파일로 다운로드 - 카테고리 항목 제거""" | |
| global _temp_files | |
| if df is None or df.empty: | |
| return None | |
| # 임시 파일로 저장 | |
| temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx') | |
| temp_file.close() | |
| filename = temp_file.name | |
| # 임시 파일 추적 목록에 추가 | |
| _temp_files.append(filename) | |
| # === 수정된 부분: 카테고리 관련 열 제거 === | |
| df_export = df.copy() | |
| # 카테고리 관련 열들 제거 | |
| columns_to_remove = ["상품 등록 카테고리(상위100위)", "관련 카테고리", "카테고리 항목"] | |
| for col in columns_to_remove: | |
| if col in df_export.columns: | |
| df_export = df_export.drop(columns=[col]) | |
| logger.info(f"엑셀 내보내기에서 '{col}' 열 제거됨") | |
| # 키워드 데이터를 엑셀 파일로 저장 | |
| with pd.ExcelWriter(filename, engine='xlsxwriter') as writer: | |
| # 키워드 목록 시트 | |
| df_export.to_excel(writer, sheet_name='키워드 목록', index=False) | |
| # 열 너비 조정 - 카테고리 열 제거 후 조정 | |
| worksheet = writer.sheets['키워드 목록'] | |
| worksheet.set_column('A:A', 20) # 조합 키워드 열 | |
| worksheet.set_column('B:B', 12) # PC검색량 열 | |
| worksheet.set_column('C:C', 12) # 모바일검색량 열 | |
| worksheet.set_column('D:D', 12) # 총검색량 열 | |
| worksheet.set_column('E:E', 12) # 검색량구간 열 | |
| worksheet.set_column('F:F', 20) # 키워드 사용자순위 열 | |
| worksheet.set_column('G:G', 12) # 키워드 사용횟수 열 | |
| # 카테고리 열들 제거로 H, I 열 설정 제거됨 | |
| # 헤더 형식 설정 | |
| header_format = writer.book.add_format({ | |
| 'bold': True, | |
| 'bg_color': '#009879', | |
| 'color': 'white', | |
| 'border': 1 | |
| }) | |
| # 헤더에 형식 적용 | |
| for col_num, value in enumerate(df_export.columns.values): | |
| worksheet.write(0, col_num, value, header_format) | |
| logger.info(f"엑셀 파일 생성: {filename}") | |
| # 파일 자동 정리 옵션 | |
| if auto_cleanup: | |
| # 별도 정리 작업 요청 없이 추적 목록에 추가만 하여 일괄 처리 | |
| pass | |
| return filename | |
| def register_cleanup_handlers(): | |
| """앱 종료 시 정리를 위한 핸들러 등록""" | |
| import atexit | |
| def cleanup_all_temp_files(): | |
| global _temp_files | |
| for file_path in _temp_files: | |
| try: | |
| if os.path.exists(file_path): | |
| os.remove(file_path) | |
| logger.info(f"종료 시 임시 파일 삭제: {file_path}") | |
| except Exception as e: | |
| logger.error(f"파일 삭제 오류: {e}") | |
| _temp_files = [] | |
| # 앱 종료 시 실행될 함수 등록 | |
| atexit.register(cleanup_all_temp_files) |