import numpy as np import asyncio, time, math # import pywinauto import os import logging import random import glob import markdown from bs4 import BeautifulSoup import re import sqlite3 import json from datetime import datetime, timedelta class DetailHandler: def __init__(self, locator_manager, browser_controller, imageProcessor, detail_text_widget, TEMP_IMAGE_DIR, logger, gpt_client, update_detail_progress_signal, set_progress_visible_signal, toggle_states): self.update_detail_progress_signal = update_detail_progress_signal self.set_progress_visible_signal = set_progress_visible_signal self.TEMP_IMAGE_DIR = TEMP_IMAGE_DIR self.locator_manager = locator_manager self.browser_controller = browser_controller self.page = self.browser_controller.page # self.clipboardImageManager = clipboardImageManager self.detail_text_widget = detail_text_widget self.logger = logger self.imageProcessor = imageProcessor self.toggle_states = toggle_states self.gpt_client = gpt_client # self.whale_translator = whale_translator # 선택자 로드 self.source_button_locator = self.locator_manager.get_locator('DetailLocators', 'source_button_locator') self.ck_source_editing_area_locator = self.locator_manager.get_locator('DetailLocators', 'ck_source_editing_area_locator') self.cke_text_editing_area_locator = self.locator_manager.get_locator('DetailLocators', 'cke_text_editing_area_locator') self.cke_img_file_input_locator = self.locator_manager.get_locator('DetailLocators', 'cke_img_file_input_locator') self.oneclick_trans_img_btn_locator = self.locator_manager.get_locator('DetailLocators', 'oneclick_trans_img_btn_locator') self.editor_modal_locator = self.locator_manager.get_locator('DetailLocators', 'editor_modal_locator') self.save_btn_locator = self.locator_manager.get_locator('DetailLocators', 'save_btn_locator') self.dialog_save_btn_locator = self.locator_manager.get_locator('DetailLocators', 'dialog_save_btn_locator') self.detail_img_trans_btn_locator = self.locator_manager.get_locator('DetailLocators', 'detail_img_trans_btn_locator') self.detail_img_trans_dialog_locator = self.locator_manager.get_locator('DetailLocators', 'detail_img_trans_dialog_locator') self.detail_img_trans_dialog_closeBTN_locator = self.locator_manager.get_locator('DetailLocators', 'detail_img_trans_dialog_closeBTN_locator') self.detail_img_trans_editBTN_locator = self.locator_manager.get_locator('DetailLocators', 'detail_img_trans_editBTN_locator') self.detail_images_locator = self.locator_manager.get_locator('DetailLocators', 'detail_images_locator') def update_page(self, page1, toggle_states, imageProcessor): self.page = page1 self.toggle_states = toggle_states self.imageProcessor = imageProcessor self.logger.log(f"page객체 업데이트 : {page1}", level=logging.DEBUG) def reset_state(self): """상세페이지 핸들러의 상태를 초기화합니다. (detail 관련 임시파일만 정리)""" self.logger.log("DetailHandler 상태 초기화 - detail 관련 임시파일 정리", level=logging.DEBUG) try: # detail 관련 임시 디렉토리의 파일들만 삭제 (더 포괄적인 패턴 사용) patterns = [ "detail_*.png", "detail_*.jpg", "detail_*.webp", "translated_detail_*" ] all_detail_files = [] for pattern in patterns: detail_pattern = os.path.join(self.TEMP_IMAGE_DIR, pattern) all_detail_files.extend(glob.glob(detail_pattern)) if all_detail_files: for file_path in all_detail_files: try: os.remove(file_path) self.logger.log(f"detail 임시 파일 삭제: {file_path}", level=logging.DEBUG) except Exception as e: self.logger.log(f"detail 임시 파일 삭제 실패: {file_path}, 오류: {e}", level=logging.WARNING) self.logger.log(f"총 {len(all_detail_files)}개의 detail 임시 파일 정리 완료", level=logging.INFO) else: self.logger.log("정리할 detail 임시 파일이 없습니다.", level=logging.DEBUG) except Exception as e: self.logger.log(f"detail 임시 파일 처리 중 오류 발생: {e}", level=logging.ERROR) async def get_ckeditor_data(self): """CKEditor에서 HTML 데이터를 가져옵니다.""" try: # # 소스 버튼 locator # source_button_locator = self.locator_manager.get_locator('BrowserControl', 'source_button_locator') # # 소스 편집 영역 locator # ck_source_editing_area_locator = self.locator_manager.get_locator('BrowserControl', 'ck_source_editing_area_locator') # 소스 모드로 전환 source_button = await self.page.wait_for_selector(self.source_button_locator, timeout=8000) await source_button.click() self.logger.log("소스 버튼 클릭 완료.", level=logging.DEBUG) # 데이터 가져오기 textarea = await self.page.wait_for_selector(self.ck_source_editing_area_locator, timeout=5000) html_data = await textarea.get_attribute("data-value") # 다시 에디터 모드로 전환 await self.page.click(self.source_button_locator) self.logger.log("소스 버튼 재클릭 완료.", level=logging.DEBUG) return html_data except Exception as e: self.logger.log(f"CKEditor 데이터 가져오기 실패: {e}", level=logging.ERROR) return "" async def clear_ckeditor_data(self): """CKEditor의 내용을 지웁니다.""" try: # # 소스 버튼 locator # source_button_locator = self.locator_manager.get_locator('BrowserControl', 'source_button_locator') # # 소스 편집 영역 locator # ck_source_editing_area_locator = self.locator_manager.get_locator('BrowserControl', 'ck_source_editing_area_locator') # 소스 모드로 전환 await self.page.click(self.source_button_locator) self.logger.log("소스 버튼 클릭 완료.", level=logging.DEBUG) # 데이터 지우기 await self.page.evaluate(f'document.querySelector("{self.ck_source_editing_area_locator}").setAttribute("data-value", "")') self.logger.log("CKEditor 데이터 지우기 완료.", level=logging.DEBUG) # 다시 에디터 모드로 전환 await self.page.click(self.source_button_locator) self.logger.log("소스 버튼 재클릭 완료.", level=logging.DEBUG) return True except Exception as e: self.logger.log(f"CKEditor 데이터 지우기 실패: {e}", level=logging.ERROR) return False def is_valid_html(self, html_data: str) -> bool: """ HTML 데이터가 정상적인지 확인하는 메서드 조건: 태그가 포함되어 있고 다수의 이미지 URL이 존재해야 함 """ try: # 태그 내의 src 속성 값을 추출 img_urls = re.findall(r']+src=["\'](.*?)["\']', html_data) # 최소 이미지 URL 개수를 기준으로 판단 (예: 1개 이상) if len(img_urls) >= 1: self.logger.log(f"HTML에 포함된 이미지 URL: {img_urls}", level=logging.DEBUG) return True else: self.logger.log(f"HTML에 포함된 이미지 URL이 부족합니다. ({len(img_urls)}개)", level=logging.WARNING) return False except Exception as e: self.logger.log(f"HTML 유효성 검사 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) return False def fetch_image_urls(self, html_content): """ HTML 콘텐츠에서 모든 태그의 URL을 추출하는 함수.
안의 태그와 독립된 태그 모두 처리하며, 지정된 도메인으로 시작하는 이미지는 제외한다. """ soup = BeautifulSoup(html_content, "html.parser") # 제외할 URL 접두사 모음 (필요 시 여기서만 손보면 됨) EXCLUDE_PREFIXES: tuple[str, ...] = ( "https://assets.alicdn.com", "https://gtms01.alicdn.com", ) image_urls: list[str] = [] # 최종 반환 목록 excluded_urls: list[str] = [] # 필터링된 URL 목록 (디버깅용) # 모든 태그를 순회 for img in soup.find_all("img"): if "src" not in img.attrs: continue # src 없는 태그는 건너뜀 image_url: str = img["src"] # 특정 도메인 시작이면 제외 if image_url.startswith(EXCLUDE_PREFIXES): excluded_urls.append(image_url) continue image_urls.append(image_url) # 디버깅 로그 self.logger.log( f"fetch_image_urls ▶ 추출 이미지 URL 수 : {len(image_urls)}개", level=logging.DEBUG ) self.logger.log( f"fetch_image_urls ▶ 추출 이미지 URL 목록 : {image_urls}", level=logging.DEBUG ) self.logger.log( f"fetch_image_urls ▶ 제외된 이미지 URL 수 : {len(excluded_urls)}개", level=logging.DEBUG ) self.logger.log( f"fetch_image_urls ▶ 제외된 이미지 URL 목록 : {excluded_urls}", level=logging.DEBUG, ) return image_urls async def upload_image(self, img_path): """ 이미지를 CKEditor에 업로드합니다. Args: img_path (str): 업로드할 이미지 파일 경로 Returns: bool: 업로드 성공 여부 """ try: # (1) 업로드 시도 전에 파일 경로가 로컬에 존재하는지 확인 if (not img_path) or (not os.path.isfile(img_path)): self.logger.log(f"유효하지 않은 이미지 경로로 업로드를 건너뜀: {img_path}", level=logging.WARNING) return False # 업로드 이전 이미지 리스트 저장 existing_images = await self.page.evaluate(""" () => Array.from(document.querySelectorAll('img')).map(img => img.src) """) # 1. 파일 업로드 file_input = self.page.locator(self.cke_img_file_input_locator) await file_input.set_input_files(img_path) self.logger.log(f"이미지가 성공적으로 업로드되었습니다: {img_path}", level=logging.INFO) # 2. 커서 이동 await self.page.keyboard.press("ArrowRight") await self.page.keyboard.press("ArrowRight") # 업로드된 이미지 URL 확인 current_images = await self.page.evaluate(""" () => Array.from(document.querySelectorAll('img')).map(img => img.src) """) new_images = list(set(current_images) - set(existing_images)) if new_images: # self.logger.log(f"업로드된 이미지 확인: {new_images[0]}", level=logging.DEBUG) return True else: self.logger.log("업로드된 이미지를 찾을 수 없습니다.", level=logging.WARNING) return False except Exception as e: self.logger.log(f"이미지 업로드 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) return False async def input_option_list(self, option_data, input_field): lines = ["# 옵션 목록"] for i, key in enumerate(option_data.keys(), 1): lines.append(f"- {i}. {key}") lines += ["", "### 나열된 옵션목록 이외의 옵션이 필요하실 경우 고객센터로 연락주세요.", "---", ""] await input_field.fill("\n".join(lines)) async def input_detail_text(self, optionHandler, input_field): """ 상세페이지에 소개글과 옵션 데이터를 입력하는 메서드 """ try: if not input_field: self.logger.log("텍스트 입력 필드를 찾을 수 없습니다.", level=logging.ERROR) return # -------------------------------------------------- # 1. VIP 등록모드 랜덤 소개 문구 (맨 윗줄) # -------------------------------------------------- vip_intro_block = "" if (self.toggle_states.get('ed_mode', False) and self.toggle_states.get('vip_detail_edit', False)): try: random_intro = self._get_random_biz_intro() vip_intro_block = f"{random_intro}\n\n---\n" self.logger.log("VIP 등록모드: 랜덤 소개 문구 추가됨", level=logging.INFO) except Exception as e: self.logger.log(f"VIP 랜덤 소개 문구 생성 실패: {e}", level=logging.WARNING) # -------------------------------------------------- # 2. 소개글 (기존 유지) # -------------------------------------------------- leading_lines = [t for t in self.detail_text_widget.get_lines() if t] leading_block = "\n".join(leading_lines) # -------------------------------------------------- # 2. 옵션 목록 (범용적인 고급 마크다운) # -------------------------------------------------- option_data = optionHandler.get_all_translated_options() is_single = optionHandler.option_info.get("is_single_option", True) options_block = "" if option_data and (len(option_data) > 1 or not is_single): lines = [ "", "### 🚀 **구매대행 특별 혜택**", "", "> 🎯 **전문 구매대행 서비스**", "- **해외직구 전문가**가 직접 상품 선별 및 구매배송", "**철저한 검수** 및 **꼼꼼한 포장**", "**빠른 배송** 및 **고객 맞춤형 서비스**", "", "💬 **문의 및 주문 안내**", "나열된 옵션목록 이외의 옵션이 필요하시거나, ", "**특별 주문**이 필요하시면 언제든 고객센터로 연락주세요!", "⚡ **빠른 응답**으로 고객님의 만족을 최우선으로 합니다.", "", "", "---", "### 📋 **옵션 목록**", "", "" ] # 이미 넘버링이 적용된 옵션명을 일관된 이모지와 함께 사용 (마크다운 자동 넘버링 방지) # 상품별로 하나의 이모지를 선택하여 모든 옵션에 동일하게 사용 selected_emoji = self._get_option_emoji(0, style="random") for i, option in enumerate(option_data.keys()): lines.append(f"{selected_emoji} **{option}**") lines.extend([ "", "---", "", "" ]) options_block = "\n".join(lines) # -------------------------------------------------- # 3. 과거 형식 옵션 목록 자동 정리 (선택적) # -------------------------------------------------- # 기존 내용에서 과거 형식 옵션 목록이 있다면 정리 시도 if leading_block and option_data: cleaned_content = self._replace_or_append_options_section(leading_block, options_block, option_data) if cleaned_content and cleaned_content != leading_block: leading_block = cleaned_content self.logger.log("기존 소개글 내의 과거 옵션 목록을 정리했습니다.", level=logging.INFO) # -------------------------------------------------- # 4. 최종 조합 & 입력 # -------------------------------------------------- # VIP 소개 문구, 기존 소개글, 옵션 목록 순서로 조합 text_blocks = [] if vip_intro_block: text_blocks.append(vip_intro_block.strip()) if leading_block: text_blocks.append(leading_block) if options_block: text_blocks.append(options_block) bulk_text = "\n".join(text_blocks).strip() try: await input_field.click() await input_field.fill("") # 내용 초기화 await input_field.press("Enter") # 청크 단위 입력으로 변경 await self.input_detail_text_chunked(input_field, bulk_text) except Exception as e: self.logger.log(f"CKEditor HTML 입력 실패: {e}", level=logging.ERROR, exc_info=True) self.logger.log("상세페이지 텍스트(소개 + 옵션) 일괄 입력 완료", level=logging.INFO) except Exception as e: self.logger.log(f"상세페이지 텍스트 입력 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) async def input_detail_text_chunked(self, input_field, bulk_text): """청크 단위로 텍스트를 입력하는 메서드""" # 긴 텍스트를 청크로 나누어 입력 chunk_size = 1000 # 한 번에 입력할 문자 수 chunks = [bulk_text[i:i+chunk_size] for i in range(0, len(bulk_text), chunk_size)] for i, chunk in enumerate(chunks): try: await input_field.type(chunk, delay=1, timeout=60000) self.logger.log(f"청크 {i+1}/{len(chunks)} 입력 완료", level=logging.DEBUG) except Exception as e: self.logger.log(f"청크 {i+1} 입력 실패: {e}", level=logging.ERROR) raise async def process_detail(self, optionHandler, toggle_states): """상세페이지 번역을 처리합니다. Args: optionHandler: 옵션 핸들러 객체 Returns: bool: 처리 성공 여부 """ self.logger.log("상세페이지 처리를 시작합니다.", level=logging.INFO) # 이전 상품의 detail 관련 임시파일들 정리 self.reset_state() # 사용값 설정 self.interval = toggle_states.get('interval', 3.0) self.watingTime = toggle_states.get('watingTime', 20) self.detail_Option = toggle_states.get('detail_Option', False) self.detail_IMGTrans = toggle_states.get('detail_IMGTrans', False) self.detail_IMGTrans_type = toggle_states.get('detail_IMGTrans_type', True) self.unwanted_words = toggle_states.get('unwanted_words', False) self.logger.log(f"self.toggle_states : {self.toggle_states}", level=logging.DEBUG) try: # 현재 HTML 가져오기 current_html = await self.get_ckeditor_data() if not current_html or not self.is_valid_html(current_html): self.logger.log("유효한 HTML을 가져오지 못했습니다.", level=logging.WARNING) return False # 이미지 URL 추출 image_urls = self.fetch_image_urls(current_html) original_image_count = len(image_urls) self.logger.log(f"추출된 이미지 URL 수: {original_image_count}", level=logging.INFO) if not image_urls: self.logger.log("변환할 이미지가 없습니다.", level=logging.INFO) return False # 텍스트 입력 필드 선택 input_field = self.page.locator(self.cke_text_editing_area_locator) # 상세페이지 옵션에 따라 처리 self.logger.log(f"self.detail_Option : {self.detail_Option}", level=logging.DEBUG) self.logger.log(f"self.detail_IMGTrans : {self.detail_IMGTrans}", level=logging.DEBUG) if (self.detail_Option or self.detail_IMGTrans) and (self.browser_controller.image_worker_mgr is not None): # if self.detail_Option: # self.logger.log("소개글 입력 시작...", level=logging.INFO) # await self.input_detail_text(optionHandler, input_field) if self.detail_IMGTrans: self.logger.log("이미지 번역 시작...", level=logging.INFO) self.set_progress_visible_signal.emit(True) self.logger.log(f"이미지 번역 엔진 : {self.detail_IMGTrans_type}", level=logging.DEBUG) # if (self.detail_IMGTrans_type in ('CPU', '자체서버')) and (self.imageProcessor is not None): if self.detail_IMGTrans_type and (self.browser_controller.image_worker_mgr is not None): # CKEditor 내용 지우기 await self.clear_ckeditor_data() # 텍스트 입력 필드 선택 await input_field.click() self.logger.log("입력 필드 선택", level=logging.DEBUG) # 소개글과 옵션데이터 입력 if self.detail_Option: await self.input_detail_text(optionHandler, input_field) # 이미지 처리 통계 success_count = 0 excluded_count = 0 error_count = 0 translated_count = 0 # 임시 파일 정리를 위한 리스트 processed_files = [] # 모든 이미지 처리 self.logger.log("이미지 처리 및 업로드 시작...", level=logging.INFO) # OCR 데이터 수집을 위한 리스트 (상품 요약 생성용) all_ocr_data = [] # 번역 파라미터 준비 font_type = self.browser_controller.toggle_states.get('font_type', '') unwanted_texts = list(self.browser_controller.unwanted_words.keys()) if hasattr(self.browser_controller, 'unwanted_words') else [] is_member_valid = self.browser_controller.user_membership_level == "premium" authenticated_by_admin = self.browser_controller.authenticated_by_admin for idx, image_url in enumerate(image_urls): try: # detail 구분자를 포함한 임시파일명으로 처리 try: final_image_result = await self.browser_controller.image_processor.process_image_url( original_image_url=image_url, index=idx, file_prefix="detail", font_type=font_type, unwanted_texts=unwanted_texts, is_member_valid=is_member_valid, authenticated_by_admin=authenticated_by_admin ) except Exception: raise # 서버가 반환한 child_results(분할 조각) 처리 child_results = (final_image_result.get('child_results') if isinstance(final_image_result, dict) else None) or [] if child_results: for split_idx, split_result in enumerate(child_results, 1): split_status = split_result.get('status') split_path = split_result.get('path') if split_status == 'translated' and split_path: translated_count += 1 split_upload_success = await self.upload_image(split_path) if split_upload_success: success_count += 1 self.logger.log(f"분할 이미지 {idx+1}-{split_idx+1} 업로드 성공", level=logging.INFO) else: error_count += 1 if split_path: processed_files.append(split_path) else: # 구버전 서버 호환: 내부 분할 목록이 있을 경우 기존 보조 경로 사용 split_results_fallback = await self.process_split_images_if_any(image_url, idx) if split_results_fallback: for split_idx, split_result in enumerate(split_results_fallback, 1): split_status = split_result.get('status') split_path = split_result.get('path') if split_status == 'translated' and split_path: translated_count += 1 split_upload_success = await self.upload_image(split_path) if split_upload_success: success_count += 1 self.logger.log(f"분할 이미지 {idx+1}-{split_idx+1} 업로드 성공", level=logging.INFO) else: error_count += 1 if split_path: processed_files.append(split_path) status = final_image_result.get('status') translated_image_path = final_image_result.get('path') # 🔧 개선: 모든 경우에 생성된 파일을 정리 목록에 추가 (한 번만) if translated_image_path and os.path.exists(translated_image_path): processed_files.append(translated_image_path) self.logger.log(f"이미지 {idx+1} 처리 결과 파일 정리 예약: {translated_image_path}", level=logging.DEBUG) if status == 'translated': translated_count += 1 upload_success = await self.upload_image(translated_image_path) elif status in ('failed', 'original'): # 실패/원본인 경우 로컬 파일이 실제 존재할 때만 업로드 시도 if translated_image_path and os.path.isfile(translated_image_path): upload_success = await self.upload_image(translated_image_path) else: upload_success = False self.logger.log(f"이미지 {idx+1} 실패/원본 처리: 로컬 파일이 존재하지 않아 업로드를 건너뜀 → {translated_image_path}", level=logging.WARNING) elif status == 'exclude': excluded_count += 1 self.logger.log(f"이미지 {idx+1} 제외됨: {image_url}", level=logging.INFO) # 제외된 경우에는 업로드하지 않음 continue elif isinstance(final_image_result, str) and final_image_result: # (이전 방식과의 호환성) upload_success = await self.upload_image(final_image_result) else: # 예상하지 못한 반환값 error_count += 1 self.logger.log(f"이미지 {idx+1} 처리 결과 예상하지 못한 값: {final_image_result}", level=logging.WARNING) continue # 인페인팅 방식/장치 집계 try: if isinstance(final_image_result, dict): used = final_image_result.get('inpaint_method') dev = (final_image_result.get('inpaint_device') or '').lower() if hasattr(self.browser_controller, 'add_image_edit_stats'): if used == 'cv': self.browser_controller.add_image_edit_stats(inpaint_cv=1) elif used == 'migan': self.browser_controller.add_image_edit_stats(inpaint_migan=1) elif used == 'request': self.browser_controller.add_image_edit_stats(inpaint_request=1) if dev == 'cpu': self.browser_controller.add_image_edit_stats(inpaint_device_cpu=1) elif dev == 'cuda': self.browser_controller.add_image_edit_stats(inpaint_device_cuda=1) elif dev == 'directml': self.browser_controller.add_image_edit_stats(inpaint_device_directml=1) elif dev == 'server': self.browser_controller.add_image_edit_stats(inpaint_device_server=1) except Exception: pass # 성공/실패 카운트 및 로그 처리 if upload_success: success_count += 1 self.logger.log(f"이미지 {idx+1} 업로드 성공: {translated_image_path}", level=logging.INFO) else: error_count += 1 self.logger.log(f"이미지 {idx+1} 업로드 실패: {translated_image_path}", level=logging.WARNING) except Exception as e: error_count += 1 self.logger.log(f"이미지 {idx+1} 처리 중 오류: {e}", level=logging.ERROR) # 프로그레스 업데이트 self.update_detail_progress_signal.emit(idx+1, len(image_urls)) self.logger.log(f"이미지 처리 진행률: {idx+1}/{len(image_urls)}", level=logging.INFO) # 모든 이미지 처리 완료 후 OCR 데이터 수집 store_ocr_to_db = self.toggle_states.get('store_ocr_data_to_db', False) if store_ocr_to_db: all_ocr_data = await self.collect_ocr_data_from_db() else: # 메모리에서 OCR 데이터 수집 (기본 방식) if self.imageProcessor and hasattr(self.imageProcessor, 'get_session_ocr_data'): all_ocr_data = self.imageProcessor.get_session_ocr_data() self.logger.log(f"세션 OCR 데이터 수집 완료: {len(all_ocr_data) if all_ocr_data else 0}개", level=logging.DEBUG) else: self.logger.log("⚠️ ImageProcessor가 초기화되지 않아 OCR 데이터 수집 건너뜀", level=logging.WARNING) all_ocr_data = [] # 처리 결과 로깅 self.logger.log(f"이미지 처리 완료 - 성공: {success_count}, 제외: {excluded_count}, 오류: {error_count}, 총: {len(image_urls)}", level=logging.INFO) # 모든 이미지 처리 완료 후 임시 파일 배치 정리 if processed_files: self.logger.log(f"임시 파일 배치 정리 시작: {len(processed_files)}개 파일", level=logging.INFO) deleted_count = 0 for file_path in processed_files: try: if file_path and os.path.exists(file_path): os.remove(file_path) deleted_count += 1 self.logger.log(f"임시 파일 삭제: {file_path}", level=logging.DEBUG) except Exception as e: self.logger.log(f"파일 삭제 실패 (무시): {file_path} - {e}", level=logging.DEBUG) self.logger.log(f"임시 파일 정리 완료: {deleted_count}/{len(processed_files)}개 삭제", level=logging.INFO) # 번역 완료 후 프로그레스바 숨김 self.set_progress_visible_signal.emit(False) # OCR 데이터 수집 완료 로깅 if all_ocr_data: self.logger.log(f"OCR 데이터 수집 완료: {len(all_ocr_data)}개 이미지에서 데이터 추출", level=logging.INFO) else: self.logger.log("OCR 데이터가 수집되지 않았습니다.", level=logging.WARNING) # 세션 OCR 데이터 정리 (메모리 모드인 경우) if self.imageProcessor and hasattr(self.imageProcessor, 'clear_session_ocr_data'): self.imageProcessor.clear_session_ocr_data() self.logger.log("세션 OCR 데이터 정리 완료", level=logging.DEBUG) # 상세이미지 통계 요약 및 집계 반영 try: self.logger.log( f"📊 상세페이지 이미지 통계: 총={original_image_count}, 번역됨={translated_count}, 제외={excluded_count}, 오류={error_count}", level=logging.INFO, ) if hasattr(self.browser_controller, 'add_image_edit_stats'): self.browser_controller.add_image_edit_stats( detail_total=original_image_count, detail_translated=translated_count, ) except Exception: pass # 추가 줄바꿈 입력 await self.page.keyboard.press("ArrowRight") await self.page.keyboard.press("Enter") await self.page.keyboard.press("Enter") await self.page.keyboard.press("Enter") else: # 상세페이지 옵션이 비활성화된 경우 self.logger.log("상세페이지 옵션이 비활성화되어 있습니다.", level=logging.INFO) return False except Exception as e: self.logger.log(f"process_detail 중 오류 발생: {str(e)}", level=logging.ERROR) self.set_progress_visible_signal.emit(False) return False # async def update_detail_for_ed_mode(self, optionHandler): # """ # ED 모드에서 옵션명이 변경되었을 때 상세페이지의 옵션 목록만 업데이트하는 메서드 # """ # try: # self.logger.log("ED 모드: 상세페이지 옵션 목록 업데이트 시작", level=logging.INFO) # # 텍스트 입력 필드 선택 # input_field = self.page.locator(self.cke_text_editing_area_locator) # # 기존 내용 가져오기 # current_content = await self.get_ckeditor_data() # if not current_content: # self.logger.log("기존 상세페이지 내용을 가져올 수 없습니다.", level=logging.WARNING) # return False # # 옵션 목록 부분만 새로 생성 # option_data = optionHandler.get_all_translated_options() # if not option_data: # self.logger.log("업데이트할 옵션 데이터가 없습니다.", level=logging.WARNING) # return False # # 새로운 옵션 목록 섹션 생성 # new_options_section = self._generate_options_section_for_ed_mode(option_data) # # 기존 옵션 목록 섹션을 찾아서 교체 시도 # updated_content = self._replace_or_append_options_section(current_content, new_options_section, option_data) # if updated_content is None: # # 패턴이 없으면 기존 내용을 건드리지 않음 # self.logger.log("ED 모드: 기존 옵션 목록 패턴이 없어 상세페이지를 업데이트하지 않습니다.", level=logging.INFO) # return False # # 패턴이 있으면 해당 부분만 교체된 내용으로 CKEditor 업데이트 # await input_field.click() # await input_field.fill("") # 전체 내용을 새로운 내용으로 교체 # await self.input_detail_text_chunked(input_field, updated_content) # self.logger.log("ED 모드: 상세페이지 옵션 목록 업데이트 완료", level=logging.INFO) # return True # except Exception as e: # self.logger.log(f"ED 모드 상세페이지 업데이트 중 오류: {e}", level=logging.ERROR, exc_info=True) # return False def _generate_options_section_for_ed_mode(self, option_data, clean_legacy=False): """ED 모드용 옵션 섹션 생성""" lines = [ "", "---", "### 📋 **옵션 목록** ", "", "" ] if clean_legacy: # 과거 형식 정리 시에는 번호 없이 깔끔하게 lines.append("") for i, option in enumerate(option_data.keys()): # 번호 제거한 깔끔한 형태 clean_option = self._remove_numbering_from_option(option) emoji = self._get_option_emoji(i, style="geometric") # 정리된 옵션은 일관된 스타일 lines.append(f"{emoji} **{clean_option}**") else: # 일반적인 경우: 이미 넘버링이 적용된 옵션명을 다양한 이모지와 함께 사용 for i, option in enumerate(option_data.keys()): emoji = self._get_option_emoji(i, style="geometric") # ED 모드는 기하학적 스타일 lines.append(f"{emoji} **{option}**") lines.extend([ "", "---", "", "" ]) return "\n".join(lines) def _remove_numbering_from_option(self, option_text): """옵션명에서 넘버링을 제거하고 깔끔한 텍스트만 반환""" # 앞쪽 넘버링 패턴들 제거 patterns = [ r'^[A-Z]\.\s*', # A. B. C. r'^[a-z]\.\s*', # a. b. c. r'^[ㄱ-ㅎ]\.\s*', # ㄱ. ㄴ. ㄷ. r'^[가-하]\.\s*', # 가. 나. 다. r'^\d+\.\s*', # 1. 2. 3. r'^[IVX]+\.\s*', # I. II. III. r'^[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳]\s*', # 동그라미 숫자 r'^\$\d+\.\s*', # $1. $2. r'^\(\d+\)\s*', # (1) (2) r'^\[\d+\]\s*', # [1] [2] r'^\d+\)\s*', # 1) 2) ] clean_text = option_text for pattern in patterns: clean_text = re.sub(pattern, '', clean_text) return clean_text.strip() def _get_option_emoji(self, index=0, style="geometric"): """옵션 목록용 이모지를 반환합니다""" emoji_sets = { "check": ["✅", "☑️", "✔️", "🗸"], # 체크 계열 "geometric": ["🔸", "🔹", "◆", "◇", "▪️", "▫️"], # 기하학적 모양 "star": ["⭐", "🌟", "✨", "💫", "🌠"], # 별 계열 "arrow": ["▶️", "➡️", "👉", "📌", "🔻"], # 화살표/포인터 계열 "colorful": ["🔴", "🟠", "🟡", "🟢", "🔵", "🟣"], # 색상 계열 } if style == "mixed": # 일관성을 위해 geometric 스타일로 통일 (상품 내 모든 옵션에 동일한 이모지 사용) chosen_set = emoji_sets["geometric"] return chosen_set[0] # 항상 첫 번째 이모지 사용 elif style == "random": # 완전 랜덤 선택 (모든 이모지 중에서 랜덤하게 하나 선택) all_emojis = [] for emoji_list in emoji_sets.values(): all_emojis.extend(emoji_list) return random.choice(all_emojis) elif style == "random_style": # 랜덤 스타일 선택 후 첫 번째 이모지만 사용 random.seed(42) # 고정 시드로 스타일 선택 chosen_style = random.choice(list(emoji_sets.keys())) chosen_set = emoji_sets[chosen_style] return chosen_set[0] # 해당 스타일의 첫 번째 이모지만 사용 elif style in emoji_sets: # 특정 스타일의 첫 번째 이모지만 사용 chosen_set = emoji_sets[style] return chosen_set[0] # 항상 첫 번째 이모지 사용 else: # 기본값 (geometric의 첫 번째 이모지) return emoji_sets["geometric"][0] def _replace_or_append_options_section(self, content, new_options_section, current_option_data=None): """기존 옵션 섹션을 찾아서 교체. 패턴이 없으면 None 반환 (기존 내용 보존)""" # 1. 현재 형식의 옵션 목록 패턴 찾기 current_options_pattern = r'---\s*\n### 📋 \*\*옵션 목록\*\*[^\n]*\n.*?\n---' if re.search(current_options_pattern, content, re.DOTALL): # 현재 형식 옵션 섹션이 있으면 교체 updated_content = re.sub(current_options_pattern, new_options_section.strip(), content, flags=re.DOTALL) self.logger.log("현재 형식의 옵션 섹션을 새로운 내용으로 교체했습니다.", level=logging.DEBUG) return updated_content # # 2. 과거 형식의 다양한 옵션 목록 패턴들 찾기 # legacy_patterns = [ # r'옵션\s*목록[^\n]*\n(.*?)(?=\n\n|\n---|\n###|$)', # 일반적인 "옵션 목록" 헤딩 # r'###\s*옵션\s*목록[^\n]*\n(.*?)(?=\n\n|\n---|\n###|$)', # ### 옵션 목록 # r'## 옵션\s*목록[^\n]*\n(.*?)(?=\n\n|\n---|\n###|$)', # ## 옵션 목록 # r'# 옵션\s*목록[^\n]*\n(.*?)(?=\n\n|\n---|\n###|$)', # # 옵션 목록 # r'\*\*옵션\s*목록\*\*[^\n]*\n(.*?)(?=\n\n|\n---|\n###|$)', # **옵션 목록** # ] # for pattern in legacy_patterns: # match = re.search(pattern, content, re.DOTALL | re.IGNORECASE) # if match: # self.logger.log(f"과거 형식의 옵션 목록 패턴을 발견했습니다: {pattern[:30]}...", level=logging.INFO) # # 과거 옵션 목록에서 번호 제거 및 정리 # legacy_options_content = match.group(1) if len(match.groups()) > 0 else match.group(0) # cleaned_options = self._clean_legacy_options(legacy_options_content) # if cleaned_options: # # 과거 형식 발견: 현재 옵션 데이터가 있으면 우선 사용, 없으면 과거 옵션 정리 # if current_option_data and len(current_option_data) > 0: # # 현재 옵션 데이터가 있으면 기존 new_options_section 사용 # updated_content = re.sub(pattern, new_options_section.strip(), content, flags=re.DOTALL | re.IGNORECASE) # self.logger.log(f"과거 형식 옵션 섹션을 현재 옵션 데이터로 업데이트했습니다.", level=logging.INFO) # else: # # 현재 옵션 데이터가 없으면 과거 옵션을 정리해서 사용 # clean_new_section = self._generate_options_section_for_ed_mode({}, clean_legacy=True) # # 과거 옵션들을 정리해서 새로운 섹션에 추가 # lines = clean_new_section.split('\n') # insert_index = -4 # "---" 앞에 삽입 # # 다양한 이모지로 옵션 정리 (과거 옵션은 특별한 스타일로) # for i, cleaned_option in enumerate(cleaned_options): # emoji = self._get_option_emoji(i, style="geometric") # 과거 옵션 정리는 일관된 스타일 # lines.insert(insert_index, f"{emoji} **{cleaned_option}**") # insert_index += 1 # final_new_section = '\n'.join(lines) # updated_content = re.sub(pattern, final_new_section.strip(), content, flags=re.DOTALL | re.IGNORECASE) # self.logger.log(f"과거 형식의 옵션 섹션을 번호 제거하여 정리했습니다: {len(cleaned_options)}개 옵션", level=logging.INFO) # return updated_content # 옵션 섹션이 없으면 기존 내용을 건드리지 않음 self.logger.log("기존 옵션 섹션 패턴을 찾을 수 없어 내용을 변경하지 않습니다.", level=logging.INFO) return None def _clean_legacy_options(self, legacy_content): """과거 형식의 옵션 목록에서 번호와 특수 기호를 제거하고 정리""" if not legacy_content or not legacy_content.strip(): return [] # 줄별로 분리 lines = legacy_content.split('\n') cleaned_options = [] for line in lines: line = line.strip() if not line: continue # 다양한 번호 패턴 제거 patterns_to_remove = [ r'^\d+\.\s*', # 1. 2. 3. 형태 r'^\d+\)\s*', # 1) 2) 3) 형태 r'^-\d+[\.,]?\s*', # -1, -2. -3 형태 r'^[-*•]\s*\d+[\.,]?\s*', # - 1. * 2. • 3. 형태 r'^[○●◦◉]\s*-?\d+[\.,]?\s*', # ○-1. ●2. ◦3 형태 (동그라미 등) r'^[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳]\s*', # 동그라미 숫자 r'^[가나다라마바사아자차카타파하]\.\s*', # 가. 나. 다. 형태 r'^[ㄱ-ㅎ]\.\s*', # ㄱ. ㄴ. ㄷ. 형태 r'^[A-Z]\.\s*', # A. B. C. 형태 r'^[a-z]\.\s*', # a. b. c. 형태 r'^[IVX]+\.\s*', # I. II. III. 로마숫자 형태 r'^[-*•]\s*', # 단순 리스트 마커 r'^\s*[\-\*\+]\s*', # 마크다운 리스트 마커 r'^\s*>\s*', # 인용 마커 ] cleaned_line = line for pattern in patterns_to_remove: cleaned_line = re.sub(pattern, '', cleaned_line, flags=re.IGNORECASE) # 남은 내용이 있으면 추가 cleaned_line = cleaned_line.strip() if cleaned_line: # HTML 태그나 마크다운 볼드 제거 cleaned_line = re.sub(r'<[^>]+>', '', cleaned_line) # HTML 태그 제거 cleaned_line = re.sub(r'\*\*(.*?)\*\*', r'\1', cleaned_line) # **텍스트** → 텍스트 cleaned_line = re.sub(r'\*(.*?)\*', r'\1', cleaned_line) # *텍스트* → 텍스트 cleaned_line = cleaned_line.strip() if cleaned_line and len(cleaned_line) > 1: # 최소 길이 확인 cleaned_options.append(cleaned_line) # 중복 제거 unique_options = [] seen = set() for option in cleaned_options: if option.lower() not in seen: unique_options.append(option) seen.add(option.lower()) self.logger.log(f"과거 옵션 목록 정리 완료: {len(legacy_content.split())}개 라인 → {len(unique_options)}개 옵션", level=logging.DEBUG) return unique_options def _get_random_biz_intro(self): """사업자 정보에서 랜덤 소개 문구를 가져옵니다 (VIP 등록모드용)""" try: from src.limited_contents.bizDBManager import BizDBManager import random # bizinfo.db에서 사업자 정보 가져오기 biz_db_path = "user_data/bizinfo.db" # 실제 경로에 맞게 수정 필요 biz_manager = BizDBManager(biz_db_path) # 선택마켓 중 스마트스토어 마켓의 사업자 정보 가져오기 smartstore_biz = self._get_smartstore_selected_biz(biz_manager) if not smartstore_biz: self.logger.log("설정된 선택마켓이 없어 기본 문구를 사용합니다.", level=logging.WARNING) return self._get_default_biz_intro() # 선택마켓의 사업자 이름으로 소개 문구 생성 biz_name = smartstore_biz.get('name', '').strip() if biz_name: default_intros = [ f"🏪 **{biz_name}**에서 직접 선별한 프리미엄 상품입니다.", f"✨ **{biz_name}** 전문 큐레이션으로 엄선된 고품질 제품을 만나보세요.", f"🎯 **{biz_name}**가 추천하는 베스트 아이템으로 특별한 경험을 선사합니다.", f"💎 **{biz_name}**의 전문성과 신뢰를 바탕으로 한 최고 품질의 상품입니다.", f"🛒 **{biz_name}**의 엄격한 품질 관리를 통과한 검증된 상품입니다.", f"🌟 **{biz_name}**에서 고객님을 위해 특별히 준비한 프리미엄 아이템입니다." ] selected_intro = random.choice(default_intros) self.logger.log(f"사업자({biz_name}) 기반 소개 문구 생성: {selected_intro}", level=logging.DEBUG) return selected_intro else: return self._get_default_biz_intro() except Exception as e: self.logger.log(f"사업자 정보 조회 중 오류: {e}", level=logging.ERROR, exc_info=True) return self._get_default_biz_intro() def _get_smartstore_selected_biz(self, biz_manager): """선택마켓 중 스마트스토어로 지정된 마켓의 사업자 정보를 가져옵니다""" try: # 선택마켓 매핑 정보 가져오기 market_selection = biz_manager.get_market_selection() # 스마트스토어 관련 마켓 타입들 확인 smartstore_types = ['smartstore', '스마트스토어', 'naver_smartstore', 'naver'] selected_biz_id = None for market_type, biz_id in market_selection.items(): # 스마트스토어 관련 마켓 타입 찾기 if any(stype in market_type.lower() for stype in ['smartstore', '스마트스토어', 'naver']): selected_biz_id = biz_id self.logger.log(f"스마트스토어 선택마켓 발견: {market_type} -> 사업자 ID {biz_id}", level=logging.DEBUG) break if not selected_biz_id: self.logger.log("선택마켓에 스마트스토어가 설정되지 않았습니다.", level=logging.INFO) return None # 해당 사업자 정보 조회 conn = biz_manager.conn cursor = conn.cursor() cursor.execute("SELECT * FROM biz WHERE id = ?", (selected_biz_id,)) result = cursor.fetchone() if result: biz_info = dict(result) self.logger.log(f"스마트스토어 선택마켓의 사업자 정보: {biz_info.get('name', 'Unknown')}", level=logging.DEBUG) return biz_info else: self.logger.log(f"사업자 ID {selected_biz_id}에 해당하는 사업자 정보를 찾을 수 없습니다.", level=logging.WARNING) return None except Exception as e: self.logger.log(f"스마트스토어 선택마켓 조회 중 오류: {e}", level=logging.ERROR, exc_info=True) return None def _get_default_biz_intro(self): """기본 소개 문구들 중 랜덤 선택""" import random default_intros = [ "🌟 **전문 구매대행 서비스**로 엄선된 고품질 제품을 안전하게 배송해드립니다.", "💯 **신뢰할 수 있는 품질 보증**과 함께 최상의 쇼핑 경험을 제공합니다.", "🚀 **빠르고 안전한 배송**으로 고객님의 소중한 시간을 절약해드립니다.", "🎁 **특별 할인 혜택**과 함께 프리미엄 상품을 합리적인 가격에 만나보세요.", "⭐ **고객 만족도 1위** 구매대행 서비스의 검증된 품질을 경험해보세요." ] selected = random.choice(default_intros) self.logger.log(f"기본 소개 문구 선택: {selected}", level=logging.DEBUG) return selected async def save_shortcut(self): """저장 단축키(Ctrl+S)를 실행하는 메서드""" try: # 저장 확인 다이얼로그가 있는지 먼저 확인 await self.handle_save_dialog_if_exists() self.logger.log("이미지 에디터 저장버튼 클릭", level=logging.DEBUG) await self.page.click(self.save_btn_locator) self.logger.log("수정사항 저장 버튼 클릭 완료", level=logging.DEBUG) editor_locator = self.page.locator(self.editor_modal_locator) if await editor_locator.is_visible(): # self.logger.log("이미지 에디터 모달 오픈 감지", level=logging.DEBUG) # await self.page.click(self.save_btn_locator) # self.logger.log("수정사항 저장 버튼 클릭 완료", level=logging.DEBUG) try: # 닫힘 대기 (예: 최대 5초) await self.page.wait_for_selector(self.editor_modal_locator, state="detached", timeout=5000) self.logger.log("에디터 정상 종료", level=logging.INFO) except Exception: self.logger.log("에디터 닫힘 timeout! ESC로 강제 종료 시도", level=logging.WARNING) await self.page.keyboard.press("1") await asyncio.sleep(1) await self.page.keyboard.press("Escape") await self.page.wait_for_selector(self.editor_modal_locator, state="detached", timeout=5000) except Exception as e: self.logger.log(f"저장 단축키 실행 실패: {e}", level=logging.ERROR, exc_info=True) async def handle_save_dialog_if_exists(self) -> bool: """ 저장 확인 다이얼로그가 있는지 확인하고 있으면 처리합니다. 다이얼로그가 없으면 아무 작업도 하지 않습니다. Returns: bool: 다이얼로그를 처리했으면 True, 없었으면 False """ try: dialog_locator = self.page.locator(self.dialog_save_btn_locator) try: is_visible = await dialog_locator.is_visible(timeout=1000) except Exception: is_visible = False if is_visible: self.logger.log("저장 확인 다이얼로그 감지됨 - 저장 버튼 클릭 시도", level=logging.INFO) await dialog_locator.click() await asyncio.sleep(0.2) return True else: self.logger.log("저장 확인 다이얼로그 없음", level=logging.DEBUG) return False except Exception as e: self.logger.log(f"저장 다이얼로그 확인 중 오류: {e}", level=logging.WARNING) return False async def click_and_wait_complete(self, btn_locator: str, timeout: float = 20.0, check_interval: float = 0.5) -> bool: """ 로딩 버튼 클릭 후 ant-btn-loading 상태 해제까지 대기. 성공시 True, 실패(타임아웃 등)시 False 반환. """ try: await self.page.click(btn_locator) self.logger.log(f"버튼 클릭: {btn_locator}", level=logging.INFO) # 로딩 상태 진입 대기 (최대 2초) loading_detected = False for attempt in range(10): try: is_loading = await asyncio.wait_for( self.page.locator(btn_locator).evaluate( "el => el.classList.contains('ant-btn-loading')" ), timeout=3.0 ) if is_loading: self.logger.log(f"로딩 상태 진입 감지: {btn_locator}", level=logging.DEBUG) loading_detected = True break except asyncio.TimeoutError: self.logger.log(f"로딩 상태 확인 타임아웃, 재시도 중... ({attempt+1}/10)", level=logging.WARNING) continue except (KeyError, Exception) as e: # Playwright 내부 에러 및 네트워크 에러 처리 if "data" in str(e) or isinstance(e, KeyError) or "ENOTFOUND" in str(e) or "getaddrinfo" in str(e): self.logger.log(f"Playwright/네트워크 에러 발생, 재시도 중... ({attempt+1}/10): {e}", level=logging.WARNING) await asyncio.sleep(0.5) # 약간 더 긴 대기 continue else: self.logger.log(f"예상치 못한 에러 발생: {e}", level=logging.ERROR) continue await asyncio.sleep(0.2) # 만약 2초 내에 로딩 진입을 못하면, 즉시 완료된 것으로 간주(성공 반환) if not loading_detected: self.logger.log("로딩 상태 미감지(즉시 완료로 판단)", level=logging.INFO) return True # 로딩 해제(완료)까지 대기 start = asyncio.get_event_loop().time() while True: try: is_loading = await asyncio.wait_for( self.page.locator(btn_locator).evaluate( "el => el.classList.contains('ant-btn-loading')" ), timeout=3.0 ) if not is_loading: self.logger.log(f"로딩 해제 감지(작업 완료): {btn_locator}", level=logging.INFO) return True except asyncio.TimeoutError: self.logger.log(f"로딩 해제 확인 타임아웃, 재시도 중...", level=logging.WARNING) # 타임아웃이 발생해도 계속 진행 except (KeyError, Exception) as e: # Playwright 내부 에러 및 네트워크 에러 처리 if "data" in str(e) or isinstance(e, KeyError) or "ENOTFOUND" in str(e) or "getaddrinfo" in str(e): self.logger.log(f"로딩 해제 확인 중 Playwright/네트워크 에러: {e}", level=logging.WARNING) await asyncio.sleep(0.5) # 내부 에러 발생 시에도 계속 진행 else: self.logger.log(f"로딩 해제 확인 중 예상치 못한 에러: {e}", level=logging.ERROR) if asyncio.get_event_loop().time() - start > timeout: self.logger.log(f"완료 대기 시간 초과: {btn_locator}", level=logging.WARNING) return False await asyncio.sleep(check_interval) except Exception as e: self.logger.log(f"버튼 클릭 또는 대기 중 예외 발생: {e}", level=logging.ERROR, exc_info=True) return False async def process_split_images_if_any(self, image_url: str, index: int) -> list: """ 매우 큰 원본이미지가 있을 경우에 대한 처리 분할된 이미지들이 있는 경우 각각을 처리 Args: image_url: 원본 이미지 URL index: 이미지 인덱스 Returns: list: 분할된 이미지 처리 결과들 """ try: # 서버가 분할 및 처리 결과를 child_results로 반환하므로, # 메인앱에서는 별도 분할 처리를 수행하지 않습니다. self.logger.log("분할 이미지 처리는 서버에서 수행됩니다(child_results 사용).", level=logging.DEBUG) return [] except Exception as e: self.logger.log(f"분할 이미지 처리 확인 중 오류: {e}", level=logging.ERROR, exc_info=True) return [] async def generate_and_add_product_summary(self, ocr_data_list: list, input_field): """ 수집된 OCR 데이터를 바탕으로 GPT를 이용해 상품 요약을 생성하고 상세페이지에 추가 Args: ocr_data_list: 모든 이미지에서 수집된 OCR 데이터 리스트 input_field: 텍스트 입력 필드 """ try: if not ocr_data_list or not self.gpt_client: return # OCR 데이터를 GPT 프롬프트용으로 정리 product_text_summary = self._prepare_ocr_data_for_gpt(ocr_data_list) if not product_text_summary: self.logger.log("GPT 요약용 OCR 데이터가 없습니다.", level=logging.DEBUG) return # GPT를 이용한 상품 요약 생성 summary = await self._generate_product_summary_with_gpt(product_text_summary) if summary: # 상세페이지에 요약 추가 await self._add_summary_to_detail_page(summary, input_field) self.logger.log("상품 요약이 상세페이지에 추가되었습니다.", level=logging.INFO) else: self.logger.log("GPT 상품 요약 생성에 실패했습니다.", level=logging.WARNING) except Exception as e: self.logger.log(f"상품 요약 생성 및 추가 중 오류: {e}", level=logging.ERROR, exc_info=True) def _prepare_ocr_data_for_gpt(self, ocr_data_list: list) -> str: """ OCR 데이터를 GPT 프롬프트용 텍스트로 정리 Args: ocr_data_list: OCR 데이터 리스트 Returns: str: GPT 프롬프트용 정리된 텍스트 """ try: # 모든 OCR 데이터를 카테고리별로 병합 all_specs = [] all_colors = [] all_materials = [] all_warnings = [] all_features = [] all_usage = [] all_others = [] for data in ocr_data_list: classified_info = data.get('classified_info', {}) all_specs.extend(classified_info.get('specs', [])) all_colors.extend(classified_info.get('colors', [])) all_materials.extend(classified_info.get('materials', [])) all_warnings.extend(classified_info.get('warnings', [])) all_features.extend(classified_info.get('features', [])) all_usage.extend(classified_info.get('usage', [])) all_others.extend(classified_info.get('others', [])) # 중복 제거 all_specs = list(set(all_specs)) all_colors = list(set(all_colors)) all_materials = list(set(all_materials)) all_warnings = list(set(all_warnings)) all_features = list(set(all_features)) all_usage = list(set(all_usage)) all_others = list(set(all_others)) # GPT 프롬프트용 텍스트 생성 summary_parts = [] if all_specs: summary_parts.append(f"제품 사양: {'; '.join(all_specs[:10])}") # 최대 10개 if all_features: summary_parts.append(f"주요 특징: {'; '.join(all_features[:10])}") if all_materials: summary_parts.append(f"소재/재질: {'; '.join(all_materials[:5])}") if all_colors: summary_parts.append(f"색상: {'; '.join(all_colors[:5])}") if all_warnings: summary_parts.append(f"주의사항: {'; '.join(all_warnings[:5])}") if all_usage: summary_parts.append(f"사용법: {'; '.join(all_usage[:5])}") return '\n'.join(summary_parts) if summary_parts else '' except Exception as e: self.logger.log(f"OCR 데이터 정리 중 오류: {e}", level=logging.ERROR, exc_info=True) return '' async def _generate_product_summary_with_gpt(self, product_info: str) -> str: """ GPT를 이용해 상품 요약 생성 Args: product_info: 정리된 상품 정보 텍스트 Returns: str: 생성된 상품 요약 """ try: prompt = f""" 다음은 상품 이미지에서 추출한 정보입니다. 이 정보를 바탕으로 고객이 구매 결정에 도움이 되는 상품 요약을 작성해주세요. 상품 정보: {product_info} 요구사항: 1. 마크다운 형식으로 작성 2. 주요 특징, 사양, 주의사항 등을 포함 3. 구매대행 서비스임을 고려한 안내문구 포함 4. 300자 이내로 간결하게 작성 5. 고객 친화적이고 신뢰감 있는 톤앤매너 형식: ## 📋 상품 정보 요약 ### 🎯 주요 특징 - [특징 1] - [특징 2] ### 📏 제품 사양 - [사양 정보] ### ⚠️ 주의사항 - [주의사항] ### 💬 구매 안내 전문 구매대행 서비스로 안전하고 신속한 배송을 보장합니다. """ # GPT API 호출 response = await self.gpt_client.chat_completion( messages=[{"role": "user", "content": prompt}], model="gpt-4", max_tokens=500, temperature=0.3 ) if response and 'choices' in response and len(response['choices']) > 0: return response['choices'][0]['message']['content'].strip() else: self.logger.log("GPT 응답이 올바르지 않습니다.", level=logging.WARNING) return None except Exception as e: self.logger.log(f"GPT 상품 요약 생성 중 오류: {e}", level=logging.ERROR, exc_info=True) return None async def _add_summary_to_detail_page(self, summary: str, input_field): """ 생성된 상품 요약을 상세페이지 마지막에 추가 Args: summary: 생성된 상품 요약 input_field: 텍스트 입력 필드 """ try: # 커서를 마지막으로 이동 await self.page.keyboard.press("End") await self.page.keyboard.press("Enter") await self.page.keyboard.press("Enter") # 구분선 추가 separator = "\n---\n\n" await input_field.type(separator, delay=1) # 요약 텍스트 청크 단위로 입력 await self.input_detail_text_chunked(input_field, summary) self.logger.log("상품 요약이 상세페이지에 성공적으로 추가되었습니다.", level=logging.INFO) except Exception as e: self.logger.log(f"상품 요약을 상세페이지에 추가하는 중 오류: {e}", level=logging.ERROR, exc_info=True) async def collect_ocr_data_from_db(self) -> list: """ 데이터베이스에서 최근 저장된 OCR 데이터를 수집 Returns: list: OCR 데이터 리스트 """ try: # 데이터베이스 파일 경로 if not self.imageProcessor or not hasattr(self.imageProcessor, 'base_dir'): self.logger.log("⚠️ ImageProcessor가 초기화되지 않아 OCR 데이터베이스 접근 불가", level=logging.WARNING) return [] db_path = os.path.join(self.imageProcessor.base_dir, "user_data", "product_ocr_data.db") if not os.path.exists(db_path): self.logger.log("OCR 데이터베이스 파일이 존재하지 않습니다.", level=logging.DEBUG) return [] conn = sqlite3.connect(db_path) cursor = conn.cursor() # 최근 10분 내 저장된 데이터를 조회 (현재 처리 세션의 데이터) recent_time = datetime.now() - timedelta(minutes=10) cursor.execute(''' SELECT raw_texts, text_count FROM ocr_raw_data WHERE created_at >= ? ORDER BY created_at DESC ''', (recent_time.strftime('%Y-%m-%d %H:%M:%S'),)) rows = cursor.fetchall() conn.close() # 데이터 파싱 (단순화됨) ocr_data_list = [] for row in rows: try: raw_texts = json.loads(row[0]) if row[0] else [] text_count = row[1] if len(row) > 1 else len(raw_texts) ocr_data_list.append({ 'raw_texts': raw_texts, 'text_count': text_count }) except json.JSONDecodeError as e: self.logger.log(f"OCR 데이터 파싱 오류: {e}", level=logging.WARNING) continue self.logger.log(f"데이터베이스에서 {len(ocr_data_list)}개의 OCR 데이터를 수집했습니다.", level=logging.DEBUG) return ocr_data_list except Exception as e: self.logger.log(f"OCR 데이터 수집 중 오류: {e}", level=logging.ERROR, exc_info=True) return [] # GPT 관련 기능들은 현재 비활성화됨 (gpt_client 사용 불가) # 향후 필요시 다시 활성화 가능