3gghdf5 / export_utils.py
ssboost's picture
Upload 15 files
106555b verified
Raw History Blame Contribute Delete
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)