from multiprocessing import set_start_method try: set_start_method("spawn", force=True) except RuntimeError: pass # 이미 설정되어 있으면 무시 from playwright.async_api import async_playwright, TimeoutError from playwright.async_api import Error as PlaywrightError from playwright.async_api import expect from playwright.__main__ import main as playwright_main from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, Slot import re # import pyautogui import time import json import stat import asyncio import os, sys, random import requests import inspect import gc import psutil import shutil from datetime import datetime, timedelta from typing import Dict, List, Optional, Tuple import math from io import BytesIO from PIL import Image as _PIL_Image # from src.wh_search import WhaleSearchParser # from src.wh_img_trans import WhaleImageTranslator # from src.whale_new import WhaleTranslator # from src.modules.clipboardImageManager import ClipboardImageManager from src.contents.option import OptionHandler from src.contents.price import PriceHandler # from src.contents.details import DetailHandler from src.contents.details import DetailHandler from src.contents.titleGenerator import TitleGenerator from src.contents.thumb import ThumbnailHandler from src.translator.papago_translator import PapagoTranslator # from src.modules.image_processor import ImageProcessor from src.contents.tags import TagsHandler from src.titleManager.sp_ForbiddenM import SupabaseForbiddenWordManager from src.titleManager.gpt_client import GPTClient from src.titleManager.grok_client import GrokClient # from src.modules.image_worker_manager import ImageWorkerManager from src.modules.image_worker_client import ImageWorkerClient import tempfile import logging # 새로운 모듈 import import string import uuid import threading from typing import Dict, Optional, Tuple class BrowserController(QThread): # 브라우저 시작 시그널 정의 browser_started = Signal() browser_create_error = Signal(str) image_processor_error = Signal(str) image_worker_fatal_signal = Signal(str) premium_event_started = Signal(str) unknown_browser_error = Signal(str) browser_login_error = Signal(str) browser_ad1_close_error = Signal(str) browser_ad2_close_error = Signal(str) browser_group_list_error = Signal(str) browser_handler_update_error = Signal(str) browser_parsing_page_error = Signal(str) browser_registered_product_page_error = Signal(str) browser_new_product_page_error = Signal(str) complete_remove_market_info_job = Signal(str) # 마켓정보 변경 관련 시그널 market_change_completed = Signal(dict) # 변경 완료 시 결과 전달 # 업로드 진행상황 관련 시그널 upload_progress_updated = Signal(int, int) # (현재 상품, 총 상품) upload_step_changed = Signal(str, int) # (단계명, 진행률 %) upload_log_message = Signal(str) # 로그 메시지 upload_completed = Signal(bool, str) # (성공여부, 메시지) # 단계별 완료 시그널 step_completed = Signal(str, bool) # (단계명, 성공여부) # 수동 로그인 요청 시그널 manual_login_required = Signal(str, str) # (마켓명, 메시지) # 로그인 완료 시그널 (QMessageBox 자동 닫기용) login_completed_signal = Signal(str) # (마켓명) # 번역 작업 시작, 완료, 오류 시그널 정의 translation_started = Signal() translation_completed = Signal(int) translation_error = Signal(str) start_stage_signal = Signal(int) complete_stage_signal = Signal(int) total_progressbar_signal = Signal(int, int) # (현재 값, 총 값) update_detail_progress_signal = Signal(int, int) # (현재 값, 총 값) set_progress_visible_signal = Signal(bool) # ProgressBar 표시/숨김 percentyJob_button_Enable = Signal(bool) selected_group_name_signal = Signal(str, int) # 선택된 그룹 이름 시그널 group_list_signal = Signal(list) # 그룹 리스트 시그널 # 일시정지 확인 시그널 pause_confirmed = Signal() def __init__(self, app, logger, locator_manager, price_setting_diag, detail_text_widget, login_infos, toggle_states, user_id, supabase_manager): super().__init__() self.app = app # Main_GUI 객체 저장 self.logger = logger self.locator_manager = locator_manager self.price_setting_diag = price_setting_diag self.detail_text_widget = detail_text_widget self.toggle_states = toggle_states self.login_infos = login_infos self.user_id = user_id self.supabase_manager = supabase_manager self.is_trans_enabled = False self.authenticated_by_admin = False self.user_membership_level = None self.is_valid_level = False self.biz_dbManager = None self.image_processor = None # self.image_worker_client = None self.is_image_processor_init = False self._restart_in_progress: bool = False # 마지막 선택된 그룹 기억하기 위한 변수 self.last_selected_group = None self.curr_page_idx: int = 1 # 1부터 시작 self.curr_product_idx: int = 0 # 0-based, 현재 페이지 안에서 몇 번째 상품까지 끝냈는지 self.curr_group_idx: int = 0 # 0-based, 현재 그룹 안에서 몇 번째 그룹까지 끝냈는지 # self.image_worker_mgr = None self.image_worker_restart_every = self.toggle_states.get("image_worker_restart_every", 10) # # 컨텍스트 자동 재시작 주기(분) 설정 및 태스크 핸들 # self.context_restart_interval_min = self.toggle_states.get("context_restart_interval_min", None) # self.context_restart_task = None # 상품 N개마다 컨텍스트를 재시작하기 위한 임계치 (기본 20개) self.products_per_context_restart = self.toggle_states.get("products_per_context_restart", 20) self.last_restart_ts = 0 self.route_registered = False self.browser_task_started = False self.parsing_page = None self.current_options_info = None self.chrome_hwnd = None self.base_path = self.toggle_states['base_dir'] self.logger.log(f"base_path: {self.base_path}", level=logging.DEBUG) # 1. 임시 이미지 폴더 (작업 중) self.TEMP_IMAGE_DIR = self.toggle_states.get('TEMP_IMAGE_DIR', "") # 2. 에러 스크린샷 폴더 (장기 보관) self.ERROR_SCREENSHOT_DIR = self.toggle_states.get('ERROR_SCREENSHOT_DIR', "") # 3. 멤버십 레벨 self.membership_level = self.toggle_states.get('membership_level', 'basic') self.biz_info = None # 이미지 처리 통계 누적 (세션 동안) self._stats = { 'thumb_total': 0, 'thumb_translated': 0, 'thumb_removed': 0, 'option_total': 0, 'option_translated': 0, 'detail_total': 0, 'detail_translated': 0, # 인페인팅 방식/장치 집계 'inpaint_cv': 0, 'inpaint_migan': 0, 'inpaint_request': 0, 'inpaint_device_cpu': 0, 'inpaint_device_cuda': 0, 'inpaint_device_directml': 0, 'inpaint_device_server': 0, } self.clear_old_screenshot_dirs(days=14) self.logger.log(f"error 디렉토리 삭제 완료", level=logging.INFO) # self.whale_translator = WhaleTranslator(logger, debug_flag=self.toggle_states['debug_mode']) self.playwright = None self.browser = None self.page = None self.parsing_page = None self.loop = None # 이벤트 루프를 저장할 변수 # 일시 정지 기능을 위한 QMutex와 QWaitCondition 설정 self.pause_mutex = QMutex() self.pause_condition = QWaitCondition() self.is_paused = False # 일시 정지 상태 플래그 self.gpt_model = self.toggle_states.get('gpt_model', 'gpt-4o-mini') self.logger.log(f"gpt_model : {self.gpt_model}", level=logging.DEBUG) # Grok 모델 선택 시 GrokClient 사용, 그 외에는 GPTClient 사용 if self.gpt_model and self.gpt_model.startswith("grok-"): self.logger.log(f"🚀 Grok 모델 사용: {self.gpt_model}", level=logging.INFO) self.gpt_client = GrokClient(logger=self.logger, supabase_manager=self.supabase_manager, model=self.gpt_model, user_id=self.user_id, membership_level=self.membership_level) else: self.gpt_client = GPTClient(logger=self.logger, supabase_manager=self.supabase_manager, model=self.gpt_model, user_id=self.user_id, membership_level=self.membership_level) self.forbidden_word_manager = SupabaseForbiddenWordManager(self.logger, self.supabase_manager, self.user_id) # self.clipboardImageManager = ClipboardImageManager(logger=self.logger, watermark_font_size=36, debug_flag=self.toggle_states['debug_mode']) # ImageProcessor를 먼저 초기화 # self.imageProcessor = ImageProcessor(self.logger, self.page, self.whale_translator, self.clipboardImageManager, self.TEMP_IMAGE_DIR, self.toggle_states) self.papago_translator = PapagoTranslator(self.logger, self, self.page) # ImageProcessor를 포함하여 다른 핸들러들 초기화 self.optionHandler = OptionHandler(self.locator_manager, self, self.TEMP_IMAGE_DIR, self.logger, self.gpt_client, imageProcessor=self.image_processor, update_detail_progress_signal=self.update_detail_progress_signal, set_progress_visible_signal=self.set_progress_visible_signal, toggle_states=self.toggle_states, papago_translator=self.papago_translator) self.priceHandler = PriceHandler(self.locator_manager, self, self.logger, self.optionHandler, self.price_setting_diag, self.toggle_states, debug_flag=self.toggle_states['debug_mode']) self.thumbnailHandler = ThumbnailHandler(self.locator_manager, self, self.logger, self.toggle_states, self.image_processor, self.update_detail_progress_signal, self.set_progress_visible_signal, self.TEMP_IMAGE_DIR) # self.titleGenerator = TitleGenerator(self.locator_manager, self, self.logger, self.whale_translator, self.toggle_states, self.gpt_client, self.forbidden_word_manager, self.user_id, self.supabase_manager) self.titleGenerator = TitleGenerator(self.locator_manager, self, self.logger, self.toggle_states, self.gpt_client, self.forbidden_word_manager, self.user_id, self.supabase_manager, papago_translator=self.papago_translator) self.tagsHandler = TagsHandler(self.locator_manager, self, self.logger, self.toggle_states) self.detailHandler = DetailHandler(self.locator_manager, self, self.image_processor, self.detail_text_widget, self.TEMP_IMAGE_DIR, self.logger, self.gpt_client, self.update_detail_progress_signal, self.set_progress_visible_signal, self.toggle_states) # BrowserController에 해당하는 모든 locator를 정의 self.chrome_window_name = self.locator_manager.get_locator('BrowserControl', 'chrome_window_name') self.login_email_locator = self.locator_manager.get_locator('BrowserControl', 'login_email_locator') self.login_password_locator = self.locator_manager.get_locator('BrowserControl', 'login_password_locator') self.login_button_locator = self.locator_manager.get_locator('BrowserControl', 'login_button_locator') self.admin_toggle_locator = self.locator_manager.get_locator('BrowserControl', 'admin_toggle_locator') self.staff_id_locator = self.locator_manager.get_locator('BrowserControl', 'staff_id_locator') self.staff_login_button_locator = self.locator_manager.get_locator('BrowserControl', 'staff_login_button_locator') self.close_ad_dialog_locator = self.locator_manager.get_locator('BrowserControl', 'close_ad_dialog_locator') self.close_ad_button_locator = self.locator_manager.get_locator('BrowserControl', 'close_ad_button_locator') self.total_product_count_locator = self.locator_manager.get_locator('BrowserControl', 'total_product_count_locator') self.total_product_count_for_registed_locator = self.locator_manager.get_locator('BrowserControl', 'total_product_count_for_registed_locator') self.items_per_page_locator = self.locator_manager.get_locator('BrowserControl', 'items_per_page_locator') self.product_edit_dialog_open_locator = self.locator_manager.get_locator('BrowserControl', 'product_edit_dialog_open_locator') self.product_dialog_close_btn = self.locator_manager.get_locator('BrowserControl', 'product_dialog_close_btn') self.product_parent_locator= self.locator_manager.get_locator('BrowserControl', 'product_parent_locator') self.product_name_inner_locator = self.locator_manager.get_locator('BrowserControl', 'product_name_inner_locator') self.product_price_inner_locator = self.locator_manager.get_locator('BrowserControl', 'product_price_inner_locator') self.product_image_inner_locator = self.locator_manager.get_locator('BrowserControl', 'product_image_inner_locator') self.product_name_for_ed_template = self.locator_manager.get_locator('BrowserControl', 'product_name_for_ed_template') self.product_price_for_ed_template = self.locator_manager.get_locator('BrowserControl', 'product_price_for_ed_template') self.product_image_for_ed_template = self.locator_manager.get_locator('BrowserControl', 'product_image_for_ed_template') self.current_page = self.locator_manager.get_locator('BrowserControl', 'current_page') self.next_page_button_template = self.locator_manager.get_locator('BrowserControl', 'next_page_button_template') self.new_product_page_locator = self.locator_manager.get_locator('BrowserControl', 'new_product_page_locator') self.registered_product_page_locator = self.locator_manager.get_locator('BrowserControl', 'registered_product_page_locator') self.current_page_locator = self.locator_manager.get_locator('BrowserControl', 'current_page_locator') # self.source_button_locator = self.locator_manager.get_locator('BrowserControl', 'source_button_locator') # self.ck_source_editing_area_locator = self.locator_manager.get_locator('BrowserControl', 'ck_source_editing_area_locator') # self.cke_text_editing_area_locator = self.locator_manager.get_locator('BrowserControl', 'cke_text_editing_area_locator') # self.cke_img_file_input_locator = self.locator_manager.get_locator('BrowserControl', 'cke_img_file_input_locator') self.option_input_field_locator = self.locator_manager.get_locator('BrowserControl', 'option_input_field_locator') self.title_tab_locator = self.locator_manager.get_locator('BrowserControl', 'title_tab_locator') self.option_tab_locator = self.locator_manager.get_locator('BrowserControl', 'option_tab_locator') self.price_tab_locator = self.locator_manager.get_locator('BrowserControl', 'price_tab_locator') self.tag_tab_locator = self.locator_manager.get_locator('BrowserControl', 'tag_tab_locator') self.thumb_tab_locator = self.locator_manager.get_locator('BrowserControl', 'thumb_tab_locator') self.detail_tab_locator = self.locator_manager.get_locator('BrowserControl', 'detail_tab_locator') # self.upload_tab_locator = self.locator_manager.get_locator('BrowserControl', 'upload_tab_locator') self.save_button_locator = self.locator_manager.get_locator('BrowserControl', 'save_button_locator') self.group_dropdown_locator = self.locator_manager.get_locator('BrowserControl', 'group_dropdown_locator') self.group_dropdown_for_ed_locator = self.locator_manager.get_locator('BrowserControl', 'group_dropdown_for_ed_locator') self.group_index_template = self.locator_manager.get_locator('BrowserControl', 'group_index_template') self.group_options_selector_locator = self.locator_manager.get_locator('BrowserControl', 'group_options_selector_locator') self.dropdown_openstatus_locator = self.locator_manager.get_locator('BrowserControl', 'dropdown_openstatus_locator') self.selected_group_name_locator = self.locator_manager.get_locator('BrowserControl', 'selected_group_name_locator') self.selected_group_name_for_ed_locator = self.locator_manager.get_locator('BrowserControl', 'selected_group_name_for_ed_locator') self.product_edit_buttons = self.locator_manager.get_locator('BrowserControl', 'product_edit_buttons') self.product_memo_buttons = self.locator_manager.get_locator('BrowserControl', 'product_memo_buttons') self.memo_dialog_close_buttons = self.locator_manager.get_locator('BrowserControl', 'product_memo_dialog_close_buttons') self.memo_input_locator = self.locator_manager.get_locator('BrowserControl', 'memo_input_locator') self.memo_exposer_locator = self.locator_manager.get_locator('BrowserControl', 'memo_exposer_locator') self.memo_save_btn_locator = self.locator_manager.get_locator('BrowserControl', 'memo_save_btn_locator') self.welcome_popup_closeBTN_selector = self.locator_manager.get_locator('BrowserControl', 'welcome_popup_closeBTN_selector') self.modal_selector = self.locator_manager.get_locator('BrowserControl', 'modal_selector') self.dialog_ok_selector = self.locator_manager.get_locator('BrowserControl', 'dialog_ok_selector') self.dialog_selector = self.locator_manager.get_locator('BrowserControl', 'dialog_selector') self.product_chain_cards = self.locator_manager.get_locator('BrowserControl', 'product_chain_cards') self.product_edit_chain_buttons = self.locator_manager.get_locator('BrowserControl', 'product_edit_chain_buttons') self.product_memo_chain_buttons = self.locator_manager.get_locator('BrowserControl', 'product_memo_chain_buttons') self.product_shipping_chain_buttons = self.locator_manager.get_locator('BrowserControl', 'product_shipping_chain_buttons') # self.text_templates = self.locator_manager.selectors.get('DetailPageTextTemplates', {}) # # 스레드 종료 시 close_whale_window_if_exists 호출 # self.finished.connect(self.cleanup) # 서버 헬스체크 캐시 (URL -> (status, timestamp)) self._server_health_cache: Dict[str, Tuple[bool, float]] = {} self._health_check_cache_ttl = 300 # 5분간 캐시 유지 # 헬스체크 타임아웃 설정 self._health_check_timeout = 5 # 5초 타임아웃 # 서버 성능 모니터링 (URL -> response_time_list) self._server_performance: Dict[str, list] = {} # 폴백 서버 설정 self._fallback_servers = { 'request_inpainting_server_url': [], 'request_rembg_server_url': [], 'request_rembg_server_url_local': [] } # 주기적 헬스체크 설정 self._periodic_health_check_enabled = True self._periodic_health_check_interval = 600 # 10분마다 self._last_periodic_check = 0 def update_image_processor_info(self, is_trans_enabled, authenticated_by_admin, user_membership_level): self.is_trans_enabled = is_trans_enabled self.authenticated_by_admin = authenticated_by_admin self.user_membership_level = user_membership_level self.is_valid_level = self.user_membership_level == 'basic' or self.user_membership_level == 'premium' or self.user_membership_level == 'vip' def init_image_processor(self): try: self.image_processor = ImageWorkerClient(logger=self.logger, api_base="http://127.0.0.1:8009") self.logger.log(f"ImageWorker 초기화 완료", level=logging.INFO) return True except Exception as e: self.logger.log(f"ImageWorker 초기화 실패: {e}", level=logging.ERROR) return False def image_worker_fatal(self, msg: str): self.logger.log(f"ImageWorker 오류 발생: {msg}", level=logging.ERROR) self.image_worker_fatal_signal.emit(msg) # UI 로 전달 # 하위 핸들러에서 통계 반영 시 호출 def add_image_edit_stats(self, **kwargs): try: for k, v in kwargs.items(): if k in self._stats and isinstance(v, (int, float)): self._stats[k] += int(v) except Exception: pass def update_toggle_states(self, toggle_states): self.toggle_states = toggle_states self.logger.log(f"toggle_states : {self.toggle_states}", level=logging.DEBUG) self.unwanted_words = self.toggle_states['unwanted_words'] self.logger.log(f"unwanted_words : {self.unwanted_words}", level=logging.DEBUG) def update_biz_info(self, biz_info): self.biz_info = biz_info self.logger.log(f"biz_info : {self.biz_info}", level=logging.DEBUG) async def init_routes(self, page): if not self.route_registered: await self.page.route("**/translate-inpaint", self.handle_route) self.route_registered = True self.logger.log("translate-inpaint 라우트 등록 완료", level=logging.DEBUG) async def handle_route(self, route, request): self.logger.log(f"요청 URL: {request.url}", level=logging.DEBUG) if "translate-inpaint" in request.url: try: # 타임아웃 설정으로 API 응답 지연 방지 (15초) response = await asyncio.wait_for(route.fetch(), timeout=15.0) # 응답 본문 추출 body = await response.text() # JSON 파싱 data = json.loads(body) # self.logger.log(f"응답 본문: {body}", level=logging.DEBUG) # unwanted_words = { # "보증": "_보증은개뿔_치환용임_", # "보상": "", # 빈 문자열은 삭제 # } for block in data["data"]["translateImage"]["blocks"]: text = block["transText"] for word, repl in self.unwanted_words.items(): # "단어 경계" 기준 치환. 한글, 영어, 숫자 모두 경계 처리 text = re.sub( rf'(?>> div:has(span.a11y-hidden:has-text("닫기")) button' try: # 여러 요소가 매칭될 수 있으므로 모두 가져와서 텍스트 확인 close_buttons = page.locator(self.welcome_popup_closeBTN_selector) count = await close_buttons.count() if count == 0: # 요소가 없으면 그냥 종료 return # 각 버튼의 텍스트를 확인해서 닫기 관련 텍스트가 있는지 검사 for i in range(count): button = close_buttons.nth(i) try: # 버튼 텍스트 확인 button_text = await button.inner_text() # 버튼의 aria-label이나 title 속성도 확인 aria_label = await button.get_attribute('aria-label') or "" title = await button.get_attribute('title') or "" # 닫기 관련 텍스트가 포함되어 있는지 확인 close_keywords = ['닫기', 'close', '닫', '취소', '취소하기', '확인'] combined_text = f"{button_text} {aria_label} {title}".lower() if any(keyword in combined_text for keyword in close_keywords): await button.wait_for(state='visible', timeout=timeout_sec * 1000) await button.click() self.logger.log(f"닫기 버튼 클릭 성공: '{button_text}'", level=logging.DEBUG) return except Exception as e: continue # 적절한 버튼을 찾지 못했으면 첫 번째 버튼 클릭 시도 first_button = close_buttons.first await first_button.wait_for(state='visible', timeout=timeout_sec * 1000) await first_button.click() self.logger.log("적절한 닫기 버튼을 찾지 못해 첫 번째 버튼 클릭", level=logging.DEBUG) except TimeoutError: # timeout 내에 닫기 버튼이 안 뜨면 그냥 넘어감 self.logger.log("환영 다이얼로그가 감지되지 않았습니다.", level=logging.DEBUG) finally: await page.keyboard.press('Escape') await asyncio.sleep(0.37) await page.keyboard.press('Escape') def allow_file_url_access(self, user_data_dir, extension_id, logger=None): pref_path = os.path.join(user_data_dir, "Default", "Preferences") if not os.path.exists(pref_path): if logger: logger.log("Preferences 파일이 아직 없습니다. 브라우저를 한 번 실행 후 다시 시도하세요.", level=20) return with open(pref_path, "r", encoding="utf-8") as f: prefs = json.load(f) # 확장 ID에 대한 설정이 없으면 새로 만듦 if "extensions" not in prefs: prefs["extensions"] = {"settings": {}} if "settings" not in prefs["extensions"]: prefs["extensions"]["settings"] = {} if extension_id not in prefs["extensions"]["settings"]: prefs["extensions"]["settings"][extension_id] = {} prefs["extensions"]["settings"][extension_id]["granted_file_scheme"] = True prefs["extensions"]["settings"][extension_id]["state"] = 1 with open(pref_path, "w", encoding="utf-8") as f: json.dump(prefs, f, indent=2) if logger: logger.log(f"{extension_id} 확장프로그램의 file:// 접근 권한을 자동 허용으로 설정했습니다.", level=20) async def start_browser_async(self): """비동기 Playwright 초기화 및 로그인 수행""" try: # ED_MODE에서도 이미지 번역이 필요한 경우 이미지 프로세서 초기화 is_ed_mode = self.toggle_states.get('ed_mode', False) detail_IMGTrans = self.toggle_states.get('detail_IMGTrans', False) optionIMGTrans = self.toggle_states.get('optionIMGTrans', False) thumb = self.toggle_states.get('thumb', False) # 일반 모드이거나, ED_MODE에서 이미지 번역이 필요한 경우 초기화 needs_image_processor = not is_ed_mode or detail_IMGTrans or optionIMGTrans or thumb if needs_image_processor: if is_ed_mode: self.logger.log(f"ED_MODE에서 이미지 번역이 필요하여 이미지 프로세서 초기화 중...", level=logging.DEBUG) else: self.logger.log(f"일반모드로 이미지 프로세서 초기화 중...", level=logging.DEBUG) self.is_image_processor_init = self.init_image_processor() if not self.is_image_processor_init: self.logger.log(f"이미지 프로세서를 사용하지 않습니다.", level=logging.INFO) self.image_processor_error.emit("이미지 프로세서 초기화 오류로 이미지번역이 실행되지 않습니다.") if self.user_membership_level == "premium" and self.authenticated_by_admin: self.logger.log(f"한시적 이벤트로 7월 한달간 프리미엄 이상 회원에게 자체번역을 제공합니다.", level=logging.INFO) self.premium_event_started.emit("한시적 이벤트로 7월 한달간 프리미엄 이상 회원에게 자체번역을 제공합니다.\n 자체번역 설정은 저장되지 않으니 실행때마다 설정해주세요") # self.whale_translator.start_trans_browser() # # 바뀐 검색주소 # # 'https://search.shopping.naver.com/search/all?adQuery=주차차단기&agency=true&frm=MOSCPRO&origQuery=주차차단기&pagingIndex=1&pagingSize=40&productSet=overseas&query=주차차단기&sort=rel×tamp=&viewType=list' # self.whale_translator.lens_Search(image_url="https://file.percenty.co.kr/public/652bed8e865b1f32ea62bf1f/products/6847c740ecbe0d32a37a8570/b637c29c-80eb-43eb-975f-58279bc5fde1.jpg") # time.sleep(1000000) try: self.logger.log('알바생 브라우저를 실행합니다...', level=logging.DEBUG) # WhaleTranslator 필요 여부 확인 및 초기화 optionIMGTrans_status = self.toggle_states.get('optionIMGTrans', False) detail_IMGTrans_status = self.toggle_states.get('detail_IMGTrans', False) thumb_status = self.toggle_states.get('thumb', False) debug_mode = self.toggle_states.get('debug_mode', False) id_ed_mode = is_ed_mode if id_ed_mode: debug_mode = True self.logger.log(f"id_ed_mode: {id_ed_mode}", level=logging.DEBUG) self.logger.log(f"optionIMGTrans_status: {optionIMGTrans_status}, detail_IMGTrans_status: {detail_IMGTrans_status}, thumb_status: {thumb_status}", level=logging.DEBUG) # Playwright 시작 및 브라우저 실행 try: # os.environ["PLAYWRIGHT_BROWSERS_PATH"] = os.path.expandvars(r"%LOCALAPPDATA%\ms-playwright") self.playwright = await async_playwright().start() except Exception as e: self.logger.log(f"브라우저 기동 오류: {e}", level=logging.ERROR, exc_info=True) return self.browser_path = os.path.join(self.base_path, 'browsers', 'chromium-1200', 'chrome-win64', 'chrome.exe') self.extension_path = os.path.join(self.base_path, 'browsers', 'extensions', '1.1.199_0') self.user_data_dir = os.path.join(self.base_path, 'browsers', 'user_data') if not os.path.exists(self.user_data_dir): os.makedirs(self.user_data_dir, exist_ok=True) self.logger.log(f"{self.user_data_dir} 디렉토리가 생성되었습니다.", level=logging.DEBUG) self.logger.log(f"브라우저 경로: {self.browser_path}", level=logging.DEBUG) self.logger.log(f"확장 프로그램 경로: {self.extension_path}", level=logging.DEBUG) self.logger.log(f"사용자 폴더 경로: {self.user_data_dir}", level=logging.DEBUG) self.allow_file_url_access(self.user_data_dir, "jlcdjppbpplpdgfeknhioedbhfceaben", self.logger) self.video_dir = os.path.join(self.base_path, "recorded_videos") if not os.path.exists(self.video_dir): os.makedirs(self.video_dir) self.logger.log(f"동영상 저장 폴더 생성됨: {self.video_dir}", level=logging.DEBUG) if not os.path.exists(self.browser_path): self.logger.log(f"브라우저 실행 파일이 없습니다: {self.browser_path}", level=logging.DEBUG) raise FileNotFoundError(f"브라우저 실행 파일이 없습니다: {self.browser_path}") # user_agent = random.choice([ # # Chrome # "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.113 Safari/537.36", # # Whale (Chrome 기반) # "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Whale/3.25.232.15 Chrome/125.0.6422.113 Safari/537.36", # ]) # self.logger.log(f"user_agent: {user_agent}", level=logging.DEBUG) self.logger.log(f"debug_mode: {debug_mode}", level=logging.DEBUG) # 메모리 추적: 브라우저 시작 전 browser_before_mem = psutil.virtual_memory() browser_before_mb = browser_before_mem.used / 1024 / 1024 self.ensure_chrome_prefs_disable_password_leak(self.user_data_dir) # 브라우저 시작 및 설정 self.browser = await self.playwright.chromium.launch_persistent_context( user_data_dir=self.user_data_dir, headless=not debug_mode, permissions=["geolocation", "notifications"], geolocation={"latitude": 37.5665, "longitude": 126.9780}, locale="ko-KR", # ================================================ # 🔑 [핵심] 자동화 감지 플래그 제거 # ================================================ ignore_default_args=["--enable-automation"], args=[ '--disable-blink-features=AutomationControlled', # ------------------------------------------------ # 🔑 [핵심 해결책] 보안 해제 옵션 - sec-fetch-site: "none" 문제 해결 # - 확장 프로그램이 네이버 쿠키에 접근할 수 있도록 보안 정책 해제 # ------------------------------------------------ '--disable-web-security', # CORS 보안 해제 (도메인 간 요청 허용) '--disable-features=IsolateOrigins,site-per-process', # 사이트 간 격리 해제 (쿠키 공유) '--disable-site-isolation-trials', # 사이트 격리 시험 기능 해제 # ------------------------------------------------ # 🔑 [핵심] 백그라운드 스크립트가 죽지 않게 설정 # ------------------------------------------------ '--disable-background-timer-throttling', # 백그라운드 타이머 제한 해제 '--disable-renderer-backgrounding', # 렌더러 백그라운드 처리 해제 # ------------------------------------------------ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--disable-infobars', '--disable-popup-blocking', f'--disable-extensions-except={self.extension_path}', f'--load-extension={self.extension_path}', '--start-maximized', '--disable-features=IsolateOrigins,site-per-process,PasswordLeakDetection,PasswordCheck,PasswordManager', # 비밀번호 관련 기능 모두 끄기 '--disable-save-password-bubble', # 비밀번호 저장 팝업 끄기 '--disable-password-generation', # 비밀번호 생성 제안 끄기 '--no-default-browser-check', # 기본 브라우저 확인 끄기 '--password-store=basic' # 비밀번호 저장소 기본으로 (시스템 연동 끄기) # '--window-size=1920,1080' ], executable_path=self.browser_path, user_agent=None, viewport=None, # user_agent=user_agent, # record_video_dir=self.video_dir, # 동영상 저장 폴더 지정 # record_video_size={"width": 1280, "height": 720} ) # ------------------------------------------------------------------ # [핵심 해결책] 확장프로그램 URL 납치(Redirect) 로직 # Playwright 환경에서 확장프로그램 ID가 변경되어 "테스트 모드"로 인식됨 # -> 테스트 API 주소가 ""(공란)이라 chrome-extension:// 내부로 요청됨 # -> 이를 실제 API 서버로 리다이렉트 # ------------------------------------------------------------------ async def handle_broken_extension_url(route): url = route.request.url # 확장프로그램이 잘못 보낸 요청 감지 (api.percenty.co.kr로 가야 할 게 내부로 옴) if "chrome-extension://" in url: # 정규식으로 경로만 추출 (/smartstore/products/... 또는 /stores/... 등) match = re.search(r'chrome-extension://[^/]+(/.+)', url) if match: real_path = match.group(1) # 실제 API 서버 주소로 변경 new_url = f"https://api.percenty.co.kr{real_path}" self.logger.log(f"🚑 URL 긴급 수정: {url[:100]}... -> {new_url}", level=logging.INFO) # 헤더(x-auth-token 등)는 유지한 채 URL만 바꿔서 전송 await route.continue_(url=new_url) return else: # 경로가 없는 경우도 로깅 self.logger.log(f"⚠️ chrome-extension:// 요청 감지 (경로 없음): {url}", level=logging.WARNING) await route.continue_() # 모든 네트워크 요청을 감시 (context 레벨에서 등록) await self.browser.route("**/*", handle_broken_extension_url) self.logger.log("✅ 확장프로그램 URL 리다이렉트 핸들러 등록 완료", level=logging.INFO) # 기본 페이지 활용 if len(self.browser.pages) > 0: self.page = self.browser.pages[0] # 이미 열린 탭 확보 else: self.page = await self.browser.new_page() # 혹시 모를 잔여 탭 정리 (안전을 위해) while len(self.browser.pages) > 1: await self.browser.pages[-1].close() # 마지막 탭부터 하나씩 닫기 self.page.set_default_navigation_timeout(30000) self.page.set_default_timeout(30000) # ------------------------------------------------------------------ # [디버깅] 네트워크 요청 실패 및 콘솔 로그 모니터링 추가 # "failed to fetch" 등의 정확한 원인(net::ERR_...)을 파악하기 위함 # ------------------------------------------------------------------ # # 1. 브라우저 콘솔 로그 출력 (에러나 fetch 관련 메시지는 ERROR 레벨로 강조) # self.page.on("console", lambda msg: self.logger.log( # f"🌏 [BROWSER_CONSOLE] {msg.type}: {msg.text}", # level=logging.DEBUG if msg.type == 'error' or 'fetch' in msg.text.lower() else logging.DEBUG # )) # # 2. 요청 실패 감지 (네트워크 레벨 에러) - 필터링 제거하여 모든 실패 기록 # def on_request_failed(request): # url = request.url # # 필터링 제거: 모든 실패 요청을 기록합니다. # try: # failure = request.failure # if isinstance(failure, dict): # error_text = failure.get('errorText', 'Unknown') # else: # error_text = str(failure) if failure else 'Unknown' # self.logger.log(f"❌ [Network Failed] URL: {url}", level=logging.DEBUG) # self.logger.log(f"❌ [Network Failed] Reason: {error_text}", level=logging.DEBUG) # # self.logger.log(f"❌ [Network Failed] Method: {request.method}", level=logging.DEBUG) # except Exception as e: # self.logger.log(f"로깅 중 오류: {e}", level=logging.ERROR) # self.page.on("requestfailed", on_request_failed) # # 3. HTTP 에러 응답 감지 (4xx, 5xx 에러) - Sentry 제외 # def on_response_error(response): # if not response.ok: # url = response.url # # Sentry 에러는 무시 (노이즈 제거) # if "sentry.io" in url: # return # # 300번대 리다이렉트는 제외하고 400 이상만 경고 # if response.status >= 400: # try: # self.logger.log(f"🔥 [HTTP Error {response.status}] URL: {url}", level=logging.WARNING) # except Exception: # pass # self.page.on("response", on_response_error) # # 4. [중요] API 응답 본문 확인 (성공했더라도 실패 사유가 담겨있는지 확인) # async def on_response_check_body(response): # url = response.url # # 스마트스토어 API, 퍼센티 상품/업로드 관련 API만 감시 # # .js, .css, 이미지 등은 제외 # if response.request.resource_type in ['xhr', 'fetch'] and \ # any(k in url for k in ["smartstore", "percenty", "product", "upload", "naver"]) and \ # "sentry.io" not in url: # try: # # 응답 본문 가져오기 (비동기) # # 일부 응답은 json이 아닐 수 있으므로 text로 먼저 가져옴 # body_text = await response.text() # # 너무 긴 응답은 앞부분만 자르기 (로그 용량 관리) # log_text = body_text[:1000] + "..." if len(body_text) > 1000 else body_text # # 실패/에러 키워드가 있거나, 스마트스토어 관련이면 일단 기록 # # "code", "message", "error", "fail" 등의 단어가 포함된 경우 # if any(w in log_text.lower() for w in ["fail", "error", "code", "message", "false", "reject"]): # self.logger.log(f"📩 [API Response] URL: {url}", level=logging.DEBUG) # self.logger.log(f"📩 [API Body] {log_text}", level=logging.DEBUG) # except Exception: # # 응답이 이미 소비되었거나 텍스트가 아닌 경우 등 무시 # pass # self.page.on("response", on_response_check_body) # ------------------------------------------------------------------ self.logger.log('새 페이지 로딩 중...', level=logging.INFO) await self.page.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})") await self.page.add_init_script(""" Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3], }); Object.defineProperty(navigator, 'languages', { get: () => ['ko-KR', 'en-US'] }); window.chrome = { runtime: {} }; """) # try: # self.page_for_extension = await self.browser.new_page() # await self.page_for_extension.bring_to_front() # if self.page_for_extension: # await self.page_for_extension.goto("chrome://extensions/?id=jlcdjppbpplpdgfeknhioedbhfceaben") # # 토글 스위치 element 찾기 (shadow root 접근 필요) # # 실제 HTML 구조 확인해서 selector 수정 필요 # toggle = self.page_for_extension.locator("extensions-toggle-row#allow-on-file-urls").locator("cr-toggle") # await toggle.click() # # 약간 기다렸다가 다시 켜기 # await self.page_for_extension.wait_for_timeout(500) # await toggle.click() # self.logger.log("확장 강제 껐다 켜기 완료", level=logging.DEBUG) # await self.page.bring_to_front() # except Exception as e: # self.logger.log(f"확장 강제 껐다 켜기 실패: {e}", level=logging.ERROR) await self.page.goto('https://percenty.co.kr/signin') self.logger.log('percenty.co.kr/signin 로딩 완료', level=logging.DEBUG) # # 첫 번째 기본 탭 닫기 # if self.browser.pages: # await self.browser.pages[0].close() # for p in self.browser.pages: # if p.url == "about:blank": # await p.close() # self.logger.debug("about:blank 탭을 닫았습니다.") # 페이지 제목을 가져와서 창 제목으로 활용 page_title = await self.page.title() self.logger.log(f'페이지 제목: {page_title}', level=logging.DEBUG) # 라우트 등록 # await self.init_routes(self.page) # if self.route_registered: # self.logger.log(f"라우트 등록 완료", level=logging.DEBUG) except Exception as e: self.logger.log(f"브라우저 객체 생성 오류: {str(e)}", level=logging.ERROR, exc_info=True) screenshot_path = await self.save_error_screenshot() self.browser_create_error.emit(str(e)) return try: is_login_success = await self.login() if not is_login_success: self.logger.log("로그인 실패", level=logging.ERROR) return except Exception as e: self.logger.log(f"브라우저 시작 오류: {str(e)}", level=logging.ERROR, exc_info=True) screenshot_path = await self.save_error_screenshot() self.browser_login_error.emit(str(e)) return try: await self.page.keyboard.press("Enter") await asyncio.sleep(0.5) await self.page.keyboard.press("Escape") await asyncio.sleep(0.5) except Exception as e: self.logger.log(f"키 누르기 오류: {str(e)}", level=logging.ERROR, exc_info=True) try: # await self.close_ad_if_exists_with_ESC_Key() # await self.close_ad_if_exists_new() await self.close_ant_modal_dialogs() except Exception as e: self.logger.log(f"광고 닫기 오류: {str(e)}", level=logging.ERROR, exc_info=True) screenshot_path = await self.save_error_screenshot() self.browser_ad1_close_error.emit(str(e)) return try: await self.page.keyboard.press("Enter") await asyncio.sleep(0.5) await self.page.keyboard.press("Escape") await asyncio.sleep(0.5) except Exception as e: self.logger.log(f"키 누르기 오류: {str(e)}", level=logging.ERROR, exc_info=True) if id_ed_mode: # await self.go_to_registered_product_page() self.logger.log('등록 상품 관리 페이지로 이동 중...', level=logging.INFO) try: registered_product_page_locator = self.locator_manager.get_locator('BrowserControl', 'registered_product_page_locator') await self.page.click(registered_product_page_locator) self.logger.log("등록 상품 관리 페이지로 이동 완료.", level=logging.INFO) except Exception as e: self.logger.log(f"등록 상품 관리 페이지 이동 중 오류: {e}", level=logging.ERROR, exc_info=True) screenshot_path = await self.save_error_screenshot() self.browser_registered_product_page_error.emit(str(e)) return else: self.logger.log('신규 상품 등록 페이지로 이동 중...', level=logging.INFO) # await self.go_to_new_product_page() try: new_product_page_locator = self.locator_manager.get_locator('BrowserControl', 'new_product_page_locator') await self.page.click(new_product_page_locator) await self.close_ad_if_exists_with_ESC_Key() await self.close_ant_modal_dialogs() self.logger.log("신규 상품 등록 페이지로 이동 완료.", level=logging.INFO) # self.logger.log(f"신규 상품 등록 페이지 이동 중 오류: {e}", level=logging.ERROR, exc_info=True) except Exception as e: self.logger.log(f"광고 닫기 오류: {str(e)}", level=logging.ERROR, exc_info=True) screenshot_path = await self.save_error_screenshot() self.browser_ad2_close_error.emit(str(e)) return # try: # # 그룹 이름 리스트 가져오기 # # group_names_list = await self.get_group_names_list_by_index() # group_names_list = await self.get_group_names_list_all() # # if "전체" in group_names_list: # # group_names_list.remove("전체") # # group_names_list.insert(0, "전체") # self.group_list_signal.emit(group_names_list) # except Exception as e: # self.logger.log(f"그룹 이름 리스트 가져오기 오류: {str(e)}", level=logging.ERROR, exc_info=True) # screenshot_path = await self.save_error_screenshot() # self.browser_group_list_error.emit(str(e)) # return self.logger.log("작업그룹 목록 가져오기 시작...", level=logging.INFO) await self.get_group_names_list_all_async() self.logger.log("작업그룹 목록 가져오기 완료...", level=logging.INFO) try: # 각 핸들러에 초기화된 page 객체 전달. self.titleGenerator.update_page(self.page ,self.toggle_states) # self.titleGenerator.update_parsing_page(self.parsing_page) self.optionHandler.update_page(self.page ,self.toggle_states, self.image_processor) self.tagsHandler.update_page(self.page ,self.toggle_states) self.thumbnailHandler.update_page(self.page ,self.toggle_states, self.image_processor) self.priceHandler.update_page(self.page ,self.toggle_states) self.detailHandler.update_page(self.page ,self.toggle_states, self.image_processor) # self.imageProcessor.update_page(self.page ,self.toggle_states) # self.optionHandler.update_whale() # self.thumbnailHandler.update_whale() # self.detailHandler.update_whale() self.logger.log(f"핸들러 업데이트 완료", level=logging.DEBUG) except Exception as e: self.logger.log(f"핸들러 업데이트 오류: {str(e)}", level=logging.ERROR, exc_info=True) screenshot_path = await self.save_error_screenshot() self.browser_handler_update_error.emit(str(e)) return try: # 파싱용 브라우저 인스턴스를 별도로 실행 (headless 모드, 실제 브라우저처럼 보이도록 옵션 추가, 시크릿 모드 포함) self.parsing_browser = await self.playwright.chromium.launch( executable_path=self.browser_path, # 추가: 메인 브라우저와 동일한 실행 파일 사용 headless=True, args=[ '--disable-blink-features=AutomationControlled', # 자동화 탐지 우회 '--no-sandbox', '--disable-infobars', '--disable-dev-shm-usage', '--disable-gpu', '--start-minimized', '--incognito', # 시크릿 모드 활성화 '--window-position=-32000,-32000', "--disable-features=PasswordLeakDetection", # [핵심] 비밀번호 유출 감지 기능 끄기 "--disable-save-password-bubble", # 비밀번호 저장 팝업 끄기 "--no-default-browser-check" # 기본 브라우저 확인 끄기 ] ) # 파싱용 컨텍스트는 기본적으로 시크릿 모드 환경이며, 추가 설정으로 실제 브라우저와 유사한 점수를 줄 수 있음. self.parsing_context = await self.parsing_browser.new_context( locale="ko-KR", user_agent=random.choice([ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", ]), viewport={"width": 1280, "height": 720}, extra_http_headers={ "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7" } ) # 파싱용 페이지 생성 self.parsing_page = await self.parsing_context.new_page() self.parsing_page.set_default_navigation_timeout(60000) self.parsing_page.set_default_timeout(60000) self.papago_translator.update_page(self.parsing_page) except Exception as e: self.logger.log(f"파싱용 페이지 생성 오류: {str(e)}", level=logging.ERROR, exc_info=True) screenshot_path = await self.save_error_screenshot() self.browser_parsing_page_error.emit(str(e)) return # 메모리 추적: 브라우저 시작 완료 후 browser_after_mem = psutil.virtual_memory() browser_after_mb = browser_after_mem.used / 1024 / 1024 browser_change_mb = browser_after_mb - browser_before_mb browser_change_percent = (browser_change_mb / browser_before_mb) * 100 if browser_before_mb > 0 else 0 self.logger.log( f"메모리 변화 [브라우저 시작]: {browser_before_mb:.1f}MB -> {browser_after_mb:.1f}MB " f"({browser_change_mb:+.1f}MB, {browser_change_percent:+.1f}%)", level=logging.DEBUG if abs(browser_change_mb) < 50 else logging.INFO ) # 신호 전송 self.browser_started.emit() # 브라우저 시작 신호 # # 주기적 컨텍스트 재시작 예약 # if self.context_restart_interval_min and ( # self.context_restart_task is None or self.context_restart_task.done() # ): # self.context_restart_task = asyncio.create_task( # self.periodic_context_restart(self.context_restart_interval_min) # ) # self.logger.log( # f"컨텍스트를 {self.context_restart_interval_min}분 간격으로 재시작하도록 예약되었습니다.", # level=logging.INFO, # ) except Exception as e: self.logger.log(f"브라우저 시작 오류: {str(e)}", level=logging.ERROR, exc_info=True) screenshot_path = await self.save_error_screenshot() self.unknown_browser_error.emit(str(e)) return async def get_group_names_list_all_async(self): """그룹 이름 목록 가져오기 비동기 작업""" try: # 그룹 이름 리스트 가져오기 # group_names_list = await self.get_group_names_list_by_index() group_names_list = await self.get_group_names_list_all() # if "전체" in group_names_list: # group_names_list.remove("전체") # group_names_list.insert(0, "전체") self.group_list_signal.emit(group_names_list) except Exception as e: self.logger.log(f"그룹 이름 리스트 가져오기 오류: {str(e)}", level=logging.ERROR, exc_info=True) screenshot_path = await self.save_error_screenshot() self.browser_group_list_error.emit(str(e)) return def ensure_chrome_prefs_disable_password_leak(self, user_data_dir: str) -> None: """ 브라우저 실행 전 Preferences 파일을 직접 수정하여 보안 경고를 원천 차단합니다. """ try: # 보통 launch_persistent_context의 user_data_dir 안에 'Default' 폴더가 생성됩니다. # 만약 프로필이 생성되지 않은 초기 상태라면 폴더를 미리 만들어줍니다. default_dir = os.path.join(user_data_dir, "Default") os.makedirs(default_dir, exist_ok=True) prefs_path = os.path.join(default_dir, "Preferences") prefs = {} # 기존 설정 파일이 있으면 읽어옵니다. if os.path.exists(prefs_path): try: with open(prefs_path, "r", encoding="utf-8") as f: prefs = json.load(f) except Exception: prefs = {} # ------------------------------------------------------- # [핵심] 보안 경고 및 비밀번호 관리자 강제 비활성화 설정 # ------------------------------------------------------- # 1. 비밀번호 저장/자동완성 서비스 끄기 prefs["credentials_enable_service"] = False # 2. 프로필 관련 설정 (비밀번호 유출 감지 OFF) if "profile" not in prefs or not isinstance(prefs["profile"], dict): prefs["profile"] = {} prefs["profile"]["password_manager_leak_detection"] = False # 유출 감지 끄기 prefs["profile"]["password_manager_enabled"] = False # 비밀번호 매니저 끄기 # 3. 세이프 브라우징 (유출 감지와 연동됨) 끄기 if "safebrowsing" not in prefs or not isinstance(prefs["safebrowsing"], dict): prefs["safebrowsing"] = {} prefs["safebrowsing"]["enabled"] = False prefs["safebrowsing"]["enhanced"] = False # 파일 저장 with open(prefs_path, "w", encoding="utf-8") as f: json.dump(prefs, f, ensure_ascii=False, indent=2) # 주의: os.chmod(read-only)는 사용하지 않습니다. # 크롬이 실행 중에 세션 정보 등을 기록하지 못해 또 다른 에러가 날 수 있습니다. self.logger.log(f"✅ Preferences(보안 설정 OFF) 강제 적용 완료: {prefs_path}", level=logging.DEBUG) except Exception as e: # 이 단계에서 실패해도 브라우저는 켜져야 하므로 로그만 남기고 넘어갑니다. self.logger.log(f"⚠️ Preferences 적용 실패(무시하고 진행): {e}", level=logging.WARNING) async def go_to_registered_product_page(self): self.logger.log('등록 상품 관리 페이지로 이동 중...', level=logging.INFO) try: # 현재 페이지 URL 로깅 current_url = self.page.url self.logger.log(f"현재 페이지: {current_url}", level=logging.DEBUG) registered_product_page_locator = self.locator_manager.get_locator('BrowserControl', 'registered_product_page_locator') # 요소가 존재하고 클릭 가능한지 확인 await self.page.wait_for_selector(registered_product_page_locator, timeout=5000, state='visible') self.logger.log(f"등록상품 페이지 버튼 확인됨: {registered_product_page_locator}", level=logging.DEBUG) # 클릭 및 페이지 이동 대기 await self.page.click(registered_product_page_locator, timeout=3000) # 페이지 로딩 대기 (URL 변경 또는 특정 요소 로딩 대기) await asyncio.sleep(2) # 페이지 로딩 시간 확보 new_url = self.page.url self.logger.log(f"이동 후 페이지: {new_url}", level=logging.DEBUG) self.logger.log("등록 상품 관리 페이지로 이동 완료.", level=logging.INFO) self.step_completed.emit("go_to_registered", True) except Exception as e: self.logger.log(f"등록 상품 관리 페이지 이동 중 오류: {e}", level=logging.ERROR, exc_info=True) # 오류 시 추가 디버깅 정보 try: current_url = self.page.url self.logger.log(f"오류 발생 시 페이지: {current_url}", level=logging.DEBUG) registered_product_page_locator = self.locator_manager.get_locator('BrowserControl', 'registered_product_page_locator') button_visible = await self.page.locator(registered_product_page_locator).is_visible() self.logger.log(f"등록상품 버튼 보임 상태: {button_visible}", level=logging.DEBUG) except Exception as debug_error: self.logger.log(f"디버그 정보 수집 실패: {debug_error}", level=logging.WARNING) screenshot_path = await self.save_error_screenshot() self.browser_registered_product_page_error.emit(str(e)) self.step_completed.emit("go_to_registered", False) return async def go_to_new_product_page(self): self.logger.log('신규 상품 등록 페이지로 이동 중...', level=logging.INFO) try: new_product_page_locator = self.locator_manager.get_locator('BrowserControl', 'new_product_page_locator') await self.page.click(new_product_page_locator) self.logger.log("신규 상품 등록 페이지로 이동 완료.", level=logging.INFO) except Exception as e: self.logger.log(f"신규 상품 등록 페이지 이동 중 오류: {e}", level=logging.ERROR, exc_info=True) screenshot_path = await self.save_error_screenshot() self.browser_new_product_page_error.emit(str(e)) return async def is_registered_product_page(self): """현재 페이지가 등록상품 페이지인지 확인 (3초 타임아웃)""" try: # 등록상품 페이지 식별자 확인 (3초 타임아웃) page_title_selector = "main.ant-layout-content div.ant-row-middle span.CharacterTitle85.H5Bold16" try: # 3초 타임아웃으로 요소 대기 element = await self.page.wait_for_selector(page_title_selector, timeout=3000) text = (await element.inner_text()).strip() is_registered_page = text == '등록 상품 목록' self.logger.log(f"등록상품 페이지 확인: '{text}' = {is_registered_page}", level=logging.DEBUG) return is_registered_page except Exception: # 타임아웃 또는 요소 없음 self.logger.log("등록상품 페이지 식별자 요소를 찾을 수 없습니다. (3초 타임아웃)", level=logging.DEBUG) return False except Exception as e: self.logger.log(f"등록상품 페이지 확인 중 오류: {e}", level=logging.DEBUG) return False async def is_new_product_page(self): """현재 페이지가 신규상품 페이지인지 확인 (3초 타임아웃)""" try: # 신규상품 페이지 식별자 확인 (3초 타임아웃) page_title_selector = "main.ant-layout-content div.ant-row-middle[style='row-gap: 0px;'] span.Body1Bold14" try: # 3초 타임아웃으로 요소 대기 element = await self.page.wait_for_selector(page_title_selector, timeout=3000) text = (await element.inner_text()).strip() is_new_page = text == '수집 상품 목록' self.logger.log(f"신규상품 페이지 확인: '{text}' = {is_new_page}", level=logging.DEBUG) return is_new_page except Exception: # 타임아웃 또는 요소 없음 self.logger.log("신규상품 페이지 식별자 요소를 찾을 수 없습니다. (3초 타임아웃)", level=logging.DEBUG) return False except Exception as e: self.logger.log(f"신규상품 페이지 확인 중 오류: {e}", level=logging.DEBUG) return False async def is_market_settings_page(self): """현재 페이지가 마켓설정 페이지인지 확인 (메뉴 선택 상태로 판단, 3초 타임아웃)""" try: # 1. 메뉴 아이템의 선택 상태로 확인 (가장 정확한 방법) # 선택된 마켓설정 메뉴 아이템 확인 # 패턴: ul[role='menu'] li.ant-menu-item.ant-menu-item-selected[data-menu-id='rc-menu-uuid-11100-1-MARKET_SETTING'][role='menuitem'] selected_market_menu_selector = "ul[role='menu'] li.ant-menu-item.ant-menu-item-selected[data-menu-id*='MARKET_SETTING'][role='menuitem']" try: selected_element = await self.page.wait_for_selector(selected_market_menu_selector, timeout=3000) if selected_element: # 메뉴 ID 확인해서 로그 출력 menu_id = await selected_element.get_attribute('data-menu-id') self.logger.log(f"마켓설정 페이지 확인 (메뉴 선택됨): {menu_id}", level=logging.DEBUG) return True except Exception: # 선택된 마켓설정 메뉴가 없음 pass # 2. 백업 방법: 마켓설정 메뉴 아이템 존재하지만 선택되지 않은 경우 확인 # 패턴: ul[role='menu'] li.ant-menu-item[data-menu-id='rc-menu-uuid-11100-1-MARKET_SETTING'][role='menuitem'] unselected_market_menu_selector = "ul[role='menu'] li.ant-menu-item:not(.ant-menu-item-selected)[data-menu-id*='MARKET_SETTING'][role='menuitem']" try: unselected_element = await self.page.wait_for_selector(unselected_market_menu_selector, timeout=1000) if unselected_element: # 메뉴가 존재하지만 선택되지 않은 경우는 마켓설정 페이지가 아님 menu_id = await unselected_element.get_attribute('data-menu-id') self.logger.log(f"마켓설정 메뉴는 존재하지만 선택되지 않음: {menu_id}", level=logging.DEBUG) return False except Exception: pass # 3. 백업 방법: URL로 확인 current_url = self.page.url if 'market' in current_url.lower() and 'setting' in current_url.lower(): self.logger.log(f"마켓설정 페이지 확인 (URL): {current_url}", level=logging.DEBUG) return True # 4. 최종 백업: 페이지 타이틀로 확인 try: page_title_selectors = [ "main.ant-layout-content h1", "main.ant-layout-content .ant-typography-title" ] for selector in page_title_selectors: try: element = await self.page.wait_for_selector(selector, timeout=1000) if element: text = (await element.inner_text()).strip() if any(keyword in text for keyword in ['마켓', '설정', 'market', 'setting']): self.logger.log(f"마켓설정 페이지 확인 (제목): '{text}'", level=logging.DEBUG) return True except Exception: continue except Exception: pass self.logger.log("마켓설정 페이지가 아닙니다.", level=logging.DEBUG) return False except Exception as e: self.logger.log(f"마켓설정 페이지 확인 중 오류: {e}", level=logging.DEBUG) return False async def get_current_selected_group_name(self, ed_mode): """현재 선택된 그룹 이름을 가져옴""" try: if ed_mode: group_selector = self.selected_group_name_for_ed_locator else: group_selector = self.selected_group_name_locator group_name = (await self.page.inner_text(group_selector)).strip() self.logger.log(f"현재 선택된 그룹: '{group_name}'", level=logging.DEBUG) return group_name except Exception as e: self.logger.log(f"현재 그룹 이름 확인 실패: {e}", level=logging.DEBUG) return None async def click_refresh_button(self): """페이지 새로고침 버튼 클릭""" try: # 새로고침 버튼 - button 요소를 우선으로 시도 refresh_button_selector = "main.ant-layout-content div.ant-row-middle button[type='button']:has(span[aria-label='reload'][role='img'])" # 새로고침 버튼 존재 확인 refresh_button = await self.page.query_selector(refresh_button_selector) if refresh_button: await refresh_button.click() self.logger.log("✅ 새로고침 버튼 클릭 완료", level=logging.INFO) await asyncio.sleep(2) # 새로고침 완료 대기 return True else: # 대안: span 요소 직접 클릭 span_selector = "main.ant-layout-content div.ant-row-middle button[type='button'] span[aria-label='reload'][role='img']" refresh_span = await self.page.query_selector(span_selector) if refresh_span: await refresh_span.click() self.logger.log("✅ 새로고침 아이콘 클릭 완료", level=logging.INFO) await asyncio.sleep(2) # 새로고침 완료 대기 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 ensure_proper_page(self, ed_mode): """ ed_mode에 따라 적절한 페이지로 이동하고, 기존 선택 그룹도 복원 - ed_mode=True: 등록상품 페이지로 이동 - ed_mode=False: 신규상품 페이지로 이동 - 마켓설정 페이지에서도 적절한 상품 페이지로 이동 - 페이지 이동 후 이전에 선택했던 그룹으로 자동 선택 """ try: current_url = self.page.url self.logger.log(f"현재 페이지 확인 중... (ed_mode: {ed_mode}, URL: {current_url})", level=logging.DEBUG) # 먼저 마켓설정 페이지인지 확인 is_market_settings = await self.is_market_settings_page() need_page_change = False current_group_name = None if is_market_settings: self.logger.log("📋 현재 마켓설정 페이지입니다. 상품 관리 페이지로 이동합니다.", level=logging.INFO) need_page_change = True # 마켓설정 페이지에는 그룹이 없으므로 마지막 선택된 그룹 사용 current_group_name = self.last_selected_group if current_group_name: self.logger.log(f"📋 마켓설정 페이지: 마지막 선택 그룹 '{current_group_name}' 사용", level=logging.INFO) else: self.logger.log("📋 마켓설정 페이지: 마지막 선택 그룹 정보 없음, 기본 그룹으로 시작", level=logging.INFO) else: # 상품 페이지에서 현재 선택된 그룹 이름 저장 (페이지 이동 전) current_group_name = await self.get_current_selected_group_name(ed_mode) if ed_mode: # 등록상품 모드 - 등록상품 페이지 확인 if is_market_settings or not await self.is_registered_product_page(): if not is_market_settings: self.logger.log("❌ 등록상품 페이지가 아닙니다. 이동 중...", level=logging.INFO) await self.go_to_registered_product_page() need_page_change = True # 이동 후 확인 await asyncio.sleep(2) # 페이지 로딩 대기 if not await self.is_registered_product_page(): self.logger.log("❌ 등록상품 페이지 이동 실패", level=logging.ERROR) return False self.logger.log("✅ 등록상품 페이지로 이동 완료", level=logging.INFO) else: self.logger.log("✅ 이미 등록상품 페이지에 있습니다.", level=logging.DEBUG) else: # 신규상품 모드 - 신규상품 페이지 확인 if is_market_settings or not await self.is_new_product_page(): if not is_market_settings: self.logger.log("❌ 신규상품 페이지가 아닙니다. 이동 중...", level=logging.INFO) await self.go_to_new_product_page() need_page_change = True # 이동 후 확인 await asyncio.sleep(2) # 페이지 로딩 대기 if not await self.is_new_product_page(): self.logger.log("❌ 신규상품 페이지 이동 실패", level=logging.ERROR) return False self.logger.log("✅ 신규상품 페이지로 이동 완료", level=logging.INFO) else: self.logger.log("✅ 이미 신규상품 페이지에 있습니다.", level=logging.DEBUG) # 페이지 이동이 발생했고, 이전에 선택된 그룹이 있으면 복원 if need_page_change and current_group_name and current_group_name != "전체": self.logger.log(f"🔄 이전 선택 그룹 '{current_group_name}' 복원 중...", level=logging.INFO) try: await self.select_group_by_name(current_group_name) self.logger.log(f"✅ 그룹 '{current_group_name}' 복원 완료", level=logging.INFO) except Exception as group_error: self.logger.log(f"⚠️ 그룹 '{current_group_name}' 복원 실패: {group_error}", level=logging.WARNING) # 그룹 선택 실패해도 페이지 이동은 성공했으므로 True 반환 elif need_page_change and is_market_settings: if current_group_name and current_group_name != "전체": # 마켓설정 페이지에서 이동 후 마지막 선택 그룹 복원 self.logger.log(f"📋 마켓설정에서 상품페이지로 이동 후 마지막 선택 그룹 '{current_group_name}' 복원 시도", level=logging.INFO) try: await self.select_group_by_name(current_group_name) self.logger.log(f"✅ 마켓설정에서 이동 후 그룹 '{current_group_name}' 복원 완료", level=logging.INFO) except Exception as group_error: self.logger.log(f"⚠️ 마켓설정에서 이동 후 그룹 '{current_group_name}' 복원 실패: {group_error}", level=logging.WARNING) else: self.logger.log("📋 마켓설정에서 이동했지만 마지막 선택 그룹 정보가 없어 기본 그룹으로 시작합니다.", level=logging.INFO) return True except Exception as e: self.logger.log(f"적절한 페이지 확인 및 이동 중 오류: {e}", level=logging.ERROR, exc_info=True) return False # async def start_browser(self): # """크롬 브라우저 실행 및 페이지 로딩""" # self.logger.log('크롬 브라우저 실행 중...', level=logging.DEBUG) # try: # # Playwright를 수동으로 실행하여 브라우저 유지 # self.playwright = await async_playwright().start() # # cx_Freeze로 패키징된 경우와 일반 Python 실행 환경 구분하여 경로 설정 # if getattr(sys, 'frozen', False): # browser_path = os.path.join(os.path.dirname(sys.executable), 'browsers', 'chromium-1112', 'chrome-win','chrome.exe') # extension_path = os.path.join(os.path.dirname(sys.executable), 'browsers', 'extensions', '1.1.199_0') # user_data_dir = os.path.join(os.path.dirname(sys.executable), 'browsers', 'user_data') # else: # browser_path = os.path.join(os.path.dirname(__file__), 'browsers', 'chromium-1112', 'chrome-win','chrome.exe') # extension_path = os.path.join(os.path.dirname(__file__), 'browsers', 'extensions', '1.1.199_0') # user_data_dir = os.path.join(os.path.dirname(__file__), 'browsers', 'user_data') # self.logger.log(f"브라우저 경로: {browser_path}", level=logging.DEBUG) # self.logger.log(f"확장 프로그램 경로: {extension_path}", level=logging.DEBUG) # self.logger.log(f"사용자 폴더 경로: {user_data_dir}", level=logging.DEBUG) # # 사용자 데이터 디렉토리가 존재하지 않으면 생성 # if not os.path.exists(user_data_dir): # os.makedirs(user_data_dir) # self.logger.log(f"{user_data_dir} 디렉토리가 생성되었습니다.", level=logging.DEBUG) # # User agent 설정 # user_agent = random.choice([ # "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", # "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.0.0", # "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:108.0) Gecko/20100101 Firefox/108.0", # "Mozilla/5.0 (Macintosh; Intel Mac OS X 12_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15", # "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36 OPR/85.0.0.0", # ]) # self.logger.log(f"user_agent: {user_agent}", level=logging.DEBUG) # # 브라우저 시작 및 설정 # self.browser = await self.playwright.chromium.launch_persistent_context( # user_data_dir, # headless=False, # permissions=["geolocation", "notifications"], # geolocation={"latitude": 37.5665, "longitude": 126.9780}, # locale="ko-KR", # args=[ # '--disable-popup-blocking', # f'--disable-extensions-except={extension_path}', # f'--load-extension={extension_path}', # '--start-maximized', # '--window-size=1920,1080' # ], # executable_path=browser_path, # user_agent=user_agent # ) # # 기본 페이지가 없을 수 있으므로 새로운 페이지 생성 # self.page = await self.browser.new_page() # self.logger.log('새 페이지 로딩 중...', level=logging.INFO) # await self.page.goto('https://percenty.co.kr/signin') # self.logger.log('percenty.co.kr/signin 로딩 완료', level=logging.INFO) # # 첫 번째 기본 탭 닫기 # if self.browser.pages: # await self.browser.pages[0].close() # # 페이지 제목을 가져와서 창 제목으로 활용 # page_title = await self.page.title() # self.logger.log(f'페이지 제목: {page_title}', level=logging.DEBUG) # # 창 핸들 찾기 (동적으로 얻은 페이지 제목 사용) # self.chrome_hwnd = self.find_window_by_title(page_title) # if not self.chrome_hwnd: # self.logger.log('크롬 창을 찾을 수 없습니다.', level=logging.WARNING) # else: # self.logger.log(f'크롬 창 핸들: {self.chrome_hwnd}', level=logging.DEBUG) # await self.login() # await self.close_ad_if_exists() # if self.toggle_states['ed_mode']: # await self.go_to_registered_product_page() # self.logger.log('등록 상품 관리 페이지로 이동 중...', level=logging.INFO) # else: # self.logger.log('신규 상품 등록 페이지로 이동 중...', level=logging.INFO) # await self.go_to_new_product_page() # self.browser_started.emit() # 브라우저 시작 신호 # except Exception as e: # self.logger.log(f"브라우저 시작 오류: {str(e)}", level=logging.ERROR) # self.browser_error.emit(str(e)) async def login(self) -> bool: """로그인 처리: 다양한 로그인 성공 지표를 확인합니다.""" is_admin = self.login_infos.get('is_admin', False) who = "관리자" if is_admin else "직원" self.logger.log(f'로그인 시도 중: {who} 계정', level=logging.INFO) # 필수 정보 누락 체크 required = ['admin_id', 'admin_pw'] if is_admin else ['admin_id', 'user_id', 'user_pw'] missing = [k for k in required if not self.login_infos.get(k)] if missing: msg = f'로그인 정보 누락: {", ".join(missing)}' self.logger.log(msg, level=logging.ERROR) self.browser_login_error.emit(msg) return False try: try: initial_body_html = await self.page.evaluate('() => document.body.innerHTML') initial_body_length = len(initial_body_html) self.logger.log(f'로그인 전 페이지 body 길이: {initial_body_length}', level=logging.DEBUG) except: initial_body_length = 0 # 1) 자격증명 입력 & 로그인 클릭 if is_admin: await self.page.fill(self.login_email_locator, self.login_infos['admin_id']) await self.page.fill(self.login_password_locator, self.login_infos['admin_pw']) await self.page.click(self.login_button_locator) else: admin_toggle = self.page.locator(self.admin_toggle_locator) if await admin_toggle.get_attribute("aria-checked") == "true": await admin_toggle.click() await self.page.fill(self.login_email_locator, self.login_infos['admin_id']) await self.page.fill(self.staff_id_locator, self.login_infos['user_id']) await self.page.fill(self.login_password_locator, self.login_infos['user_pw']) await self.page.click(self.staff_login_button_locator) self.logger.log(f'로그인 버튼 클릭 완료: {who} 계정', level=logging.DEBUG) # 2) 다양한 방법으로 로그인 성공 확인 # 방법 1: 기존 선택자들로 확인 success_selectors = [ '[role="menu"]', # ul[role="menu"]보다 더 포괄적 '[role="dialog"]', 'span:text("신규 상품 등록")', # 신규 상품 등록 텍스트 'span:text("등록 상품 관리")', # 등록 상품 관리 텍스트 'button[name="퍼센티 상담 버튼"]' # 로그인 후에만 보이는 상담 버튼 ] try: # 여러 선택자를 동시에 기다림 (짧은 시간) selector_string = ', '.join(success_selectors) self.logger.log(f'로그인 성공 요소 대기 중... 선택자: {selector_string}', level=logging.DEBUG) locator = await self.page.wait_for_selector( selector_string, timeout=5000 # 5초로 단축 ) if locator: self.logger.log(f'로그인 성공: {who} 계정', level=logging.INFO) self.logger.log(f'환영메세지 확인 중...', level=logging.INFO) await self.close_welcome_popup_if_exists(self.page) return True except Exception as wait_error: self.logger.log(f'메뉴 요소 대기 실패: {str(wait_error)}', level=logging.DEBUG) # DOM 변경 감지 try: new_body_html = await self.page.evaluate('() => document.body.innerHTML') new_body_length = len(new_body_html) body_change_ratio = abs(new_body_length - initial_body_length) / max(initial_body_length, 1) self.logger.log(f'로그인 후 페이지 body 길이: {new_body_length} (변경률: {body_change_ratio:.2%})', level=logging.DEBUG) # 페이지 내용이 10% 이상 변경되었다면 로그인 성공으로 간주 if body_change_ratio > 0.1: self.logger.log(f'페이지 내용 변경으로 로그인 성공 확인: {who} 계정 (변경률: {body_change_ratio:.2%})', level=logging.DEBUG) return True except Exception as dom_error: self.logger.log(f'DOM 변경 감지 중 오류: {dom_error}', level=logging.DEBUG) # 방법 3: 로그인 폼 사라짐 확인 try: login_form_exists = await self.page.query_selector(self.login_button_locator) is not None if not login_form_exists: self.logger.log(f'로그인 폼 사라짐으로 로그인 성공 확인: {who} 계정', level=logging.DEBUG) return True except: pass # 모든 방법으로 확인 실패 msg = f'로그인은 성공하였으나 페이지 확인 실패: {who} 계정' self.logger.log(msg, level=logging.ERROR) self.logger.log(f'대기 오류 상세: {str(wait_error)}', level=logging.DEBUG) self.browser_login_error.emit(msg) await self.save_error_screenshot(f'login_failed_{who}') return False except Exception as e: # 클릭/입력 중 예외 msg = f'로그인 처리 중 오류: {who} 계정 – {e}' self.logger.log(msg, level=logging.ERROR, exc_info=True) self.browser_login_error.emit(msg) await self.save_error_screenshot(f'login_exception_{who}') return False # def find_window_by_title(self, window_name): # """창 제목을 통해 핸들을 찾는 메서드""" # def enum_windows_callback(hwnd, result): # if win32gui.IsWindowVisible(hwnd) and window_name in win32gui.GetWindowText(hwnd): # result.append(hwnd) # result = [] # win32gui.EnumWindows(enum_windows_callback, result) # return result[0] if result else None # def switch_to_chrome(self): # """크롬으로 포커스 전환""" # if self.chrome_hwnd: # win32gui.ShowWindow(self.chrome_hwnd, win32con.SW_RESTORE) # win32gui.SetForegroundWindow(self.chrome_hwnd) # self.logger.log('크롬 창으로 포커스 이동.', level=logging.DEBUG) # else: # self.logger.log('크롬 창을 찾을 수 없습니다.', level=logging.ERROR) async def get_total_product_count(self): total_count = 0 items_per_page = 0 try: await asyncio.sleep(1) # total_count_elements = await self.page.query_selector_all(".sc-dOvA-dm.jqRNYf") total_count_element = await self.page.query_selector(self.total_product_count_locator) items_per_page_element = await self.page.query_selector(self.items_per_page_locator) self.logger.log(f"total_count_element : {total_count_element}", level=logging.DEBUG) if total_count_element: total_count_text = await total_count_element.inner_text() if "총" in total_count_text and "개 상품" in total_count_text: total_count = int(''.join(re.findall(r'\d+', total_count_text))) self.logger.log(f"총 상품수 확인: {total_count} 개", level=logging.INFO) # 페이지당 상품 수 추출 if items_per_page_element: items_per_page_text = await items_per_page_element.get_attribute("title") if items_per_page_text and "개씩 보기" in items_per_page_text: items_per_page = int(''.join(re.findall(r'\d+', items_per_page_text))) self.logger.log(f"페이지당 상품수 확인: {items_per_page} 개씩 보기", level=logging.INFO) # 결과 반환 if total_count: return {"total_count": total_count, "items_per_page": items_per_page} return {"total_count": 0, "items_per_page": 0} except Exception as e: self.logger.log(f"상품 수를 가져오는 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) screenshot_path = await self.save_error_screenshot() return {"total_count": 0, "items_per_page": 0} # def fetch_image_urls(self, html_content): # """ # HTML 콘텐츠에서 모든 태그의 URL을 추출하는 함수. #
안의 태그와 독립된 태그 모두 처리. # """ # soup = BeautifulSoup(html_content, 'html.parser') # # 모든 태그를 찾기 # image_urls = [] # img_tags = soup.find_all('img') # for img in img_tags: # # img 태그에서 src 속성 추출 # if 'src' in img.attrs: # image_url = img['src'] # 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) # return image_urls async def close_ant_modal_dialogs(self, max_loops=3): """ antd 모달 다이얼로그 및 drawer에서 '다시 보지 않기', '닫기' 버튼, aria-label='Close' 버튼, drawer의 aria-label='close' 버튼을 최대 max_loops번 반복 클릭합니다. self.page, self.logger 사용 """ total_closed = 0 for loop in range(max_loops): closed_this_round = 0 # 다이얼로그 생성될 때까지 3초간 대기 (한 번만) try: await self.page.wait_for_selector('.ant-modal-root', timeout=1000) except Exception: # screenshot_path = await self.save_error_screenshot() self.logger.log("3초 대기 내에 다이얼로그 미등장", level=logging.WARNING) # '다시 보지 않기' 버튼 (span 텍스트 포함) try: dont_show_btns = self.page.locator( '.ant-modal-root button span:text("다시 보지 않기")' ).locator('..') # span의 부모 button count1 = await dont_show_btns.count() if count1 > 0: for i in range(count1): try: await dont_show_btns.nth(i).click() closed_this_round += 1 self.logger.log(f'"다시 보지 않기" 버튼 클릭 (loop {loop+1}, {i+1}/{count1})', level=logging.DEBUG) await self.page.wait_for_timeout(200) except Exception as e: self.logger.log(f'"다시 보지 않기" 버튼 클릭 실패: {e}', level=logging.DEBUG) except Exception: pass # 버튼이 없으면 그냥 지나감 # '닫기' 버튼 (span 텍스트 포함) try: close_btns = self.page.locator( '.ant-modal-root button span:text("닫기")' ).locator('..') count2 = await close_btns.count() if count2 > 0: for i in range(count2): try: await close_btns.nth(i).click() closed_this_round += 1 self.logger.log(f'"닫기" 버튼 클릭 (loop {loop+1}, {i+1}/{count2})', level=logging.DEBUG) await self.page.wait_for_timeout(200) except Exception as e: self.logger.log(f'"닫기" 버튼 클릭 실패: {e}', level=logging.DEBUG) except Exception: pass # 버튼이 없으면 그냥 지나감 # aria-label='Close' 버튼 try: aria_close_btns = self.page.locator( 'div.ant-modal-content button[aria-label="Close"]' ) count3 = await aria_close_btns.count() if count3 > 0: for i in range(count3): try: await aria_close_btns.nth(i).click() closed_this_round += 1 self.logger.log(f'aria-label="Close" 버튼 클릭 (loop {loop+1}, {i+1}/{count3})', level=logging.DEBUG) await self.page.wait_for_timeout(200) except Exception as e: self.logger.log(f'aria-label="Close" 버튼 클릭 실패: {e}', level=logging.DEBUG) except Exception: pass # 버튼이 없으면 그냥 지나감 # drawer의 aria-label='close' 버튼 try: drawer_close_btns = self.page.locator( 'div.ant-drawer-content [aria-label="close"]' ) count4 = await drawer_close_btns.count() if count4 > 0: for i in range(count4): try: await drawer_close_btns.nth(i).click() closed_this_round += 1 self.logger.log(f'drawer aria-label="close" 버튼 클릭 (loop {loop+1}, {i+1}/{count4})', level=logging.DEBUG) await self.page.wait_for_timeout(200) except Exception as e: self.logger.log(f'drawer aria-label="close" 버튼 클릭 실패: {e}', level=logging.DEBUG) except Exception: pass # 버튼이 없으면 그냥 지나감 total_closed += closed_this_round if closed_this_round == 0: break await self.page.wait_for_timeout(300) self.logger.log(f"총 {total_closed}개 다이얼로그 버튼 클릭 완료", level=logging.INFO) return total_closed > 0 async def close_ad_if_exists_with_ESC_Key(self): """ESC 키를 두 번 전송하여 다이얼로그를 닫는 메서드""" try: # JavaScript로 ESC 키 이벤트 발생 await self.page.evaluate(""" (() => { const escEvent = new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true }); document.dispatchEvent(escEvent); return true; })() """) time.sleep(1) await self.page.evaluate(""" (() => { const escEvent = new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true }); document.dispatchEvent(escEvent); return true; })() """) self.logger.log("ESC 키를 전송하여 다이얼로그를 닫았습니다.", level=logging.INFO) except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"ESC 키 전송 중 오류: {e}", level=logging.ERROR, exc_info=True) async def close_ad_if_exists_new(self): """ 다이얼로그가 존재하면 해당 다이얼로그 내부의 "닫기" 버튼을 클릭하고, 다이얼로그를 찾지 못하거나 내부에 닫기 버튼이 없으면 페이지 전체에서 "닫기" 버튼을 검색하여 클릭하는 메서드 """ # 새로운 다이얼로그의 닫기(X) 버튼 먼저 시도 self.logger.log("새로운 다이얼로그의 닫기(X) 버튼 먼저 시도", level=logging.INFO) # 1. 모달 다이얼로그 처리 (ant-modal-wrap) try: # 모달 처리 JavaScript 코드 modal_js = """ () => { // 모달 찾기 const modalWraps = document.querySelectorAll('.ant-modal-wrap.ant-modal-centered'); if (modalWraps.length === 0) return 0; console.log(`${modalWraps.length}개의 모달 발견`); let closed = 0; // 각 모달에 대해 for (const modal of modalWraps) { try { // 1. 닫기 버튼 찾기 const closeBtn = modal.querySelector('.ant-modal-close'); if (closeBtn) { closeBtn.click(); console.log('모달 닫기 버튼 클릭'); closed++; continue; } // 2. '닫기' 텍스트 버튼 찾기 const buttons = modal.querySelectorAll('button'); let found = false; for (const btn of buttons) { if (btn.textContent.includes('닫기')) { btn.click(); console.log('닫기 텍스트 버튼 클릭'); closed++; found = true; break; } } if (found) continue; // 3. 마지막 수단: 모달 숨기기 modal.style.display = 'none'; const mask = document.querySelector('.ant-modal-mask'); if (mask) mask.style.display = 'none'; console.log('모달 강제 숨김'); closed++; } catch (e) { console.error('모달 처리 오류:', e); } } return closed; } """ modal_count = await self.page.evaluate(modal_js) if modal_count > 0: self.logger.log(f"{modal_count}개의 모달 다이얼로그 닫기 처리됨", level=logging.INFO) await self.page.wait_for_timeout(1000) # 모달이 사라질 때까지 대기 return True except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"모달 다이얼로그 처리 중 오류: {e}", level=logging.INFO) # 2. drawer-close 버튼 처리 try: drawer_js = """ () => { const drawerCloseButtons = Array.from(document.querySelectorAll('button.ant-drawer-close')); let count = 0; drawerCloseButtons.forEach(button => { try { button.click(); console.log('drawer-close 버튼 클릭'); count++; } catch (e) { console.error('drawer-close 클릭 오류:', e); } }); return count; } """ drawer_count = await self.page.evaluate(drawer_js) if drawer_count > 0: self.logger.log(f"{drawer_count}개의 close 버튼 클릭됨", level=logging.DEBUG) await self.page.wait_for_timeout(1000) return True except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"close 버튼 처리 중 오류: {e}", level=logging.ERROR, exc_info=True) # 3. 배경 클릭 시도 try: backdrop_js = """ () => { const backdropSelectors = [ '.ant-modal-mask', '.ant-drawer-mask', '.modal-backdrop', '.dialog-backdrop', '[role="dialog"] + div', '.overlay', '.modal-overlay' ]; let clicked = 0; for (const selector of backdropSelectors) { const backdrops = document.querySelectorAll(selector); for (const backdrop of backdrops) { try { backdrop.click(); console.log(`${selector} 배경 클릭`); clicked++; } catch (e) { console.error(`${selector} 클릭 오류:`, e); } } } return clicked; } """ backdrop_count = await self.page.evaluate(backdrop_js) if backdrop_count > 0: self.logger.log(f"{backdrop_count}개의 배경 요소 클릭됨", level=logging.INFO) await self.page.wait_for_timeout(1000) return True except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"배경 클릭 처리 중 오류: {e}", level=logging.INFO) # 4. ESC 키 시도 try: await self.page.keyboard.down("Escape") await self.page.wait_for_timeout(100) await self.page.keyboard.up("Escape") self.logger.log("ESC 키 전송 (down-up 방식)", level=logging.INFO) await self.page.wait_for_timeout(1000) except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"ESC 키 전송 중 오류: {e}", level=logging.INFO) # 5. 기존 다이얼로그 처리 (이전 코드) try: # 다이얼로그가 나타날 때까지 최대 3초 대기 dialog_element = await self.page.wait_for_selector(self.close_ad_dialog_locator, timeout=3000, state='visible') self.logger.log("다이얼로그가 발견되었습니다. 내부의 닫기 버튼을 찾습니다.", level=logging.INFO) if dialog_element: # 다이얼로그 내부에서 "닫기" 텍스트를 가진 버튼을 xpath로 찾음 close_button = await dialog_element.query_selector("xpath=.//button[span[text()='닫기']]") if close_button: await close_button.click() self.logger.log("다이얼로그 내부의 닫기 버튼을 클릭하여 닫았습니다.", level=logging.INFO) return # 닫기 성공 시 함수 종료 else: self.logger.log("다이얼로그 내부에서 닫기 버튼을 찾지 못했습니다.", level=logging.WARNING) else: self.logger.log("대기 후에도 다이얼로그 엘리먼트를 찾지 못했습니다.", level=logging.WARNING) try: await self.page.keyboard.press("Escape") await self.page.wait_for_timeout(100) await self.page.dispatch_event("body", "keydown", { "key": "Escape", "code": "Escape", "keyCode": 27, "which": 27 }) await self.page.wait_for_timeout(50) await self.page.dispatch_event("body", "keyup", { "key": "Escape", "code": "Escape", "keyCode": 27, "which": 27 }) await self.page.wait_for_timeout(100) self.logger.log("ECS를 전송하여 닫았습니다.", level=logging.INFO) return except Exception as e: self.logger.log(f"닫기 중 오류: {e}", level=logging.ERROR, exc_info=True) screenshot_path = await self.save_error_screenshot() except TimeoutError: screenshot_path = await self.save_error_screenshot() self.logger.log("다이얼로그가 나타나지 않았습니다.", level=logging.INFO) except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"다이얼로그 닫기 시도 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) async def close_ad_if_exists_ori(self): """광고 다이얼로그가 있으면 닫기 버튼을 클릭하는 메서드""" try: # 광고 다이얼로그가 나타날 때까지 기다림 await self.page.wait_for_selector(self.close_ad_dialog_locator, timeout=3000, state='visible') self.logger.log("다이얼로그가 발견되었습니다. 닫기 버튼을 클릭합니다.", level=logging.INFO) # 닫기 버튼 클릭 close_button = await self.page.query_selector(self.close_ad_button_locator) if close_button: await close_button.click() self.logger.log("다이얼로그를 성공적으로 닫았습니다.", level=logging.INFO) else: self.logger.log("닫기 버튼을 찾지 못했습니다.", level=logging.WARNING) except TimeoutError: # 다이얼로그가 없을 때: info 수준의 로그로 기록 screenshot_path = await self.save_error_screenshot() self.logger.log("다이얼로그가 발견되지 않았습니다. 타임아웃이 발생했습니다.", level=logging.INFO) except Exception as e: screenshot_path = await self.save_error_screenshot() # 다른 예외 상황 발생 시 error로 기록 self.logger.log(f"다이얼로그 닫기 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) # async def go_to_new_product_page(self): # """신규 상품 등록 페이지로 이동""" # try: # new_product_page_locator = self.locator_manager.get_locator('BrowserControl', 'new_product_page_locator') # await self.page.click(new_product_page_locator) # self.logger.log("신규 상품 등록 페이지로 이동 완료.", level=logging.INFO) # except Exception as e: # self.logger.log(f"신규 상품 등록 페이지 이동 중 오류: {e}", level=logging.ERROR, exc_info=True) # async def go_to_registered_product_page(self): # """신규 상품 등록 페이지로 이동""" # try: # registered_product_page_locator = self.locator_manager.get_locator('BrowserControl', 'registered_product_page_locator') # await self.page.click(registered_product_page_locator) # self.logger.log("등록 상품 관리 페이지로 이동 완료.", level=logging.INFO) # except Exception as e: # self.logger.log(f"등록 상품 관리 페이지 이동 중 오류: {e}", level=logging.ERROR, exc_info=True) async def is_button_disabled(self, button): """버튼이 disabled 상태인지 확인""" try: # 버튼의 disabled 속성 확인 is_disabled = await button.get_attribute('disabled') return is_disabled is not None # disabled 속성이 있으면 True 반환 except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"상품 수정 버튼 상태 확인 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) return False # 오류 발생 시 기본적으로 활성화된 것으로 처리 async def select_group_index(self, group_index: int): """그룹 드롭다운 열고 옵션 선택""" try: # 페이지 이동 (등록상품 or 신규상품) ed_mode = self.toggle_states.get('ed_mode', False) try: if ed_mode: locator = self.registered_product_page_locator await self.page.click(locator) else: locator = self.new_product_page_locator await self.page.click(locator) await self.close_ant_modal_dialogs() except Exception as e: self.logger.log(f"페이지 이동 중 오류: {e}", level=logging.ERROR, exc_info=True) self.logger.log(f"group_index : {group_index}", level=logging.INFO) await self.page.evaluate(""" const targetElement = Array.from(document.querySelectorAll('span')) .find(el => el.textContent.trim() === '수집 상품 목록'); if (targetElement) { targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); } """) self.logger.log("그룹박스 요소로 스크롤", level=logging.DEBUG) await asyncio.sleep(1) group_option_locator = self.group_index_template.format(index=group_index+1) # 드롭다운 열기 if self.toggle_states.get('ed_mode', False): await self.page.wait_for_selector(self.group_dropdown_for_ed_locator, timeout=3000, state='visible') await self.page.click(self.group_dropdown_for_ed_locator, timeout=3000, force=True) self.logger.log("ED모드 드롭다운을 성공적으로 클릭했습니다.", level=logging.DEBUG) else: await self.page.wait_for_selector(self.group_dropdown_locator, timeout=3000, state='visible') await self.page.click(self.group_dropdown_locator, timeout=3000, force=True) self.logger.log("드롭다운을 성공적으로 클릭했습니다.", level=logging.DEBUG) # await self.page.wait_for_selector(self.group_dropdown_locator, timeout=3000, state='visible') # await self.page.click(self.group_dropdown_locator, timeout=3000, force=True) # 드롭다운 열림 상태 확인 await self.page.wait_for_selector(self.dropdown_openstatus_locator, timeout=3000) self.logger.log("드롭다운이 열렸습니다.", level=logging.DEBUG) # 옵션 선택 self.logger.log(f"[{group_option_locator}] : group_option_locator", level=logging.DEBUG) await self.page.click(group_option_locator, timeout=3000) self.logger.log(f"[{group_index}]번 그룹 선택 완료", level=logging.INFO) self.curr_group_idx = group_index if self.toggle_states.get('ed_mode', False): selected_group_name = await self.page.inner_text(self.selected_group_name_for_ed_locator) else: selected_group_name = await self.page.inner_text(self.selected_group_name_locator) self.selected_group_name_for_ed_locator self.logger.log(f"선택된 그룹 이름 : [{selected_group_name}]", level=logging.INFO) # 총 상품 개수 가져오기 product_count_info = await self.get_total_product_count() total_products = product_count_info.get("total_count", 0) items_per_page = product_count_info.get("items_per_page", 0) self.logger.log(f"총 상품 개수 : [{total_products}]", level=logging.INFO) # 선택된 그룹 이름을 시그널로 전달 self.selected_group_name_signal.emit(selected_group_name, total_products) except TimeoutError: self.logger.log("드롭다운 또는 옵션 선택 중 타임아웃이 발생했습니다.", level=logging.WARNING) screenshot_path = await self.save_error_screenshot() self.logger.log(f"그룹 선택 중 타임아웃이 발생했습니다. 스크린샷 저장됨: {screenshot_path}", level=logging.WARNING) # 그룹 선택 실패 시 빈 문자열 시그널 전달 self.selected_group_name_signal.emit("") except Exception as e: self.logger.log(f"그룹 선택 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) screenshot_path = await self.save_error_screenshot() self.selected_group_name_signal.emit("") # finally: # await self.update_group_names_list() # async def update_group_names_list(self): # try: # # 그룹 이름 리스트 가져오기 # group_names_list = await self.get_group_names_list_by_index() # self.group_list_signal.emit(group_names_list) # except Exception as e: # self.logger.log(f"그룹 이름 리스트 가져오기 오류: {str(e)}", level=logging.ERROR, exc_info=True) # screenshot_path = await self.save_error_screenshot() # self.browser_group_list_error.emit(str(e)) # return async def get_group_names_list_by_index(self): """그룹 드롭다운을 열고 모든 그룹 이름 목록을 가져오기""" try: self.logger.log("그룹 이름 목록 가져오기 시작", level=logging.INFO) # 수집 상품 목록 요소로 스크롤 await self.page.evaluate(""" const targetElement = Array.from(document.querySelectorAll('span')) .find(el => el.textContent.trim() === '수집 상품 목록'); if (targetElement) { targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); } """) self.logger.log("그룹박스 요소로 스크롤", level=logging.DEBUG) await asyncio.sleep(1) # 드롭다운 열기 if self.toggle_states.get('ed_mode', False): await self.page.wait_for_selector(self.group_dropdown_for_ed_locator, timeout=3000, state='visible') await self.page.click(self.group_dropdown_for_ed_locator, timeout=3000, force=True) self.logger.log("ED모드 드롭다운을 성공적으로 클릭했습니다.", level=logging.DEBUG) else: await self.page.wait_for_selector(self.group_dropdown_locator, timeout=3000, state='visible') await self.page.click(self.group_dropdown_locator, timeout=3000, force=True) self.logger.log("드롭다운을 성공적으로 클릭했습니다.", level=logging.DEBUG) # 드롭다운 열림 상태 확인 await self.page.wait_for_selector(self.dropdown_openstatus_locator, timeout=3000) self.logger.log("드롭다운이 열렸습니다.", level=logging.DEBUG) # rc-virtual-list가 로딩될 때까지 잠시 대기 await asyncio.sleep(0.5) # 그룹 옵션들이 로딩될 때까지 대기 await self.page.wait_for_selector(self.group_options_selector_locator, timeout=3000) # 모든 그룹 이름 텍스트 가져오기 group_names = await self.page.evaluate(f""" () => {{ const elements = document.querySelectorAll('{self.group_options_selector_locator}'); return Array.from(elements).map(el => el.textContent.trim()); }} """) self.logger.log(f"발견된 그룹 목록: {group_names}", level=logging.INFO) # 드롭다운 닫기 (ESC 키 또는 다른 곳 클릭) await self.page.keyboard.press("Escape") self.logger.log("드롭다운 닫기 완료", level=logging.INFO) return group_names except TimeoutError: self.logger.log("그룹 목록 가져오기 중 타임아웃이 발생했습니다.", level=logging.WARNING) # 드롭다운이 열려있을 수 있으므로 ESC로 닫기 시도 save_screenshot_path = await self.save_error_screenshot() self.logger.log(f"self.group_options_selector_locator: {self.group_options_selector_locator}", level=logging.WARNING) self.logger.log(f"그룹 목록 가져오기 중 타임아웃이 발생했습니다. 스크린샷 저장됨: {save_screenshot_path}", level=logging.WARNING) try: await self.page.keyboard.press("Escape") except: pass return [] except Exception as e: self.logger.log(f"그룹 목록 가져오기 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) screenshot_path = await self.save_error_screenshot() # 드롭다운이 열려있을 수 있으므로 ESC로 닫기 시도 try: await self.page.keyboard.press("Escape") except: pass return [] async def get_group_names_list_all(self): """그룹 드롭다운을 열고 모든 그룹 이름 목록을 가져오기 (virtual list 대응)""" try: self.logger.log("그룹 이름 목록 가져오기 시작", level=logging.INFO) # '수집 상품 목록' 위치로 스크롤 await self.page.evaluate(""" const targetElement = Array.from(document.querySelectorAll('span')) .find(el => el.textContent.trim() === '수집 상품 목록'); if (targetElement) { targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); } """) await asyncio.sleep(1) # 드롭다운 열기 (타임아웃 증가: 3000 -> 10000) if self.toggle_states.get('ed_mode', False): await self.page.wait_for_selector(self.group_dropdown_for_ed_locator, timeout=10000, state='visible') await self.page.click(self.group_dropdown_for_ed_locator, timeout=10000, force=True) self.logger.log("ED모드 드롭다운 클릭 완료", level=logging.DEBUG) else: await self.page.wait_for_selector(self.group_dropdown_locator, timeout=10000, state='visible') await self.page.click(self.group_dropdown_locator, timeout=10000, force=True) self.logger.log("드롭다운 클릭 완료", level=logging.DEBUG) # 드롭다운 열림 확인 (타임아웃 증가: 3000 -> 10000) await self.page.wait_for_selector(self.dropdown_openstatus_locator, timeout=10000) self.logger.log("드롭다운이 열렸습니다.", level=logging.DEBUG) # 드롭다운이 완전히 렌더링될 때까지 대기 await asyncio.sleep(0.3) group_names = [] seen = set() first_item = None consecutive_repeats = 0 # 연속된 반복 횟수 추적 # 드롭다운이 열릴 때 이미 활성화된 첫 번째 항목을 먼저 읽기 current_option = await self.page.evaluate(""" () => { const active = document.querySelector('.ant-select-item-option-active .ant-select-item-option-content'); return active ? active.textContent.trim() : null; } """) if current_option: first_item = current_option seen.add(current_option) group_names.append(current_option) self.logger.log(f"첫 번째 항목 발견: {current_option}", level=logging.DEBUG) # 이제 ArrowDown을 눌러서 다음 항목으로 이동 for _ in range(200): # 최대 200개 그룹 가정 await self.page.keyboard.press("ArrowDown") await asyncio.sleep(0.1) # 키 입력 후 대기 시간 증가 current_option = await self.page.evaluate(""" () => { const active = document.querySelector('.ant-select-item-option-active .ant-select-item-option-content'); return active ? active.textContent.trim() : null; } """) if not current_option: # 활성 항목을 찾을 수 없으면 종료 break if current_option not in seen: # 새로운 항목 발견 seen.add(current_option) group_names.append(current_option) consecutive_repeats = 0 # 연속 반복 카운터 리셋 self.logger.log(f"발견: {current_option}", level=logging.DEBUG) else: # 이미 본 항목을 다시 만남 (순환 시작) consecutive_repeats += 1 # 첫 번째 항목을 다시 만났고, 이미 모든 항목을 수집했다면 종료 if current_option == first_item and len(group_names) > 0: self.logger.log(f"첫 번째 항목으로 돌아옴. 수집 완료: {len(group_names)}개", level=logging.DEBUG) break # 연속으로 같은 항목을 여러 번 만나면 종료 (안전장치) if consecutive_repeats >= 3: self.logger.log(f"연속 반복 감지. 수집 완료: {len(group_names)}개", level=logging.DEBUG) break await self.page.keyboard.press("Escape") self.logger.log(f"그룹 이름 목록 가져오기 완료: {len(group_names)}개", level=logging.INFO) return group_names except Exception as e: self.logger.log(f"❌ get_group_names_list 오류: {e}", level=logging.ERROR, exc_info=True) try: await self.page.keyboard.press("Escape") except: pass return [] # async def get_group_names_list_all(self): # """nth + scroll_into_view_if_needed 방식""" # try: # self.logger.log("그룹 이름 목록(nth + scroll 방식) 수집 시작", level=logging.INFO) # # 수집 상품 목록 요소로 스크롤 # await self.page.evaluate(""" # const targetElement = Array.from(document.querySelectorAll('span')) # .find(el => el.textContent.trim() === '수집 상품 목록'); # if (targetElement) { # targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); # } # """) # self.logger.log("그룹박스 요소로 스크롤", level=logging.DEBUG) # await asyncio.sleep(1) # # 드롭다운 열기 # if self.toggle_states.get('ed_mode', False): # await self.page.wait_for_selector(self.group_dropdown_for_ed_locator, timeout=3000, state='visible') # await self.page.click(self.group_dropdown_for_ed_locator, timeout=3000, force=True) # self.logger.log("ED모드 드롭다운을 성공적으로 클릭했습니다.", level=logging.DEBUG) # else: # await self.page.wait_for_selector(self.group_dropdown_locator, timeout=3000, state='visible') # await self.page.click(self.group_dropdown_locator, timeout=3000, force=True) # self.logger.log("드롭다운을 성공적으로 클릭했습니다.", level=logging.DEBUG) # # 드롭다운 열림 상태 확인 # await self.page.wait_for_selector(self.dropdown_openstatus_locator, timeout=3000) # self.logger.log("드롭다운이 열렸습니다.", level=logging.DEBUG) # # rc-virtual-list가 로딩될 때까지 잠시 대기 # await asyncio.sleep(0.5) # # 그룹 옵션들이 로딩될 때까지 대기 # await self.page.wait_for_selector(self.group_options_selector_locator, timeout=3000) # # # 드롭다운 뜰 때까지 기다리기 (timeout 늘림) # # await self.page.wait_for_selector("div.ant-select-dropdown:not(.ant-select-dropdown-hidden)", timeout=8000) # group_names = [] # for i in range(200): # 최대 200개 그룹 가정 # option = self.page.locator(f"{self.group_options_selector_locator}").nth(i) # try: # await option.scroll_into_view_if_needed(timeout=1000) # name = await option.inner_text() # group_names.append(name.strip()) # self.logger.log(f"발견[{i}]: {name}", level=logging.DEBUG) # except Exception: # break # 더 이상 없음 # await self.page.keyboard.press("Escape") # return group_names # except Exception as e: # self.logger.log(f"❌ get_group_names_list 오류: {e}", level=logging.ERROR, exc_info=True) # try: # await self.page.keyboard.press("Escape") # except: # pass # return [] async def select_group_by_name(self, group_name: str): """드롭다운에서 그룹 이름을 찾아 선택 (ArrowDown/ArrowUp 한바퀴 탐색)""" try: # 드롭다운 열기 (더 안정적인 방법) locator = self.group_dropdown_for_ed_locator if self.toggle_states.get('ed_mode', False) else self.group_dropdown_locator # 1단계: 드롭다운 요소가 보이는지 확인 await self.page.wait_for_selector(locator, timeout=5000, state='visible') self.logger.log(f"드롭다운 요소 확인됨: {locator}", level=logging.DEBUG) # 2단계: 페이지가 완전히 로드될 때까지 잠깐 대기 await asyncio.sleep(0.5) # 3단계: 드롭다운 클릭 시도 (여러 방법) click_success = False for attempt in range(3): try: self.logger.log(f"드롭다운 클릭 시도 {attempt + 1}/3", level=logging.DEBUG) await self.page.click(locator, timeout=2000, force=True) # 4단계: 드롭다운이 열렸는지 확인 (더 긴 타임아웃) await self.page.wait_for_selector(self.dropdown_openstatus_locator, timeout=5000) click_success = True self.logger.log("드롭다운 열림 완료", level=logging.DEBUG) break except Exception as e: self.logger.log(f"드롭다운 클릭 시도 {attempt + 1} 실패: {e}", level=logging.WARNING) if attempt < 2: # 마지막 시도가 아니면 잠깐 대기 await asyncio.sleep(1) if not click_success: self.logger.log("드롭다운 열기 실패 - 모든 시도 실패", level=logging.ERROR) self.step_completed.emit("select_group", False) return # 탐색 상태 seen = set() option_found = False direction = "ArrowDown" # 처음엔 아래 방향 max_steps = 400 for step in range(max_steps): # 현재 활성화 항목 텍스트 읽기 - 더 구체적인 선택자 사용 active_elements = self.page.locator(".ant-select-item-option-active") active_count = await active_elements.count() if active_count > 0: # 여러 개의 active 요소가 있을 경우, 그룹 관련 요소만 선택 text = None for i in range(active_count): element = active_elements.nth(i) element_text = (await element.inner_text()).strip() # 페이지당 상품수 드롭다운 항목은 무시 ("개씩 보기"가 포함된 것들) if "개씩 보기" not in element_text: text = element_text break if text is None: # 그룹 관련 요소를 찾지 못한 경우, 첫 번째 요소 사용 text = (await active_elements.first().inner_text()).strip() self.logger.log(f"현재 활성 항목: '{text}' (전체 {active_count}개 중)", level=logging.DEBUG) if text == group_name: await self.page.keyboard.press("Enter") option_found = True break if text not in seen: seen.add(text) else: # 같은 텍스트 반복 → 끝까지 온 것 if direction == "ArrowDown": self.logger.log("⬇️ 바닥 도달, 위로 전환", level=logging.DEBUG) direction = "ArrowUp" continue elif direction == "ArrowUp": self.logger.log("⬆️ 천장 도달, 탐색 종료", level=logging.DEBUG) break # 다음 항목으로 이동 await self.page.keyboard.press(direction) await asyncio.sleep(0.05) if option_found: self.logger.log(f"✅ 그룹 '{group_name}' 선택 완료", level=logging.INFO) # 마지막 선택된 그룹 기억 self.last_selected_group = group_name # 총 상품 개수 가져오기 product_count_info = await self.get_total_product_count() total_products = product_count_info.get("total_count", 0) self.selected_group_name_signal.emit(group_name, total_products) self.step_completed.emit("select_group", True) else: raise ValueError(f"그룹 '{group_name}'를 찾지 못함") except Exception as e: self.logger.log(f"그룹 선택 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) # 오류 시 스크린샷 저장 try: screenshot_path = await self.save_error_screenshot() self.logger.log(f"오류 스크린샷 저장됨: {screenshot_path}", level=logging.INFO) except Exception as screenshot_error: self.logger.log(f"스크린샷 저장 실패: {screenshot_error}", level=logging.WARNING) # 현재 페이지 상태 로깅 try: current_url = self.page.url self.logger.log(f"현재 페이지 URL: {current_url}", level=logging.DEBUG) # 드롭다운 요소 상태 확인 locator = self.group_dropdown_for_ed_locator if self.toggle_states.get('ed_mode', False) else self.group_dropdown_locator dropdown_visible = await self.page.locator(locator).is_visible() self.logger.log(f"드롭다운 요소 보임 상태: {dropdown_visible}", level=logging.DEBUG) # 드롭다운이 열린 상태인지 확인 dropdown_open = await self.page.locator(self.dropdown_openstatus_locator).count() self.logger.log(f"드롭다운 열림 상태: {dropdown_open > 0}", level=logging.DEBUG) except Exception as debug_error: self.logger.log(f"디버그 정보 수집 실패: {debug_error}", level=logging.WARNING) # 드롭다운 닫기 시도 try: await self.page.keyboard.press("Escape") await asyncio.sleep(0.5) except Exception: pass self.selected_group_name_signal.emit("") self.step_completed.emit("select_group", False) async def get_product_edit_buttons_by_template(self): """ - 상품카드 단위로 버튼을 수집 (체인 방식) - 일반 모드: '세부사항 수정 및 업로드' 버튼이 disabled면 해당 카드 건너뜀, 해외배송비·메모 버튼 매칭 - ed_mode: 각 카드 내 '판매가' 텍스트를 가진 span 요소를 편집 트리거로 수집 """ # ed_mode 전용 체인 수집: 카드 → 카드 내부의 '판매가' 스팬을 편집 트리거로 사용 if self.toggle_states.get('ed_mode'): try: # Supabase LocatorManager에서 선택자 로드 (카드는 절대경로, 내부 요소는 상대 선택자 사용) ed_cards_selector = self.locator_manager.get_locator('BrowserControl', 'ed_product_chain_cards') ed_price_span_in_card_selector = self.locator_manager.get_locator('BrowserControl', 'ed_price_span_in_card') cards = self.page.locator(ed_cards_selector) card_count = await cards.count() self.logger.log(f"[ed_mode] 상품카드 수: {card_count}", level=logging.INFO) results = [] for i in range(card_count): card = cards.nth(i) # 카드 내부 트리거 요소: Supabase에서 제공된 전체 선택자 사용 (예: span:has-text("판매가")) price_trigger = card.locator(ed_price_span_in_card_selector) if await price_trigger.count() == 0: self.logger.log(f"[ed_mode][SKIP] {i}번 카드: 편집 트리거 요소 없음", level=logging.DEBUG) continue results.append({ "edit_button": price_trigger.first, "shipping_button": None, "memo_button": None, }) self.logger.log(f"[ed_mode] 수집 결과: {len(results)}건", level=logging.INFO) return results except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"[ed_mode] 상품 수정 트리거 수집 중 오류: {e}", level=logging.ERROR, exc_info=True) return [] # 일반 모드 체인 수집 cards = self.page.locator(self.product_chain_cards) self.logger.log(f"활성 상품카드 수: {await cards.count()}", level=logging.INFO) results = [] for i in range(await cards.count()): card = cards.nth(i) # 1) 편집 버튼 edit_btn = card.locator(self.product_edit_chain_buttons) if await edit_btn.count() == 0 or await edit_btn.is_disabled(): self.logger.log(f"[SKIP] {i}번 카드: 편집 버튼 없음/비활성", level=logging.DEBUG) continue # 2) 해외배송비 버튼 (edit SVG) shipping_btn = card.locator(self.product_shipping_chain_buttons).first if await shipping_btn.count() == 0: shipping_btn = None # 비활성 카드일 땐 원래 존재 X # 3) 메모 버튼 (file‑text SVG) ※편집 버튼 활성 카드에서만 수집 memo_btn = card.locator(self.product_memo_chain_buttons).first if await memo_btn.count() == 0: self.logger.log(f"[SKIP] {i}번 카드: 메모 버튼 없음", level=logging.WARNING) continue results.append( { "edit_button": edit_btn.first, "shipping_button": shipping_btn, # 없으면 None "memo_button": memo_btn, } ) self.logger.log(f"활성 상품카드 수집 결과: {len(results)}건", level=logging.INFO) return results async def open_product_edit_dialog(self, button): """상품 수정 다이얼로그 열기""" try: # 요소가 화면에 없을 경우 스크롤하여 보이도록 함 await button.scroll_into_view_if_needed() self.logger.log("상품의 '세부사항 수정 및 업로드' 버튼을 화면에 보이도록 스크롤.", level=logging.DEBUG) await self.page.keyboard.press("Escape") self.logger.log("이전 다이얼로그 닫기 완료.", level=logging.INFO) await button.click() self.logger.log("세부사항 수정 다이얼로그 열기 완료.", level=logging.INFO) # await self.page.wait_for_selector('div.ant-tabs-nav') # 다이얼로그가 완전히 로딩될 때까지 기다림 await self.page.wait_for_selector(self.product_edit_dialog_open_locator) # 다이얼로그가 완전히 로딩될 때까지 기다림 except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"세부사항 수정 다이얼로그 열기 중 오류: {e}", level=logging.ERROR, exc_info=True) # 에러 발생 시 스크린샷 저장 screenshot_path = await self.save_error_screenshot() self.logger.log(f"세부사항 수정 다이얼로그 열기 중 오류 발생으로 스크린샷 저장됨: {screenshot_path}", level=logging.INFO) async def save_error_screenshot(self, error_tag: str = None) -> str: """ 에러 발생시 스크린샷 저장. - error_tag가 None이면 호출한 함수 이름과 줄 번호를 자동으로 사용. """ try: if self.page: # 1) 호출자 정보를 가져온다 caller = inspect.stack()[1] func_name = caller.function lineno = caller.lineno # 코드 컨텍스트(한 줄) 추출 (없으면 빈 문자열) code_line = caller.code_context[0].strip() if caller.code_context else "" # 2) error_tag 미지정 시 자동 생성 if not error_tag: error_tag = f"{func_name}_L{lineno}" # 3) 날짜별 디렉토리 준비 today = datetime.now().strftime("%Y%m%d") date_dir = os.path.join(self.ERROR_SCREENSHOT_DIR, today) os.makedirs(date_dir, exist_ok=True) # 4) 타임스탬프 now = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"screenshot_{error_tag}_{now}.png" full_path = os.path.join(date_dir, filename) # 5) 스크린샷 찍기 (페이지가 살아있는지 추가 확인) try: # 페이지가 이미 닫혔으면 스크린샷을 건너뜀 if self.page.is_closed(): self.logger.log("스크린샷 건너뜀: 페이지가 닫혀 있습니다.", level=logging.WARNING) return None except Exception: # is_closed() 호출 자체가 실패할 수도 있으므로 무시 pass # full_page=False 로 렌더링 시간 단축, 타임아웃은 60초로 상향 await self.page.screenshot(path=full_path, full_page=False, timeout=60000) # 6) 디버깅용으로 호출 줄과 코드도 로그에 남기기 self.logger.log(f"스크린샷 저장: {full_path} (called at {func_name} L{lineno}: {code_line})", level=logging.INFO) return full_path else: self.logger.log("스크린샷 저장 중 오류: 페이지가 없습니다.", level=logging.ERROR) return None except Exception as e: self.logger.log(f"스크린샷 저장 중 오류: {e}", level=logging.ERROR, exc_info=True) return None def clear_old_screenshot_dirs(self, days=14): """에러스크린샷 디렉토리에서 지정일(기본 14일) 지난 하위폴더 삭제""" now = datetime.now() for dir_name in os.listdir(self.ERROR_SCREENSHOT_DIR): dir_path = os.path.join(self.ERROR_SCREENSHOT_DIR, dir_name) if os.path.isdir(dir_path): try: # 폴더명 YYYYMMDD로 파싱 dir_date = datetime.strptime(dir_name, '%Y%m%d') if now - dir_date > timedelta(days=days): # 오래된 폴더 삭제 import shutil shutil.rmtree(dir_path) self.logger.log(f"[스크린샷] {dir_path} 삭제됨", level=logging.INFO) except Exception as e: self.logger.log(f"[스크린샷] {dir_path} 삭제 중 오류: {e}", level=logging.ERROR, exc_info=True) def get_error_screenshot_dir(self): """에러 스크린샷 디렉토리 경로 반환""" return self.ERROR_SCREENSHOT_DIR async def click_detail_tab(self): """상세페이지 탭 클릭""" try: await self.page.click(self.detail_tab_locator) self.logger.log("상세페이지 탭 클릭 완료.", level=logging.INFO) except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"상세페이지 탭 클릭 중 오류: {e}", level=logging.ERROR, exc_info=True) async def click_option_tab(self): """옵션 탭 클릭""" try: await self.page.click(self.option_tab_locator) self.logger.log("옵션 탭 클릭 완료.", level=logging.INFO) except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"옵션 탭 클릭 중 오류: {e}", level=logging.ERROR, exc_info=True) async def click_price_tab(self): """가격 탭 클릭""" try: await self.page.click(self.price_tab_locator) self.logger.log("가격 탭 클릭 완료.", level=logging.INFO) except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"가격 탭 클릭 중 오류: {e}", level=logging.ERROR, exc_info=True) async def click_thumb_tab(self): """썸네일 탭 클릭""" try: await self.page.click(self.thumb_tab_locator) self.logger.log("썸네일 탭 클릭 완료.", level=logging.INFO) except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"썸네일 탭 클릭 중 오류: {e}", level=logging.ERROR, exc_info=True) async def click_tags_tab(self): """키워드 태그 탭 클릭""" try: await self.page.click(self.tag_tab_locator) self.logger.log("키워드 태그 탭 클릭 완료.", level=logging.INFO) except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"키워드 태그 클릭 중 오류: {e}", level=logging.ERROR, exc_info=True) async def click_title_tab(self): """상품명 탭 클릭""" try: await self.page.click(self.title_tab_locator) self.logger.log("상품명 탭 클릭 완료.", level=logging.INFO) except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"상품명 탭 클릭 중 오류: {e}", level=logging.ERROR, exc_info=True) # def generate_restored_html(self, urls): # """이미지 URL 목록을 HTML 형식으로 변환하는 메서드""" # html_content = '

 

' # for url in urls: # html_content += f'
\n' # return html_content def generate_restored_html(self, urls): """이미지 URL 목록을 HTML 형식으로 변환하는 메서드, 각 이미지의 가로세로 비율 추가""" html_content = '

 

\n' for url in urls: width, height = self.get_image_size(url) aspect_ratio = f"{width}/{height}" if width and height else "1/1" if width and height: html_content += ( f'
' f'' f'
\n' ) else: # 이미지 크기를 확인할 수 없을 경우 기본 형식으로 추가 html_content += f'
\n' return html_content def get_image_size(self, url): """이미지 URL로부터 가로와 세로 크기를 가져오는 메서드""" try: # URL에서 불필요한 따옴표 제거 cleaned_url = url.strip("'\"") response = requests.get(cleaned_url, timeout=5) response.raise_for_status() image = _PIL_Image.open(BytesIO(response.content)) return image.width, image.height except Exception as e: self.logger.log(f"이미지 크기 확인 실패 - {cleaned_url}: {e}", level=logging.WARNING) return None, None # async def extract_image_urls(self, optionHandler, is_option_data=False): # """상세페이지에서 이미지 URL 추출""" # try: # # 소스 편집 모드로 전환 # source_button_locator = self.locator_manager.get_locator('BrowserControl', 'source_button_locator') # ck_source_editing_area_locator = self.locator_manager.get_locator('BrowserControl', 'ck_source_editing_area_locator') # # 소스 편집 모드로 전환 # await self.page.click(source_button_locator) # self.logger.log("소스 버튼 클릭 완료.", level=logging.DEBUG) # # 'data-value' 속성 값을 추출 (textarea 요소) # textarea = await self.page.wait_for_selector(ck_source_editing_area_locator, timeout=5000) # data_value = await textarea.get_attribute("data-value") # # HTML 소스에서 이미지 URL 추출 # image_urls = self.fetch_image_urls(data_value) # self.logger.log(f'추출된 이미지 URL 수: {len(image_urls)}', level=logging.INFO) # # HTML 소스에서 이미지 URL 삭제 # self.logger.log('img 태그를 삭제 중...', level=logging.DEBUG) # data_value_element = await self.page.query_selector(ck_source_editing_area_locator) # new_value = "" # if data_value_element: # await self.page.evaluate(f'() => document.querySelector("{ck_source_editing_area_locator}").setAttribute("data-value", "{new_value}")') # updated_value = await data_value_element.get_attribute('data-value') # self.logger.log(f'Updated data-value: {updated_value}', level=logging.DEBUG) # else: # self.logger.log('Element with data-value not found.', level=logging.DEBUG) # self.logger.log('img 태그 삭제 완료.', level=logging.DEBUG) # # img 태그의 class 삭제 후 다시 소스 버튼 클릭 # await self.page.click(source_button_locator) # self.logger.log('소스 버튼 재 클릭 완료.', level=logging.DEBUG) # # 텍스트 입력 필드 선택 # input_field = await self.page.wait_for_selector(self.option_input_field_locator, timeout=5000) # await input_field.press('Enter') # # # 선두부 텍스트 입력 # # for key in sorted(self.text_templates.keys()): # # leading_text = self.text_templates[key] # # if 'leading_text' in key and leading_text: # leading_text 항목만 가져오기 # # await input_field.type(leading_text) # # await input_field.press('Enter') # # self.logger.log(f"{key} 텍스트 입력 완료: {leading_text}", level=logging.INFO) # # 선두부 텍스트 입력 (self.detail_text_widget의 get_lines 메서드 사용) # for leading_text in self.detail_text_widget.get_lines(): # if leading_text: # 내용이 있는 경우에만 # await input_field.type(leading_text) # await input_field.press('Enter') # self.logger.log(f"텍스트 입력 완료: {leading_text}", level=logging.INFO) # option_data = optionHandler.get_selected_translated_options() # self.logger.log(f'option_data : {option_data}', level=logging.DEBUG) # # if is_option_data: # if option_data or option_data != {}: # self.logger.log('옵션 데이터 입력 시작', level=logging.DEBUG) # # option_data = {} # option_data 초기화 # is_single = optionHandler.option_info['is_single_option'] # # option_data = optionHandler.get_selected_translated_options() # # is_single = True # 옵션입력 일단 제외 # # self.logger.log('옵션입력 일단 제외', level=logging.DEBUG) # self.logger.log('가져온 옵션 데이터', level=logging.DEBUG) # self.logger.log(f'{option_data}', level=logging.DEBUG) # # # 옵션 입력 필드 선택 # # input_field = await self.page.wait_for_selector(self.option_input_field_locator, timeout=5000) # # await input_field.press('Enter') # # # 선두부 텍스트 입력 # # for key in sorted(self.text_templates.keys()): # # leading_text = self.text_templates[key] # # if 'leading_text' in key and leading_text: # leading_text 항목만 가져오기 # # await input_field.type(leading_text) # # await input_field.press('Enter') # # self.logger.log(f"{key} 텍스트 입력 완료: {leading_text}", level=logging.INFO) # if not is_single: # self.logger.log('단일옵션이 아니므로 옵션목록을 입력', level=logging.INFO) # # 각 옵션을 한 줄씩 입력 # await input_field.type("# 옵션 목록") # await input_field.press('Enter') # # 첫 번째 옵션의 번역된 옵션명만 입력 # first_key = list(option_data.keys())[0] # key는 옵션이름 value는 가격 # first_value = option_data[first_key] # key는 옵션이름 value는 가격 # await input_field.type(f"- 1. {first_key}") # await input_field.press('Enter') # 첫 번째 옵션 이후 엔터로 줄바꿈 # # 나머지 옵션도 번역된 옵션명만 입력 # for i, (key, value) in enumerate(list(option_data.items())[1:], start=2): # await input_field.type(f"{key}") # 2번옵션부터 번호 떼고 입력. key는 옵션이름 value는 가격 # await input_field.press('Enter') # 엔터 키를 입력하여 줄바꿈 # # 목록 끝을 알리기 위해 엔터 두 번 입력 # await input_field.press('Enter') # await input_field.press('Enter') # # 후두부 텍스트 입력 # await input_field.type('### 나열된 옵션목록 이외의 옵션이 필요하실 경우 고객센터로 연락주세요.') # await input_field.press('Enter') # await input_field.type('---') # await input_field.press('Enter') # self.logger.log('옵션 데이터 입력 완료', level=logging.INFO) # return image_urls # except Exception as e: # self.logger.log(f"이미지 URL 추출 & 옵션데이터 입력 처리 중 오류: {e}", level=logging.ERROR, exc_info=True) # return image_urls if image_urls else [] # def paste_image_in_chrome(self, url, is_success_translated, toggle_states, is_watermark=False, watermark_text= ""): # """크롬으로 포커스를 옮기고 클립보드의 이미지를 붙여넣고 엔터 입력""" # self.logger.log("크롬으로 포커스를 옮기고 클립보드의 이미지를 붙여넣고 엔터 입력", level=logging.DEBUG) # try: # # self.switch_to_chrome() # 크롬으로 포커스 이동 # self.clipboardImageManager.process_clipboard(original_url=url, is_success_translated=is_success_translated, toggle_states=toggle_states) # 클립보드 내용을 처리 # # clipboard_content = pyperclip.paste() # if self.clipboardImageManager.is_clipboard_image(): # pyautogui.hotkey('ctrl', 'v') # 클립보드 이미지 붙여넣기 # self.logger.log("이미지 붙여넣기 완료.", level=logging.INFO) # pyautogui.press('right') # 오른쪽 입력 # self.logger.log("이미지 붙여넣기 완료.", level=logging.DEBUG) # self.clipboardImageManager.clear_clipboard() # self.logger.log("이미지 붙여넣기 완료로 클립보드 비우기.", level=logging.INFO) # 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 handle_exist_productname_popup(self, timeout_sec: int = 1) -> bool: """ 이미 존재하는 상품명 알림(다이얼로그/모달) 처리 - 다이얼로그가 뜨면 확인버튼 클릭 후 True 반환 - 다이얼로그가 안 뜨면 모달 감지 후 True 반환 - 둘 다 없으면 False 반환 """ # 1. 다이얼로그 감지 try: dialog = self.page.locator(self.dialog_selector) await dialog.wait_for(state="visible", timeout=timeout_sec * 1000) self.logger.log("다이얼로그 팝업 감지됨(이미 존재하는 상품명).", level=logging.INFO) ok_btn = self.page.locator(self.dialog_ok_selector) await ok_btn.click() self.logger.log("다이얼로그 확인버튼 클릭 완료.", level=logging.INFO) try: await self.page.locator(self.dialog_selector).wait_for(state="detached", timeout=1000) return True except TimeoutError: await ok_btn.click() await self.page.locator(self.dialog_selector).wait_for(state="detached", timeout=1000) return True except TimeoutError: self.logger.log("다이얼로그 팝업 감지 안됨.", level=logging.WARNING) return False # 상품명 재수정 필요 없음 async def handle_save_with_error_recovery(self, title_infos: dict, context: str = "일반") -> str: """ 저장 시 발생할 수 있는 오류를 처리하는 통합 메서드 Args: title_infos (dict): 상품 정보 딕셔너리 context (str): 호출 컨텍스트 (옵션, 가격, 썸네일, 태그, 상세페이지, 최종저장 등) Returns: str: "SAVED" - 정상 저장 완료 "DELETED" - 상품 삭제 완료 "ERROR" - 처리 실패 "DUPLICATE_HANDLED" - 중복 상품명 처리 완료 """ # ──────────────────────────────────────────────────────────────── # 내부: 중복상품명 팝업 처리 → True(중복 처리함) / False(중복 없음) # ──────────────────────────────────────────────────────────────── async def _resolve_duplication(step_desc: str) -> bool: """저장/닫기 단계에서 중복 팝업이 뜨면 상품명 재설정 후 재저장한다.""" await asyncio.sleep(0.2) # 팝업 렌더링 대기 rename_need = await self.handle_exist_productname_popup() if not rename_need: return False # 중복 없음 self.logger.log(f"[{context}] {step_desc} 중 상품명 중복 발생: 재설정 실행.", level=logging.INFO) # 새 상품명 생성 (재생성 로직 적용) new_title = await self.titleGenerator.regenerate_title_for_duplication() # 재생성 실패 시 Fallback 로직 if not new_title: if title_infos.get("generated_name"): new_title = title_infos["generated_name"] else: random_suffix = self.generate_random_suffix() new_title = "중복상품" + title_infos.get("collection_name", "____") + random_suffix duplication_suffix = f'{context}중복' # 상품명 탭 이동 → 제목 재설정 await self.edit_product_name() await asyncio.sleep(0.2) is_set = await self.titleGenerator.set_product_name(new_title, duplication_suffix) if not is_set: self.logger.log(f"{context} 상품명 재설정 실패", level=logging.ERROR) raise RuntimeError("PRODUCTNAME_DUPLICATE_RENAME_FAILED") self.logger.log(f"{context} 상품명 재설정 완료, 다시 저장 진행.", level=logging.INFO) # 재저장 await self.page.click(self.save_button_locator) self.logger.log(f"{context} 재저장 클릭 완료.", level=logging.INFO) return True # 중복 처리 후 재저장 완료 # ──────────────────────────────────────────────────────────────── # 1) 최초 저장 # ──────────────────────────────────────────────────────────────── try: await self.page.click(self.save_button_locator) self.logger.log(f"{context} 저장 버튼 클릭 완료.", level=logging.INFO) # 중복 팝업 처리 루프 if await _resolve_duplication("저장"): return "DUPLICATE_HANDLED" # ──────────────────────────────────────────────────────────── # 2) 최종저장 컨텍스트: 다이얼로그 닫기 + 다시 중복 확인 # ──────────────────────────────────────────────────────────── if context == "최종저장": while True: try: if await self.page.is_visible(self.product_dialog_close_btn): await self.page.click(self.product_dialog_close_btn) self.logger.log(f"[{context}] 다이얼로그 닫기 버튼 클릭.", level=logging.INFO) # 닫기 과정에서도 중복 팝업이 뜰 수 있음 if await _resolve_duplication("다이얼로그 닫기"): return "DUPLICATE_HANDLED" # 중복이 더 이상 없으면 다이얼로그 닫기 성공 self.logger.log(f"{context} 다이얼로그 닫기 완료.", level=logging.INFO) break except Exception as close_err: self.logger.log(f"{context} 다이얼로그 닫기 중 오류: {close_err}", level=logging.WARNING) raise # 필요 시 재시도 로직을 둘 수도 있음 return "SAVED" # ──────────────────────────────────────────────────────────────── # 3) 예외 처리 (1차: 상품명 재설정 / 2차: 상품 삭제) # ──────────────────────────────────────────────────────────────── except Exception as e: self.logger.log(f"{context} 저장 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) # 에러 상태 스크린샷 저장 screenshot_path = await self.save_error_screenshot(f"{context}_save_error") self.logger.log(f"{context} 오류 스크린샷 저장: {screenshot_path}", level=logging.INFO) # 1차 복구: 상품명 재설정 후 재저장 try: self.logger.log(f"{context} 1차 복구: 상품명 재설정 시도.", level=logging.INFO) await self.edit_product_name() await asyncio.sleep(0.5) # 상품명 재생성 시도 new_title = await self.titleGenerator.regenerate_title_for_duplication() if not new_title: # 재생성 실패 시 기존 로직(랜덤 접미사) 사용 if title_infos.get("generated_name"): new_title = title_infos["generated_name"] else: random_suffix = self.generate_random_suffix() new_title = "중복상품" + title_infos.get("collection_name", "====") + random_suffix error_suffix = f'{context}오류복구' is_set = await self.titleGenerator.set_product_name(new_title, error_suffix) if is_set: self.logger.log(f"[{context}] 상품명 재설정 완료, 재저장 시도.", level=logging.INFO) await self.page.click(self.save_button_locator) await asyncio.sleep(0.5) # 최종저장이라면 다시 다이얼로그 닫기 + 중복 확인 if context == "최종저장": while True: try: if await self.page.is_visible(self.product_dialog_close_btn): await self.page.click(self.product_dialog_close_btn) self.logger.log(f"[{context}] 다이얼로그 닫기 버튼 클릭.", level=logging.INFO) if await _resolve_duplication("다이얼로그 닫기(오류복구)"): return "DUPLICATE_HANDLED" self.logger.log(f"{context} 다이얼로그 닫기 완료.", level=logging.INFO) break except Exception as close_err: self.logger.log(f"{context} 다이얼로그 닫기 오류(오류복구): {close_err}", level=logging.WARNING) raise return "SAVED" except Exception as retry_error: self.logger.log(f"{context} 상품명 재설정 실패: {retry_error}", level=logging.ERROR, exc_info=True) # 2차 복구: 상품 삭제 try: self.logger.log(f"{context} 2차 복구: 상품 삭제 시도.", level=logging.WARNING) await self.handle_other_dialogs_before_delete() delete_btn = "div.Product_Edit_Detail_Dialog_Drawer .ant-btn-dangerous[type='button']" await self.page.wait_for_selector(delete_btn, timeout=5000) await self.page.click(delete_btn) await asyncio.sleep(0.5) confirm_btn = "div.ant-modal-centered [role='dialog'] .ant-btn.ant-btn-dangerous[type='button']" await self.page.wait_for_selector(confirm_btn, timeout=5000) await self.page.click(confirm_btn) await asyncio.sleep(0.5) await self.page.wait_for_selector(delete_btn, state="detached", timeout=10000) self.logger.log(f"[{context}] 상품 삭제 완료.", level=logging.INFO) return "DELETED" except Exception as delete_error: self.logger.log(f"[{context}] 상품 삭제 실패: {delete_error}", level=logging.ERROR, exc_info=True) return "ERROR" async def handle_other_dialogs_before_delete(self): """ 상품 삭제 전에 다른 다이얼로그들을 처리하는 메서드 div.ant-modal-confirm[role='dialog'] 형태의 다이얼로그에서 삭제, 저장, 닫기, 확인, 취소 버튼을 찾아 처리 """ try: # 최대 3번 시도 for attempt in range(3): # 다이얼로그가 있는지 확인 modal_dialogs = await self.page.query_selector_all("div.ant-modal-confirm[role='dialog']") if not modal_dialogs: self.logger.log("처리할 다이얼로그가 없습니다.", level=logging.DEBUG) break self.logger.log(f"시도 {attempt + 1}: {len(modal_dialogs)}개의 다이얼로그 발견", level=logging.INFO) for i, dialog in enumerate(modal_dialogs): try: # 버튼 우선순위: 삭제 > 확인 > 저장 > 닫기 > 취소 button_texts = ["삭제", "확인", "저장", "닫기", "취소"] clicked = False for button_text in button_texts: # 버튼 찾기 button = await dialog.query_selector(f"button:has-text('{button_text}')") if button: await button.click() self.logger.log(f"다이얼로그 {i+1}에서 '{button_text}' 버튼 클릭", level=logging.INFO) clicked = True await asyncio.sleep(0.3) break if not clicked: self.logger.log(f"다이얼로그 {i+1}에서 처리할 버튼을 찾지 못했습니다.", level=logging.WARNING) except Exception as dialog_error: self.logger.log(f"다이얼로그 {i+1} 처리 중 오류: {dialog_error}", level=logging.WARNING) # 다이얼로그 처리 후 잠시 대기 await asyncio.sleep(0.5) except Exception as e: self.logger.log(f"다이얼로그 처리 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) async def save_product_edit(self): """상품 수정 후 저장 버튼 클릭""" try: await self.page.click(self.save_button_locator) self.logger.log("상품 수정 내용 저장 완료.", level=logging.INFO) except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"저장 버튼 클릭 중 오류: {e}", level=logging.ERROR, exc_info=True) async def back_to_first_page(self): try: # 현재 활성 페이지 번호 확인 active_page = self.page.locator("ul.ant-pagination li.ant-pagination-item-active") current_page = (await active_page.inner_text()).strip() if current_page != "1": page1_btn = self.page.locator("ul.ant-pagination li[title='1'] a") await page1_btn.click() await self.page.wait_for_load_state("networkidle") await self.scroll_page_to_top() self.logger.log("작업 종료 후 1페이지로 복귀했습니다.", level=logging.INFO) else: self.logger.log("이미 1페이지이므로 이동하지 않았습니다.", level=logging.DEBUG) except Exception as e: self.logger.log(f"1페이지 복귀 실패: {e}", level=logging.ERROR) async def go_to_next_page(self): """ 수정된 CSS 선택자를 사용하여 다음 페이지로 이동합니다. """ try: current_page_element = await self.page.query_selector(self.current_page_locator) if not current_page_element: self.logger.log("현재 페이지 정보를 찾을 수 없습니다.", level=logging.WARNING) return False current_page_number = int(await current_page_element.get_attribute("title")) self.logger.log(f"현재 페이지 번호: {current_page_number}", level=logging.INFO) next_page_number = current_page_number + 1 next_page_button_locator = self.locator_manager.get_locator('BrowserControl', 'next_page_button_template').format(page_number=next_page_number) self.logger.log(f"다음 페이지 선택자: {next_page_button_locator}", level=logging.DEBUG) next_page_button = await self.page.query_selector(next_page_button_locator) if next_page_button: await next_page_button.click() await self.page.wait_for_load_state('domcontentloaded', timeout=5000) self.logger.log(f"페이지 {next_page_number}로 이동 완료.", level=logging.INFO) return True else: self.logger.log("다음 페이지가 없습니다.", level=logging.WARNING) return False except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"다음 페이지로 이동 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) return False async def scroll_with_wheel(self, direction="down", pause_time=0.5, max_scrolls=50): """ 휠 스크롤을 사용하여 페이지를 위나 아래로 천천히 스크롤. Parameters: - direction: 스크롤 방향 ("down"은 아래로, "up"은 위로). - pause_time: 스크롤 사이의 대기 시간 (초). - max_scrolls: 최대 스크롤 횟수. """ scroll_count = 0 self.logger.log(f"스크롤 시작", level=logging.DEBUG) # 현재 페이지 높이 가져오기 last_height = await self.page.evaluate("document.body.scrollHeight") self.logger.log(f"현재 페이지 높이 가져오기 - {last_height}", level=logging.DEBUG) while scroll_count < max_scrolls: if direction == "down": # 아래로 스크롤 self.logger.log(f"scroll_count[{scroll_count}]회 : 휠 아래로 1000px", level=logging.DEBUG) await self.page.evaluate("window.scrollBy(0, 1000);") elif direction == "up": # 위로 스크롤 self.logger.log(f"scroll_count[{scroll_count}]회 : 휠 위로 1000px", level=logging.DEBUG) await self.page.evaluate("window.scrollBy(0, -1000);") else: raise ValueError("direction 인자는 'down' 또는 'up'만 허용됩니다.") self.logger.log(f"pause_time 슬립 : {pause_time}", level=logging.DEBUG) await asyncio.sleep(pause_time) # 새로운 페이지 높이 가져오기 new_height = await self.page.evaluate("document.body.scrollHeight") self.logger.log(f"새로운 페이지 높이 가져오기 - {new_height}", level=logging.DEBUG) # 스크롤이 더 이상 필요 없는 경우(페이지 끝에 도달) if direction == "down" and new_height == last_height: self.logger.log(f"페이지 끝에 도달했습니다. 스크롤 횟수: {scroll_count}", level=logging.DEBUG) break elif direction == "up" and new_height == 0: self.logger.log(f"페이지 시작에 도달했습니다. 스크롤 횟수: {scroll_count}", level=logging.DEBUG) break self.logger.log(f"새로운 페이지 높이를 현재높이로 재설정 - {new_height}", level=logging.DEBUG) last_height = new_height scroll_count += 1 self.logger.log(f"스크롤 카운트 + 1", level=logging.DEBUG) if scroll_count == max_scrolls: self.logger.log("최대 스크롤 횟수에 도달했습니다.", level=logging.DEBUG) async def scroll_with_keyboard(self, direction="down", pause_time=0.5, max_scrolls=50): """ 키보드를 사용하여 페이지를 위나 아래로 천천히 스크롤. Parameters: - direction: 스크롤 방향 ("down"은 아래로, "up"은 위로). - pause_time: 스크롤 사이의 대기 시간 (초). - max_scrolls: 최대 스크롤 횟수. """ scroll_count = 0 while scroll_count < max_scrolls: if direction == "down": # 아래로 스크롤 (Page Down 키 사용) await self.page.keyboard.press("PageDown") elif direction == "up": # 위로 스크롤 (Page Up 키 사용) await self.page.keyboard.press("PageUp") else: raise ValueError("direction 인자는 'down' 또는 'up'만 허용됩니다.") await asyncio.sleep(pause_time) scroll_count += 1 if scroll_count == max_scrolls: self.logger.log("최대 스크롤 횟수에 도달했습니다.", level=logging.DEBUG) async def collect_product_info(self, items_per_page, ed_mode, total_products=None): """ 상품 정보를 수집하는 메서드 """ try: product_infos = [] product_name_elements = [] # product_name_element를 저장할 리스트 # 실제 수집할 상품 개수 결정 (총 상품수와 페이지당 상품수 중 작은 값) actual_items_to_collect = items_per_page if total_products is not None: actual_items_to_collect = min(items_per_page, total_products) self.logger.log(f"실제 수집할 상품 개수: {actual_items_to_collect} (총 상품: {total_products}, 페이지당: {items_per_page})", level=logging.DEBUG) # ed_mode에 따라 product_elements 설정 if ed_mode: # 각 상품의 이름, 가격, 이미지를 위한 선택자 리스트 구성 (index가 2부터 시작) product_elements = [ { "name": self.product_name_for_ed_template.format(index=i), "price": self.product_price_for_ed_template.format(index=i), "image": self.product_image_for_ed_template.format(index=i) } for i in range(2, actual_items_to_collect + 2) # 실제 상품 개수만큼만 생성 ] else: # ed_mode=False일 때는 각 상품의 부모 요소를 모두 선택 all_product_elements = await self.page.query_selector_all(self.product_parent_locator) product_elements = all_product_elements[:actual_items_to_collect] # 실제 상품 개수만큼만 선택 for i, element in enumerate(product_elements, start=1): try: if ed_mode: # ed_mode=True일 때는 각 상품의 개별 선택자 사용 product_name_element = await self.page.wait_for_selector(element["name"], timeout=3000, state="attached") product_price_element = await self.page.wait_for_selector(element["price"], timeout=3000, state="attached") product_image_element = await self.page.wait_for_selector(element["image"], timeout=3000, state="attached") else: # ed_mode=False일 때 부모 요소 내의 선택자를 사용 product_name_element = await self.page.wait_for_selector(self.product_name_inner_locator, timeout=3000, state="attached") product_price_element = await self.page.wait_for_selector(self.product_price_inner_locator, timeout=3000, state="attached") product_image_element = await self.page.wait_for_selector(self.product_image_inner_locator, timeout=3000, state="attached") # 요소가 존재하면 정보 추출 self.logger.log(f"product_name_element : {product_name_element}", level=logging.DEBUG) self.logger.log(f"product_price_element : {product_price_element}", level=logging.DEBUG) self.logger.log(f"product_image_element : {product_image_element}", level=logging.DEBUG) if product_name_element and product_price_element and product_image_element: # await의 결과를 각 변수에 저장 product_name_text = (await product_name_element.inner_text()).strip() product_price_text = (await product_price_element.inner_text()).strip() product_image_url = await product_image_element.get_attribute('src') # product_info 딕셔너리에 결과 저장 product_info = { "name": product_name_text, "price": product_price_text, "image_url": product_image_url } self.logger.log(f"상품 {i}: {product_info}", level=logging.DEBUG) product_infos.append(product_info) product_name_elements.append(product_name_element) # 각 product_name_element 추가 except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"상품 {i} 정보 수집 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) continue return product_infos, product_name_elements # product_infos와 product_name_elements 함께 반환 except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"상품 정보 수집 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) return [] # async def scroll_page_to_bottom(self, pause_time=0.2): # """페이지의 맨 아래까지 스크롤하여 모든 동적 요소를 로드""" # self.logger.log('페이지 스크롤 시작...', level=logging.INFO) # try: # # 타임아웃 설정으로 API 대기 중에도 안전하게 동작 # previous_height = await asyncio.wait_for( # self.page.evaluate("() => document.body.scrollHeight"), # timeout=5.0 # ) # retry_count = 0 # max_retries = 10 # 최대 재시도 횟수 # while retry_count < max_retries: # try: # # 스크롤 실행에도 타임아웃 적용 # await asyncio.wait_for( # self.page.evaluate("window.scrollBy(0, window.innerHeight);"), # timeout=3.0 # ) # await asyncio.sleep(pause_time) # # 현재 높이 체크에도 타임아웃 적용 # current_height = await asyncio.wait_for( # self.page.evaluate("() => document.body.scrollHeight"), # timeout=3.0 # ) # if current_height == previous_height: # break # 더 이상 스크롤할 내용이 없으면 종료 # previous_height = current_height # retry_count += 1 # except asyncio.TimeoutError: # self.logger.log(f'스크롤 중 타임아웃 발생 (시도 {retry_count + 1}/{max_retries})', level=logging.WARNING) # retry_count += 1 # await asyncio.sleep(1.0) # 타임아웃 후 잠시 대기 # # 페이지 상태 재확인 # try: # previous_height = await asyncio.wait_for( # self.page.evaluate("() => document.body.scrollHeight"), # timeout=2.0 # ) # except asyncio.TimeoutError: # self.logger.log('페이지 상태 확인 실패, 스크롤 중단', level=logging.WARNING) # break # if retry_count >= max_retries: # self.logger.log('최대 재시도 횟수 도달, 스크롤 강제 종료', level=logging.WARNING) # else: # self.logger.log('페이지 스크롤 완료.', level=logging.INFO) # cnt = await self.page.locator(self.product_chain_cards).count() # self.logger.log(f"카드 수 확인: {cnt}", level=logging.DEBUG) # except asyncio.TimeoutError: # self.logger.log('페이지 스크롤 초기화 중 타임아웃 발생, 스크롤 건너뛰기', level=logging.WARNING) # except Exception as e: # self.logger.log(f'페이지 스크롤 중 예외 발생: {e}', level=logging.ERROR) # # 에러가 발생해도 작업을 계속 진행 async def scroll_page_to_bottom( self, pause_time: float = 0.2, stable_loops: int = 3, max_loops: int = 50 ): self.logger.log("페이지 스크롤(혼합 방식) 시작...", level=logging.INFO) loc_cards = self.page.locator(self.product_chain_cards) prev_height = await self.page.evaluate("() => document.body.scrollHeight") prev_cnt = await loc_cards.count() stable_rounds = 0 loop = 0 while stable_rounds < stable_loops and loop < max_loops: loop += 1 # 자연스러운 휠 스크롤 await self.page.mouse.wheel( 0, await self.page.evaluate("() => window.innerHeight") ) await asyncio.sleep(pause_time) curr_height = await self.page.evaluate("() => document.body.scrollHeight") curr_cnt = await loc_cards.count() self.logger.log( f"[{loop}] height: {prev_height}->{curr_height}, " f"cards: {prev_cnt}->{curr_cnt}", level=logging.DEBUG ) if curr_height == prev_height and curr_cnt == prev_cnt: stable_rounds += 1 # 두 값 모두 변화 없으면 안정 라운드 +1 else: stable_rounds = 0 # 하나라도 변했으면 초기화 prev_height, prev_cnt = curr_height, curr_cnt self.logger.log( f"스크롤 완료. 최종 height={prev_height}, 카드={prev_cnt}", level=logging.DEBUG ) async def ensure_all_products_loaded(self, expected_count: int, max_attempts: int = 3): """ 모든 상품이 로딩될 때까지 스크롤을 반복하여 보장 - expected_count: 예상되는 상품 수 - max_attempts: 최대 시도 횟수 """ self.logger.log(f"모든 상품 로딩 보장 시작 - 예상 상품 수: {expected_count}", level=logging.INFO) for attempt in range(max_attempts): # 현재 로딩된 상품 수 확인 current_count = await self.page.locator(self.product_chain_cards).count() self.logger.log(f"시도 {attempt + 1}/{max_attempts} - 현재 로딩된 상품: {current_count}/{expected_count}", level=logging.DEBUG) if current_count >= expected_count: self.logger.log("모든 상품 로딩 완료", level=logging.INFO) return True # 상품이 부족하면 강력한 스크롤 수행 self.logger.log("상품 부족 - 추가 스크롤 수행", level=logging.DEBUG) # 1. 아래로 스크롤 await self.scroll_page_to_bottom(pause_time=0.3, stable_loops=5, max_loops=80) # 2. 위로 스크롤했다가 다시 아래로 (레이지 로딩 트리거) await self.scroll_page_to_top(pause_time=0.3) await asyncio.sleep(0.5) # 3. 다시 아래로 스크롤 (더 강력하게) await self.scroll_page_to_bottom(pause_time=0.3, stable_loops=5, max_loops=80) # 4. 각 상품 요소를 개별적으로 스크롤하여 로딩 유도 try: product_cards = self.page.locator(self.product_chain_cards) visible_count = await product_cards.count() for i in range(min(visible_count, expected_count)): try: card = product_cards.nth(i) await card.scroll_into_view_if_needed() await asyncio.sleep(0.1) # 각 상품마다 약간의 지연 except Exception as e: self.logger.log(f"상품 {i} 스크롤 중 오류: {e}", level=logging.DEBUG) continue except Exception as e: self.logger.log(f"개별 상품 스크롤 중 오류: {e}", level=logging.DEBUG) await asyncio.sleep(1) # 추가 로딩 시간 부여 # 최종 확인 final_count = await self.page.locator(self.product_chain_cards).count() if final_count >= expected_count: self.logger.log(f"최종 상품 로딩 완료: {final_count}/{expected_count}", level=logging.INFO) return True else: self.logger.log(f"상품 로딩 불완전: {final_count}/{expected_count} - 일부 상품이 로딩되지 않았을 수 있음", level=logging.WARNING) return False async def scroll_page_to_top(self, pause_time=0.2): """페이지의 맨 위까지 스크롤""" self.logger.log('페이지 위로 스크롤 시작...', level=logging.DEBUG) try: # 타임아웃 설정으로 API 대기 중에도 안전하게 동작 previous_height = await asyncio.wait_for( self.page.evaluate("() => window.pageYOffset"), timeout=5.0 ) retry_count = 0 max_retries = 10 # 최대 재시도 횟수 while previous_height > 0 and retry_count < max_retries: try: # 스크롤 실행에도 타임아웃 적용 await asyncio.wait_for( self.page.evaluate("window.scrollBy(0, -window.innerHeight);"), timeout=3.0 ) await asyncio.sleep(pause_time) # 현재 높이 체크에도 타임아웃 적용 current_height = await asyncio.wait_for( self.page.evaluate("() => window.pageYOffset"), timeout=3.0 ) if current_height == previous_height: break # 더 이상 스크롤할 내용이 없으면 종료 previous_height = current_height retry_count += 1 except asyncio.TimeoutError: self.logger.log(f'스크롤 중 타임아웃 발생 (시도 {retry_count + 1}/{max_retries})', level=logging.WARNING) retry_count += 1 await asyncio.sleep(0.2) # 타임아웃 후 잠시 대기 # 페이지 상태 재확인 try: previous_height = await asyncio.wait_for( self.page.evaluate("() => window.pageYOffset"), timeout=1.0 ) except asyncio.TimeoutError: self.logger.log('페이지 상태 확인 실패, 스크롤 중단', level=logging.WARNING) break if retry_count >= max_retries: self.logger.log('최대 재시도 횟수 도달, 스크롤 강제 종료', level=logging.WARNING) else: self.logger.log('페이지 위로 스크롤 완료.', level=logging.DEBUG) except asyncio.TimeoutError: self.logger.log('페이지 스크롤 초기화 중 타임아웃 발생, 스크롤 건너뛰기', level=logging.WARNING) except Exception as e: self.logger.log(f'페이지 스크롤 중 예외 발생: {e}', level=logging.ERROR) # 에러가 발생해도 작업을 계속 진행 async def start_ChangeBiz_task(self): self.logger.log('사업자 변경 작업을 시작합니다...', level=logging.DEBUG) self.running = True # 번역 작업이 시작됨 # 마켓정보 변경 시작 시그널 self.upload_step_changed.emit("마켓정보 변경 중", 20) self.upload_log_message.emit("마켓정보 변경 작업을 시작합니다...") try: if not self.biz_info: self.logger.log('사업자 정보가 없습니다. 작업을 종료합니다.', level=logging.DEBUG) self.upload_log_message.emit("사업자 정보가 없어 마켓정보 변경을 건너뜁니다.") return # self.logger.log(f'사업자 정보: {self.biz_info}', level=logging.DEBUG) self.logger.log('마켓 설정 페이지로 이동 중...', level=logging.INFO) self.upload_log_message.emit("마켓 설정 페이지로 이동 중...") try: market_management_page_locator = self.locator_manager.get_locator('BrowserControl', 'market_management_page_locator') await self.page.click(market_management_page_locator) self.logger.log("마켓 설정 페이지로 이동 완료.", level=logging.INFO) except Exception as e: self.logger.log(f"마켓 설정 페이지 이동 중 오류: {e}", level=logging.ERROR, exc_info=True) screenshot_path = await self.save_error_screenshot() self.browser_market_management_page_error.emit(str(e)) return #------------마켓번경 로직------- try: # 동적 선택자 - ID가 매번 바뀌므로 더 안정적인 선택자 사용 # 탭 선택자 (data-node-key 또는 텍스트 기반) tab_cp_sel = "div[data-node-key='cp']" # 또는 "[id$='-tab-cp']" tab_ss_sel = "div[data-node-key='ss']" # 또는 "[id$='-tab-ss']" tab_esm_sel = "div[data-node-key='esm']" # 또는 "[id$='-tab-esm']" tab_est_sel = "div[data-node-key='est']" # 또는 "[id$='-tab-est']" tab_estg_sel = "div[data-node-key='est_global']" # 또는 "[id$='-tab-est_global']" tab_lotteon_sel = "div[data-node-key='lotteon']" # 또는 "[id$='-tab-lotteon']" tab_kakao_sel = "div[data-node-key='kakao']" # 또는 "[id$='-tab-kakao']" # 패널 선택자 (ID 끝부분 매칭 사용) cp_panel_sel = "[id$='-panel-cp']" cp_switch_btn_sel = "button[role='switch']" cp_api_verify_btn = "button[type='button']:has(span:has-text('API 검증'))" cp_api_verified_mark = "[title='검증 완료']" cp_profile_all_btns = "button[type='button']:has(span:has-text('기본 프로필로 설정'))" esm_panel_sel = "[id$='-panel-esm']" esm_switch_btn_sel = "button[role='switch']" esm_api_verify_btn = "button[type='button']:has(span:has-text('API 검증'))" esm_api_verified_mark= "[title='검증 완료']" esm_profile_all_btns = "button[type='button']:has(span:has-text('기본 프로필로 설정'))" est_panel_sel = "[id$='-panel-est']" est_switch_btn_sel = "button[role='switch']" est_api_verify_btn = "button[type='button']:has(span:has-text('API 검증'))" est_api_verified_mark= "[title='검증 완료']" est_profile_all_btns = "button[type='button']:has(span:has-text('기본 프로필로 설정'))" estg_panel_sel = "[id$='-panel-est_global']" estg_switch_btn_sel = "button[role='switch']" estg_api_verify_btn = "button[type='button']:has(span:has-text('API 검증'))" estg_api_verified_mark= "[title='검증 완료']" estg_profile_all_btns= "button[type='button']:has(span:has-text('기본 프로필로 설정'))" lot_panel_sel = "[id$='-panel-lotteon']" lot_switch_btn_sel = "button[role='switch']" lot_api_verify_btn = "button[type='button']:has(span:has-text('API 검증'))" lot_api_verified_mark= "[title='검증 완료']" lot_profile_all_btns = "button[type='button']:has(span:has-text('기본 프로필로 설정'))" kk_panel_sel = "[id$='-panel-kakao']" kk_switch_btn_sel = "button[role='switch']" kk_api_verify_btn = "button[type='button']:has(span:has-text('API 검증'))" kk_api_verified_mark = "[title='검증 완료']" kk_profile_all_btns = "button[type='button']:has(span:has-text('기본 프로필로 설정'))" ss_panel_sel = "[id$='-panel-ss']" ss_switch_btn_sel = "button[role='switch']" ss_upload_account_btn= "button[type='button']:has(span:has-text('업로드할 계정 설정하기'))" ss_profile_all_btns = "button[type='button']:has(span:has-text('기본 프로필로 설정'))" except Exception as e: self.logger.log(f"사업자변경 선택자 로딩 실패: {e}", level=logging.ERROR) return # 호환 가능한 구조: {'order': 1, 'markets': {...}} 또는 {'biz': {}, 'markets': {...}} markets = {} if self.biz_info and isinstance(self.biz_info, dict): if 'markets' in self.biz_info and isinstance(self.biz_info['markets'], dict): markets = self.biz_info['markets'] order = self.biz_info.get('order', 1) if 'order' in self.biz_info: # 새로운 구조 self.logger.log(f"✅ {order}번 작업의 마켓 개수: {len(markets)}", level=logging.INFO) else: # 기존 구조 (legacy) self.logger.log(f"✅ 선택마켓에서 가져온 마켓 개수: {len(markets)}", level=logging.INFO) for mtype, mdata in markets.items(): field1_preview = str(mdata.get('field1', ''))[:10] + '...' if mdata.get('field1') else '[빈값]' self.logger.log(f" 📋 {mtype}: field1={field1_preview}", level=logging.DEBUG) else: self.logger.log("❌ biz_info에 올바른 markets 구조가 없습니다.", level=logging.WARNING) return else: self.logger.log("❌ biz_info가 없거나 올바르지 않습니다.", level=logging.WARNING) return if not markets: self.logger.log("❌ 선택된 마켓이 없습니다. 사업자관리 > 선택마켓에서 마켓을 선택하세요.", level=logging.WARNING) return async def find_panel(panel_selector: str): """패널 찾기 - 여러 선택자 시도""" selectors_to_try = [ panel_selector, panel_selector.replace("[id$=", "div[id*=").replace("]", "]") # 포함 매칭 ] for selector in selectors_to_try: try: panel_locator = self.page.locator(selector).first if await panel_locator.count() > 0: return panel_locator except Exception: continue self.logger.log(f"패널을 찾을 수 없음: {panel_selector}", level=logging.ERROR) return None async def click_tab(tab_selector: str): """탭 클릭 - 여러 선택자 시도""" selectors_to_try = [ tab_selector, # 기본 선택자 tab_selector.replace("div[data-node-key=", "[id$='-tab-").replace("']", "']"), # ID 기반 대안 ] for selector in selectors_to_try: try: element = self.page.locator(selector).first if await element.count() > 0: await element.click() await asyncio.sleep(0.3) self.logger.log(f"✅ 탭 클릭 성공: {selector}", level=logging.DEBUG) return True except Exception as e: self.logger.log(f"❌ 탭 클릭 실패: {selector} - {e}", level=logging.DEBUG) continue self.logger.log(f"❌ 모든 탭 선택자 실패: {tab_selector}", level=logging.ERROR) return False async def fill_texts_in_chain(panel_selector: str, values: list): """패널 내 텍스트 입력""" try: panel = await find_panel(panel_selector) if panel is None: return False inputs = panel.locator("input[type='text']") cnt = await inputs.count() for idx, val in enumerate(values): if val is None: continue if idx >= cnt: break # 입력값의 양 끝 공백 제거 clean_val = str(val).strip() await inputs.nth(idx).fill(clean_val) await asyncio.sleep(0.1) # 입력 간 짧은 대기 return True except Exception as e: self.logger.log(f"텍스트 입력 실패: {e}", level=logging.ERROR) return False async def ensure_switch_on(panel_selector: str, switch_selector: str): """스위치 ON 확인/설정""" try: # 패널 찾기 panel = await find_panel(panel_selector) if panel is None: return False sw = panel.locator(switch_selector).first if await sw.count() == 0: return True aria = await sw.get_attribute("aria-checked") if aria == 'true': return True await sw.click() await asyncio.sleep(0.2) return True except Exception as e: self.logger.log(f"스위치 ON 실패: {e}", level=logging.ERROR) return False async def click_and_wait_verify(panel_selector: str, verify_btn_sel: str, verified_mark_sel: str, timeout: float = 3.0): """API 검증 버튼 클릭 및 검증 대기 (개선된 버전)""" try: panel = await find_panel(panel_selector) if panel is None: return False btn = panel.locator(verify_btn_sel).first if await btn.count() == 0: self.logger.log("❌ API 검증 버튼을 찾을 수 없음", level=logging.WARNING) return False self.logger.log("🔍 API 검증 준비 중...", level=logging.DEBUG) # 검증 버튼 클릭 전 0.5초 대기 await asyncio.sleep(0.2) # 첫 번째 클릭 self.logger.log("🔍 API 검증 버튼 첫 번째 클릭", level=logging.DEBUG) await btn.click() # # 0.5초 후 두 번째 클릭 # await asyncio.sleep(0.5) # self.logger.log("🔍 API 검증 버튼 두 번째 클릭", level=logging.DEBUG) # await btn.click() # 검증 완료 대기 (최대 15초, 더 유연하게) verification_success = False if verified_mark_sel: self.logger.log(f"⏳ API 검증 완료 대기 중... (최대 15초)", level=logging.DEBUG) await asyncio.sleep(0.3) # 초기 대기 시간 # 15초 동안 1초마다 체크 for check_attempt in range(15): try: if await self.page.locator(verified_mark_sel).count() > 0: verification_success = True elapsed_time = (check_attempt + 1) self.logger.log(f"✅ API 검증 성공 확인됨 ({elapsed_time}초 후)", level=logging.INFO) break except Exception: pass await asyncio.sleep(1) if not verification_success: self.logger.log("❌ 15초 대기 후에도 API 검증 실패 - 해당 마켓 변경 실패", level=logging.WARNING) return False return verification_success except Exception as e: self.logger.log(f"❌ API 검증 중 오류 발생: {e} - 해당 마켓 변경 실패", level=logging.ERROR) return False async def select_shipping_profile(panel_selector: str, all_btns_selector: str, desired_index: int, market_name: str = ""): """배송 프로필 선택 (개선된 버전)""" try: if not desired_index or desired_index <= 0: return {"success": True, "warning": None} panel = await find_panel(panel_selector) if panel is None: return {"success": False, "warning": f"{market_name} 패널을 찾을 수 없음"} btns = panel.locator(all_btns_selector) total = await btns.count() if total == 0: warning_msg = f"{market_name} 선택가능한 배송프로필이 없음" self.logger.log(warning_msg, level=logging.WARNING) return {"success": True, "warning": warning_msg} idx = desired_index - 1 if idx >= total: warning_msg = f"{market_name} 배송프로필 {desired_index}번 요청했으나 {total}개만 존재 - 1번으로 대체" self.logger.log(warning_msg, level=logging.WARNING) # 범위를 벗어나면 1번 프로필로 대체 idx = 0 return {"success": True, "warning": warning_msg, "fallback": True} target = btns.nth(idx) is_disabled = await target.get_attribute('disabled') if is_disabled is not None: # 이미 선택된 프로필 return {"success": True, "warning": None} await target.click() await asyncio.sleep(0.2) return {"success": True, "warning": None} except Exception as e: error_msg = f"{market_name} 배송프로필 선택 실패: {e}" self.logger.log(error_msg, level=logging.ERROR) return {"success": False, "warning": error_msg} def valid_market_values(mdict: dict, keys: list): if not mdict: self.logger.log("❌ valid_market_values: mdict가 비어있음", level=logging.DEBUG) return False for k in keys: v = (mdict.get(k) or '').strip() if isinstance(mdict.get(k), str) else mdict.get(k) if v in (None, ""): self.logger.log(f"❌ valid_market_values: '{k}' 필드가 비어있음 (값: {repr(v)})", level=logging.DEBUG) return False else: self.logger.log(f"✅ valid_market_values: '{k}' = {repr(v)}", level=logging.DEBUG) self.logger.log("✅ valid_market_values: 모든 필드 검증 통과", level=logging.DEBUG) return True # 순서: 쿠팡 → esm → 11st → 11stg → lotteon → talkstore → ss sleep_time = 0.5 # 통계 수집 변수 total_markets = 0 success_markets = [] failed_markets = [] skipped_markets = [] shipping_warnings = [] # 배송프로필 관련 경고들 # [수정] 다중 업로드 시, "선택한 마켓만" 마켓 정보를 변경해야 합니다. # self.biz_info는 {'markets': {'coupang': ..., 'ss': ...}} 형태로, # 현재 작업에 포함된 마켓들만 담고 있어야 합니다. # 하지만 혹시라도 불필요한 마켓이 포함되어 있거나, # DB에서 가져온 전체 마켓 목록이 포함될 수도 있으므로 여기서 명확히 타겟팅합니다. target_market_list = list(markets.keys()) self.logger.log(f"🎯 변경 대상 마켓 목록: {target_market_list}", level=logging.INFO) # ---------------- 쿠팡 ---------------- cp = markets.get('coupang', {}) if 'coupang' in markets and valid_market_values(cp, ['field1','field2','field3','field4','shipping_profile']): total_markets += 1 self.logger.log("🔄 쿠팡 마켓 정보 변경 시작...", level=logging.INFO) await click_tab(tab_cp_sel) await fill_texts_in_chain(cp_panel_sel, [cp.get('field1'), cp.get('field2'), cp.get('field3'), cp.get('field4')]) await ensure_switch_on(cp_panel_sel, cp_switch_btn_sel) # API 검증 성공 시에만 다음 단계 진행 verify_success = await click_and_wait_verify(cp_panel_sel, cp_api_verify_btn, cp_api_verified_mark) if verify_success: try: prof_idx = int(str(cp.get('shipping_profile') or '1')) except Exception: prof_idx = 1 # 배송프로필 선택 및 경고 수집 shipping_result = await select_shipping_profile(cp_panel_sel, cp_profile_all_btns, prof_idx, "쿠팡") if shipping_result.get("warning"): shipping_warnings.append(shipping_result["warning"]) self.logger.log("✅ 쿠팡 마켓 정보 변경 완료", level=logging.INFO) success_markets.append("쿠팡") else: self.logger.log("❌ 쿠팡 API 검증 실패로 인한 불완전 처리", level=logging.WARNING) failed_markets.append("쿠팡") await asyncio.sleep(sleep_time) else: self.logger.log("⏭️ 쿠팡 마켓 정보 건너뜀 (설정되지 않음)", level=logging.DEBUG) skipped_markets.append("쿠팡") # ---------------- ESM ---------------- esm = markets.get('esm', {}) if 'esm' in markets and valid_market_values(esm, ['field1','field2','shipping_profile']): total_markets += 1 self.logger.log("🔄 ESM 마켓 정보 변경 시작...", level=logging.INFO) await click_tab(tab_esm_sel) await fill_texts_in_chain(esm_panel_sel, [esm.get('field1'), esm.get('field2')]) await ensure_switch_on(esm_panel_sel, esm_switch_btn_sel) # API 검증 성공 시에만 다음 단계 진행 verify_success = await click_and_wait_verify(esm_panel_sel, esm_api_verify_btn, esm_api_verified_mark) if verify_success: try: prof_idx = int(str(esm.get('shipping_profile') or '1')) except Exception: prof_idx = 1 # 배송프로필 선택 및 경고 수집 shipping_result = await select_shipping_profile(esm_panel_sel, esm_profile_all_btns, prof_idx, "ESM") if shipping_result.get("warning"): shipping_warnings.append(shipping_result["warning"]) self.logger.log("✅ ESM 마켓 정보 변경 완료", level=logging.INFO) success_markets.append("ESM") else: self.logger.log("❌ ESM API 검증 실패로 인한 불완전 처리", level=logging.WARNING) failed_markets.append("ESM") await asyncio.sleep(sleep_time) else: self.logger.log("⏭️ ESM 마켓 정보 건너뜀 (설정되지 않음)", level=logging.DEBUG) skipped_markets.append("ESM") # ---------------- 11번가 (일반) ---------------- est = markets.get('11st', {}) if '11st' in markets and valid_market_values(est, ['field1','shipping_profile']): total_markets += 1 self.logger.log("🔄 11번가(일반) 마켓 정보 변경 시작...", level=logging.INFO) await click_tab(tab_est_sel) await fill_texts_in_chain(est_panel_sel, [est.get('field1')]) await ensure_switch_on(est_panel_sel, est_switch_btn_sel) # API 검증 성공 시에만 다음 단계 진행 verify_success = await click_and_wait_verify(est_panel_sel, est_api_verify_btn, est_api_verified_mark) if verify_success: try: prof_idx = int(str(est.get('shipping_profile') or '1')) except Exception: prof_idx = 1 # 배송프로필 선택 및 경고 수집 shipping_result = await select_shipping_profile(est_panel_sel, est_profile_all_btns, prof_idx, "11번가(일반)") if shipping_result.get("warning"): shipping_warnings.append(shipping_result["warning"]) self.logger.log("✅ 11번가(일반) 마켓 정보 변경 완료", level=logging.INFO) success_markets.append("11번가(일반)") else: self.logger.log("❌ 11번가(일반) API 검증 실패로 인한 불완전 처리", level=logging.WARNING) failed_markets.append("11번가(일반)") await asyncio.sleep(sleep_time) else: self.logger.log("⏭️ 11번가(일반) 마켓 정보 건너뜀 (설정되지 않음)", level=logging.DEBUG) skipped_markets.append("11번가(일반)") # ---------------- 11번가 (글로벌) ---------------- estg = markets.get('11stg', {}) if '11stg' in markets and valid_market_values(estg, ['field1','shipping_profile']): total_markets += 1 self.logger.log("🔄 11번가(글로벌) 마켓 정보 변경 시작...", level=logging.INFO) await click_tab(tab_estg_sel) await fill_texts_in_chain(estg_panel_sel, [estg.get('field1')]) await ensure_switch_on(estg_panel_sel, estg_switch_btn_sel) # API 검증 성공 시에만 다음 단계 진행 verify_success = await click_and_wait_verify(estg_panel_sel, estg_api_verify_btn, estg_api_verified_mark) if verify_success: try: prof_idx = int(str(estg.get('shipping_profile') or '1')) except Exception: prof_idx = 1 # 배송프로필 선택 및 경고 수집 shipping_result = await select_shipping_profile(estg_panel_sel, estg_profile_all_btns, prof_idx, "11번가(글로벌)") if shipping_result.get("warning"): shipping_warnings.append(shipping_result["warning"]) self.logger.log("✅ 11번가(글로벌) 마켓 정보 변경 완료", level=logging.INFO) success_markets.append("11번가(글로벌)") else: self.logger.log("❌ 11번가(글로벌) API 검증 실패로 인한 불완전 처리", level=logging.WARNING) failed_markets.append("11번가(글로벌)") await asyncio.sleep(sleep_time) else: self.logger.log("⏭️ 11번가(글로벌) 마켓 정보 건너뜀 (설정되지 않음)", level=logging.DEBUG) skipped_markets.append("11번가(글로벌)") # ---------------- 롯데온 ---------------- lot = markets.get('lotteon', {}) if 'lotteon' in markets and valid_market_values(lot, ['field1','shipping_profile']): total_markets += 1 self.logger.log("🔄 롯데온 마켓 정보 변경 시작...", level=logging.INFO) await click_tab(tab_lotteon_sel) await fill_texts_in_chain(lot_panel_sel, [lot.get('field1')]) await ensure_switch_on(lot_panel_sel, lot_switch_btn_sel) # API 검증 성공 시에만 다음 단계 진행 verify_success = await click_and_wait_verify(lot_panel_sel, lot_api_verify_btn, lot_api_verified_mark) if verify_success: try: prof_idx = int(str(lot.get('shipping_profile') or '1')) except Exception: prof_idx = 1 # 배송프로필 선택 및 경고 수집 shipping_result = await select_shipping_profile(lot_panel_sel, lot_profile_all_btns, prof_idx, "롯데온") if shipping_result.get("warning"): shipping_warnings.append(shipping_result["warning"]) self.logger.log("✅ 롯데온 마켓 정보 변경 완료", level=logging.INFO) success_markets.append("롯데온") else: self.logger.log("❌ 롯데온 API 검증 실패로 인한 불완전 처리", level=logging.WARNING) failed_markets.append("롯데온") await asyncio.sleep(sleep_time) else: self.logger.log("⏭️ 롯데온 마켓 정보 건너뜀 (설정되지 않음)", level=logging.DEBUG) skipped_markets.append("롯데온") # ---------------- 톡스토어 ---------------- kk = markets.get('talkstore', {}) if 'talkstore' in markets and valid_market_values(kk, ['field1','field2','shipping_profile']): total_markets += 1 self.logger.log("🔄 톡스토어 마켓 정보 변경 시작...", level=logging.INFO) await click_tab(tab_kakao_sel) await fill_texts_in_chain(kk_panel_sel, [kk.get('field1'), kk.get('field2')]) await ensure_switch_on(kk_panel_sel, kk_switch_btn_sel) # API 검증 성공 시에만 다음 단계 진행 verify_success = await click_and_wait_verify(kk_panel_sel, kk_api_verify_btn, kk_api_verified_mark) if verify_success: try: prof_idx = int(str(kk.get('shipping_profile') or '1')) except Exception: prof_idx = 1 # 배송프로필 선택 및 경고 수집 shipping_result = await select_shipping_profile(kk_panel_sel, kk_profile_all_btns, prof_idx, "톡스토어") if shipping_result.get("warning"): shipping_warnings.append(shipping_result["warning"]) self.logger.log("✅ 톡스토어 마켓 정보 변경 완료", level=logging.INFO) success_markets.append("톡스토어") else: self.logger.log("❌ 톡스토어 API 검증 실패로 인한 불완전 처리", level=logging.WARNING) failed_markets.append("톡스토어") await asyncio.sleep(sleep_time) else: self.logger.log("⏭️ 톡스토어 마켓 정보 건너뜀 (설정되지 않음)", level=logging.DEBUG) skipped_markets.append("톡스토어") # ---------------- 스마트스토어 ---------------- ss = markets.get('ss', {}) # 스마트스토어는 field1(계정)은 자동입력, field2(API연동용판매자ID)는 수동입력이므로 # biz_info에 값이 있는지 확인 if 'ss' in markets: total_markets += 1 self.logger.log("🔄 스마트스토어 마켓 정보 변경 시작...", level=logging.INFO) # 퍼센티 익스텐션 활성화 (껐다 켜기) is_ext_ready = await self.off_on_ext_Percenty() if not is_ext_ready: self.logger.log("⚠️ 퍼센티 익스텐션 활성화 실패 - 스마트스토어 로그인이 동작하지 않을 수 있습니다", level=logging.WARNING) else: self.logger.log("✅ 퍼센티 익스텐션 활성화 완료", level=logging.DEBUG) await click_tab(tab_ss_sel) # (수정) field1은 건너뛰고 field2(API 연동용 판매자 ID)만 입력 try: panel = await find_panel(ss_panel_sel) if panel: # input 요소들 찾기 inputs = panel.locator("input[type='text']") count = await inputs.count() if count >= 2: # 두 번째 input (field2) field2_val = ss.get('field2', '') if field2_val: # 혹시 disabled인지 확인 if await inputs.nth(1).is_enabled(): await inputs.nth(1).fill(field2_val) self.logger.log(f"스마트스토어 API 연동용 판매자 ID 입력 완료", level=logging.DEBUG) else: self.logger.log("⚠️ 스마트스토어 두 번째 입력 필드가 비활성화되어 있습니다.", level=logging.WARNING) else: self.logger.log(f"⚠️ 스마트스토어 입력 필드 개수 부족 ({count}개)", level=logging.WARNING) except Exception as e: self.logger.log(f"스마트스토어 텍스트 입력 중 오류: {e}", level=logging.WARNING) # 스스: 스위치 켜기 await ensure_switch_on(ss_panel_sel, ss_switch_btn_sel) # 업로드 계정 설정 버튼 클릭 -> 팝업 처리 try: upload_btn = await find_panel(ss_panel_sel) if upload_btn: btn = upload_btn.locator(ss_upload_account_btn) if await btn.count() > 0: # 버튼 클릭하여 로그인 팝업 유도 await btn.click() self.logger.log("스마트스토어 계정 설정 버튼 클릭", level=logging.DEBUG) # 팝업 처리 (로그인 필요한 경우) # field3: ID, field4: PW login_success = await self.handle_smartstore_login_popup( user_id=ss.get('field3', ''), password=ss.get('field4', '') ) if login_success: self.logger.log("✅ 스마트스토어 로그인 확인 완료", level=logging.INFO) else: # 팝업이 안 떴거나 실패한 경우 -> 이미 로그인 되어있을 수 있음 self.logger.log("ℹ️ 스마트스토어 로그인 팝업이 없거나 처리됨 (이미 로그인 상태 가능성)", level=logging.INFO) except Exception as e: self.logger.log(f"스마트스토어 로그인 버튼 처리 중 오류: {e}", level=logging.WARNING) pass # 값 검증 (선택사항) # 현재 입력된 API 키(계정ID)가 biz_info와 일치하는지 확인 try: # panel 내 첫 번째 input (disabled 상태) panel = await find_panel(ss_panel_sel) if panel: api_input = panel.locator("input[type='text']").first if await api_input.count() > 0: current_val = await api_input.get_attribute("value") expected_val = ss.get('field1', '') # field1이 연동용 ID라고 가정 # expected_val이 있는데 current_val과 다르면 경고 if expected_val and current_val and current_val != expected_val: self.logger.log(f"⚠️ 스마트스토어 계정 불일치 경고: 현재({current_val}) != 설정({expected_val})", level=logging.WARNING) self.logger.log(f"스마트스토어 현재 연동 계정: {current_val}", level=logging.DEBUG) except Exception: pass try: prof_idx = int(str(ss.get('shipping_profile') or '1')) except Exception: prof_idx = 1 # 배송프로필 선택 및 경고 수집 shipping_result = await select_shipping_profile(ss_panel_sel, ss_profile_all_btns, prof_idx, "스마트스토어") if shipping_result.get("warning"): shipping_warnings.append(shipping_result["warning"]) self.logger.log("✅ 스마트스토어 마켓 정보 변경 완료", level=logging.INFO) success_markets.append("스마트스토어") await asyncio.sleep(sleep_time) else: self.logger.log("⏭️ 스마트스토어 마켓 정보 건너뜀 (설정되지 않음)", level=logging.DEBUG) skipped_markets.append("스마트스토어") # 통계 수집 및 결과 메시지 생성 self.logger.log("🎉 모든 마켓 정보 변경 작업 완료!", level=logging.INFO) # 결과 메시지 생성 msg = "🎯 마켓정보 변경 결과\n" msg += "=" * 30 + "\n\n" msg += f"📊 전체 통계:\n" msg += f" • 변경 시도: {total_markets}개 마켓\n" msg += f" • 성공: {len(success_markets)}개\n" msg += f" • 실패: {len(failed_markets)}개\n" msg += f" • 건너뜀: {len(skipped_markets)}개\n\n" if success_markets: msg += "✅ 성공한 마켓:\n" for market in success_markets: msg += f" • {market}\n" msg += "\n" if failed_markets: msg += "❌ 실패한 마켓:\n" for market in failed_markets: msg += f" • {market}\n" msg += "\n" if skipped_markets: msg += "⏭️ 건너뛴 마켓:\n" for market in skipped_markets: msg += f" • {market} (정보 불완전)\n" msg += "\n" # 배송프로필 경고 표시 if shipping_warnings: msg += "⚠️ 배송프로필 관련 경고:\n" for warning in shipping_warnings: msg += f" • {warning}\n" msg += "\n" # 성공률 계산 if total_markets > 0: success_rate = (len(success_markets) / total_markets) * 100 msg += f"📈 성공률: {success_rate:.1f}%" # 메시지 타입 결정 if len(success_markets) == total_markets and total_markets > 0: message_type = "information" elif len(success_markets) > 0: message_type = "warning" else: message_type = "critical" # 결과를 시그널로 emit result_data = { 'message': msg, 'type': message_type, 'title': '마켓정보 변경 결과' } self.market_change_completed.emit(result_data) self.logger.log(f"📊 변경 통계: 시도 {total_markets}개, 성공 {len(success_markets)}개, 실패 {len(failed_markets)}개, 건너뜀 {len(skipped_markets)}개", level=logging.INFO) # 마켓정보 변경 완료 시그널 self.upload_step_changed.emit("마켓정보 변경 완료", 40) self.upload_log_message.emit(f"마켓정보 변경 완료 - 시도 {total_markets}개, 성공 {len(success_markets)}개") self.step_completed.emit("market_change", True) except Exception as e: self.logger.log(f'사업자 정보 변경 작업 중 오류 발생: {e}', level=logging.ERROR, exc_info=True) self.running = False # 마켓정보 변경 실패 시그널 self.upload_log_message.emit(f"마켓정보 변경 중 오류 발생: {str(e)}") self.step_completed.emit("market_change", False) # self.change_biz_error.emit(e) return async def handle_smartstore_login_popup(self, expected_account: str = "", user_id: str = "", password: str = "", panel_selector: str = "") -> bool: """ 스마트스토어 로그인 팝업 처리 - 확인 버튼 클릭 → 로그인 팝업 열기 - 팝업 중복 시 1개 닫기 - 로그인 완료될 때까지 대기 - expected_account 있으면 계정 확인까지 수행 """ try: self.logger.log("스마트스토어 로그인 팝업 처리 시작", level=logging.DEBUG) # # "확인" 버튼 클릭 (로그인 요구 모달) # if expected_account == "": # try: # confirm_btn = self.page.locator("div.ant-modal-content button[type='button']:has(span:has-text('확인'))") # if await confirm_btn.count() > 0: # await confirm_btn.click() # self.logger.log("🔗 스마트스토어 로그인 요구 확인 버튼 클릭", level=logging.INFO) # except Exception as e: # self.logger.log(f"❌ 스마트스토어 로그인 확인 버튼 클릭 실패: {e}", level=logging.ERROR) self.logger.log(f"스마트스토어 로그인 팝업 처리 시작 - user_id: {user_id}, password: {password}, panel_selector: {panel_selector}", level=logging.DEBUG) # biz_info 확인 및 자동 로그인 정보 준비 auto_login_available = False if not user_id and not password and not panel_selector: # DB 접근 없이 메모리상의 biz_info 사용 markets = {} if self.biz_info and isinstance(self.biz_info, dict): # 구조: {'order': 1, 'markets': {'coupang': ..., 'ss': ...}} if 'markets' in self.biz_info: markets = self.biz_info['markets'] # 딕셔너리 또는 리스트 구조 처리 if isinstance(markets, list): # 리스트라면 딕셔너리로 변환 필요하지만, # 보통 update_biz_info에서 딕셔너리로 오거나, # 여기서 필요한 건 'ss' 키에 해당하는 정보임. # 리스트인 경우엔 값을 찾기 어려우므로 여기선 딕셔너리라고 가정하거나 변환 로직이 필요. # 하지만 현재 시스템상 딕셔너리로 관리됨. pass self.logger.log(f"✅ 선택마켓에서 가져온 마켓 개수: {len(markets)}", level=logging.INFO) else: self.logger.log("❌ biz_info에 markets 키가 없습니다.", level=logging.WARNING) else: self.logger.log("❌ biz_info가 없거나 비어있습니다.", level=logging.WARNING) # biz_info가 제대로 있는 경우에만 자동 로그인 시도 if markets is not None: # markets가 빈 딕셔너리일 수도 있으므로 None 체크 # 스마트스토어 마켓 정보 찾기 # markets가 {'coupang': {...}, 'ss': {...}} 딕셔너리라고 가정 ss = markets.get('ss') if isinstance(markets, dict) else None # 딕셔너리가 아닌 리스트인 경우도 고려 (혹시 모르니) if not ss and isinstance(markets, list): for m in markets: if isinstance(m, dict) and m.get('market_type') == 'ss': ss = m break # 그래도 없으면 'naver'로도 찾아봄 (가끔 키가 다를 수 있음) if not ss and isinstance(markets, dict): ss = markets.get('naver') # 그래도 없으면 backup_ss 확인 (MainUI에서 전달해준 예비 정보) if not ss and self.biz_info.get('backup_ss'): ss = self.biz_info.get('backup_ss') self.logger.log("ℹ️ 현재 작업 마켓에는 없으나 백업된 스마트스토어 정보를 사용합니다.", level=logging.INFO) # 스마트스토어 정보가 있는지 확인 if not ss: # 로깅을 위해 현재 마켓 키들 출력 keys = list(markets.keys()) if isinstance(markets, dict) else "List" self.logger.log(f"❌ 선택된 스마트스토어 마켓이 없습니다. (biz_info 내 'ss' 키 부재, 현재 키: {keys})", level=logging.WARNING) self.manual_login_required.emit("스마트스토어", "사업자 관리 > 선택마켓에서 스마트스토어를 선택해주세요.\n또는 팝업창에서 직접 로그인하세요.") else: # field3: ID, field4: PW (bizDBManager 정의 기준) user_id = ss.get('field3', '') password = ss.get('field4', '') if not user_id or not password: self.logger.log("❌ 스마트스토어 아이디/비밀번호가 설정되지 않았습니다.", level=logging.WARNING) self.manual_login_required.emit("스마트스토어", "스마트스토어 아이디와 비밀번호를 사업자 관리에서 설정해주세요.\n또는 팝업창에서 직접 로그인하세요.") else: # 자동 로그인 정보가 완전히 준비됨 auto_login_available = True ss_panel_sel = "[id$='-panel-ss']" panel_selector = ss_panel_sel # 팝업창 대기 login_pages = [] max_popup_wait = 10 for _ in range(max_popup_wait): await asyncio.sleep(1) current_login_pages = [] for page in self.browser.pages: try: url = page.url title = await page.title() if "accounts.commerce.naver.com/login" in url or "네이버 커머스" in title: self.logger.log(f"🔍 네이버 커머스 로그인 팝업창 감지: {url}", level=logging.DEBUG) current_login_pages.append(page) except Exception: continue if current_login_pages: login_pages = current_login_pages self.logger.log(f"🔍 네이버 커머스 로그인 팝업창 {len(login_pages)}개 감지", level=logging.INFO) break # 팝업창 개수 조정 login_page = None if len(login_pages) > 1: self.logger.log(f"⚠️ 중복 팝업창 감지 - {len(login_pages)}개 중 1개 닫기", level=logging.INFO) try: await login_pages[1].close() self.logger.log("✅ 중복 로그인 팝업창 닫기 완료", level=logging.INFO) except Exception as e: self.logger.log(f"❌ 중복 로그인 팝업창 닫기 실패: {e}", level=logging.ERROR) login_page = login_pages[0] elif len(login_pages) == 1: login_page = login_pages[0] self.logger.log("✅ 네이버 커머스 로그인 팝업창 1개 확인", level=logging.INFO) else: self.logger.log("ℹ️ 네이버 커머스 로그인 팝업창이 감지되지 않음 (이미 로그인 상태일 수 있음)", level=logging.INFO) return False # 로그인 정보 입력 (user_id, password가 있을 때만) if user_id and password: try: # 아이디 입력 id_input = login_page.locator("input[placeholder='아이디 또는 이메일 주소']") await id_input.click() await id_input.fill("") # 기존 값 지우기 for char in user_id: await id_input.type(char, delay=random.uniform(0.05, 0.15)) # 비밀번호 입력 pw_input = login_page.locator("input[placeholder='비밀번호']") await pw_input.click() await pw_input.fill("") for char in password: await pw_input.type(char, delay=random.uniform(0.08, 0.18)) self.logger.log("✅ 아이디/비밀번호 입력 완료", level=logging.INFO) # 로그인 버튼 클릭 (자연스럽게) login_btn = login_page.get_by_role("button", name="로그인", exact=True) await login_btn.hover() await asyncio.sleep(random.uniform(0.3, 0.7)) await login_btn.click() self.logger.log("🔑 로그인 버튼 자연스럽게 클릭 완료", level=logging.INFO) except Exception as e: self.logger.log(f"❌ 로그인 입력/클릭 실패: {e}", level=logging.ERROR) return False # 로그인 완료 대기 if auto_login_available: self.logger.log("⏳ 자동 로그인 완료 대기 중...", level=logging.INFO) else: self.logger.log("⏳ 수동 로그인 완료 대기 중... (팝업창에서 직접 로그인해주세요)", level=logging.WARNING) login_completed = False max_wait_time = 600 # 최대 10분 check_interval = 5 for wait_count in range(max_wait_time // check_interval): # 살아있는 팝업창 다시 확인 alive_login_pages = [p for p in self.browser.pages if p.is_closed() is False] # 팝업창이 아예 사라졌으면 로그인 완료 if not any("accounts.commerce.naver.com" in p.url for p in alive_login_pages): login_completed = True self.logger.log("✅ 로그인 팝업창이 닫혔습니다. 로그인 완료로 간주", level=logging.INFO) # 로그인 완료 시그널 발송 (QMessageBox 자동 닫기용) self.login_completed_signal.emit("스마트스토어") break # 팝업창이 여전히 존재하면 URL 상태 확인 for p in alive_login_pages: url = p.url if "accounts.commerce.naver.com/login" in url: # 여전히 로그인 페이지 = 아직 처리 안됨 pass else: # 다른 단계 (2단계 인증, 캡차 등)에 진입한 상태 self.logger.log(f"⏳ 로그인 진행 중... 현재 URL: {url}", level=logging.DEBUG) if wait_count % 6 == 0: elapsed = wait_count * check_interval self.logger.log(f"⏳ 스마트스토어 로그인 대기 중... ({elapsed}초 경과)", level=logging.DEBUG) await asyncio.sleep(check_interval) if not login_completed: self.logger.log("❌ 로그인 대기 시간 초과 (10분)", level=logging.ERROR) # 계정 확인 (expected_account가 있을 때만) if expected_account and panel_selector: try: # 패널 선택자를 사용해서 스마트스토어 패널 찾기 panel = self.page.locator(panel_selector) if await panel.count() > 0: # 스마트스토어 패널 내의 첫 번째 텍스트 입력 (업로드 할 스마트스토어 계정) inputs = panel.locator("input[type='text']") current_account = await inputs.nth(0).input_value() current_account = (current_account or "").strip() self.logger.log(f"🔍 스마트스토어 패널에서 계정 확인: '{current_account}'", level=logging.DEBUG) if current_account == expected_account: self.logger.log("✅ 스마트스토어 계정 일치 확인됨", level=logging.INFO) return True elif current_account: self.logger.log( f"❌ 계정 불일치 - 현재: '{current_account}', 예상: '{expected_account}'", level=logging.ERROR ) return False else: self.logger.log("❌ 계정이 비어 있음", level=logging.ERROR) return False else: self.logger.log(f"❌ 스마트스토어 패널을 찾을 수 없음 (selector: {panel_selector})", level=logging.ERROR) return False except Exception as e: self.logger.log(f"❌ 스마트스토어 계정 확인 실패: {e}", level=logging.ERROR) return False return login_completed except Exception as e: self.logger.log(f"❌ handle_smartstore_login_popup 오류: {e}", level=logging.ERROR, exc_info=True) return False async def handle_ss_login_for_upload_or_delete(self): # ✅ 스마트스토어 로그인 확인 버튼 처리 try: modal = self.page.locator("div.ant-modal-confirm") # 2초 내에 모달이 나타나는지 확인 (타임아웃 시 예외 발생하지 않도록 처리) try: await expect(modal).to_be_visible(timeout=2000) except: # 타임아웃 발생 시 이전 로그인 세션이 유지되고 있다고 간주 self.logger.log("로그인 요구 모달이 없음 - 이전 로그인 세션 유지 상태로 간주", level=logging.INFO) return False if not await modal.count() > 0: self.logger.log("❌ 스마트스토어 로그인 요구 모달을 찾지 못함", level=logging.ERROR) return False confirm_btn = modal.locator("button.ant-btn-primary span:has-text('확인')") # await confirm_btn.click() # self.logger.log("✅ 확인 버튼 클릭 성공", level=logging.INFO) if await confirm_btn.count() > 0: self.logger.log("⚠️ 스마트스토어 로그인 요구 모달 확인 버튼 발견됨", level=logging.INFO) await confirm_btn.click() self.logger.log("✅ 확인 버튼 클릭 성공", level=logging.INFO) popup_ok = await self.handle_smartstore_login_popup() if popup_ok: self.logger.log("✅ 스마트스토어 로그인 팝업 처리 완료", level=logging.INFO) return True else: self.logger.log("❌ 스마트스토어 로그인 팝업 처리 실패", level=logging.ERROR) return False else: self.logger.log("❌ 스마트스토어 로그인 확인 버튼을 찾지 못함", level=logging.ERROR) return False except Exception as e: self.logger.log(f"스마트스토어 확인 버튼 처리 중 오류: {e}", level=logging.ERROR) return False async def off_on_ext_Percenty(self): try: self.page_for_extension = await self.browser.new_page() self.logger.log("확장페이지 열기 완료", level=logging.DEBUG) if self.page_for_extension: await self.page_for_extension.goto("chrome://extensions/?id=jlcdjppbpplpdgfeknhioedbhfceaben") toggle = self.page_for_extension.locator("extensions-toggle-row#allow-on-file-urls").locator("cr-toggle") await toggle.click() await expect(toggle).to_have_attribute("aria-pressed", "false") await toggle.click() await expect(toggle).to_have_attribute("aria-pressed", "true") self.logger.log("확장 강제 껐다 켜기 완료", level=logging.DEBUG) await self.page_for_extension.close() return True except Exception as e: self.logger.log(f"확장 강제 껐다 켜기 실패: {e}", level=logging.ERROR) return False async def collect_total_products_count(self): await asyncio.sleep(1) # total_count_elements = await self.page.query_selector_all(".sc-dOvA-dm.jqRNYf") total_count_element = await self.page.query_selector(self.total_product_count_locator) if total_count_element: total_count_text = await total_count_element.inner_text() if "총" in total_count_text and "개 상품" in total_count_text: total_count = int(''.join(re.findall(r'\d+', total_count_text))) self.logger.log(f"총 상품수 확인: {total_count} 개", level=logging.INFO) return total_count return None async def collect_selected_products_count(self): await asyncio.sleep(1) selected_count_element = await self.page.query_selector(self.total_product_count_locator) if selected_count_element: selected_count_text = await selected_count_element.inner_text() if "선택" in selected_count_text and "개 상품" in selected_count_text: selected_count = int(''.join(re.findall(r'\d+', selected_count_text))) self.logger.log(f"선택된 상품수 확인: {selected_count} 개", level=logging.INFO) return selected_count return None async def ed_bulk_delete_market_info(self): """ ed_mode용 - (1) 기존 그룹 내 마켓 업로드정보 삭제 - 현재 그룹명이 '전체'가 아닐 때만 동작 - 각 페이지에서 전체상품 체크 → 삭제 버튼 → 모달에서 옵션 3 선택 → 전체 체크 → 선택 상품 일괄 삭제 - 그룹 전체 페이지를 순회하며 수행 - 삭제 완료 후 성과 로그 추출 """ try: # 적절한 페이지로 이동 확인 (ed_mode용이므로 True) ed_mode = self.toggle_states.get('ed_mode', True) # ed_mode용 메서드이므로 기본값 True if not await self.ensure_proper_page(ed_mode): self.logger.log("❌ 적절한 페이지로 이동 실패", level=logging.ERROR) return is_already_ext = await self.off_on_ext_Percenty() if not is_already_ext: self.logger.log("확장 페이지 열기 실패", level=logging.ERROR) return # 그룹명 확인 try: group_label_sel = self.selected_group_name_for_ed_locator except Exception: group_label_sel = self.locator_manager.get_locator('BrowserControl', 'selected_group_name_for_ed_locator') current_group = "" try: current_group = (await self.page.locator(group_label_sel).inner_text()).strip() except Exception: pass if current_group == "전체": self.logger.log("현재 그룹이 '전체'이므로 삭제 작업을 건너뜁니다.", level=logging.INFO) return # 선택자 정의 select_all_checked_sel = "span.ant-checkbox.ant-checkbox-checked input[aria-label='Select all'][type='checkbox']" select_all_unchecked_sel = "span.ant-checkbox input[aria-label='Select all'][type='checkbox']" delete_button_sel = "th.ant-table-cell button[type='button']:has(span:has-text('삭제'))" modal_select_sel = "div.ant-modal-content div.ant-select" modal_option_delete_sel = "div.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option[title='3. 퍼센티에서 해당 마켓 업로드 정보만 삭제하기']" modal_all_checkbox_sel = "div.ant-modal-content label.ant-checkbox-wrapper:has-text('전체') input[type='checkbox']" modal_delete_confirm_sel = "div.ant-modal-content button[type='button']:has(span:has-text('선택 상품 일괄 삭제'))" modal_partialexcept_delete_confirm_sel = "div.ant-modal-content button[type='button']:has(span:has-text('판매 상품 제외하고 삭제'))" # 삭제 완료 메시지 + 성과 선택자 modal_done_alert = "div.ant-modal-content div.ant-alert-message" modal_result_box = "div.ant-modal-content div.ant-flex-justify-space-between" modal_close_btn_sel = "div.ant-modal-content [aria-label='Close'][type='button']" # 페이지 수집 및 모든 상품 로딩 보장 result = await self.get_total_product_count() total_products = result.get("total_count", 0) items_per_page = result.get("items_per_page", 0) total_pages = math.ceil(total_products / items_per_page) if items_per_page else 1 if total_products == 0: self.logger.log('삭제할 상품이 없습니다.', level=logging.INFO) return async def ensure_select_all_checked(): """전체상품 체크박스가 항상 체크 상태가 되도록 보장""" try: if await self.page.locator(select_all_checked_sel).count() > 0: return True unchecked = self.page.locator(select_all_unchecked_sel).first if await unchecked.count() > 0: await unchecked.check() await expect(unchecked).to_be_checked() return True return False except Exception as e: self.logger.log(f"전체상품 체크박스 체크 실패: {e}", level=logging.ERROR) return False # 삭제 통계 추적 total_success = 0 total_fail = 0 # 페이지 순회 for page_no in range(1, total_pages + 1): # 현재 페이지의 실제 로딩된 상품 수 확인 및 로딩 보장 # (페이지 설정과 무관하게 현재 보이는 상품들이 모두 로딩되도록 함) current_loaded_count = await self.page.locator(self.product_chain_cards).count() self.logger.log(f"페이지 {page_no}/{total_pages} - 현재 로딩된 상품: {current_loaded_count}개", level=logging.DEBUG) # 현재 로딩된 상품들이 모두 완전히 로딩되도록 보장 # await self.ensure_all_products_loaded(current_loaded_count) # 전체 체크박스 클릭 후 실제 선택된 상품 수 확인 ok = await ensure_select_all_checked() if ok: # 전체 체크박스 클릭 성공 시 실제 선택된 상품 수 동적 수집 selected_count = await self.collect_selected_products_count() if selected_count is not None and selected_count > 0: current_loaded_count = selected_count # 실제 선택된 상품 수로 업데이트 self.logger.log(f"전체 체크 후 실제 선택된 상품 수: {selected_count}개", level=logging.DEBUG) else: self.logger.log("선택된 상품 수 확인 실패 - 기존 로딩된 상품 수 사용", level=logging.WARNING) if not ok: self.logger.log("전체상품 체크 실패 - 페이지 건너뜀", level=logging.WARNING) if page_no < total_pages: await self.go_to_next_page() continue # 삭제 버튼 클릭 try: await self.page.click(delete_button_sel) except Exception as e: self.logger.log(f"삭제 버튼 클릭 실패: {e}", level=logging.ERROR) if page_no < total_pages: await self.go_to_next_page() continue try: # 모달 처리 await self.page.locator(modal_select_sel).click() self.logger.log("삭제 모달 처리 시작", level=logging.DEBUG) await self.page.locator(modal_option_delete_sel).click() self.logger.log("3번 옵션 선택 완료", level=logging.DEBUG) cb = self.page.locator(modal_all_checkbox_sel) self.logger.log("전체 삭제 체크 완료", level=logging.DEBUG) if not await cb.is_checked(): await cb.check() self.logger.log("전체 체크되어있지 않음. 전체 체크 다시 선택", level=logging.DEBUG) await expect(cb).to_be_checked() self.logger.log("전체 체크 확인 완료", level=logging.DEBUG) # 판매 상품 제외 삭제 버튼이 있는지 확인 후 적절한 버튼 클릭 partial_except_btn = self.page.locator(modal_partialexcept_delete_confirm_sel) normal_delete_btn = self.page.locator(modal_delete_confirm_sel) if await partial_except_btn.count() > 0: await partial_except_btn.click() self.logger.log("판매 상품 제외 삭제 버튼 클릭 완료", level=logging.DEBUG) delete_btn_selector = modal_partialexcept_delete_confirm_sel else: await normal_delete_btn.click() self.logger.log("선택 상품 일괄 삭제 버튼 클릭 완료", level=logging.DEBUG) delete_btn_selector = modal_delete_confirm_sel handle_login_result = await self.handle_ss_login_for_upload_or_delete() if handle_login_result: await self.page.locator(delete_btn_selector).click() self.logger.log("삭제 확정 버튼 추가 클릭 완료", level=logging.DEBUG) else: self.logger.log("스마트스토어 로그인 상태 간주 - 삭제 계속 진행", level=logging.DEBUG) except Exception as e: self.logger.log(f"삭제 모달 처리 실패: {e}", level=logging.ERROR) continue # ✅ 삭제 완료 메시지 확인 및 모든 상품 삭제 완료 대기 try: alert = self.page.locator(modal_done_alert) # 먼저 alert 요소가 나타날 때까지 기다림 await expect(alert).to_be_visible(timeout=10000) self.logger.log("삭제 작업 시작 알림 확인됨", level=logging.INFO) # 현재 페이지의 상품 수만큼 삭제가 완료될 때까지 대기 expected_total_processed = current_loaded_count self.logger.log(f"현재 페이지 상품 수만큼 삭제 완료 대기: {expected_total_processed}개", level=logging.DEBUG) # 효율적인 타임아웃 전략: 초기 빠른 체크 + 점진적 간격 증가 max_total_wait_time = 15 # 전체 최대 대기 시간 (초) start_time = asyncio.get_event_loop().time() wait_attempt = 0 last_success_count = 0 last_fail_count = 0 completed = False while asyncio.get_event_loop().time() - start_time < max_total_wait_time: wait_attempt += 1 elapsed_time = asyncio.get_event_loop().time() - start_time try: # ✅ 성과 결과 추출 - 클래스 이름에 의존하지 않고 정규식으로 파싱 result_box = self.page.locator(modal_result_box) all_spans = result_box.locator("span") span_count = await all_spans.count() if span_count >= 2: # 첫 번째 span: "0/48 삭제 진행 중", "15/48 삭제 진행 중", "48/48 삭제 완료" 등 파싱 first_span = all_spans.nth(0) first_text = await first_span.inner_text() # 두 번째 span: "48건 성공 / 0건 실패" - 성공/실패 건수 파싱 second_span = all_spans.nth(1) second_text = await second_span.inner_text() # "(\d+)/(\d+) 삭제" 패턴으로 진행 상황 파싱 (완료/진행 중 모두 포함) progress_match = re.search(r'(\d+)/(\d+)\s*삭제', first_text) count_match = re.findall(r"(\d+)건 성공\s*/\s*(\d+)건 실패", second_text) if progress_match and count_match: current_progress = int(progress_match.group(1)) # 분자 (현재 진행 수) actual_target_count = int(progress_match.group(2)) # 분모 (총 삭제 대상) success_count, fail_count = count_match[0] total_processed = int(success_count) + int(fail_count) # expected_total_processed를 실제 삭제 대상 수로 업데이트 (최초 1회만) if expected_total_processed != actual_target_count: self.logger.log(f"실제 삭제 대상 수 확인: {actual_target_count}개 (예상: {expected_total_processed}개)", level=logging.DEBUG) expected_total_processed = actual_target_count # 진행상황 로깅 (변화가 있을 때만, 또는 5번째 시도마다) should_log = (success_count != last_success_count or fail_count != last_fail_count or wait_attempt % 5 == 1) # 5번째 시도마다 강제 로깅 if should_log: self.logger.log(f"삭제 진행 중 [{elapsed_time:.1f}s/{max_total_wait_time}s] - {current_progress}/{actual_target_count}, 성공: {success_count}건, 실패: {fail_count}건 (총: {total_processed}/{expected_total_processed})", level=logging.DEBUG) last_success_count, last_fail_count = success_count, fail_count # 모든 상품 삭제 완료 확인 (진행 표시와 카운트 모두 완료되었을 때) if current_progress >= actual_target_count and total_processed >= expected_total_processed: total_success += int(success_count) total_fail += int(fail_count) self.logger.log(f"✅ 현재 페이지 모든 상품 삭제 완료 ({elapsed_time:.1f}s) - 최종 결과: 성공 {success_count}건, 실패 {fail_count}건", level=logging.INFO) completed = True break else: if wait_attempt % 5 == 1: # 5번째 시도마다 경고 second_text = await all_spans.nth(1).inner_text() if span_count >= 2 else "N/A" self.logger.log(f"삭제 건수 파싱 실패 ({elapsed_time:.1f}s 경과) - 텍스트: '{second_text}'", level=logging.WARNING) else: if wait_attempt % 5 == 1: # 5번째 시도마다 경고 self.logger.log(f"결과 span 요소 부족 ({elapsed_time:.1f}s 경과) - span 개수: {span_count}", level=logging.WARNING) except Exception as parse_error: if wait_attempt % 5 == 1: # 5번째 시도마다 오류 로깅 self.logger.log(f"결과 파싱 중 오류 ({elapsed_time:.1f}s 경과): {parse_error}", level=logging.DEBUG) # 동적 대기 시간: 초기에는 빠르게, 나중에는 느리게 체크 if elapsed_time < 2: # 처음 2초: 0.3초 간격 await asyncio.sleep(0.3) elif elapsed_time < 5: # 2-5초: 0.5초 간격 await asyncio.sleep(0.5) else: # 5초 이후: 1초 간격 await asyncio.sleep(1.0) # 타임아웃 또는 완료 처리 if not completed: final_success = int(last_success_count) if last_success_count else 0 final_fail = int(last_fail_count) if last_fail_count else 0 final_total = final_success + final_fail if final_total > 0: self.logger.log(f"⚠️ 삭제 완료 대기 시간 초과 ({max_total_wait_time}s) - 현재까지: 성공 {final_success}건, 실패 {final_fail}건 (총: {final_total}/{expected_total_processed})", level=logging.WARNING) total_success += final_success total_fail += final_fail else: self.logger.log(f"⚠️ 삭제 결과 확인 실패 - 타임아웃 ({max_total_wait_time}s)으로 진행 중단", level=logging.WARNING) # 결과가 0건이면 최소 1회 재시도 후 진행 await self.page.locator(modal_close_btn_sel).click() self.logger.log("삭제 완료 모달 닫기 버튼 클릭 완료", level=logging.DEBUG) await asyncio.sleep(0.5) except Exception as e: self.logger.log(f"삭제 완료 메시지 확인 실패: {e}", level=logging.ERROR, exc_info=True) # 삭제 완료 메시지 확인에 실패해도 모달을 강제로 닫기 시도 try: await self.page.locator(modal_close_btn_sel).click() self.logger.log("삭제 완료 모달 강제 닫기 완료", level=logging.DEBUG) await asyncio.sleep(0.5) except Exception as close_error: self.logger.log(f"삭제 완료 모달 강제 닫기 실패: {close_error}", level=logging.ERROR) # ESC 키로도 시도 try: await self.page.keyboard.press('Escape') self.logger.log("ESC 키로 모달 닫기 시도 완료", level=logging.DEBUG) await asyncio.sleep(0.5) except: pass # 다음 페이지 이동 if page_no < total_pages: success = await self.go_to_next_page() if not success: self.logger.log(f"페이지 {page_no} → {page_no+1} 이동 실패(삭제)", level=logging.ERROR, exc_info=True) break self.logger.log("ed_mode 업로드정보 삭제 완료", level=logging.INFO) # ✅ 모든 작업 후 1페이지로 돌아가기 await self.back_to_first_page() # 최종 삭제 통계를 포함한 완료 메시지 전달 completion_msg = f"삭제 작업 완료 - 총 성공: {total_success}건, 총 실패: {total_fail}건" self.logger.log(completion_msg, level=logging.INFO) self.complete_remove_market_info_job.emit(completion_msg) self.step_completed.emit("delete_upload_info", True) # 업로드정보 삭제 완료 후 새로고침 버튼 클릭 await asyncio.sleep(1) # 1초 대기 self.logger.log("🔄 업로드정보 삭제 완료 후 새로고침 실행 중...", level=logging.INFO) await self.click_refresh_button() except Exception as e: self.logger.log(f"ed_bulk_delete_market_info 오류: {e}", level=logging.ERROR, exc_info=True) self.step_completed.emit("delete_upload_info", False) async def ed_bulk_upload_selected_markets(self, upload_conditions=None, work_queue=None): """ ed_mode용 - (2) 업로드 다이얼로그에서 선택 마켓 체크 후 일괄 업로드 - 현재 그룹명이 '전체'가 아닐 때만 동작 - 각 페이지에서 전체상품 체크 → 업로드 버튼 → 모달에서 각 마켓 체크 → 선택 상품 일괄 업로드 - 그룹 전체 페이지를 순회하며 수행 """ try: # ------------------------------------------------------------------ # [추가] DB에서 최신 작업 정보 가져오기 (실시간 반영) # 사용자가 실행 버튼 클릭 후 실제 실행 시점 사이의 변경사항을 반영하기 위함 if hasattr(self.app, 'biz_dbManager') and self.app.biz_dbManager: try: self.logger.log("🔄 DB에서 최신 작업 정보를 조회합니다...", level=logging.DEBUG) # DB에서 순수 정보 가져오기 combinations = self.app.biz_dbManager.get_biz_full_info() if combinations: # work_queue 형식으로 변환 new_work_queue = [] for combo in combinations: order = combo.get('order', 1) markets_map = combo.get('markets', {}) market_list = list(markets_map.keys()) new_work_queue.append({ 'order': order, 'markets': market_list, 'market_info': markets_map, 'work_name': f"{order}번 작업" }) # DB에서 가져온 정보로 교체 # 주의: work_queue가 특정 목적(예: 2번 작업만 실행)으로 전달된 경우라면 # 이 덮어쓰기가 의도를 해칠 수 있으나, 현재 구조상 '업로드' 버튼은 # 현재 설정을 기준으로 동작하므로 최신화하는 것이 맞음. if new_work_queue: # 기존 큐가 있으면 길이 등을 비교해서 로그 남기기 old_len = len(work_queue) if work_queue else 0 work_queue = new_work_queue self.logger.log(f"✅ 작업 큐가 최신 DB 정보로 갱신되었습니다. (기존 {old_len}개 -> 신규 {len(work_queue)}개)", level=logging.INFO) else: self.logger.log("⚠️ DB에 설정된 마켓 정보가 없습니다.", level=logging.WARNING) except Exception as db_e: self.logger.log(f"최신 작업 정보 조회 실패: {db_e}", level=logging.ERROR) # ------------------------------------------------------------------ # 업로드조건 설정 로그 if upload_conditions: price_policy = upload_conditions.get('price_policy', 1) smartstore_policy = upload_conditions.get('smartstore_market_policy', True) self.logger.log(f"🔧 업로드조건 적용 - 가격정책: {price_policy}, 스마트스토어정책: {smartstore_policy}", level=logging.INFO) else: self.logger.log("🔧 업로드조건 미설정 - 기본값 사용", level=logging.INFO) # 적절한 페이지로 이동 확인 ed_mode = self.toggle_states.get('ed_mode', False) if not await self.ensure_proper_page(ed_mode): self.logger.log("❌ 적절한 페이지로 이동 실패", level=logging.ERROR) self.upload_log_message.emit("❌ 페이지 이동 실패") return # 업로드 시작 시그널 self.upload_step_changed.emit("상품 업로드 준비 중", 5) self.upload_log_message.emit("업로드 작업을 시작합니다...") is_already_ext = await self.off_on_ext_Percenty() if not is_already_ext: self.logger.log("확장 페이지 열기 실패", level=logging.ERROR) self.upload_completed.emit(False, "확장 페이지 열기 실패") return # 그룹명 확인 try: group_label_sel = self.selected_group_name_for_ed_locator except Exception: group_label_sel = self.locator_manager.get_locator('BrowserControl', 'selected_group_name_for_ed_locator') current_group = "" try: current_group = (await self.page.locator(group_label_sel).inner_text()).strip() except Exception: pass if current_group == "전체": self.logger.log("현재 그룹이 '전체'이므로 업로드 작업을 건너뜁니다.", level=logging.INFO) return # 선택자 정의 select_all_checked_sel = "span.ant-checkbox.ant-checkbox-checked input[aria-label='Select all'][type='checkbox']" select_all_unchecked_sel = "span.ant-checkbox input[aria-label='Select all'][type='checkbox']" upload_button_sel = "th.ant-table-cell button[type='button']:has(span:has-text('업로드'))" # 모달 내 체크박스 modal_cp_cb = "div.ant-modal-content input#cp[type='checkbox']" modal_ss_cb = "div.ant-modal-content input#ss[type='checkbox']" modal_esm_cb = "div.ant-modal-content input#esm[type='checkbox']" modal_est_cb = "div.ant-modal-content input#est[type='checkbox']" modal_estg_cb = "div.ant-modal-content input#est_global[type='checkbox']" modal_lotteon_cb = "div.ant-modal-content input#lotteon[type='checkbox']" modal_ip_cb = "div.ant-modal-content input#ip[type='checkbox']" modal_kakao_cb = "div.ant-modal-content input#kakao[type='checkbox']" # 업로드 확정 버튼 modal_upload_confirm = "div.ant-modal-content button[type='button']:has(span:has-text('선택 상품 일괄 업로드'))" # 업로드 완료 확인 modal_done_alert = "div.ant-modal-content div[style='text-align: center;']" modal_result_box = "div[style='display: flex; justify-content: space-between; padding-bottom: 16px;']" # 페이지 수집 self.upload_step_changed.emit("상품 정보 수집 중", 10) self.upload_log_message.emit("페이지 정보를 수집하고 있습니다...") await self.scroll_page_to_bottom() result = await self.get_total_product_count() total_products = result.get("total_count", 0) items_per_page = result.get("items_per_page", 0) total_pages = math.ceil(total_products / items_per_page) if items_per_page else 1 if total_products == 0: self.logger.log('업로드할 상품이 없습니다.', level=logging.INFO) self.upload_completed.emit(False, "업로드할 상품이 없습니다") return # 총 상품수 정보 전달 self.upload_progress_updated.emit(0, total_products) self.upload_log_message.emit(f"총 {total_products}개 상품, {total_pages}페이지를 처리합니다.") # 업로드 통계 추적 total_upload_success = 0 total_upload_fail = 0 async def ensure_select_all_checked(): """전체상품 체크박스가 항상 체크 상태가 되도록 보장""" try: if await self.page.locator(select_all_checked_sel).count() > 0: return True unchecked = self.page.locator(select_all_unchecked_sel).first if await unchecked.count() > 0: await unchecked.check() await expect(unchecked).to_be_checked() return True return False except Exception as e: self.logger.log(f"전체상품 체크박스 체크 실패: {e}", level=logging.ERROR) return False async def ensure_checkbox_checked(input_selector: str): """모달 내 마켓 체크박스 보장""" try: cb = self.page.locator(input_selector) if not await cb.is_checked(): await cb.check() await expect(cb).to_be_checked() return True except Exception as e: self.logger.log(f"체크박스 체크 실패: {input_selector} - {e}", level=logging.ERROR) return False # 페이지 순회 processed_products = 0 # 1. work_queue에서 활성화된 마켓 키 추출 # (수정) 스레드 안전성을 위해 DB 직접 조회를 제거하고, 전달받은 work_queue를 신뢰하여 사용합니다. # mainUI에서 이미 최신 정보를 조회하여 work_queue로 넘겨주므로 이를 활용합니다. target_markets = [] # work_queue(인자로 받은 값)에서 추출 시도 # 현재 단일 작업/다중 작업 모두 start_multi_work_upload_process를 통해 # 현재 순서에 해당하는 work_info 딕셔너리 하나가 work_queue 인자로 넘어올 수 있음 (구조 확인 필요) # mainUI_SP.py -> execute_next_upload_step -> start_UploadSelectedMarketsJob_task 호출 시 # 인자로 아무것도 안 넘김 (None). 따라서 work_queue는 None일 가능성이 높음. # 확인: mainUI_SP.py의 execute_next_upload_step 메서드를 보면: # self.browser_controller.start_UploadSelectedMarketsJob_task() 라고 호출함. 인자가 없음. # 즉 upload_conditions=None, work_queue=None 상태임. # 그렇다면 현재 어떤 마켓을 업로드해야 하는지 browser_control은 모르는 상태임. # 해결책 1: mainUI에서 인자를 넘겨주도록 수정 (가장 확실) # 해결책 2: browser_control 내에서 biz_info를 참조 (start_ChangeBiz_task에서 업데이트됨) # 여기서는 해결책 2를 우선 적용해봄. start_ChangeBiz_task가 선행되었다면 self.biz_info에 현재 작업 정보가 있을 것임. if self.biz_info and isinstance(self.biz_info, dict): # 구조: {'order': 1, 'markets': {'coupang': ..., 'ss': ...}} if 'markets' in self.biz_info: raw_markets = self.biz_info['markets'] if isinstance(raw_markets, dict): target_markets = list(raw_markets.keys()) elif isinstance(raw_markets, list): target_markets = raw_markets self.logger.log(f"🎯 self.biz_info 기준 업로드 대상 마켓: {target_markets}", level=logging.INFO) # 만약 위 방법으로도 안되면(self.biz_info가 없으면), work_queue 인자 확인 if not target_markets and work_queue: try: # work_queue가 리스트인 경우 (전체 큐) -> 첫 번째 요소 사용? 아니면 현재 작업? # work_queue가 딕셔너리인 경우 (현재 작업) if isinstance(work_queue, list) and len(work_queue) > 0: raw_markets = work_queue[0].get('markets', []) elif isinstance(work_queue, dict): raw_markets = work_queue.get('markets', []) else: raw_markets = [] if isinstance(raw_markets, list): target_markets = raw_markets elif isinstance(raw_markets, dict): target_markets = list(raw_markets.keys()) if target_markets: self.logger.log(f"🎯 work_queue 인자 기준 업로드 대상 마켓: {target_markets}", level=logging.INFO) except Exception as e: self.logger.log(f"work_queue 파싱 실패: {e}", level=logging.WARNING) # 그래도 없으면(target_markets가 비어있으면) -> 스레드 문제로 DB 조회도 못하므로, # '전체 선택'으로 fallback하거나 경고 출력. # 기존에는 "선택된 마켓만"이 중요하므로, 식별되지 않으면 아무것도 체크하지 않는게 안전하지만, # 사용성 측면에서는 전체가 낫을 수도 있음. 여기서는 경고만 남김. if not target_markets: self.logger.log("⚠️ 대상 마켓이 식별되지 않았습니다. (biz_info 및 work_queue 없음) 모든 마켓이 체크 해제될 수 있습니다.", level=logging.WARNING) # 마켓 키(bizDBManager 기준)와 선택자 매핑 market_selector_map = { 'coupang': modal_cp_cb, 'ss': modal_ss_cb, 'esm': modal_esm_cb, '11st': modal_est_cb, '11stg': modal_estg_cb, 'lotteon': modal_lotteon_cb, 'talkstore': modal_kakao_cb, 'interpark': modal_ip_cb # 인터파크 (키 확인 필요, 일단 ip 매핑) } for page_no in range(1, total_pages + 1): # 현재 페이지 진행상황 업데이트 page_progress = int((page_no / total_pages) * 90) + 10 # 10%에서 시작해서 100%까지 current_page_products = min(items_per_page, total_products - processed_products) self.upload_step_changed.emit(f"페이지 {page_no}/{total_pages} 처리 중", page_progress) self.upload_log_message.emit(f"페이지 {page_no} 업로드 중... ({current_page_products}개 상품)") ok = await ensure_select_all_checked() if not ok: self.logger.log("전체상품 체크 실패 - 페이지 건너뜀", level=logging.WARNING) self.upload_log_message.emit(f"페이지 {page_no} 건너뜀 (전체상품 체크 실패)") if page_no < total_pages: await self.go_to_next_page() processed_products += current_page_products continue # 업로드 버튼 클릭 try: await self.page.click(upload_button_sel, force=True) except Exception as e: self.logger.log(f"업로드 버튼 클릭 실패: {e}", level=logging.ERROR) if page_no < total_pages: await self.go_to_next_page() continue # 모달 내 마켓 체크박스 상태 설정 (set_checked 활용) try: # target_markets가 설정된 경우 (다중 작업 또는 단일 작업에서 특정 마켓만 선택 시) if target_markets: self.logger.log(f"🎯 타겟 마켓 기준 체크박스 설정: {target_markets}", level=logging.INFO) for m_key, selector in market_selector_map.items(): is_target = m_key in target_markets # 해당 선택자가 화면에 있는지 확인 후 처리 if await self.page.locator(selector).count() > 0: # 이미 체크된 상태와 목표 상태가 다를 때만 변경 (불필요한 클릭 방지) is_checked = await self.page.locator(selector).is_checked() if is_checked != is_target: await self.page.locator(selector).set_checked(is_target, force=True) self.logger.log(f" - {m_key}: {'체크' if is_target else '해제'}", level=logging.DEBUG) else: # 선택자가 없는데 타겟이면 로그 (인터파크 등 없을 수 있음) if is_target: self.logger.log(f"⚠️ 마켓 선택자 없음: {m_key}", level=logging.DEBUG) # work_queue가 있는 경우 (target_markets 실패 시 fallback) elif work_queue: self.logger.log("⚠️ target_markets 없음, work_queue 기준 체크박스 설정", level=logging.WARNING) # (기존 로직 유지 가능하지만 target_markets로 통합되었으므로 사실상 여기 올 일 없음) pass else: # 아무 조건도 없는 경우 (기존 동작): 보이는 것 모두 체크 self.logger.log("⚠️ 타겟 마켓 없음 - 전체 체크박스 선택", level=logging.WARNING) for selector in market_selector_map.values(): if await self.page.locator(selector).count() > 0: await self.page.locator(selector).set_checked(True, force=True) except Exception as e: self.logger.log(f"마켓 체크박스 설정 중 오류: {e}", level=logging.ERROR) # 오류 발생 시 진행을 멈추거나 계속할지 결정 (여기선 계속 시도) # 업로드조건을 웹페이지에 실제 적용 if upload_conditions: await self.apply_upload_conditions_to_page(price_policy, smartstore_policy, target_markets) # 업로드 확정 버튼 클릭 try: await self.page.locator(modal_upload_confirm).click(force=True) self.logger.log("업로드 확정 버튼 클릭", level=logging.INFO) # 스마트스토어 로그인 팝업 처리 handle_login_result = await self.handle_ss_login_for_upload_or_delete() if handle_login_result: self.logger.log("✅ 스마트스토어 로그인 처리 완료", level=logging.INFO) await self.page.locator(modal_upload_confirm).click(force=True) self.logger.log("업로드 확정 버튼 추가 클릭 완료", level=logging.DEBUG) else: self.logger.log("✅ 이미 로그인된 상태 - 업로드 진행", level=logging.INFO) except Exception as e: self.logger.log(f"❌ 업로드 처리 실패: {e}", level=logging.ERROR) continue # ✅ 업로드 완료 메시지 대기 - 3단계 검증 방식 (데이터 수집중 → 업로드중 → 완료) try: upload_start_time = time.time() max_upload_wait = 300 # 최대 5분 대기 # 1단계: "데이터 수집중" 단계 대기 self.logger.log("📊 데이터 수집 단계 대기 중...", level=logging.INFO) data_collecting = True while data_collecting: elapsed = time.time() - upload_start_time if elapsed > max_upload_wait: raise Exception(f"업로드 시간 초과 ({max_upload_wait}초)") try: # "데이터 수집중" 텍스트 확인 modal_content = self.page.locator("div.ant-modal-content") modal_text = await modal_content.inner_text(timeout=2000) if "데이터" in modal_text and "수집중" in modal_text: self.logger.log("📊 데이터 수집 중...", level=logging.DEBUG) await asyncio.sleep(2) continue else: # 데이터 수집 단계 종료 data_collecting = False self.logger.log("✅ 데이터 수집 완료 - 업로드 단계로 이동", level=logging.INFO) except Exception: # 데이터 수집중 텍스트를 찾을 수 없음 = 다음 단계로 data_collecting = False # 2단계: "업로드중" 단계 스마트 대기 self.logger.log("📤 업로드 진행 단계 대기 중...", level=logging.INFO) # 업로드 시작 확인 try: # 업로드 시작 대기 ("업로드중" 또는 "업로드 완료" 텍스트가 나타날 때까지) # 상품이 적거나 이미 처리된 경우 순식간에 완료될 수 있음 upload_started_or_finished = self.page.locator("div.ant-modal-content").filter( has_text=re.compile(r"업로드중|업로드 완료") ) await expect(upload_started_or_finished).to_be_visible(timeout=30000) # 현재 텍스트 확인하여 바로 완료인지 체크 modal_text = await self.page.locator("div.ant-modal-content").inner_text() # 정확한 완료 패턴 확인: "N/N 업로드 완료" 형태이고 current == total인 경우만 완료로 판단 completion_pattern = re.search(r'(\d+)/(\d+)\s*업로드\s*완료', modal_text) if completion_pattern: current_num, total_num = int(completion_pattern.group(1)), int(completion_pattern.group(2)) if current_num == total_num: # 실제로 모든 업로드가 완료된 경우 self.logger.log(f"📤 업로드 순식간에 완료됨: {current_num}/{total_num}", level=logging.INFO) # 바로 3단계로 이동 else: # 진행 중인 경우 (예: "1/20 업로드 완료") self.logger.log(f"📤 업로드 진행 중으로 감지됨: {current_num}/{total_num} (완료로 오판 방지)", level=logging.INFO) # 진행률 추적 루프로 이동 current_progress = current_num total_items = total_num # 아래 while 루프로 진입하도록 함 else: # 완료 패턴이 없는 경우 - 진행 중일 가능성 self.logger.log("📤 업로드 시작 확인됨 (완료 패턴 없음)", level=logging.INFO) # 진행률 추적이 필요한 경우 (완료가 아닌 경우) is_already_completed = False if completion_pattern: is_already_completed = int(completion_pattern.group(1)) == int(completion_pattern.group(2)) if not is_already_completed: # 진행률 추적 시작 if completion_pattern: # 이미 진행 중인 상태로 시작 (예: "1/20 업로드 완료") current_progress = int(completion_pattern.group(1)) total_items = int(completion_pattern.group(2)) else: # 아직 진행률 패턴이 없는 경우 current_progress = 0 total_items = 0 self.logger.log(f"📤 업로드 진행률 추적 시작 (현재: {current_progress}/{total_items if total_items > 0 else '?'})", level=logging.INFO) while True: elapsed = time.time() - upload_start_time if elapsed > max_upload_wait: raise Exception(f"업로드 시간 초과 ({max_upload_wait}초)") try: modal_content = self.page.locator("div.ant-modal-content") modal_text = await modal_content.inner_text(timeout=2000) # 진행률 또는 완료 상태 확인 upload_progress_match = re.search(r'(\d+)/(\d+)\s*업로드', modal_text) if upload_progress_match: current, total = upload_progress_match.groups() current_num, total_num = int(current), int(total) # 정확한 완료 판단: "N/N 업로드 완료" 형태이고 current == total인 경우만 완료 completion_check = re.search(r'(\d+)/(\d+)\s*업로드\s*완료', modal_text) if completion_check and int(completion_check.group(1)) == int(completion_check.group(2)): # 실제 완료 상태 self.logger.log(f"📤 업로드 최종 완료: {current}/{total}", level=logging.INFO) break elif "업로드중" in modal_text or current_num < total_num: # 진행 중 상태 if current_num != current_progress: # 진행률이 변경됨 self.logger.log(f"📤 업로드 진행 중: {current}/{total}", level=logging.DEBUG) current_progress = current_num total_items = total_num # 다음 진행률 또는 완료 상태를 expect로 대기 if current_num < total_num: # 다음 진행률 예상: (current+1)/total next_progress = current_num + 1 try: # 다음 진행률 또는 완료를 기다림 (최대 60초) next_state = self.page.locator("div.ant-modal-content").filter( has_text=f"{next_progress}/{total_items}" ).or_( self.page.locator("div.ant-modal-content").filter(has_text="업로드 완료") ) await expect(next_state).to_be_visible(timeout=60000) # 상태 변화 감지됨 - 다시 루프로 continue except Exception: # 타임아웃 - 현재 상태 다시 확인 self.logger.log(f"⏳ {current}/{total} 상태에서 대기 중...", level=logging.DEBUG) await asyncio.sleep(2) continue else: # 마지막 진행률 도달 - 완료 대기 completion_locator = self.page.locator("div.ant-modal-content").filter(has_text="업로드 완료") await expect(completion_locator).to_be_visible(timeout=60000) break else: # 같은 진행률 - 잠시 대기 후 다시 확인 await asyncio.sleep(1) continue else: # 알 수 없는 상태 await asyncio.sleep(1) continue else: # 진행률 패턴을 찾을 수 없음 - 일반적인 업로드 메시지 확인 # 정확한 완료 패턴 확인 (진행 중 메시지와 구분) completion_check = re.search(r'(\d+)/(\d+)\s*업로드\s*완료', modal_text) if completion_check: comp_current, comp_total = int(completion_check.group(1)), int(completion_check.group(2)) if comp_current == comp_total: self.logger.log(f"📤 업로드 완료 (패턴 확인): {comp_current}/{comp_total}", level=logging.INFO) break else: # 진행 중 메시지 (예: "1/20 업로드 완료") self.logger.log(f"📤 업로드 진행 중... (진행률: {comp_current}/{comp_total})", level=logging.DEBUG) await asyncio.sleep(2) continue elif "업로드" in modal_text and "중" in modal_text: self.logger.log("📤 업로드 진행 중... (진행률 미확인)", level=logging.DEBUG) await asyncio.sleep(2) continue else: # 업로드 단계 종료 혹은 알 수 없음 break except Exception: # 텍스트 확인 실패 - 잠시 대기 후 재시도 await asyncio.sleep(1) continue self.logger.log("✅ 업로드 진행 단계 완료", level=logging.INFO) except Exception as upload_error: self.logger.log(f"⚠️ 업로드 진행 추적 중 오류: {upload_error}", level=logging.WARNING) # 기본 완료 확인으로 넘어감 # 3단계: 업로드 완료 메시지 정확히 찾기 completion_found = False completion_start_time = time.time() # 남은 시간 계산 (전체 대기 시간에서 이미 사용한 시간 제외) remaining_time = max_upload_wait - (completion_start_time - upload_start_time) completion_timeout = max(remaining_time, 30) # 최소 30초는 보장 while not completion_found: elapsed = time.time() - completion_start_time if elapsed > completion_timeout: raise Exception(f"업로드 완료 메시지 대기 시간 초과 ({completion_timeout}초, 전체 대기: {max_upload_wait}초 중 {elapsed:.1f}초 사용)") try: modal_content = self.page.locator("div.ant-modal-content") modal_text = await modal_content.inner_text(timeout=2000) # "N/N 업로드 완료" 패턴 찾기 (current == total인 경우만 완료로 판단) completion_match = re.search(r'(\d+)/(\d+)\s*업로드\s*완료', modal_text) if completion_match: comp_current, comp_total = int(completion_match.group(1)), int(completion_match.group(2)) if comp_current == comp_total: # 실제 완료 상태 completion_found = True self.logger.log(f"✅ 업로드 완료 메시지 확인됨: {comp_current}/{comp_total}", level=logging.INFO) break else: # 진행 중 메시지 (예: "1/20 업로드 완료") - 계속 대기 self.logger.log(f"⏳ 진행 중 메시지 감지: {comp_current}/{comp_total} (완료 대기 계속)", level=logging.DEBUG) await asyncio.sleep(1) except Exception: await asyncio.sleep(1) continue # ✅ 성과 결과 추출 try: result_box = self.page.locator(modal_result_box) text_content = await result_box.inner_text() # 예: "1/1 업로드 완료\n0건 성공 / 1건 실패" m = re.findall(r"(\d+)건 성공\s*/\s*(\d+)건 실패", text_content) if m: success_count, fail_count = m[0] total_upload_success += int(success_count) total_upload_fail += int(fail_count) msg = f"업로드 결과 - 성공: {success_count}건, 실패: {fail_count}건" self.logger.log(msg, level=logging.INFO) # [추가] 실패가 있는 경우, 모달 내의 상세 에러 메시지를 전부 긁어온다. if int(fail_count) > 0: try: # 모달 전체 텍스트 가져오기 full_modal_text = await self.page.locator("div.ant-modal-content").inner_text() self.logger.log(f"❌ [업로드 실패 상세] 모달 전체 내용:\n{full_modal_text}", level=logging.ERROR) # 혹시 '실패 사유' 같은 별도 엘리먼트가 있다면 더 명확히 찾기 (예상) # 보통 리스트 형태로 실패한 상품명과 사유가 나열될 수 있음 error_rows = self.page.locator("div.ant-modal-content tr, div.ant-modal-content li") count = await error_rows.count() if count > 0: self.logger.log("❌ [업로드 실패 상세] 목록형 에러 메시지 분석 시도:", level=logging.ERROR) for i in range(min(count, 10)): # 최대 10개만 row_text = await error_rows.nth(i).inner_text() self.logger.log(f" - {row_text}", level=logging.ERROR) except Exception as e: self.logger.log(f"실패 사유 텍스트 추출 중 오류: {e}", level=logging.ERROR) else: self.logger.log("업로드 결과 파싱 실패", level=logging.WARNING) except Exception as result_error: self.logger.log(f"업로드 결과 추출 실패: {result_error}", level=logging.WARNING) # ✅ 확인 버튼 클릭하여 모달 닫기 try: self.logger.log("🔘 업로드 완료 모달 확인 버튼 클릭 시도", level=logging.INFO) # 확인 버튼 찾기 (여러 가능한 선택자 시도) confirm_selectors = [ "div.ant-modal-content button:has(span:has-text('닫기'))", "div.ant-modal-content button:has(span:has-text('확인'))", "div.ant-modal-content button.ant-btn-primary", "div.ant-modal-content button[type='button']:last-child" ] button_clicked = False for selector in confirm_selectors: try: confirm_btn = self.page.locator(selector) if await confirm_btn.count() > 0: await confirm_btn.click() self.logger.log(f"✅ 확인 버튼 클릭 성공: {selector}", level=logging.INFO) button_clicked = True break except Exception: continue if not button_clicked: self.logger.log("⚠️ 확인 버튼을 찾을 수 없음 - ESC 키로 모달 닫기 시도", level=logging.WARNING) await self.page.keyboard.press("Escape") # 모달이 사라질 때까지 대기 (최대 10초) modal_close_start = time.time() while time.time() - modal_close_start < 10: try: modal_still_visible = await self.page.locator("div.ant-modal-content").count() > 0 if not modal_still_visible: self.logger.log("✅ 업로드 완료 모달이 정상적으로 닫혔습니다", level=logging.INFO) break await asyncio.sleep(0.5) except Exception: # 모달을 찾을 수 없음 = 닫혔음 break else: self.logger.log("⚠️ 모달 닫기 대기 시간 초과", level=logging.WARNING) except Exception as modal_close_error: self.logger.log(f"⚠️ 모달 닫기 처리 실패: {modal_close_error}", level=logging.WARNING) except Exception as e: self.logger.log(f"업로드 완료 메시지 확인 실패: {e}", level=logging.ERROR) # 다음 페이지 이동 전 잠시 대기 (안정성 확보) await asyncio.sleep(1) # 다음 페이지 이동 if page_no < total_pages: success = await self.go_to_next_page() if not success: self.logger.log(f"페이지 {page_no} → {page_no+1} 이동 실패(업로드)", level=logging.ERROR) break self.logger.log("ed_mode 선택상품 일괄 업로드 완료", level=logging.INFO) # 최종 업로드 통계를 포함한 완료 메시지 전달 completion_msg = f"업로드 작업 완료 - 총 성공: {total_upload_success}건, 총 실패: {total_upload_fail}건" self.logger.log(completion_msg, level=logging.INFO) # 업로드 완료 시그널 (통계 포함) self.upload_step_changed.emit("업로드 완료", 100) self.upload_log_message.emit(completion_msg) self.upload_completed.emit(True, completion_msg) self.step_completed.emit("upload_products", True) # ✅ 모든 작업 후 1페이지로 돌아가기 await self.back_to_first_page() except Exception as e: self.logger.log(f"ed_bulk_upload_selected_markets 오류: {e}", level=logging.ERROR, exc_info=True) # 오류 발생 시 실패 시그널 self.upload_completed.emit(False, f"업로드 중 오류 발생: {str(e)}") self.step_completed.emit("upload_products", False) async def ed_bulk_upload_via_extension(self, upload_conditions=None, work_queue=None): """ 확장 프로그램(wrmc_ext2)을 통한 업로드 메서드 (봇 탐지 우회용) - 기존 ed_bulk_upload_selected_markets와 동일한 기능을 확장 프로그램이 수행 - Playwright는 명령 전달 및 결과 수신만 담당 """ import json as _json try: self.logger.log("🔌 [Extension] 확장 프로그램 기반 업로드 시작", level=logging.INFO) # ================================================================== # 🕵️‍♀️ [디버깅] 네트워크 패킷 강제 캡처 (400 에러 분석용) # ================================================================== def log_request(request): try: # 퍼센티 API로 가는 POST 요청만 감시 if "api.percenty.co.kr" in request.url and request.method == "POST": self.logger.log(f"🚀 [Net-Req] POST {request.url}", level=logging.DEBUG) # 헤더 검사 has_cookie = 'cookie' in request.headers or 'Cookie' in request.headers has_auth = 'authorization' in request.headers or 'Authorization' in request.headers self.logger.log(f" ㄴ [헤더] Cookie: {has_cookie}, Auth: {has_auth}", level=logging.DEBUG) # Payload 데이터 확인 (너무 길면 일부만) try: # post_data = request.post_data_json # self.logger.log(f" ㄴ [데이터] {_json.dumps(post_data, ensure_ascii=False)[:200]}...", level=logging.DEBUG) pass except: pass except Exception: pass async def log_response(response): try: # 400 에러가 뜬 경우 무조건 잡아서 내용 확인 if "api.percenty.co.kr" in response.url and response.status == 400: self.logger.log(f"🚨 [Net-Res] 400 에러 검거! URL: {response.url}", level=logging.ERROR) try: # 서버가 보낸 진짜 에러 사유 (JSON) error_json = await response.json() self.logger.log(f"❌ [서버 메시지]: {_json.dumps(error_json, indent=2, ensure_ascii=False)}", level=logging.ERROR) except: try: text = await response.text() self.logger.log(f"❌ [응답 텍스트]: {text}", level=logging.ERROR) except: pass except Exception: pass # 리스너 등록 (중복 등록 방지 로직은 생략, 함수 내 지역 함수라 안전) self.page.on("request", log_request) self.page.on("response", log_response) self.logger.log("🕵️‍♀️ 네트워크 패킷 감청 시작됨 (400 에러 추적)", level=logging.INFO) # ================================================================== # ------------------------------------------------------------------ # [추가] DB에서 최신 작업 정보 가져오기 (기존 로직과 동일) if hasattr(self.app, 'biz_dbManager') and self.app.biz_dbManager: try: self.logger.log("🔄 DB에서 최신 작업 정보를 조회합니다...", level=logging.DEBUG) combinations = self.app.biz_dbManager.get_biz_full_info() if combinations: new_work_queue = [] for combo in combinations: order = combo.get('order', 1) markets_map = combo.get('markets', {}) market_list = list(markets_map.keys()) new_work_queue.append({ 'order': order, 'markets': market_list, 'market_info': markets_map, 'work_name': f"{order}번 작업" }) if new_work_queue: old_len = len(work_queue) if work_queue else 0 work_queue = new_work_queue self.logger.log(f"✅ 작업 큐가 최신 DB 정보로 갱신되었습니다. (기존 {old_len}개 -> 신규 {len(work_queue)}개)", level=logging.INFO) else: self.logger.log("⚠️ DB에 설정된 마켓 정보가 없습니다.", level=logging.WARNING) except Exception as db_e: self.logger.log(f"최신 작업 정보 조회 실패: {db_e}", level=logging.ERROR) # ------------------------------------------------------------------ # 업로드조건 설정 로그 if upload_conditions: price_policy = upload_conditions.get('price_policy', 1) smartstore_policy = upload_conditions.get('smartstore_market_policy', True) self.logger.log(f"🔧 업로드조건 적용 - 가격정책: {price_policy}, 스마트스토어정책: {smartstore_policy}", level=logging.INFO) else: self.logger.log("🔧 업로드조건 미설정 - 기본값 사용", level=logging.INFO) # 적절한 페이지로 이동 확인 ed_mode = self.toggle_states.get('ed_mode', False) if not await self.ensure_proper_page(ed_mode): self.logger.log("❌ 적절한 페이지로 이동 실패", level=logging.ERROR) self.upload_log_message.emit("❌ 페이지 이동 실패") return # 업로드 시작 시그널 self.upload_step_changed.emit("상품 업로드 준비 중 (Extension)", 5) self.upload_log_message.emit("확장 프로그램 기반 업로드를 시작합니다...") is_already_ext = await self.off_on_ext_Percenty() if not is_already_ext: self.logger.log("확장 페이지 열기 실패", level=logging.ERROR) self.upload_completed.emit(False, "확장 페이지 열기 실패") return # 그룹명 확인 try: group_label_sel = self.selected_group_name_for_ed_locator except Exception: group_label_sel = self.locator_manager.get_locator('BrowserControl', 'selected_group_name_for_ed_locator') current_group = "" try: current_group = (await self.page.locator(group_label_sel).inner_text()).strip() except Exception: pass if current_group == "전체": self.logger.log("현재 그룹이 '전체'이므로 업로드 작업을 건너뜁니다.", level=logging.INFO) return # 타겟 마켓 추출 (기존 로직과 동일) target_markets = [] if self.biz_info and isinstance(self.biz_info, dict): if 'markets' in self.biz_info: raw_markets = self.biz_info['markets'] if isinstance(raw_markets, dict): target_markets = list(raw_markets.keys()) elif isinstance(raw_markets, list): target_markets = raw_markets self.logger.log(f"🎯 self.biz_info 기준 업로드 대상 마켓: {target_markets}", level=logging.INFO) if not target_markets and work_queue: try: if isinstance(work_queue, list) and len(work_queue) > 0: raw_markets = work_queue[0].get('markets', []) elif isinstance(work_queue, dict): raw_markets = work_queue.get('markets', []) else: raw_markets = [] if isinstance(raw_markets, list): target_markets = raw_markets elif isinstance(raw_markets, dict): target_markets = list(raw_markets.keys()) if target_markets: self.logger.log(f"🎯 work_queue 인자 기준 업로드 대상 마켓: {target_markets}", level=logging.INFO) except Exception as e: self.logger.log(f"work_queue 파싱 실패: {e}", level=logging.WARNING) if not target_markets: self.logger.log("⚠️ 대상 마켓이 식별되지 않았습니다. 전체 마켓이 선택될 수 있습니다.", level=logging.WARNING) # 페이지 수집 self.upload_step_changed.emit("상품 정보 수집 중", 10) self.upload_log_message.emit("페이지 정보를 수집하고 있습니다...") await self.scroll_page_to_bottom() result = await self.get_total_product_count() total_products = result.get("total_count", 0) items_per_page = result.get("items_per_page", 0) total_pages = math.ceil(total_products / items_per_page) if items_per_page else 1 if total_products == 0: self.logger.log('업로드할 상품이 없습니다.', level=logging.INFO) self.upload_completed.emit(False, "업로드할 상품이 없습니다") return self.upload_progress_updated.emit(0, total_products) self.upload_log_message.emit(f"총 {total_products}개 상품, {total_pages}페이지를 처리합니다.") # 업로드 통계 추적 total_upload_success = 0 total_upload_fail = 0 # 페이지 순회 for page_no in range(1, total_pages + 1): page_progress = int((page_no / total_pages) * 90) + 10 current_page_products = min(items_per_page, total_products - ((page_no - 1) * items_per_page)) self.upload_step_changed.emit(f"페이지 {page_no}/{total_pages} 처리 중 (Extension)", page_progress) self.upload_log_message.emit(f"페이지 {page_no} 업로드 중... ({current_page_products}개 상품)") # ------------------------------------------------------------------ # 🔌 확장 프로그램에 작업 시작 명령 전달 # ------------------------------------------------------------------ self.logger.log(f"🔌 [Extension] 페이지 {page_no} 업로드 명령 전송", level=logging.INFO) # body의 상태 속성 초기화 await self.page.evaluate(""" document.body.removeAttribute('data-upload-status'); document.body.removeAttribute('data-upload-msg'); document.body.removeAttribute('data-upload-result'); """) # 확장 프로그램에 이벤트 전달 await self.page.evaluate(f""" const event = new CustomEvent('StartUploadRoutine', {{ detail: {{ targetMarkets: {_json.dumps(target_markets)} }} }}); window.dispatchEvent(event); """) # ------------------------------------------------------------------ # 확장 프로그램 결과 대기 # ------------------------------------------------------------------ max_wait_time = 300 # 5분 start_time = time.time() upload_result = None while (time.time() - start_time) < max_wait_time: try: status = await self.page.get_attribute("body", "data-upload-status") if status == "success": result_json = await self.page.get_attribute("body", "data-upload-result") if result_json: upload_result = _json.loads(result_json) self.logger.log(f"✅ [Extension] 페이지 {page_no} 업로드 성공: {upload_result}", level=logging.INFO) break elif status == "failed": error_msg = await self.page.get_attribute("body", "data-upload-msg") self.logger.log(f"❌ [Extension] 페이지 {page_no} 업로드 실패: {error_msg}", level=logging.ERROR) break elif status == "running": msg = await self.page.get_attribute("body", "data-upload-msg") self.logger.log(f"🔄 [Extension] 진행 중: {msg}", level=logging.DEBUG) except Exception as e: self.logger.log(f"상태 확인 중 오류: {e}", level=logging.DEBUG) await asyncio.sleep(1) else: self.logger.log(f"⏰ [Extension] 페이지 {page_no} 시간 초과", level=logging.ERROR) # 결과 집계 if upload_result: total_upload_success += upload_result.get('success', 0) total_upload_fail += upload_result.get('fail', 0) self.upload_log_message.emit(f"페이지 {page_no} 결과 - 성공: {upload_result.get('success', 0)}건, 실패: {upload_result.get('fail', 0)}건") # 다음 페이지 이동 await asyncio.sleep(1) if page_no < total_pages: success = await self.go_to_next_page() if not success: self.logger.log(f"페이지 {page_no} → {page_no+1} 이동 실패(업로드)", level=logging.ERROR) break self.logger.log("🔌 [Extension] 전체 업로드 완료", level=logging.INFO) # 최종 업로드 통계 completion_msg = f"업로드 작업 완료 (Extension) - 총 성공: {total_upload_success}건, 총 실패: {total_upload_fail}건" self.logger.log(completion_msg, level=logging.INFO) # 완료 시그널 self.upload_step_changed.emit("업로드 완료", 100) self.upload_log_message.emit(completion_msg) self.upload_completed.emit(True, completion_msg) self.step_completed.emit("upload_products", True) # 1페이지로 돌아가기 await self.back_to_first_page() except Exception as e: self.logger.log(f"ed_bulk_upload_via_extension 오류: {e}", level=logging.ERROR, exc_info=True) self.upload_completed.emit(False, f"업로드 중 오류 발생: {str(e)}") self.step_completed.emit("upload_products", False) async def start_Percenty_task(self): self.logger.log('퍼센티 상품수정 작업을 시작합니다...', level=logging.DEBUG) is_ed_mode = self.toggle_states.get('ed_mode', False) # 적절한 페이지로 이동 확인 if not await self.ensure_proper_page(is_ed_mode): self.logger.log("❌ 적절한 페이지로 이동 실패", level=logging.ERROR) self.translation_error.emit("페이지 이동 실패") return self.running = True # 번역 작업이 시작됨 self.translation_started.emit() # GPT/Grok 모델 업데이트 - 모델 타입이 변경되면 클라이언트 재생성 new_model = self.toggle_states.get('gpt_model', 'gpt-4o-mini') current_is_grok = isinstance(self.gpt_client, GrokClient) new_is_grok = new_model.startswith("grok-") if new_model else False if current_is_grok != new_is_grok: # GPT <-> Grok 전환 시 클라이언트 재생성 if new_is_grok: self.logger.log(f"🚀 GPT → Grok 전환: {new_model}", level=logging.INFO) self.gpt_client = GrokClient(logger=self.logger, supabase_manager=self.supabase_manager, model=new_model, user_id=self.user_id, membership_level=self.membership_level) else: self.logger.log(f"Grok → GPT 전환: {new_model}", level=logging.INFO) self.gpt_client = GPTClient(logger=self.logger, supabase_manager=self.supabase_manager, model=new_model, user_id=self.user_id, membership_level=self.membership_level) # 의존 핸들러들의 gpt_client 참조 업데이트 if hasattr(self, 'optionHandler'): self.optionHandler.gpt_client = self.gpt_client if hasattr(self, 'titleGenerator'): self.titleGenerator.gpt_client = self.gpt_client if hasattr(self, 'detailHandler'): self.detailHandler.gpt_client = self.gpt_client else: # 같은 타입 내에서 모델만 변경 self.gpt_client.update_gpt_model(new_model) # 이미지워커의 toggle_states 업데이트 if hasattr(self, 'image_processor') and self.image_processor: try: await self.image_processor.update_toggle_states(self.toggle_states) self.logger.log("이미지워커 toggle_states 업데이트 완료", level=logging.DEBUG) except Exception as e: self.logger.log(f"이미지워커 toggle_states 업데이트 중 오류: {e}", level=logging.WARNING) # if not is_ed_mode: # # 서버 URL 상태 체크 및 필요시 업데이트 # try: # self.logger.log('서버 URL 상태 체크 중...', level=logging.DEBUG) # url_updated = await self.refresh_server_urls_if_needed() # if url_updated: # self.logger.log('서버 URL이 업데이트되었습니다. 새로운 URL로 작업을 진행합니다.', level=logging.INFO) # else: # self.logger.log('모든 서버 URL이 정상 상태입니다.', level=logging.DEBUG) # except Exception as e: # self.logger.log(f'서버 URL 체크 중 오류 발생: {e}', level=logging.ERROR) # # 체크 실패해도 작업은 계속 진행 try: # 금지어 목록 업데이트 self.forbidden_word_manager.refresh_forbidden_words() optionIMGTrans_type = self.toggle_states['optionIMGTrans_type'] detail_IMGTrans_type = self.toggle_states['detail_IMGTrans_type'] thumb_trans_type = self.toggle_states['thumb_trans_type'] self.logger.log(f"optionIMGTrans_type : {optionIMGTrans_type}", level=logging.DEBUG) self.logger.log(f"detail_IMGTrans_type : {detail_IMGTrans_type}", level=logging.DEBUG) self.logger.log(f"thumb_trans_type : {thumb_trans_type}", level=logging.DEBUG) # 1. 총 상품 수 수집 self.check_pause() # 일시중지 상태 확인 await self.scroll_page_to_bottom() # 동적 로딩을 위해 끝까지 스크롤 # total_products = await self.browser_controller.get_total_product_count(ed_mode=self.toggle_states['ed_mode']) # get_total_product_count 메서드 호출 후 결과를 딕셔너리로 받음 result = await self.get_total_product_count() # 딕셔너리에서 총 상품 수와 페이지당 상품 수를 추출 total_products = result.get("total_count", 0) items_per_page = result.get("items_per_page", 0) total_pages = math.ceil(total_products / items_per_page) self.logger.log(f"총 상품: {total_products}, 페이지당: {items_per_page}, 총 페이지: {total_pages}", level=logging.DEBUG) self.logger.log(f"[ self.toggle_states 상태 ]\n{self.toggle_states}", level=logging.DEBUG) if total_products == 0: self.logger.log('수집할 상품이 없습니다. 작업을 종료합니다.', level=logging.DEBUG) return completed_count = 0 page_number = 1 # 3. 총 상품 수만큼 반복 작업 수행 # while self.running and completed_count < total_products: for page_number in range(1, total_pages + 1): self.check_pause() # 일시중지 상태 확인 # self.logger.log(f'현재 페이지: {page_number}', level=logging.DEBUG) self.logger.log(f'=== 페이지 {page_number}/{total_pages} 시작 ===', level=logging.INFO) # if not page_number == 1: # await self.scroll_page_to_top() # self.logger.log(f'1페이지가 아니므로 동적로딩을 위해 휠 스크롤 업', level=logging.DEBUG) await self.scroll_page_to_top() self.logger.log(f'동적로딩을 위해 휠 스크롤 업', level=logging.DEBUG) if not is_ed_mode: # 4. 현재 페이지의 모든 "세부사항 수정 및 업로드" 버튼 찾기 self.logger.log('수정모드가 아니므로 상품수정 버튼 elements를 수집합니다.', level=logging.DEBUG) product_buttons = await self.get_product_edit_buttons_by_template() else: self.logger.log('상품정보 수집', level=logging.DEBUG) product_infos, product_name_elements = await self.collect_product_info(items_per_page, ed_mode=is_ed_mode, total_products=total_products) self.logger.log(f"product_infos : {product_infos}", level=logging.DEBUG) self.logger.log('수정모드이므로 상품명 elements를 수정버튼으로 활용합니다.', level=logging.DEBUG) product_buttons = [{"edit_button": name_element, "memo_button": None, "shipping_button": None} for name_element in product_name_elements] self.logger.log(f"product_buttons 갯수 : [{len(product_buttons)}]개", level=logging.DEBUG) if not product_buttons: self.logger.log('수정할 상품이 없습니다. 작업을 종료합니다.', level=logging.DEBUG) break # 5. 각 상품에 대해 상품수정작업 수행 for index, button_set in enumerate(product_buttons, start=1): try: self.check_pause() # 일시중지 상태 확인 edit_button = button_set.get("edit_button") memo_button = button_set.get("memo_button") shipping_button = button_set.get("shipping_button") # 해외배송비 수정 버튼 # 상태 초기화 코드 추가 self.titleGenerator.reset_state() self.optionHandler.reset_state() self.priceHandler.reset_state() self.tagsHandler.reset_state() self.thumbnailHandler.reset_state() self.detailHandler.reset_state() # self.clipboardImageManager.reset_state() # 클립보드 초기화 # 그 외 임시 변수 초기화 self.current_options_info = None if not is_ed_mode: # 상품명 수집 오류 처리 self.logger.log(f'{index}/{len(product_buttons)} 버튼의 활성상태 확인 중...', level=logging.DEBUG) is_disabled = await self.is_button_disabled(edit_button) if is_disabled: self.logger.log(f'{index}/{len(product_buttons)}: 상품의 수정버튼이 비활성화되어 있어 작업을 건너뜁니다.', level=logging.DEBUG) # completed_count += 1 # self.total_progressbar_signal.emit(completed_count, total_products) continue self.logger.log(f'{index}/{len(product_buttons)}: 세부사항 수정 작업 중...', level=logging.DEBUG) # 상품 수정 다이얼로그 열기 await self.open_product_edit_dialog(edit_button) # self.check_pause() # 일시중지 상태 확인 # if not is_ed_mode: title_infos = await self.titleGenerator.get_initial_info(self.price_setting_diag) category = title_infos.get('category', '') self.logger.log(f"title_infos : {title_infos}", level=logging.DEBUG) self.logger.log(f"category : {category}", level=logging.DEBUG) # 금지카테고리 여부가 True이면, 금지카테고리 제목 설정 후 저장하고 다음 상품으로 넘어감. # if title_infos.get("is_banned_category", False): if not is_ed_mode and title_infos.get("is_banned_category", False): banned_title = self.titleGenerator.set_banned_category_title(title_infos.get("banned_category_info")) self.logger.log(f"금지카테고리 상품 처리: 새 제목: {banned_title}", level=logging.INFO) # 랜덤 4자리 붙이기 random_suffix = self.generate_random_suffix() # 상품명 설정 (TitleGenerator 내부 또는 별도 메서드를 통해) is_set = await self.titleGenerator.set_product_name(banned_title + random_suffix) if is_set: self.logger.log("금지카테고리 상품명 설정 완료.", level=logging.INFO) else: self.logger.log("금지카테고리 상품명 설정 실패.", level=logging.WARNING) # 최종 저장 (다이얼로그 닫기 포함) save_result = await self.handle_save_with_error_recovery(title_infos, ".") if save_result == "DUPLICATE_HANDLED": self.logger.log("금지카테고리 상품명 중복 처리 완료.", level=logging.INFO) elif save_result == "SAVED": self.logger.log("금지카테고리 상품 저장 완료.", level=logging.INFO) elif save_result == "DELETED": self.logger.log("금지카테고리 상품 삭제 완료.", level=logging.INFO) else: self.logger.log("금지카테고리 상품 처리 실패.", level=logging.ERROR) continue # 다음 상품으로 넘어감 if not is_ed_mode: await self.random_human_behavior(self.page) # 정상상품이면 상품명 수정과 카테고리 수집 is_title = self.toggle_states['title'] is_title_shuffle = self.toggle_states['title_shuffle'] if is_title or is_title_shuffle: self.check_pause() # 일시중지 상태 확인 self.logger.log(f"상품명 수정 : {is_title} ", level=logging.DEBUG) self.start_stage_signal.emit(0) title_infos["generated_name"] = await self.titleGenerator.process_title() self.complete_stage_signal.emit(0) if not is_ed_mode: # 옵션 수정 self.start_stage_signal.emit(1) await self.random_human_behavior(self.page) is_optionTrnas = self.toggle_states.get('optionTrnas', False) is_optionIMGTrans = self.toggle_states.get('optionIMGTrans', False) is_optionAutoSelect = self.toggle_states.get('optionAutoSelect', False) if is_optionTrnas or is_optionIMGTrans or is_optionAutoSelect: self.check_pause() # 일시중지 상태 확인 self.logger.log(f"옵션수정 : optionTrnas={is_optionTrnas} + optionIMGTrans={is_optionIMGTrans} + optionAutoSelect={is_optionAutoSelect}", level=logging.DEBUG) option_result = await self.edit_option(title_infos.get("original_name", None), title_infos) if option_result == "DELETED": self.logger.log("옵션 탭에서 상품이 삭제되었습니다. 다음 상품으로 넘어갑니다.", level=logging.INFO) # 총 상품수 감소 및 버튼 재수집 total_products, product_buttons = await self.handle_product_deletion_in_loop(total_products, items_per_page) continue # 다음 상품으로 넘어감 await self.random_human_behavior(self.page) self.complete_stage_signal.emit(1) # 가격 수정 self.start_stage_signal.emit(2) is_price = self.toggle_states.get('price', False) remove_overprice = self.toggle_states.get('remove_overprice', False) is_price_range_enabled = self.toggle_states.get('price_range_enabled', False) await self.random_human_behavior(self.page) if is_price or remove_overprice or is_price_range_enabled: self.check_pause() # 일시중지 상태 확인 self.logger.log(f"가격수정 : {is_price} + 가격범위 초과제외 :{remove_overprice}", level=logging.DEBUG) price_result = await self.edit_price(title_infos) if price_result == "DELETED": self.logger.log("가격 탭에서 상품이 삭제되었습니다. 다음 상품으로 넘어갑니다.", level=logging.INFO) # 총 상품수 감소 및 버튼 재수집 total_products, product_buttons = await self.handle_product_deletion_in_loop(total_products, items_per_page) continue # 다음 상품으로 넘어감 self.complete_stage_signal.emit(2) # 썸네일 수정 self.start_stage_signal.emit(3) thumb = self.toggle_states.get('thumb') thumb_nukki = self.toggle_states.get('thumb_nukki') thumb_represent = self.toggle_states.get('thumb_represent', False) # ED_MODE에서는 thumb_represent가 True일 때만, 일반 모드에서는 thumb 또는 thumb_nukki가 True일 때 실행 should_process_thumb = False if is_ed_mode: should_process_thumb = thumb_represent if should_process_thumb: self.logger.log(f"썸네일수정 (ED_MODE): thumb_represent={thumb_represent}", level=logging.DEBUG) else: should_process_thumb = thumb or thumb_nukki if should_process_thumb: self.logger.log(f"썸네일수정 : thumb={thumb}, thumb_nukki={thumb_nukki}", level=logging.DEBUG) if should_process_thumb: await self.random_human_behavior(self.page) self.check_pause() # 일시중지 상태 확인 thumb_result = await self.edit_thumb(title_infos) if thumb_result == "DELETED": self.logger.log("썸네일 탭에서 상품이 삭제되었습니다. 다음 상품으로 넘어갑니다.", level=logging.INFO) # 총 상품수 감소 및 버튼 재수집 total_products, product_buttons = await self.handle_product_deletion_in_loop(total_products, items_per_page) continue # 다음 상품으로 넘어감 self.complete_stage_signal.emit(3) if not is_ed_mode: # 태그 수정 tag = self.toggle_states.get('tag') tag_ai = self.toggle_states.get('tag_ai') tag_lens = self.toggle_states.get('tag_lens', False) await self.random_human_behavior(self.page) if tag or tag_ai or tag_lens: self.check_pause() # 일시중지 상태 확인 self.logger.log(f"키워드태그 수정 : tag={tag}, tag_ai={tag_ai}, tag_lens={tag_lens}", level=logging.DEBUG) self.start_stage_signal.emit(4) tag_result = await self.edit_tags(title_infos, tag, tag_ai, tag_lens) if tag_result == "DELETED": self.logger.log("태그 탭에서 상품이 삭제되었습니다. 다음 상품으로 넘어갑니다.", level=logging.INFO) # 총 상품수 감소 및 버튼 재수집 total_products, product_buttons = await self.handle_product_deletion_in_loop(total_products, items_per_page) continue # 다음 상품으로 넘어감 self.complete_stage_signal.emit(4) # 상세페이지 수정 수정 detail_Option = self.toggle_states.get('detail_Option') detail_IMGTrans = self.toggle_states.get('detail_IMGTrans') detail_IMGTrans_type = self.toggle_states.get('detail_IMGTrans_type') # ED_MODE에서는 detail_IMGTrans만 허용 (detail_Option은 제외) # 일반 모드에서는 detail_Option과 detail_IMGTrans 모두 허용 should_process_detail = False if is_ed_mode: # ED_MODE: 상세페이지 이미지 번역만 허용 should_process_detail = detail_IMGTrans else: # 일반 모드: 옵션과 이미지 번역 모두 허용 should_process_detail = detail_Option or detail_IMGTrans if should_process_detail: self.check_pause() # 일시중지 상태 확인 if is_ed_mode: self.logger.log(f"상세페이지 수정 (ED_MODE): detail_IMGTrans={detail_IMGTrans}, type={detail_IMGTrans_type}", level=logging.DEBUG) else: self.logger.log(f"상세페이지 수정 : {detail_Option} + {detail_IMGTrans} + {detail_IMGTrans_type}", level=logging.DEBUG) # 상세페이지 수정 await self.random_human_behavior(self.page) self.start_stage_signal.emit(5) detail_result = await self.edit_detail(title_infos) if detail_result == "DELETED": self.logger.log("상세페이지 탭에서 상품이 삭제되었습니다. 다음 상품으로 넘어갑니다.", level=logging.INFO) # 총 상품수 감소 및 버튼 재수집 total_products, product_buttons = await self.handle_product_deletion_in_loop(total_products, items_per_page) continue # 다음 상품으로 넘어감 self.complete_stage_signal.emit(5) await self.random_human_behavior(self.page) # 수정 후 저장 self.logger.log('상품 세부사항 저장 중...', level=logging.DEBUG) save_result = await self.handle_save_with_error_recovery(title_infos, "최종저장") if save_result == "DELETED": # 상품이 삭제된 경우 총 상품수 감소 total_products, product_buttons = await self.handle_product_deletion_in_loop(total_products, items_per_page) self.logger.log(f"상품 삭제로 인해 총 상품수 감소: {total_products}", level=logging.INFO) self.logger.log(f"상품 삭제 후 재수집된 product_buttons 갯수: [{len(product_buttons)}]개", level=logging.INFO) continue # 다음 상품으로 넘어가지 않고 현재 인덱스를 유지 elif save_result == "ERROR": # 저장도 삭제도 실패한 경우 self.logger.log("상품 저장 및 삭제 모두 실패했습니다. 다음 상품으로 넘어갑니다.", level=logging.ERROR) # 에러가 발생해도 다음 상품으로 진행 # save_result == "SAVED"인 경우는 정상 저장이므로 그대로 진행 # 메모 입력: 저장이 정상 완료(SAVED) 또는 중복 처리 완료(DUPLICATE_HANDLED)된 경우에만 진행 if save_result in ("SAVED", "DUPLICATE_HANDLED") and memo_button and self.toggle_states['memo'] and not is_ed_mode: self.check_pause() # 일시중지 상태 확인 self.logger.log(f'{index}/{len(product_buttons)}: 메모 입력 중...', level=logging.DEBUG) try: memo_timeout = int(self.toggle_states.get('memo_timeout_sec', 15)) except Exception: memo_timeout = 15 try: await asyncio.wait_for(self.insert_product_infos_to_memo(memo_button, title_infos), timeout=memo_timeout) except asyncio.TimeoutError: self.logger.log(f"메모 입력 타임아웃({memo_timeout}s) - 건너뜀", level=logging.WARNING) try: await self._close_memo_popup() except Exception: pass await self.random_human_behavior(self.page) except PlaywrightError as fatal: # (C) 재귀 재시작 가드 ---------------------------------- if not getattr(self, "_restart_in_progress", False): self._restart_in_progress = True self.logger.log("Playwright 오류 – 컨텍스트 재시작", level=logging.ERROR, exc_info=True) await self.restart_main_context() await self.resume_after_restart() self._restart_in_progress = False return # 외부에서 다시 호출 else: self.logger.log("재시작 중 중복 진입 차단", level=logging.WARNING) return except Exception as item_err: # 이 상품만 실패로 기록하고, 다음 상품으로 screenshot_path = await self.save_error_screenshot() self.logger.log(f" ▶ 상품 {index}/{len(product_buttons)} 처리중 오류: {item_err}", level=logging.ERROR, exc_info=True) finally: # 이전 상품 처리 중 예외로 다이얼로그가 남아 있을 수 있으므로 먼저 닫아준다. try: if await self.page.is_visible(self.product_dialog_close_btn): await self.page.click(self.product_dialog_close_btn) self.logger.log("상품 다이얼로그 닫기 버튼 클릭", level=logging.INFO) await asyncio.sleep(0.2) await self.close_ant_modal_dialogs() if await self.page.is_visible(self.memo_dialog_close_buttons): await self.page.click(self.memo_dialog_close_buttons) self.logger.log("메모 다이얼로그 닫기 버튼 클릭", level=logging.INFO) await asyncio.sleep(0.2) await self.close_ant_modal_dialogs() except Exception as close_err: self.logger.log(f"잔존 다이얼로그 닫기 실패: {close_err}", level=logging.DEBUG) # 성공·실패 관계없이 진행 카운트와 프로그레스바 업데이트 completed_count += 1 self.logger.log(f'{completed_count}/[{total_products}]개 상품 수정 완료.', level=logging.INFO) title_infos.clear() # 메모 입력 후 초기화 self.logger.log(f"title_infos 초기화", level=logging.DEBUG) # if self.is_image_processor_init: # is_reset_ocr = self.image_processor.reset_ocr_module() # if is_reset_ocr: # self.logger.log(f"상품수정 완료로 인해 OCR 모듈 초기화", level=logging.DEBUG) self.total_progressbar_signal.emit(completed_count, total_products) if (completed_count % 10) == 0: gc.collect() mem = psutil.virtual_memory() self.logger.log(f"GC 호출 후, 메모리 사용량: {mem.percent}% 사용중", level=logging.DEBUG) # 목표 도달 체크 if completed_count >= total_products: break # (3) 다음 페이지로 이동 await self.random_human_behavior(self.page) if page_number < total_pages: success = await self.go_to_next_page() if not success: err = f"페이지 {page_number} → {page_number+1} 이동 실패" self.logger.log(err, level=logging.ERROR) self.translation_error.emit(err) return else: self.logger.log("마지막 페이지까지 모두 처리했습니다.", level=logging.INFO) except Exception as fatal: screenshot_path = await self.save_error_screenshot() # 전체 작업 중에 발생한 치명적 예외 self.logger.log(f"전체 작업 중 오류: {fatal}", level=logging.ERROR, exc_info=True) self.translation_error.emit(str(fatal)) self.step_completed.emit("product_edit", False) else: # 예외 없이 '정상적으로' 전체 루프를 마쳤을 때 self.logger.log(f"모든 상품({completed_count}/{total_products}) 처리 완료", level=logging.INFO) self.translation_completed.emit(completed_count) self.step_completed.emit("product_edit", True) # 전체 작업 통계 요약 (가능한 경우 각 핸들러에서 이미 출력된 통계가 있으므로, 여기서는 총 상품수만 재강조) try: stats = getattr(self, '_stats', {}) or {} thumb_total = stats.get('thumb_total', 0) thumb_translated = stats.get('thumb_translated', 0) thumb_removed = stats.get('thumb_removed', 0) option_total = stats.get('option_total', 0) option_translated = stats.get('option_translated', 0) detail_total = stats.get('detail_total', 0) detail_translated = stats.get('detail_translated', 0) self.logger.log( ( f"📦 전체 상품 통계: 총상품={total_products}, 처리완료={completed_count} | " f"옵션이미지: 총={option_total}, 번역됨={option_translated} | " f"썸네일: 총={thumb_total}, 번역됨={thumb_translated}, 배경제거={thumb_removed} | " f"상세이미지: 총={detail_total}, 번역됨={detail_translated}" ), level=logging.INFO, ) except Exception: pass # ✅ 모든 작업 후 1페이지로 돌아가기 await self.back_to_first_page() async def _close_memo_popup(self): """메모 팝업을 닫는 메서드""" try: # 1. 닫기 버튼이 보이면 클릭 if await self.page.is_visible(self.memo_dialog_close_buttons): self.logger.log("메모 다이얼로그 닫기 버튼 발견, 클릭 시도", level=logging.DEBUG) await self.page.click(self.memo_dialog_close_buttons) self.logger.log("메모 다이얼로그 닫기 버튼 클릭 완료", level=logging.INFO) await asyncio.sleep(0.2) else: self.logger.log("메모 다이얼로그 닫기 버튼이 보이지 않음, ESC 키 시도", level=logging.DEBUG) # ESC 두 번 전송 await self.page.keyboard.press("Escape") await asyncio.sleep(0.1) await self.page.keyboard.press("Escape") except Exception as e: self.logger.log(f"메모 다이얼로그 닫기 버튼 클릭 실패: {e}, ESC 키 시도", level=logging.WARNING) # 버튼 클릭 실패 시 ESC 키로 대체 try: await self.page.keyboard.press("Escape") await asyncio.sleep(0.1) await self.page.keyboard.press("Escape") except Exception as esc_err: self.logger.log(f"ESC 키 전송 실패: {esc_err}", level=logging.WARNING) # textarea 가 DOM 에서 사라질 때까지(또는 숨길 때까지) 대기 try: await self.page.wait_for_selector(self.memo_input_locator, state="detached", # hidden 으로 바꿔도 됨 timeout=1000) self.logger.log("메모 팝업 닫기 완료", level=logging.DEBUG) except TimeoutError: # 남아있어도 다음 재시도 전에 old-element 가 선택되지 않도록 self.logger.log("메모 팝업이 닫히지 않았습니다 – selector 재생성", level=logging.WARNING) async def insert_product_infos_to_memo(self, memo_button, title_infos): """메모 입력을 시도하고, 실패 시 팝업을 정리한 뒤 재시도합니다. - 최대 `max_retry`회까지 시도합니다. - 각 재시도 전 `_close_memo_popup()`을 호출해 잔존 팝업을 확실히 제거합니다. - 같은 `title_infos` 를 사용하므로 다른 상품 정보가 섞이지 않습니다. """ # ───── 기본 설정 및 유효성 검사 ───────────────────────────── collect_method = self.toggle_states.get('collect_method_combo') is_new_memo_first = self.toggle_states.get('memo_toggle_order') is_memo_exposure = self.toggle_states.get('memo_toggle_exposer') # 1) 버튼 활성 여부 if await self.is_button_disabled(memo_button): self.logger.log("메모 버튼이 비활성화되어 있어 작업을 건너뜁니다.", level=logging.WARNING) return # 2) title_infos 에 기록할 데이터가 있는지 확인 top_5_titles = title_infos.get("top_5_titles", []) top_5_prices = title_infos.get("top_5_prices", []) if not top_5_titles or not top_5_prices: self.logger.log("title_infos에 메모할 내용이 없어 건너뜁니다.", level=logging.WARNING) return # 3) 메모 텍스트 생성 new_memo = self.generate_titles_with_prices(title_infos) if not new_memo: self.logger.log("생성된 메모 텍스트가 비어 있어 건너뜁니다.", level=logging.WARNING) return # ───── 메모 입력 재시도 루프 ────────────────────────────── max_retry = 3 # 최초 1회 + 추가 2회 재시도 for attempt in range(max_retry): try: # 4) 메모 다이얼로그 열기 await memo_button.click() self.logger.log(f" try {attempt+1}/{max_retry} - current_memo_button : {memo_button}", level=logging.DEBUG) self.logger.log(f"메모 버튼 클릭 (시도 {attempt+1}/{max_retry})", level=logging.DEBUG) memo_input = self.page.locator(self.memo_input_locator) await memo_input.wait_for(state="visible", timeout=3000) self.logger.log("메모 입력란 대기 완료", level=logging.DEBUG) # 5) 기존 메모 읽기 existing = await memo_input.input_value() combined = existing.strip() # 6) 새 메모 조합 suffix = "\n" + "-"*10 + "\n" if combined: combined = (new_memo + suffix + combined) if is_new_memo_first else (combined + suffix + new_memo) else: combined = new_memo # 7) 길이 제한 (2000자) if len(combined) > 2000: combined = combined[:2000] # 8) 포커스 이동 (fill 전에 포커스 확보) await memo_input.focus() await asyncio.sleep(0.2) # 9) 채우기 await memo_input.fill(combined) self.logger.log(f"메모 입력: {combined!r}", level=logging.DEBUG) # 10) 노출 체크박스 처리 exposer = self.page.locator(self.memo_exposer_locator) await exposer.wait_for(state="attached", timeout=2000) await exposer.wait_for(state="visible", timeout=3000) # 활성화될 때까지 대기 max_check = 5 for _ in range(max_check): if await exposer.get_attribute('disabled') is None: break await asyncio.sleep(0.3) else: self.logger.log("노출 체크박스가 활성화되지 않았습니다.", level=logging.WARNING) if is_memo_exposure: await exposer.check() self.logger.log("메모 노출 체크 ON", level=logging.DEBUG) else: await exposer.uncheck() self.logger.log("메모 노출 체크 OFF", level=logging.DEBUG) # 11) 저장 버튼 클릭 save_btn = self.page.locator(self.memo_save_btn_locator) await save_btn.wait_for(state="visible", timeout=3000) # 저장 버튼이 비활성화되어 있지 않은지 확인 max_save_wait = 5 for _ in range(max_save_wait): if await save_btn.get_attribute('disabled') is None: break await asyncio.sleep(0.3) else: self.logger.log("저장 버튼이 활성화되지 않았습니다.", level=logging.WARNING) # 저장 버튼 클릭 시도 try: await save_btn.click() self.logger.log("메모 저장 버튼 클릭 완료", level=logging.DEBUG) except Exception as save_err: self.logger.log(f"메모 저장 버튼 클릭 실패: {save_err}, 재시도 중...", level=logging.WARNING) # 클릭 실패 시 force 옵션으로 재시도 try: await save_btn.click(force=True) self.logger.log("메모 저장 버튼 강제 클릭 완료", level=logging.DEBUG) except Exception as force_err: self.logger.log(f"메모 저장 버튼 강제 클릭도 실패: {force_err}", level=logging.ERROR) raise # 저장 후 다이얼로그가 닫히는지 확인 await asyncio.sleep(0.5) # 저장 처리 대기 # 다이얼로그가 여전히 열려있는지 확인 try: memo_input_visible = await memo_input.is_visible(timeout=1000) if memo_input_visible: self.logger.log("저장 후 메모 다이얼로그가 닫히지 않음, 닫기 버튼 클릭 시도", level=logging.DEBUG) await self._close_memo_popup() await asyncio.sleep(0.2) else: self.logger.log("메모 저장 후 다이얼로그가 자동으로 닫힘", level=logging.DEBUG) except Exception: # 다이얼로그가 닫혔거나 보이지 않음 (정상) self.logger.log("메모 저장 후 다이얼로그 상태 확인 완료", level=logging.DEBUG) # 성공 시 종료 return except Exception as e: screenshot_path = await self.save_error_screenshot() self.logger.log(f"메모 입력 오류 (시도 {attempt+1}/{max_retry}): {e}", level=logging.ERROR, exc_info=True) # 팝업 정리 후 재시도 (마지막 시도 후에는 그대로 실패 처리) await self._close_memo_popup() await asyncio.sleep(0.5) # 모든 재시도 실패 → 팝업 강제 정리 후 종료 try: await self._close_memo_popup() except Exception: pass self.logger.log("메모 입력 최종 실패 – 해당 상품의 메모 입력을 건너뜁니다.", level=logging.ERROR) def generate_titles_with_prices(self, title_infos): """top_5_titles와 top_5_prices를 매칭하여 텍스트 생성""" try: titles = title_infos.get("top_5_titles", []) prices = title_infos.get("top_5_prices", []) if not titles or not prices: self.logger.log("top_5_titles 또는 top_5_prices가 비어 있습니다.", level=logging.WARNING) return "" # 매칭된 텍스트 생성 formatted_lines = [] for title, price in zip(titles, prices): formatted_price = f"{int(price):,}원" # 3자리수마다 콤마, "원" 추가 formatted_lines.append(f"{title} - {formatted_price}") # 최종 텍스트 생성 result_text = "\n".join(formatted_lines) self.logger.log(f"Generated titles with prices:\n{result_text}", level=logging.DEBUG) return result_text except Exception as e: self.logger.log(f"텍스트 생성 중 오류 발생: {e}", level=logging.ERROR, exc_info=True) return "" def clear_temp_folder(self, folder_path): """ 폴더 내 모든 파일 삭제, 폴더 없으면 생성 """ if os.path.exists(folder_path): # 폴더 내 파일/폴더 삭제 for filename in os.listdir(folder_path): file_path = os.path.join(folder_path, filename) try: if os.path.isfile(file_path) or os.path.islink(file_path): os.unlink(file_path) elif os.path.isdir(file_path): shutil.rmtree(file_path) except Exception as e: self.logger.log(f"파일 삭제 실패: {file_path}, 에러: {e}", level=logging.ERROR, exc_info=True) else: os.makedirs(folder_path, exist_ok=True) async def edit_product_name(self): # 상세페이지 탭 클릭 await self.click_title_tab() async def edit_option(self, product_name, title_infos=None): # 옵션 탭 클릭 await self.click_option_tab() # 옵션 최대선택갯수 self.current_options_info = await self.optionHandler.process_options(product_name, self.forbidden_word_manager, self.toggle_states, title_infos) # # ED 모드에서 옵션명이 변경되었다면 상세페이지도 업데이트 # if self.toggle_states.get('ed_mode', False): # if self.optionHandler.option_info.get('ed_mode_detail_update_needed', False): # self.logger.log("ED 모드: 옵션명 변경으로 인한 상세페이지 업데이트 시작", level=logging.INFO) # try: # # 상세페이지 탭으로 이동 # await self.click_detail_tab() # # 상세페이지 옵션 목록 업데이트 # update_success = await self.detailHandler.update_detail_for_ed_mode(self.optionHandler) # if update_success: # self.logger.log("ED 모드: 상세페이지 옵션 목록 업데이트 완료", level=logging.INFO) # else: # self.logger.log("ED 모드: 상세페이지에 기존 옵션 목록 패턴이 없어 업데이트를 건너뜁니다.", level=logging.INFO) # # 다시 옵션 탭으로 돌아가기 # await self.click_option_tab() # # 플래그 초기화 # self.optionHandler.option_info['ed_mode_detail_update_needed'] = False # except Exception as e: # self.logger.log(f"ED 모드 상세페이지 업데이트 중 오류: {e}", level=logging.ERROR, exc_info=True) # 수정 후 저장 if title_infos: return await self.save_tab_changes(title_infos, "옵션") return "SAVED" async def edit_price(self, title_infos): # 가격 탭 클릭 await self.click_price_tab() # 가격 수정 프로세스 await self.priceHandler.process_price(title_infos=title_infos) # 수정 후 저장 return await self.save_tab_changes(title_infos, "가격") async def edit_thumb(self, title_infos=None): # 썸네일 탭 클릭 await self.click_thumb_tab() # 썸네일 수정 프로세스 await self.thumbnailHandler.process_thumbnails(self.toggle_states) # 수정 후 저장 if title_infos: return await self.save_tab_changes(title_infos, "썸네일") return "SAVED" async def edit_tags(self, title_infos, tag, tag_ai, tag_lens=False): # 태그 탭 클릭 await self.click_tags_tab() # 태그 수정 프로세스 (렌즈 기반 태그 옵션 추가) await self.tagsHandler.process_tags( self.forbidden_word_manager, title_infos=title_infos, gpt_client=self.gpt_client, tag=tag, tag_ai=tag_ai, tag_lens=tag_lens ) # 수정 후 저장 return await self.save_tab_changes(title_infos, "태그") async def edit_detail(self, title_infos=None): # 상세페이지 탭 클릭 await self.click_detail_tab() # 상세페이지 수정 프로세스 await self.detailHandler.process_detail(self.optionHandler, self.toggle_states) # 수정 후 저장 if title_infos: return await self.save_tab_changes(title_infos, "상세페이지") return "SAVED" def run(self): """QThread의 run 메서드에서 이벤트 루프 생성 및 실행""" self.logger.log("run() - 이벤트 루프 초기화 시작", level=logging.DEBUG) # 이벤트 루프가 없거나 종료된 경우에만 초기화 if not self.loop or self.loop.is_closed(): self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) self.logger.log("run() - 이벤트 루프가 생성되었습니다.", level=logging.DEBUG) # 이벤트 루프를 유지하여 외부에서 작업 추가 가능 self.loop.run_forever() def start_browser_task(self): """이벤트 루프에서 브라우저 초기화 작업 추가""" self.logger.log(f"start_browser_task - 브라우저 초기화 작업 시작 : {self.toggle_states}", level=logging.DEBUG) # 실행 중인 이벤트 루프에 비동기 작업 추가 if self.loop and not self.loop.is_closed(): asyncio.run_coroutine_threadsafe(self.start_browser_async(), self.loop) self.browser_task_started = True # 작업 실행 플래그 설정 self.logger.log("start_browser_task - 비동기 작업이 추가되었습니다.", level=logging.DEBUG) else: self.logger.log("start_browser_task - 실행 중인 이벤트 루프가 없습니다.", level=logging.ERROR) def get_group_names_list_all_task(self): """이벤트 루프에서 작업그룹 목록 가져오기 작업 추가""" self.logger.log(f"get_group_names_list_all_async - 작업그룹 이름 목록 가져오기 시작 : {self.toggle_states}", level=logging.DEBUG) # 실행 중인 이벤트 루프에 비동기 작업 추가 if self.loop and not self.loop.is_closed(): asyncio.run_coroutine_threadsafe(self.get_group_names_list_all_async(), self.loop) self.browser_task_started = True # 작업 실행 플래그 설정 self.logger.log("get_group_names_list_all_async - 비동기 작업이 추가되었습니다.", level=logging.DEBUG) else: self.logger.log("get_group_names_list_all_async - 실행 중인 이벤트 루프가 없습니다.", level=logging.ERROR) def select_group_task_list(self, group_index: int): """이벤트 루프에서 그룹 선택 작업 실행""" self.logger.log(f"select_group_task - 그룹 선택 작업 추가: {group_index}", level=logging.DEBUG) if self.loop and not self.loop.is_closed(): asyncio.run_coroutine_threadsafe(self.select_group_index(group_index), self.loop) self.logger.log("select_group_task - 비동기 그룹선택 작업이 추가됨.", level=logging.DEBUG) else: self.logger.log("select_group_task - 실행 중인 이벤트 루프가 없습니다.", level=logging.ERROR) def select_group_task(self, group_name: str): """이벤트 루프에서 그룹 선택 작업 실행""" self.logger.log(f"select_group_task - 그룹 선택 작업 추가: {group_name}", level=logging.DEBUG) if self.loop and not self.loop.is_closed(): asyncio.run_coroutine_threadsafe(self.select_group_by_name(group_name), self.loop) self.logger.log("select_group_task - 비동기 그룹선택 작업이 추가됨.", level=logging.DEBUG) else: self.logger.log("select_group_task - 실행 중인 이벤트 루프가 없습니다.", level=logging.ERROR) def start_ChangeBizJob_task(self): """번역 작업을 이벤트 루프에서 실행""" # 이벤트 루프가 없거나 닫혀 있으면 새로 생성 # self.initialize_event_loop() if self.loop and not self.loop.is_closed(): # 이미 실행 중인 이벤트 루프에 번역 작업 추가 asyncio.run_coroutine_threadsafe(self.start_ChangeBiz_task(), self.loop) else: self.logger.log("이벤트 루프가 초기화되지 않았거나 이미 종료되었습니다.", level=logging.ERROR) def go_to_registered_product_page_task(self): """등록 상품 관리 페이지로 이동 작업을 이벤트 루프에서 실행""" # 이벤트 루프가 없거나 닫혀 있으면 새로 생성 # self.initialize_event_loop() if self.loop and not self.loop.is_closed(): # 이미 실행 중인 이벤트 루프에 등록 상품 관리 페이지로 이동 작업 추가 asyncio.run_coroutine_threadsafe(self.go_to_registered_product_page(), self.loop) else: self.logger.log("이벤트 루프가 초기화되지 않았거나 이미 종료되었습니다.", level=logging.ERROR) def go_to_new_product_page_task(self): """신규 상품 등록 페이지로 이동 작업을 이벤트 루프에서 실행""" # 이벤트 루프가 없거나 닫혀 있으면 새로 생성 # self.initialize_event_loop() if self.loop and not self.loop.is_closed(): # 이미 실행 중인 이벤트 루프에 신규 상품 등록 페이지로 이동 작업 추가 asyncio.run_coroutine_threadsafe(self.go_to_new_product_page(), self.loop) else: self.logger.log("이벤트 루프가 초기화되지 않았거나 이미 종료되었습니다.", level=logging.ERROR) def start_RemoveMarketInfoJob_task(self): """번역 작업을 이벤트 루프에서 실행""" # 이벤트 루프가 없거나 닫혀 있으면 새로 생성 # self.initialize_event_loop() if self.loop and not self.loop.is_closed(): # 이미 실행 중인 이벤트 루프에 번역 작업 추가 asyncio.run_coroutine_threadsafe(self.ed_bulk_delete_market_info(), self.loop) else: self.logger.log("이벤트 루프가 초기화되지 않았거나 이미 종료되었습니다.", level=logging.ERROR) def cancel_upload(self): """업로드 취소""" try: self.logger.log("업로드 취소가 요청되었습니다.", level=logging.INFO) # 여기에 실제 업로드 취소 로직 구현 # 예: 진행 중인 작업 중단, 플래그 설정 등 if hasattr(self, 'upload_cancelled'): self.upload_cancelled = True self.upload_log_message.emit("업로드가 사용자에 의해 취소되었습니다.") except Exception as e: self.logger.log(f"업로드 취소 실패: {e}", level=logging.ERROR) def pause_upload(self): """업로드 일시정지""" try: self.logger.log("업로드 일시정지가 요청되었습니다.", level=logging.INFO) # 여기에 실제 업로드 일시정지 로직 구현 if hasattr(self, 'upload_paused'): self.upload_paused = True self.upload_log_message.emit("업로드가 일시정지되었습니다.") except Exception as e: self.logger.log(f"업로드 일시정지 실패: {e}", level=logging.ERROR) def resume_upload(self): """업로드 재개""" try: self.logger.log("업로드 재개가 요청되었습니다.", level=logging.INFO) # 여기에 실제 업로드 재개 로직 구현 if hasattr(self, 'upload_paused'): self.upload_paused = False self.upload_log_message.emit("업로드가 재개되었습니다.") except Exception as e: self.logger.log(f"업로드 재개 실패: {e}", level=logging.ERROR) def start_PercentyJob_task(self): """번역 작업을 이벤트 루프에서 실행""" # 현재 이벤트 루프 상태 로깅 try: current_loop = asyncio.get_running_loop() except RuntimeError: current_loop = None self.logger.log(f"현재 이벤트 루프 상태 - self.loop: {self.loop}, is_closed: {self.loop.is_closed() if self.loop else 'None'}, running_loop: {current_loop}", level=logging.DEBUG) # 이벤트 루프가 없거나 닫혀 있으면 새로 생성 if not self.loop or self.loop.is_closed(): self.logger.log("이벤트 루프가 초기화되지 않았거나 이미 종료되었습니다. 새로 생성합니다.", level=logging.WARNING) self.initialize_event_loop() if self.loop and not self.loop.is_closed(): # 이미 실행 중인 이벤트 루프에 번역 작업 추가 try: asyncio.run_coroutine_threadsafe(self.start_Percenty_task(), self.loop) self.logger.log("상품수정 작업이 이벤트 루프에 성공적으로 추가되었습니다.", level=logging.INFO) except Exception as e: self.logger.log(f"이벤트 루프에 작업 추가 실패: {e}", level=logging.ERROR) # 이벤트 루프가 여전히 문제가 있으면 다시 초기화 시도 try: self.initialize_event_loop() asyncio.run_coroutine_threadsafe(self.start_Percenty_task(), self.loop) self.logger.log("재초기화 후 상품수정 작업이 이벤트 루프에 성공적으로 추가되었습니다.", level=logging.INFO) except Exception as e2: self.logger.log(f"재초기화 후에도 작업 추가 실패: {e2}", level=logging.ERROR) self.translation_error.emit(f"작업 시작 실패: {e2}\n\n프로그램을 재시작해주세요.") else: self.logger.log("이벤트 루프 초기화 실패 - 작업을 시작할 수 없습니다.", level=logging.ERROR) self.translation_error.emit("이벤트 루프 초기화 실패로 작업을 시작할 수 없습니다.\n\n다시 시도하거나 프로그램을 재시작해주세요.") def initialize_event_loop(self): """이벤트 루프가 없거나 닫힌 경우 새로 생성""" if not self.loop or self.loop.is_closed(): self.logger.log("initialize_event_loop - 새로운 이벤트 루프 생성 중", level=logging.DEBUG) self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) self.logger.log("initialize_event_loop - 이벤트 루프가 생성되었습니다.", level=logging.DEBUG) else: self.logger.log("initialize_event_loop - 기존 이벤트 루프 사용 중", level=logging.DEBUG) def stop(self): """Playwright를 안전하게 종료""" try: # 비동기 함수 호출을 동기식으로 처리 asyncio.run(self._stop_playwright()) except RuntimeError as e: self.logger.log(f"Playwright 종료 중 오류 발생: {e}", level=logging.ERROR) async def _stop_playwright(self): """모든 페이지/브라우저/Playwright 종료""" # 1. 페이지 닫기 if self.browser: for page in self.browser.pages: try: await self.page.close() await self.page.context.close() await self.parsing_page.close() await self.parsing_page.context.close() except: pass # 메모리 추적: 브라우저 종료 전 browser_close_before_mem = psutil.virtual_memory() browser_close_before_mb = browser_close_before_mem.used / 1024 / 1024 try: await self.browser.close() except: pass # 메모리 추적: 브라우저 종료 후 browser_close_after_mem = psutil.virtual_memory() browser_close_after_mb = browser_close_after_mem.used / 1024 / 1024 browser_close_change_mb = browser_close_after_mb - browser_close_before_mb browser_close_change_percent = (browser_close_change_mb / browser_close_before_mb) * 100 if browser_close_before_mb > 0 else 0 self.logger.log( f"메모리 변화 [브라우저 종료]: {browser_close_before_mb:.1f}MB -> {browser_close_after_mb:.1f}MB " f"({browser_close_change_mb:+.1f}MB, {browser_close_change_percent:+.1f}%)", level=logging.DEBUG if abs(browser_close_change_mb) < 50 else logging.INFO ) # 2. Playwright 종료 if self.playwright: try: await self.playwright.stop() except: pass # 3. 루프 종료 if self.loop and not self.loop.is_closed(): self.loop.call_soon_threadsafe(self.loop.stop) def terminate(self): """QThread 종료시 정리""" self.logger.log("크롬 스레드 종료", level=logging.INFO) self.cleanup() self.request_cleanup() # QThread 이벤트루프에서 정리 super().terminate() def cleanup(self): """BrowserController 종료 시 리소스 정리""" try: # image_processor 리소스 정리 if hasattr(self, 'image_processor') and self.image_processor: self.logger.log("image_processor 리소스 정리 중...", level=logging.INFO) self.image_processor.cleanup() except Exception as e: self.logger.log(f"image_processor 리소스 정리 중 오류 발생: {e}", level=logging.ERROR) def request_cleanup(self): """브라우저 리소스 정리 명령을 QThread 이벤트루프에 올림""" if self.loop and not self.loop.is_closed(): asyncio.run_coroutine_threadsafe(self._stop_playwright(), self.loop) else: self.logger.log("정리 요청 시 루프가 없습니다.", level=logging.ERROR) def check_pause(self): """일시 정지 상태라면 재개될 때까지 대기""" self.pause_mutex.lock() if self.is_paused: # 일시정지 시작 시그널 발생 self.pause_confirmed.emit() self.logger.log("일시정지 상태 확인 - 작업 일시중지됨", level=logging.INFO) self.pause_condition.wait(self.pause_mutex) self.pause_mutex.unlock() def pause(self): """스레드 일시 정지""" self.pause_mutex.lock() self.is_paused = True self.pause_mutex.unlock() self.logger.log("스레드가 일시 정지되었습니다.", level=logging.DEBUG) def resume(self): """스레드 재개""" self.pause_mutex.lock() self.is_paused = False self.pause_condition.wakeAll() self.pause_mutex.unlock() self.logger.log("스레드가 재개되었습니다.", level=logging.DEBUG) async def get_focused_element(self, page): """ 현재 포커스된 엘리먼트 반환 (없으면 None) """ handle = await self.page.evaluate_handle("document.activeElement") # BODY 태그인 경우 의미 없음 → None 처리 tagname = await handle.evaluate("el => el.tagName") if tagname.lower() == "body": return None return handle async def restore_focus(self, page, handle): """ 전달받은 handle에 다시 focus (handle이 None이면 무시) """ if handle: try: await handle.evaluate("el => el.focus()") except Exception: pass async def random_human_like_mouse_move(self, page, move_area=None): """ 사람처럼 마우스를 랜덤하게 이동시키는 함수. - page: playwright의 page 객체 - move_area: (x1, y1, x2, y2) tuple. 지정하지 않으면 브라우저 전체창 기준 """ # 브라우저 크기 확인 viewport = page.viewport_size or {"width": 1280, "height": 720} x1, y1 = 0, 0 x2, y2 = viewport["width"], viewport["height"] if move_area: x1, y1, x2, y2 = move_area # 랜덤 출발점 sx = random.randint(x1, x2-1) sy = random.randint(y1, y2-1) ex = random.randint(x1, x2-1) ey = random.randint(y1, y2-1) # 점차적으로 이동 (5~20 step) steps = random.randint(8, 18) await self.page.mouse.move(sx, sy) await asyncio.sleep(random.uniform(0.1, 0.3)) for i in range(steps): nx = int(sx + (ex-sx)*i/steps + random.uniform(-2, 2)) ny = int(sy + (ey-sy)*i/steps + random.uniform(-2, 2)) await self.page.mouse.move(nx, ny) await asyncio.sleep(random.uniform(0.015, 0.04)) # 마지막 위치 await self.page.mouse.move(ex, ey) await asyncio.sleep(random.uniform(0.1, 0.4)) async def random_human_like_keyboard_input(self, page): """ 사람처럼 무의미한 키를 랜덤하게 누르는 함수. - tab, shift, alt, ctrl, esc 등은 영향 거의 없음 - 단, 자동화 중간 입력창 포커스 상태는 주의! """ keys = ["Shift", "Alt", "Control"] # 영향이 적은 키들 (Ctrl -> Control로 변경) key = random.choice(keys) await self.page.keyboard.down(key) await asyncio.sleep(random.uniform(0.05, 0.13)) await self.page.keyboard.up(key) await asyncio.sleep(random.uniform(0.05, 0.13)) async def random_human_behavior(self, page): """ - 60%: 마우스 이동 - 20%: 키보드 의미 없는 입력 - 20%: 아무 행동도 안 함 (휴식/멍 때림) 포커스 백업/복원 포함한 랜덤 인간 행동 """ # 포커스 백업 orig_focus = await self.get_focused_element(page) # 행동 r = random.random() if r < 0.6: await self.random_human_like_mouse_move(page) elif r < 0.8: await self. random_human_like_keyboard_input(page) else: # 아무것도 안하고 잠시 휴식 (랜덤 대기) await asyncio.sleep(random.uniform(0.3, 1.3)) # 약간의 랜덤 대기 후 포커스 복원 await asyncio.sleep(random.uniform(0.03, 0.13)) await self.restore_focus(page, orig_focus) async def save_tab_changes(self, title_infos, tab_name): """ 각 탭에서 변경사항을 저장할 때 사용하는 메서드 Args: title_infos (dict): 상품 정보 딕셔너리 tab_name (str): 탭 이름 (옵션, 가격, 썸네일, 태그, 상세페이지) Returns: str: 저장 결과 ("SAVED", "DELETED", "DUPLICATE_HANDLED", "ERROR") """ save_result = await self.handle_save_with_error_recovery(title_infos, tab_name) if save_result in ["SAVED", "DUPLICATE_HANDLED"]: self.logger.log(f"[{tab_name}] 탭 저장 완료.", level=logging.INFO) elif save_result == "DELETED": self.logger.log(f"[{tab_name}] 탭에서 상품 삭제 완료.", level=logging.INFO) else: self.logger.log(f"[{tab_name}] 탭 저장 실패: {save_result}", level=logging.ERROR) return save_result async def handle_product_deletion_in_loop(self, total_products, items_per_page): """ 상품 삭제 후 처리를 위한 헬퍼 함수 - 총 상품수 감소 - 페이지 스크롤 후 상품 버튼 재수집 Returns: tuple: (updated_total_products, updated_product_buttons) """ total_products -= 1 await asyncio.sleep(1) await self.scroll_page_to_bottom() await asyncio.sleep(1) await self.scroll_page_to_top() if not self.toggle_states['ed_mode']: product_buttons = await self.get_product_edit_buttons_by_template() else: product_infos, product_name_elements = await self.collect_product_info(items_per_page, ed_mode=self.toggle_states['ed_mode'], total_products=total_products) product_buttons = [{"edit_button": name_element, "memo_button": None, "shipping_button": None} for name_element in product_name_elements] return total_products, product_buttons async def restart_main_context(self): """메인 브라우저 컨텍스트(self.page)를 재시작하고 필요한 초기화 작업을 다시 수행합니다.""" try: self.logger.log("메인 브라우저 컨텍스트 재시작 시작", level=logging.INFO) # 1-1. 기존 페이지/컨텍스트 정리 if self.page: try: await self.page.close() except Exception: pass if self.browser: try: await self.browser.close() except Exception: pass # 1-2. 기존 parsing 브라우저 정리 if self.parsing_page: try: await self.parsing_page.close() except Exception: pass if self.parsing_browser: try: await self.parsing_browser.close() except Exception: pass if self.playwright: await self.playwright.stop() # 2) 남은 chrome.exe 프로세스 kill for proc in psutil.process_iter(["pid","name","cmdline"]): name = proc.info["name"] or "" cmd = " ".join(proc.info.get("cmdline") or []) if "chrome" in name.lower() and self.user_data_dir in cmd: try: proc.kill() self.logger.info(f"Killed leftover Chrome PID={proc.pid}") except Exception as e: self.logger.warning(f"Failed to kill PID={proc.pid}: {e}") # 2. 새 컨텍스트 생성 (로그인 세션은 user_data_dir에 보존됨) debug_mode = self.toggle_states.get('debug_mode', False) # browser_path = os.path.join(self.base_path, 'browsers', 'chromium-1200', 'chrome-win', 'chrome.exe') # extension_path = os.path.join(self.base_path, 'browsers', 'extensions', '1.1.199_0') # user_data_dir = os.path.join(self.base_path, 'browsers', 'user_data') user_agent = random.choice([ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.113 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Whale/3.25.232.15 Chrome/125.0.6422.113 Safari/537.36", ]) self.browser = await self.playwright.chromium.launch_persistent_context( user_data_dir=self.user_data_dir, headless=not debug_mode, permissions=["geolocation", "notifications"], geolocation={"latitude": 37.5665, "longitude": 126.9780}, locale="ko-KR", # ================================================ # 🔑 [핵심] 자동화 감지 플래그 제거 # ================================================ ignore_default_args=["--enable-automation"], args=[ '--disable-blink-features=AutomationControlled', # ------------------------------------------------ # 🔑 [핵심 해결책] 보안 해제 옵션 - sec-fetch-site: "none" 문제 해결 # ------------------------------------------------ '--disable-web-security', # CORS 보안 해제 '--disable-features=IsolateOrigins,site-per-process', # 사이트 간 격리 해제 '--disable-site-isolation-trials', # 사이트 격리 시험 기능 해제 # ------------------------------------------------ # 🔑 [핵심] 백그라운드 스크립트가 죽지 않게 설정 # ------------------------------------------------ '--disable-background-timer-throttling', # 백그라운드 타이머 제한 해제 '--disable-renderer-backgrounding', # 렌더러 백그라운드 처리 해제 # ------------------------------------------------ '--no-sandbox', '--disable-infobars', '--disable-popup-blocking', f'--disable-extensions-except={self.extension_path}', f'--load-extension={self.extension_path}', '--start-maximized', '--window-size=1920,1080' ], executable_path=self.browser_path, user_agent=user_agent ) # ------------------------------------------------------------------ # [핵심 해결책] 확장프로그램 URL 납치(Redirect) 로직 (재시작 시에도 적용) # ------------------------------------------------------------------ async def handle_broken_extension_url_restart(route): url = route.request.url if "chrome-extension://" in url: match = re.search(r'chrome-extension://[^/]+(/.+)', url) if match: real_path = match.group(1) new_url = f"https://api.percenty.co.kr{real_path}" self.logger.log(f"🚑 URL 긴급 수정: {url[:100]}... -> {new_url}", level=logging.INFO) await route.continue_(url=new_url) return else: # 경로가 없는 경우도 로깅 self.logger.log(f"⚠️ chrome-extension:// 요청 감지 (경로 없음): {url}", level=logging.WARNING) await route.continue_() await self.browser.route("**/*", handle_broken_extension_url_restart) self.logger.log("✅ 확장프로그램 URL 리다이렉트 핸들러 등록 완료 (재시작)", level=logging.INFO) # 3. 새 페이지 설정 self.page = await self.browser.new_page() self.page.set_default_navigation_timeout(30000) self.page.set_default_timeout(30000) # 🔑 [핵심] navigator.webdriver 속성 숨기기 await self.page.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})") await self.page.add_init_script( """ Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3]}); Object.defineProperty(navigator, 'languages', {get: () => ['ko-KR', 'en-US']}); window.chrome = { runtime: {} }; """ ) await self.page.goto('https://percenty.co.kr') # 불필요한 첫 탭 닫기 if self.browser.pages: await self.browser.pages[0].close() # for p in self.browser.pages: # if p.url == "about:blank": # await p.close() # self.logger.debug("about:blank 탭을 닫았습니다.") # 라우트 재등록 # await self.init_routes(self.page) # 광고/모달 닫기 시도 try: await self.close_ant_modal_dialogs() except Exception: pass # 4. 페이지 이동 (등록상품 or 신규상품) ed_mode = self.toggle_states.get('ed_mode', False) try: if ed_mode: locator = self.registered_product_page_locator await self.page.click(locator) else: locator = self.new_product_page_locator await self.page.click(locator) await self.close_ant_modal_dialogs() except Exception as e: self.logger.log(f"페이지 이동 중 오류: {e}", level=logging.ERROR, exc_info=True) # 5. 그룹 선택 # 재시작 상황에서는 curr_group_idx 우선 group_index = getattr(self, "curr_group_idx", None) if group_index is None: group_index = self.toggle_states.get('group_index', None) if group_index is not None: try: await self.select_group_index(group_index) except Exception as e: self.logger.log(f"그룹 선택 중 오류: {e}", level=logging.ERROR, exc_info=True) # 6. 핸들러 페이지 업데이트 try: self.titleGenerator.update_page(self.page ,self.toggle_states) self.optionHandler.update_page(self.page ,self.toggle_states, self.image_processor) self.tagsHandler.update_page(self.page ,self.toggle_states) self.thumbnailHandler.update_page(self.page ,self.toggle_states, self.image_processor) self.priceHandler.update_page(self.page ,self.toggle_states) self.detailHandler.update_page(self.page ,self.toggle_states, self.image_processor) except Exception as e: self.logger.log(f"핸들러 업데이트 오류: {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) await self.save_error_screenshot() # 3. parsing_browser 재생성 self.parsing_browser = await self.playwright.chromium.launch( executable_path=self.browser_path, headless=True, args=[ '--disable-blink-features=AutomationControlled', '--no-sandbox', '--disable-infobars', '--disable-dev-shm-usage', '--disable-gpu', '--start-minimized', '--incognito', '--window-position=-32000,-32000', ] ) self.parsing_context = await self.parsing_browser.new_context( locale="ko-KR", user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", viewport={"width": 1280, "height": 720}, extra_http_headers={"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7"}, ) self.parsing_page = await self.parsing_context.new_page() self.parsing_page.set_default_navigation_timeout(30000) self.parsing_page.set_default_timeout(30000) # 2) PapagoTranslator 에 새 페이지 전달 self.papago_translator.update_page(self.parsing_page) # 3) 파파고 번역기 재초기화 (새 페이지로 파파고 사이트 접속) try: self.papago_translator.initialized = False # 초기화 상태 리셋 await self.papago_translator.initialize() # 파파고 사이트로 이동 및 초기화 self.logger.log("파파고 번역기 재초기화 완료", level=logging.INFO) except Exception as e: self.logger.log(f"파파고 번역기 재초기화 실패: {e}", level=logging.ERROR, exc_info=True) async def resume_after_restart(self): """브라우저 재시작 후 이전 진행 위치(그룹·페이지·상품)를 복원한다.""" try: # 1) 그룹 복원 if getattr(self, "curr_group_idx", None) is not None: await self.select_group_index(self.curr_group_idx) # 2) 페이지 복원 (1페이지가 아닌 경우에만 이동) if getattr(self, "curr_page_idx", 1) > 1: for _ in range(1, self.curr_page_idx): success = await self.go_to_next_page() if not success: break # 3) Lazy-loading 대비: 페이지 하단까지 한번 스크롤해 요소 로드 → 다시 상단 복귀 await self.scroll_page_to_bottom() await asyncio.sleep(1) await self.scroll_page_to_top() # 4) 상품 인덱스 초기화 (재수집 후 처음부터 시작) # self.curr_product_idx = 0 # self.curr_product_idx 는 재시작 직전까지 완료한 상품 개수를 유지해 # 재수집 후 enumerate(start=self.curr_product_idx+1) 로 이어서 처리한다. self.logger.log(f"재시작 복원 완료 → 페이지:{self.curr_page_idx}, 상품인덱스:{self.curr_product_idx}", level=logging.INFO) except Exception as e: self.logger.log(f"resume_after_restart 오류: {e}", level=logging.ERROR, exc_info=True) async def stop_browser(self): """ 1) Playwright 컨텍스트/Playwright 인스턴스 종료 2) user_data_dir 인자를 가진 모든 chrome.exe 프로세스 강제 kill """ # 1) Playwright 컨텍스트 종료 try: if self.browser_context: await self.browser_context.close() self.logger.debug("BrowserContext closed") except Exception as e: self.logger.warning(f"BrowserContext close error: {e}") # 2) Playwright 자체 종료 try: if self.playwright: await self.playwright.stop() self.logger.debug("Playwright stopped") except Exception as e: self.logger.warning(f"Playwright stop error: {e}") # 3) 남아 있는 chrome.exe 프로세스 kill (user_data_dir 필터링) for proc in psutil.process_iter(["pid","name","cmdline"]): name = proc.info["name"] or "" cmd = " ".join(proc.info.get("cmdline") or []) if "chrome" in name.lower() and self.user_data_dir in cmd: try: proc.kill() self.logger.info(f"Killed leftover Chrome PID={proc.pid}") except Exception as e: self.logger.warning(f"Failed to kill PID={proc.pid}: {e}") async def check_server_health(self, url: str, cache_key: str = None) -> bool: """ 서버 URL의 상태를 체크합니다. Args: url: 체크할 서버 URL cache_key: 캐시 키 (None이면 url을 키로 사용) Returns: bool: 서버가 정상이면 True, 아니면 False """ if not url: return False cache_key = cache_key or url current_time = time.time() # 캐시된 결과 확인 if cache_key in self._server_health_cache: status, timestamp = self._server_health_cache[cache_key] if current_time - timestamp < self._health_check_cache_ttl: self.logger.log(f"서버 헬스체크 캐시 사용: {url} -> {status}", level=logging.DEBUG) return status try: # 헬스체크 엔드포인트 시도 health_endpoints = [ f"{url}/health", f"{url}/api/health", f"{url}/status", f"{url}/api/status", url # 기본 URL ] for endpoint in health_endpoints: try: response = await asyncio.get_event_loop().run_in_executor( None, lambda: requests.get(endpoint, timeout=self._health_check_timeout) ) if response.status_code in [200, 404]: # 404도 서버가 살아있다는 뜻 self._server_health_cache[cache_key] = (True, current_time) self.logger.log(f"서버 헬스체크 성공: {endpoint}", level=logging.DEBUG) return True except requests.exceptions.RequestException: continue # 모든 엔드포인트 실패 self._server_health_cache[cache_key] = (False, current_time) self.logger.log(f"서버 헬스체크 실패: {url}", level=logging.WARNING) return False except Exception as e: self._server_health_cache[cache_key] = (False, current_time) self.logger.log(f"서버 헬스체크 오류: {url} - {e}", level=logging.ERROR) return False async def refresh_server_urls_if_needed(self) -> bool: """ 서버 URL들의 상태를 체크하고 필요시 재조회하여 업데이트합니다. Returns: bool: 업데이트가 발생했으면 True, 아니면 False """ try: # 현재 URL들 가져오기 current_inpaint_url = self.toggle_states.get('request_inpainting_server_url') current_rembg_url = self.toggle_states.get('request_rembg_server_url') current_rembg_local_url = self.toggle_states.get('request_rembg_server_url_local') urls_to_check = { 'request_inpainting_server_url': current_inpaint_url, 'request_rembg_server_url': current_rembg_url, 'request_rembg_server_url_local': current_rembg_local_url } # 서버 상태 체크 health_results = {} for key, url in urls_to_check.items(): if url: health_results[key] = await self.check_server_health(url, key) else: health_results[key] = False # 비정상적인 서버가 있는지 확인 failed_servers = [key for key, status in health_results.items() if not status and urls_to_check[key]] if failed_servers: self.logger.log(f"비정상 서버 발견: {failed_servers}", level=logging.WARNING) # locator_manager를 통해 새로운 URL 조회 updated = False new_urls = {} for key in failed_servers: # 'Global' 섹션에서 재조회 new_url = self.locator_manager.get_locator('Global', key) if new_url and new_url != urls_to_check[key]: # 새 URL의 상태도 체크 if await self.check_server_health(new_url, f"{key}_new"): self.toggle_states[key] = new_url new_urls[key] = new_url updated = True self.logger.log(f"서버 URL 업데이트: {key} -> {new_url}", level=logging.INFO) else: self.logger.log(f"새 서버 URL도 비정상: {key} -> {new_url}", level=logging.WARNING) # # 이미지 워커의 toggle_states 업데이트 # if updated and hasattr(self, 'image_worker_mgr') and self.image_worker_mgr is not None: # self.image_worker_mgr.update_states(toggle_states=self.toggle_states) # self.logger.log("이미지 워커 toggle_states 업데이트 완료", level=logging.INFO) return updated else: self.logger.log("모든 서버가 정상 상태입니다", level=logging.DEBUG) return False except Exception as e: self.logger.log(f"서버 URL 갱신 중 오류: {e}", level=logging.ERROR, exc_info=True) return False def clear_health_cache(self): """헬스체크 캐시를 클리어합니다.""" self._server_health_cache.clear() self.logger.log("서버 헬스체크 캐시 클리어됨", level=logging.DEBUG) def set_fallback_servers(self, server_type: str, fallback_urls: list): """폴백 서버 URL들을 설정합니다.""" if server_type in self._fallback_servers: self._fallback_servers[server_type] = fallback_urls self.logger.log(f"폴백 서버 설정됨 - {server_type}: {fallback_urls}", level=logging.INFO) def get_server_performance(self, server_type: str) -> dict: """서버 성능 정보를 반환합니다.""" primary_url = self.toggle_states.get(server_type) fallback_urls = self._fallback_servers.get(server_type, []) performance_info = {} for url in [primary_url] + fallback_urls: if url and url in self._server_performance: times = self._server_performance[url] if times: performance_info[url] = { 'avg_response_time': sum(times) / len(times), 'min_response_time': min(times), 'max_response_time': max(times), 'sample_count': len(times) } return performance_info async def check_server_health_with_performance(self, url: str, cache_key: str = None) -> Tuple[bool, float]: """ 서버 URL의 상태와 응답시간을 체크합니다. Returns: Tuple[bool, float]: (서버 상태, 응답 시간) """ if not url: return False, float('inf') cache_key = cache_key or url start_time = time.time() try: response = await asyncio.get_event_loop().run_in_executor( None, lambda: requests.get(url + "/health", timeout=self._health_check_timeout) ) response_time = time.time() - start_time if response.status_code in [200, 404]: # 성능 데이터 저장 if cache_key not in self._server_performance: self._server_performance[cache_key] = [] perf_list = self._server_performance[cache_key] perf_list.append(response_time) if len(perf_list) > 10: perf_list.pop(0) self._server_health_cache[cache_key] = (True, time.time()) return True, response_time return False, response_time except Exception: response_time = time.time() - start_time self._server_health_cache[cache_key] = (False, time.time()) return False, response_time def start_UploadSelectedMarketsJob_task(self, upload_conditions=None, work_queue=None, use_extension=False): """ 업로드 작업을 이벤트 루프에서 실행 :param upload_conditions: 업로드 조건 딕셔너리 :param work_queue: 작업 큐 :param use_extension: True면 확장 프로그램 기반 업로드(봇 탐지 우회), False면 기존 Playwright 방식 """ if self.loop and not self.loop.is_closed(): if use_extension: # 🔌 확장 프로그램 기반 업로드 (봇 탐지 우회용) self.logger.log("🔌 확장 프로그램 기반 업로드 모드로 실행합니다.", level=logging.INFO) asyncio.run_coroutine_threadsafe(self.ed_bulk_upload_via_extension(upload_conditions, work_queue), self.loop) else: # 기존 Playwright 방식 업로드 self.logger.log("📋 기존 Playwright 방식으로 업로드합니다.", level=logging.INFO) asyncio.run_coroutine_threadsafe(self.ed_bulk_upload_selected_markets(upload_conditions, work_queue), self.loop) else: self.logger.log("이벤트 루프가 초기화되지 않았거나 이미 종료되었습니다.", level=logging.ERROR) async def apply_upload_conditions_to_page(self, price_policy: int, smartstore_policy: bool, target_markets: list = None): """ 업로드조건을 웹페이지에 실제로 적용 :param price_policy: 가격 정책 (1: 대표옵션가, 2: 최저옵션가) :param smartstore_policy: 스마트스토어 전용 정책 사용 여부 :param target_markets: 현재 업로드 대상 마켓 리스트 (예: ['ss', 'coupang']). None이면 전체로 간주. """ try: self.logger.log(f"🔧 웹페이지에 업로드조건 적용 시작 - 가격정책: {price_policy}, 스마트스토어정책: {smartstore_policy}", level=logging.INFO) # 1. 가격정책 드롭다운 변경 await self.apply_price_policy_dropdown(price_policy) # 2. 스마트스토어정책 체크박스 변경 # 스마트스토어(ss)가 타겟에 포함되어 있거나, 타겟 정보가 없으면(전체) 적용 should_apply_ss = True if target_markets is not None: if 'ss' not in target_markets: should_apply_ss = False if should_apply_ss: await self.apply_smartstore_policy_checkbox(smartstore_policy) else: self.logger.log("⏭️ 스마트스토어가 업로드 대상이 아니므로 정책 설정을 건너뜁니다.", level=logging.DEBUG) self.logger.log("✅ 웹페이지 업로드조건 적용 완료", level=logging.INFO) except Exception as e: self.logger.log(f"❌ 웹페이지 업로드조건 적용 실패: {e}", level=logging.ERROR, exc_info=True) async def apply_price_policy_dropdown(self, price_policy: int): """가격정책 드롭다운 변경 (1: 최대 업로드 갯수 기준, 2: 최저 옵션가 기준, 3: 상품별 각각의 대표가격 설정)""" try: # 가격정책 옵션 텍스트 매핑 policy_options = { 1: "최대 업로드 갯수 기준", 2: "최저 옵션가 기준", 3: "상품별 각각의 대표 가격 설정 적용" } target_option = policy_options.get(price_policy, policy_options[1]) self.logger.log(f"📋 가격정책 변경 시도: {target_option} (정책번호: {price_policy})", level=logging.DEBUG) # 현재 선택된 값 확인 current_selection = self.page.locator("div.ant-modal-content .ant-select-selection-item") try: current_text = await current_selection.inner_text(timeout=5000) if target_option in current_text: self.logger.log(f"✅ 가격정책이 이미 '{target_option}'로 설정되어 있음", level=logging.DEBUG) return except Exception: pass # 드롭다운 클릭하여 열기 dropdown_selector = "div.ant-modal-content .ant-select" dropdown = self.page.locator(dropdown_selector) await dropdown.click() self.logger.log("📋 가격정책 드롭다운 열기", level=logging.DEBUG) # 드롭다운 옵션 리스트 대기 await asyncio.sleep(1) # 충분한 시간 확보 # 더 안전한 방식으로 옵션 선택: 드롭다운 내의 옵션들을 찾기 option_elements = await self.page.query_selector_all(".ant-select-dropdown .ant-select-item") self.logger.log(f"📋 발견된 옵션 수: {len(option_elements)}", level=logging.DEBUG) for i, option_element in enumerate(option_elements): try: option_text = await option_element.inner_text() self.logger.log(f"📋 옵션 {i+1}: '{option_text}'", level=logging.DEBUG) if target_option in option_text: await option_element.click() self.logger.log(f"📋 가격정책 옵션 클릭 성공: {target_option}", level=logging.DEBUG) break except Exception as e: continue else: self.logger.log(f"❌ 가격정책 옵션을 찾지 못함: {target_option}", level=logging.ERROR) return # 변경 확인 await asyncio.sleep(0.5) try: current_text = await current_selection.inner_text(timeout=5000) if target_option in current_text: self.logger.log(f"✅ 가격정책 변경 완료: {target_option}", level=logging.INFO) else: self.logger.log(f"⚠️ 가격정책 변경 확인 실패. 현재: '{current_text}', 목표: '{target_option}'", level=logging.WARNING) except Exception as e: self.logger.log(f"⚠️ 가격정책 변경 확인 중 오류: {e}", level=logging.WARNING) except Exception as e: self.logger.log(f"❌ 가격정책 드롭다운 변경 실패: {e}", level=logging.ERROR) async def apply_smartstore_policy_checkbox(self, smartstore_policy: bool): """스마트스토어정책 체크박스 변경""" try: self.logger.log(f"📋 스마트스토어정책 변경 시도: {'ON' if smartstore_policy else 'OFF'}", level=logging.DEBUG) # 더 구체적인 선택자 사용: 스마트스토어 마켓 정책 체크박스만 선택 try: # 방법 1: get_by_role을 사용해서 구체적으로 선택 (로그에서 확인된 정확한 이름 사용) smartstore_checkbox = self.page.get_by_role("checkbox", name="스마트스토어 마켓 정책 적용하기- 1") # 현재 체크 상태 확인 is_currently_checked = await smartstore_checkbox.is_checked() self.logger.log(f"📋 현재 스마트스토어정책 체크 상태: {'ON' if is_currently_checked else 'OFF'}", level=logging.DEBUG) if is_currently_checked == smartstore_policy: self.logger.log(f"✅ 스마트스토어정책이 이미 {'ON' if smartstore_policy else 'OFF'}으로 설정되어 있음", level=logging.DEBUG) return # 체크박스 상태 변경 await smartstore_checkbox.click() self.logger.log(f"📋 스마트스토어정책 체크박스 {'ON' if smartstore_policy else 'OFF'} 클릭 완료", level=logging.DEBUG) # 변경 확인 await asyncio.sleep(0.5) final_checked = await smartstore_checkbox.is_checked() if final_checked == smartstore_policy: self.logger.log(f"✅ 스마트스토어정책 변경 완료: {'ON' if smartstore_policy else 'OFF'}", level=logging.INFO) else: self.logger.log(f"⚠️ 스마트스토어정책 변경 확인 실패. 현재: {'ON' if final_checked else 'OFF'}, 목표: {'ON' if smartstore_policy else 'OFF'}", level=logging.WARNING) except Exception as e1: self.logger.log(f"❌ 방법 1 실패, 방법 2 시도: {e1}", level=logging.DEBUG) # 방법 2: 더 구체적인 선택자 사용 try: # 모든 체크박스를 찾아서 텍스트로 구분 all_checkboxes = await self.page.query_selector_all("div.ant-modal-content input[type='checkbox']") self.logger.log(f"📋 발견된 체크박스 수: {len(all_checkboxes)}", level=logging.DEBUG) target_checkbox = None for i, checkbox in enumerate(all_checkboxes): try: # 체크박스 근처의 라벨 텍스트 확인 parent_element = await checkbox.query_selector("..") if parent_element: parent_text = await parent_element.inner_text() self.logger.log(f"📋 체크박스 {i+1} 텍스트: '{parent_text}'", level=logging.DEBUG) if "스마트스토어 마켓 정책" in parent_text or "마켓 정책" in parent_text: target_checkbox = checkbox self.logger.log(f"📋 스마트스토어정책 체크박스 발견: {i+1}번째", level=logging.DEBUG) break except Exception: continue if target_checkbox: is_currently_checked = await target_checkbox.is_checked() self.logger.log(f"📋 현재 스마트스토어정책 체크 상태: {'ON' if is_currently_checked else 'OFF'}", level=logging.DEBUG) if is_currently_checked != smartstore_policy: await target_checkbox.click() self.logger.log(f"📋 스마트스토어정책 체크박스 {'ON' if smartstore_policy else 'OFF'} 클릭 완료", level=logging.DEBUG) await asyncio.sleep(0.5) final_checked = await target_checkbox.is_checked() if final_checked == smartstore_policy: self.logger.log(f"✅ 스마트스토어정책 변경 완료: {'ON' if smartstore_policy else 'OFF'}", level=logging.INFO) else: self.logger.log(f"⚠️ 스마트스토어정책 변경 확인 실패", level=logging.WARNING) else: self.logger.log("❌ 스마트스토어정책 체크박스를 찾지 못함", level=logging.ERROR) except Exception as e2: self.logger.log(f"❌ 방법 2도 실패: {e2}", level=logging.ERROR) except Exception as e: self.logger.log(f"❌ 스마트스토어정책 체크박스 변경 실패: {e}", level=logging.ERROR) # #------------------------------------ # # 이미지 처리 관련 코드 # #------------------------------------ # from multiprocessing import Process # import uuid # import time # import queue # import logging # import sys # import gc # import psutil # import threading # from src.modules.image_worker import worker_main # worker_main 이 들어있는 별도 파일 # class ImageWorkerManager: # def __init__(self, logger, base_dir, toggle_states, unwanted_words, authenticated_by_admin, image_worker_fatal): # """멀티프로세스 이미지 작업 매니저. # logger : Logger 인스턴스(커스텀 Logger 혹은 logging.Logger) # base_dir : 프로젝트 루트(로그 폴더 등의 기본 위치) # toggle_states : 현재 토글 딕셔너리 (워커에 전달) # """ # from multiprocessing import Process, Queue # import logging, os # # ─── 로깅 설정 ────────────────────────────────────────── # # 커스텀 Logger(LoggerModule.Logger) 는 .logger 속성에 실제 logging.Logger 를 보유 # if hasattr(logger, "logger"): # py_logger = logger.logger # else: # py_logger = logger # log_path = None # for h in getattr(py_logger, "handlers", []): # if isinstance(h, logging.FileHandler): # log_path = getattr(h, "baseFilename", None) # if log_path: # break # # 파일핸들러를 찾지 못하면 base_dir/logs/ImageWorker.log 로 기록 # if log_path is None: # log_dir = os.path.join(base_dir, "logs") if base_dir else os.getcwd() # os.makedirs(log_dir, exist_ok=True) # log_path = os.path.join(log_dir, "ImageWorker.log") # # ─── 필드 보관 ─────────────────────────────────────────── # self.logger = logger # self.toggle_states = toggle_states # self.base_dir = base_dir # self.unwanted_words = unwanted_words # self.authenticated_by_admin = authenticated_by_admin # self.first_call = True # # 동시 접근 제어용 Lock (재시작 중 큐 접근 보호) # self._lock = threading.Lock() # self.restart_count = 0 # self.max_restart = 5 # self.image_worker_fatal = image_worker_fatal # # 정책 로드(토글 기반) # self._success_count = 0 # self._consecutive_mem_errors = 0 # self._periodic_restart_every = 0 # self._mem_restart_threshold = 0 # self._mem_error_escalate_after = 2 # self._load_worker_policies() # # ─── 세대 관리 및 UID 상관관계 ─────────────────────────── # self._generation = 0 # 워커 세대 번호 # self._pending_requests = {} # {uid: (future, generation, timestamp)} # self._restart_in_progress = False # 재시작 진행 중 플래그 # self._restart_scheduled = False # 재시작 예약 플래그 # self._restart_timer = None # 재시작 예약 타이머 # self._heartbeat_timer = None # 하트비트 타이머 # self._last_heartbeat = 0 # 마지막 하트비트 응답 시간 # self._consecutive_failures = 0 # 연속 실패 횟수 # self._circuit_breaker_threshold = 3 # 서킷브레이커 임계값 # self._circuit_breaker_last_failure_time = 0 # 마지막 실패 시간 # self._circuit_breaker_timeout = 600 # 서킷브레이커 자동 해제 시간 (10분) # self._backoff_delay = 1 # 백오프 지연 시간 (초) # self._max_backoff = 30 # 최대 백오프 지연 시간 (초) # # ─── 프로세스/큐 생성 ─────────────────────────────────── # self.task_q = Queue() # self.result_q = Queue() # self.log_q = Queue() # 로그 큐 추가 # self.proc_args = ( # self.task_q, # self.result_q, # self.log_q, # 로그 큐 추가 # log_path, # self.base_dir, # NEW # self.toggle_states, # NEW # self.unwanted_words, # self.authenticated_by_admin # ) # self._spawn_proc() # # def _spawn_proc(self): # # self.proc = Process(target=worker_main, args=self.proc_args, daemon=True) # # self.proc.start() # # ───────── 새 프로세스 스폰 ───────── # def _spawn_proc(self): # # 세대 증가 및 이전 세대 요청 정리 # self._generation += 1 # self._cleanup_old_generation_requests() # # 재시작 예약 취소 # self._cancel_restart_schedule() # self.proc = Process(target=worker_main, # args=self.proc_args, # daemon=True) # self.proc.start() # # ② READY 메시지 수신 대기: 최대 90초, 실패 시 즉시 재시작 # # 이전 프로세스가 남긴 메시지가 있을 수 있으므로 큐 비우기 # try: # while not self.result_q.empty(): # self.result_q.get_nowait() # except Exception: # pass # ready = None # deadline = time.time() + 60 # 초기화가 길어질 수 있으므로 60초까지 대기 # while time.time() < deadline: # try: # msg = self.result_q.get(timeout=1) # if msg.get("id") == "__READY__": # ready = msg # break # except queue.Empty: # continue # if ready is None: # self.logger.log("ImageWorker READY wait fail", level=logging.ERROR) # # 초기 기동 실패 시 프로세스 정리 후 재시작 # try: # if self.proc.is_alive(): # self.proc.terminate() # self.proc.join(timeout=3) # except Exception as e: # self.logger.log(f"프로세스 정리 중 오류: {e}", level=logging.WARNING) # # 초기화 실패 시 대기 중인 요청들 정리 # self._cleanup_old_generation_requests() # # 재시작은 호출자에게 위임 (무한 루프 방지) # self.logger.log("ImageWorker 초기화 실패 - 수동 재시작 필요", level=logging.ERROR) # return # else: # self.logger.log(f"ImageWorker READY 수신: {ready.get('data')}", level=logging.INFO) # # READY 수신 후 하트비트 시작 및 재시작 예약 초기화 # self._start_heartbeat() # self._reset_restart_flags() # # READY 수신 후 일정 시간 재시작 방지 (워커가 안정화될 때까지) # self._last_ready_time = time.time() # self._ready_stabilization_period = 60 # 60초 동안 재시작 방지 # # ───────── 메모리 모니터링(매니저 전담) ───────── # def get_memory_info(self) -> Optional[Dict]: # try: # if not getattr(self, 'proc', None) or not self.proc.is_alive(): # return None # p = psutil.Process(self.proc.pid) # mi = p.memory_info() # return { # 'pid': self.proc.pid, # 'rss_mb': mi.rss / 1024 / 1024, # 'vms_mb': mi.vms / 1024 / 1024, # 'cpu_percent': p.cpu_percent(interval=0.1), # 'memory_percent': p.memory_percent(), # 'num_threads': p.num_threads(), # } # except Exception as e: # self.logger.log(f"ImageWorker 메모리 정보 조회 실패: {e}", level=logging.ERROR) # return None # def log_memory_status(self, context: str = "") -> None: # info = self.get_memory_info() # if info: # ctx = f" [{context}]" if context else "" # self.logger.log( # f"워커 메모리 상태{ctx}: PID={info['pid']}, RSS={info['rss_mb']:.1f}MB, " # f"VMS={info['vms_mb']:.1f}MB, CPU={info['cpu_percent']:.1f}%, Threads={info['num_threads']}", # level=logging.DEBUG, # ) # else: # self.logger.log(f"워커 메모리 상태 조회 실패{context}", level=logging.WARNING) # def _cleanup_old_generation_requests(self): # """이전 세대의 미해결 요청들을 정리하고 실패 처리""" # current_time = time.time() # old_requests = [] # for uid, (future, generation, timestamp) in self._pending_requests.items(): # if generation < self._generation: # old_requests.append(uid) # # Future가 완료되지 않았다면 타임아웃으로 처리 # if not future.done(): # future.set_exception(TimeoutError(f"워커 재시작으로 인한 요청 취소 (세대: {generation} -> {self._generation})")) # # 이전 세대 요청 제거 # for uid in old_requests: # del self._pending_requests[uid] # if old_requests: # self.logger.log(f"이전 세대({self._generation-1})의 {len(old_requests)}개 미해결 요청 정리 완료", level=logging.INFO) # def _cancel_restart_schedule(self): # """재시작 예약 취소""" # if self._restart_timer: # try: # self._restart_timer.cancel() # except Exception: # pass # self._restart_timer = None # self._restart_scheduled = False # def _reset_restart_flags(self): # """재시작 관련 플래그 초기화""" # self._restart_in_progress = False # self._restart_scheduled = False # self._consecutive_failures = 0 # self._backoff_delay = 1 # def _start_heartbeat(self): # """하트비트 타이머 시작""" # if self._heartbeat_timer: # try: # self._heartbeat_timer.cancel() # except Exception: # pass # # 30초마다 하트비트 전송 # self._heartbeat_timer = threading.Timer(30.0, self._send_heartbeat) # self._heartbeat_timer.daemon = True # self._heartbeat_timer.start() # def _send_heartbeat(self): # """하트비트 전송""" # try: # # 워커가 작업 중이면 하트비트 건너뛰기 (정상 동작) # if len(self._pending_requests) > 0: # oldest_request_time = min(timestamp for _, _, timestamp in self._pending_requests.values()) # if time.time() - oldest_request_time < 90: # 1.5분 이내 # self.logger.log("워커 작업 중 → 하트비트 건너뜀", level=logging.DEBUG) # self._heartbeat_timer = threading.Timer(30.0, self._send_heartbeat) # self._heartbeat_timer.daemon = True # self._heartbeat_timer.start() # return # else: # self.logger.log("작업이 1.5분 초과 → 하트비트 강제 체크", level=logging.WARNING) # if self.proc.is_alive() and not self._restart_in_progress: # uid = f"__HEARTBEAT_{int(time.time())}__" # self.task_q.put({"id": uid, "cmd": "__PING__", "kwargs": {}}) # # 하트비트 응답 대기 (30초) # start_time = time.time() # while time.time() - start_time < 30: # try: # msg = self.result_q.get(timeout=0.1) # if msg.get("id") == uid and msg.get("data") == "__PONG__": # self._last_heartbeat = time.time() # # 다음 하트비트 예약 # self._heartbeat_timer = threading.Timer(30.0, self._send_heartbeat) # self._heartbeat_timer.daemon = True # self._heartbeat_timer.start() # return # except queue.Empty: # continue # # 하트비트 응답 없음 - 워커 문제로 판단 # if self.proc.is_alive(): # self.logger.log("하트비트 응답 없음 (30초) - 워커 재시작 필요", level=logging.WARNING) # self._schedule_restart("heartbeat_timeout") # else: # self.logger.log("워커 프로세스 죽음 감지 - 즉시 재시작", level=logging.WARNING) # self._schedule_restart("worker_died") # except Exception as e: # self.logger.log(f"하트비트 전송 중 오류: {e}", level=logging.ERROR) # def _schedule_restart(self, cause="unknown"): # """재시작 예약 (백오프 적용)""" # if self._restart_in_progress or self._restart_scheduled: # return # # READY 수신 후 안정화 기간 동안 재시작 방지 # if hasattr(self, '_last_ready_time') and self._last_ready_time: # stabilization_remaining = self._ready_stabilization_period - (time.time() - self._last_ready_time) # if stabilization_remaining > 0: # self.logger.log(f"READY 수신 후 안정화 기간 중 - 재시작 취소 (남은 시간: {stabilization_remaining:.1f}초)", level=logging.WARNING) # return # self._restart_scheduled = True # delay = min(self._backoff_delay, self._max_backoff) # # 재시작 원인별 상세 정보 로깅 # memory_info = f", 메모리: {self._get_memory_usage():.1f}%" if hasattr(self, '_get_memory_usage') else "" # pending_info = f", 대기중 요청: {len(self._pending_requests)}개" if hasattr(self, '_pending_requests') else "" # consecutive_info = f", 연속 실패: {self._consecutive_failures}회" if hasattr(self, '_consecutive_failures') else "" # self.logger.log(f"워커 재시작 예약: {cause} (지연: {delay}초, 백오프: {self._backoff_delay}초{memory_info}{pending_info}{consecutive_info})", level=logging.WARNING) # self._restart_timer = threading.Timer(delay, self._execute_restart, args=[cause]) # self._restart_timer.daemon = True # self._restart_timer.start() # # 백오프 지연 시간 증가 (지수적 백오프) # self._backoff_delay = min(self._backoff_delay * 2, self._max_backoff) # def _execute_restart(self, cause): # """재시작 실행""" # if self._restart_in_progress: # return # self._restart_in_progress = True # # 재시작 실행 시 상세 정보 로깅 # memory_info = f" (메모리: {self._get_memory_usage():.1f}%)" if hasattr(self, '_get_memory_usage') else "" # worker_status = f" (워커 상태: {'실행중' if self.proc and self.proc.is_alive() else '중단됨'})" if hasattr(self, 'proc') else "" # self.logger.log(f"워커 재시작 실행: {cause}{memory_info}{worker_status}", level=logging.INFO) # try: # # 재시작 원인별 대기 시간 설정 # restart_delays = { # "timeout": 5, # "memory_threshold": 3, # "memory_error": 10, # 메모리 에러는 더 긴 대기 # "error": 5, # "periodic": 2 # } # delay = restart_delays.get(cause, 3) # # 프로세스 안전하게 종료 # if hasattr(self, 'proc') and self.proc and self.proc.is_alive(): # try: # self.logger.log(f"워커 프로세스 종료 시도 (PID: {self.proc.pid})", level=logging.DEBUG) # self.proc.terminate() # # 정상 종료 대기 (더 긴 시간) # if not self.proc.join(timeout=8): # self.logger.log("정상 종료 실패 → 강제 종료", level=logging.WARNING) # self.proc.kill() # self.proc.join(timeout=2) # if self.proc.is_alive(): # self.logger.log("⚠️ 프로세스가 여전히 살아있음", level=logging.WARNING) # else: # self.logger.log("✅ 워커 프로세스 종료 완료", level=logging.DEBUG) # except Exception as e: # self.logger.log(f"프로세스 종료 중 오류: {e}", level=logging.WARNING) # # 안전한 대기 (메모리 안정화) # if delay > 0: # self.logger.log(f"메모리 안정화 대기: {delay}초", level=logging.DEBUG) # import time # time.sleep(delay) # # 강화된 메모리 정리 # self.logger.log("ImageWorker으로 인한 강화된 메모리 정리", level=logging.INFO) # # 1단계: 메모리 상태 확인 # mem1 = psutil.virtual_memory() # self.logger.log(f"정리 전, 메모리 사용량: {mem1.percent}% 사용중 ({mem1.used/1024/1024:.1f}MB / {mem1.total/1024/1024:.1f}MB)", level=logging.DEBUG) # # 2단계: Python GC (여러 번 실행으로 더 강력한 정리) # import gc # total_collected = 0 # for i in range(3): # collected = gc.collect() # total_collected += collected # if collected > 0: # self.logger.log(f"GC 실행 {i+1}: {collected}개 객체 정리", level=logging.DEBUG) # if total_collected > 0: # self.logger.log(f"총 {total_collected}개 객체 정리 완료", level=logging.INFO) # # 3단계: 메모리 상태 재확인 # mem2 = psutil.virtual_memory() # self.logger.log(f"정리 후, 메모리 사용량: {mem2.percent}% 사용중 ({mem2.used/1024/1024:.1f}MB / {mem2.total/1024/1024:.1f}MB)", level=logging.DEBUG) # # 4단계: 메모리 해제 효과 분석 # memory_freed_mb = (mem1.used - mem2.used) / 1024 / 1024 # memory_freed_percent = mem1.percent - mem2.percent # if memory_freed_mb > 0: # self.logger.log(f"✅ 메모리 해제 성공: {memory_freed_mb:.1f}MB ({memory_freed_percent:.1f}%) 해제", level=logging.INFO) # elif memory_freed_mb < 0: # self.logger.log(f"⚠️ 메모리 증가: {abs(memory_freed_mb):.1f}MB ({abs(memory_freed_percent):.1f}%) 증가", level=logging.WARNING) # else: # self.logger.log(f"ℹ️ 메모리 변화 없음", level=logging.INFO) # # 5단계: 메모리 사용량이 높은 경우 경고 # if mem2.percent > 80: # self.logger.log(f"🚨 경고: 메모리 사용량이 높습니다 ({mem2.percent}%)", level=logging.WARNING) # elif mem2.percent > 60: # self.logger.log(f"⚠️ 주의: 메모리 사용량이 중간 수준입니다 ({mem2.percent}%)", level=logging.INFO) # # 🔧 큐를 새로 생성하여 오염된 큐 상태 해결 # self.logger.log("🔄 재시작용 새 큐 생성 중...", level=logging.INFO) # # 기존 큐 정리 # if hasattr(self, 'task_q') and self.task_q: # try: # # 기존 큐에 남은 메시지들 정리 # while not self.task_q.empty(): # try: # self.task_q.get_nowait() # except: # break # except: # pass # if hasattr(self, 'result_q') and self.result_q: # try: # while not self.result_q.empty(): # try: # self.result_q.get_nowait() # except: # break # except: # pass # # 새 큐 생성 # import multiprocessing # self.task_q = multiprocessing.Queue() # self.result_q = multiprocessing.Queue() # self.log_q = multiprocessing.Queue() # self.logger.log("✅ 새 큐 생성 완료", level=logging.INFO) # # 🧹 _pending_requests 정리 (재시작 시) # with self._lock: # old_count = len(self._pending_requests) # self._pending_requests.clear() # if old_count > 0: # self.logger.log(f"🗑️ 재시작 시 {old_count}개 pending requests 정리 완료", level=logging.INFO) # # 재시작 원인별 안전 설정 적용 # updated_toggle_states = self.toggle_states.copy() # # 메모리/에러로 인한 재시작 시 더 안전한 설정 적용 # if cause in ["memory_error", "timeout", "error"]: # self.logger.log(f"🔒 {cause}로 인한 재시작 → 안전 모드 설정 적용", level=logging.INFO) # # GPU 기능 비활성화 # updated_toggle_states['use_cuda'] = False # updated_toggle_states['migan_use_cuda'] = False # # 모든 이미지 처리를 CPU 모드로 전환 # updated_toggle_states['optionIMGTrans_type'] = 'CPU' # updated_toggle_states['detail_IMGTrans_type'] = 'CPU' # updated_toggle_states['thumb_trans_type'] = 'CPU' # # 백그라운드 제거도 안전하게 # updated_toggle_states['thumb_nukki'] = False # 배경제거 비활성화 # # 메모리 절약 설정 # updated_toggle_states['max_image_size'] = 800 # 이미지 크기 제한 # updated_toggle_states['enable_aggressive_memory_cleanup'] = True # self.logger.log("🔒 안전 모드: GPU 비활성화, CPU 모드, 배경제거 비활성화", level=logging.INFO) # # 업데이트된 toggle_states를 사용 # self.toggle_states = updated_toggle_states # # proc_args 업데이트 # log_path = self.proc_args[3] # log_path 위치 수정 # self.proc_args = ( # self.task_q, # self.result_q, # self.log_q, # log_path, # self.base_dir, # self.toggle_states, # self.unwanted_words, # self.authenticated_by_admin # ) # self.logger.log("재시작 시 현재 toggle_states 반영 완료", level=logging.INFO) # # 🔄 워커 전용 LogBridge 재시작 (새 log_q에 맞춰) # try: # # 기존 워커 LogBridge 정지 # if hasattr(self, '_worker_log_bridge') and self._worker_log_bridge: # self._worker_log_bridge.stop() # self.logger.log("기존 워커 LogBridge 정지 완료", level=logging.DEBUG) # # 새 워커 LogBridge 시작 # from src.modules.log_bridge import LogBridge # self._worker_log_bridge = LogBridge(self.logger, self.log_q) # self._worker_log_bridge.start() # self.logger.log("✅ 워커 LogBridge 재시작 완료 - 새 log_q로 연결됨", level=logging.INFO) # except Exception as e: # self.logger.log(f"워커 LogBridge 재시작 실패: {e}", level=logging.WARNING) # # 새 프로세스 시작 # self._spawn_proc() # except Exception as e: # self.logger.log(f"재시작 중 오류: {e}", level=logging.ERROR) # finally: # self._restart_in_progress = False # # 재시작 후 첫 호출 플래그 재설정 # self.first_call = True # def _get_memory_usage(self): # """현재 메모리 사용률을 반환""" # try: # mem = psutil.virtual_memory() # return mem.percent # except Exception: # return 0.0 # async def process_single_image(self, **kwargs): # # return await self._call_worker("process_single_image", **kwargs) # # 이미지 처리 시간 예측: OCR + 번역 + 인페인팅에 따라 동적 타임아웃 적용 # # 기본 3분, 메모리 사용량이 높으면 5분으로 증가 # base_timeout = 180 # 3분 기본 # memory_boost = 120 if self._get_memory_usage() > 70 else 0 # 메모리 70% 이상 시 +2분 # timeout = base_timeout + memory_boost # self.logger.log(f"이미지 처리 타임아웃 설정: {timeout}초 (메모리 사용량: {self._get_memory_usage():.1f}%)", level=logging.DEBUG) # # 메모리 스냅샷: 이미지 처리 시작 전 # self._create_memory_snapshot("image_process_start") # result = await self._call_worker("process_single_image", timeout=timeout, **kwargs) # self.first_call = False # # 성공 시 연속 메모리 오류 카운터 초기화 # try: # self._consecutive_mem_errors = 0 # except Exception: # pass # # 메모리 스냅샷: 이미지 처리 완료 후 # self._create_memory_snapshot("image_process_end") # # 메모리 분석 로깅 # self._log_memory_analysis("이미지 처리 완료") # # 이미지 처리 성공 후 메모리 정리 # memory_usage = self._get_memory_usage() # if memory_usage > 75: # self.logger.log(f"메모리 사용량 높음 ({memory_usage:.1f}%) - GC 수행", level=logging.WARNING) # import gc # collected = gc.collect() # after_memory = self._get_memory_usage() # self.logger.log(f"GC 완료: {collected}개 객체 수집, 메모리 사용량 {memory_usage:.1f}% -> {after_memory:.1f}%", level=logging.INFO) # # 메모리 임계치 기반 재시작 (비활성화됨) # try: # # 메모리 임계치 재시작 기능을 비활성화 - 주기적 재시작만 사용 # if False: # self._mem_restart_threshold and self._mem_restart_threshold > 0: # vm = psutil.virtual_memory() # if vm.percent >= self._mem_restart_threshold: # self.logger.log(f"메모리 임계치 초과({vm.percent}% >= {self._mem_restart_threshold}%) → 워커 재시작", level=logging.WARNING) # self._schedule_restart("memory_threshold") # except Exception: # pass # # 주기적 재시작 트리거: 성공으로 간주되는 경우에만 카운트 # try: # if self._periodic_restart_every and isinstance(result, dict) and result.get("status") in {"translated", "original", "exclude", "success", "no_text"}: # self._success_count += 1 # if self._success_count % self._periodic_restart_every == 0: # self._schedule_restart("periodic") # except Exception: # pass # return result # async def safe_process_single_image(self, **kwargs): # for attempt in range(2): # 최대 1회 재시도 (총 2회) # try: # return await self.process_single_image(**kwargs) # except requests.exceptions.Timeout as e: # self.logger.log(f"이미지 처리 타임아웃({attempt+1}/2): {e}", level=logging.WARNING) # if attempt < 1: # 마지막 시도가 아니면 재시도 # await asyncio.sleep(1) # 1초 유휴시간 # continue # else: # self.logger.log("최대 재시도 횟수 초과로 실패", level=logging.ERROR) # self._schedule_restart("max_retry_exceeded") # except Exception as e: # self.logger.log(f"이미지 처리 실패({attempt+1}): {e}", level=logging.WARNING) # await asyncio.sleep(1) # # 1회 실패 때 바로 재시작(선택 사항) # self.restart(cause="error") # return {"status": "failed", "path": None} # async def remove_background(self, **kwargs): # return await self._call_worker("remove_background", timeout=60, **kwargs) # 1분 타임아웃 # async def safe_remove_background(self, **kwargs): # for attempt in range(2): # 최대 1회 재시도 (총 2회) # try: # return await self.remove_background(**kwargs) # except requests.exceptions.Timeout as e: # self.logger.log(f"배경제거 타임아웃({attempt+1}/2): {e}", level=logging.WARNING) # if attempt < 1: # 마지막 시도가 아니면 재시도 # await asyncio.sleep(1) # 1초 유휴시간 # continue # else: # self.logger.log("최대 재시도 횟수 초과로 실패", level=logging.ERROR) # self._schedule_restart("max_retry_exceeded") # except Exception as e: # self.logger.log(f"배경제거 실패({attempt+1}): {e}", level=logging.WARNING) # await asyncio.sleep(1) # # 1회 실패 때 바로 재시작(선택 사항) # self.restart(cause="error") # return {"status": "failed", "path": None} # async def _call_worker(self, cmd, timeout=60, **kwargs): # # 서킷브레이커 체크 (시간 기반 자동 해제 포함) # if self._consecutive_failures >= self._circuit_breaker_threshold: # # 10분 경과 시 자동 해제 # current_time = time.time() # if current_time - self._circuit_breaker_last_failure_time > self._circuit_breaker_timeout: # self.logger.log(f"서킷브레이커 자동 해제 - {self._circuit_breaker_timeout/60:.0f}분 경과", level=logging.INFO) # self._consecutive_failures = 0 # self._circuit_breaker_last_failure_time = 0 # else: # remaining_time = self._circuit_breaker_timeout - (current_time - self._circuit_breaker_last_failure_time) # self.logger.log(f"서킷브레이커 활성화 - 연속 실패 {self._consecutive_failures}회 (해제까지 {remaining_time/60:.1f}분)", level=logging.WARNING) # raise RuntimeError("서킷브레이커: 워커가 너무 자주 실패하고 있습니다") # # 워커 프로세스가 죽었으면 재시작 # uid = uuid.uuid4().hex # 고유 UID 먼저 생성 # if not self.proc.is_alive(): # self.logger.log(f"image worker died (exit code: {self.proc.exitcode}) → restarting…", level=logging.WARNING) # self._schedule_restart("worker_died") # # pending_requests 정리 # if uid in self._pending_requests: # del self._pending_requests[uid] # raise RuntimeError("워커 프로세스가 죽었습니다") # current_generation = self._generation # # 내부에서 ImageProcessor3 가 toggle_states, base_dir 이 필요하므로 함께 전달 # kwargs["_toggle_states"] = self.toggle_states # kwargs["_base_dir"] = self.base_dir # # 요청을 pending_requests에 등록 # loop = asyncio.get_running_loop() # future = loop.create_future() # self._pending_requests[uid] = (future, current_generation, time.time()) # # ③ Lock 으로 재시작-중 접근 방지 # with self._lock: # try: # task_data = {"id": uid, "cmd": cmd, "kwargs": kwargs} # self.logger.log(f"🔄 큐에 작업 전송 시도: cmd={cmd}, uid={uid[:8]}, 워커 alive={self.proc.is_alive()}", level=logging.DEBUG) # self.task_q.put(task_data, timeout=5) # 5초 타임아웃 추가 # self.logger.log(f"✅ 큐에 작업 전송 완료: cmd={cmd}, uid={uid[:8]}", level=logging.DEBUG) # except Exception as e: # self.logger.log(f"❌ 큐에 작업 전송 실패: {e}, cmd={cmd}, uid={uid[:8]}", level=logging.ERROR) # # pending_requests에서 제거 # if uid in self._pending_requests: # del self._pending_requests[uid] # raise RuntimeError(f"워커 큐 전송 실패: {e}") # try: # result = await loop.run_in_executor(None, self._wait_result, uid, timeout, current_generation) # # 성공 시 실패 카운터 초기화 # self._consecutive_failures = 0 # self.restart_count = 0 # return result # except TimeoutError: # self.logger.log("⏱ 워커 응답 지연 → 재시작 시도", level=logging.WARNING) # self._consecutive_failures += 1 # self._circuit_breaker_last_failure_time = time.time() # self._schedule_restart("timeout") # raise # 상위에서 필요하다면 재시도 로직을 추가 # except Exception as e: # ← RuntimeError 포함 # self._consecutive_failures += 1 # self._circuit_breaker_last_failure_time = time.time() # # primitive/메모리 오류 에스컬레이션 처리 # msg = str(e).lower() # if any(s in msg for s in ["memory", "primitive", "out of memory", "unable to allocate", "cv::outofmemoryerror"]): # try: # self._consecutive_mem_errors += 1 # except Exception: # self._consecutive_mem_errors = 1 # self.logger.log(f"메모리/primitive 오류 {self._consecutive_mem_errors}회: {e}", level=logging.WARNING) # if self._consecutive_mem_errors >= max(1, self._mem_error_escalate_after): # self.logger.log("연속 메모리 오류 기준 충족 → 워커 재시작", level=logging.WARNING) # self._schedule_restart("memory_error") # self._consecutive_mem_errors = 0 # else: # # 네트워크/외부 서버 오류는 재시작 대상에서 제외 # network_keywords = [ # "requests.exceptions", "connection", "timeout", # "inpaint", "rembg", "failed to establish", # "max retries exceeded", "server", "refused" # ] # if any(k in msg for k in network_keywords): # self.logger.log(f"외부 서버/네트워크 오류: 재시작 생략 → {e}", level=logging.WARNING) # else: # self.logger.log(f"워커 내부 오류 → 재시작: {e}", level=logging.WARNING) # self._schedule_restart("worker_error") # raise # finally: # # pending_requests에서 제거 # if uid in self._pending_requests: # del self._pending_requests[uid] # def _wait_result(self, uid, timeout=60, generation=None): # self.logger.log(f"🔍 응답 대기 시작: uid={uid[:8]}, timeout={timeout}초, gen={generation}", level=logging.DEBUG) # end = time.time() + timeout # wait_start = time.time() # last_log_time = wait_start # while time.time() < end: # try: # res = self.result_q.get(timeout=1) # self.logger.log(f"📥 결과 큐에서 응답 수신: {res.get('id', 'no_id')[:8]} (대기: uid={uid[:8]})", level=logging.DEBUG) # if res["id"] == uid: # elapsed = time.time() - wait_start # self.logger.log(f"✅ 대상 응답 수신 완료: uid={uid[:8]}, 소요시간={elapsed:.1f}초", level=logging.DEBUG) # # 세대 검증 # if generation is not None and generation != self._generation: # raise TimeoutError(f"워커 재시작으로 인한 요청 취소 (세대: {generation} -> {self._generation})") # if "error" in res: # raise RuntimeError(res["error"]) # return res["data"] # except queue.Empty: # current_time = time.time() # # 10초마다 대기 상태 로그 # if current_time - last_log_time >= 10: # elapsed = current_time - wait_start # remaining = timeout - elapsed # self.logger.log(f"⏳ 응답 대기 중: uid={uid[:8]}, 경과={elapsed:.1f}초, 남은시간={remaining:.1f}초", level=logging.DEBUG) # last_log_time = current_time # continue # elapsed = time.time() - wait_start # self.logger.log(f"❌ 응답 타임아웃: uid={uid[:8]}, 총 대기시간={elapsed:.1f}초", level=logging.WARNING) # raise TimeoutError("image worker response timeout") # def restart(self, cause="periodic"): # """cause: 'periodic' | 'error'""" # """현재 워커 프로세스를 종료하고 새로 띄운다""" # # 새로운 재시작 시스템 사용 # if cause == "periodic": # self.logger.log("🌀 ImageWorker 주기적 재시작", level=logging.INFO) # self._execute_restart(cause) # else: # # 기존 restart 메서드는 _schedule_restart로 대체 # self.logger.log(f"ImageWorker 재시작 요청: {cause}", level=logging.INFO) # self._schedule_restart(cause) # def close(self): # # 하트비트 타이머 정리 # if self._heartbeat_timer: # try: # self._heartbeat_timer.cancel() # except Exception: # pass # self._heartbeat_timer = None # # 재시작 타이머 정리 # if self._restart_timer: # try: # self._restart_timer.cancel() # except Exception: # pass # self._restart_timer = None # self.task_q.put(None) # self.proc.join(timeout=5) # def update_states(self, toggle_states=None, unwanted_words=None): # """이미지 워커의 상태 정보를 업데이트합니다""" # if toggle_states is not None: # self.toggle_states = toggle_states # self.logger.log("ImageWorker toggle_states 업데이트됨", level=logging.DEBUG) # self._load_worker_policies() # if unwanted_words is not None: # self.unwanted_words = unwanted_words # self.logger.log("ImageWorker unwanted_words 업데이트됨", level=logging.DEBUG) # def _load_worker_policies(self): # """toggle_states 로부터 워커 재시작 정책을 로드한다.""" # try: # every = int(self.toggle_states.get("image_worker_periodic_restart_every", 0) or 0) # mem_thr = int(self.toggle_states.get("image_worker_mem_restart_threshold", 0) or 0) # esc_after = int(self.toggle_states.get("image_worker_mem_error_escalate_after", 2) or 2) # self._periodic_restart_every = max(0, every) # self._mem_restart_threshold = max(0, mem_thr) # self._mem_error_escalate_after = max(1, esc_after) # self.logger.log( # f"워커 정책 로드: periodic={self._periodic_restart_every}, mem_thr={self._mem_restart_threshold}%, escalate_after={self._mem_error_escalate_after}", # level=logging.INFO, # ) # except Exception as e: # self.logger.log(f"워커 정책 로드 실패: {e}", level=logging.WARNING) # def _create_memory_snapshot(self, label: str = "") -> Optional[Dict]: # """메모리 스냅샷 생성 (프로파일링용)""" # try: # snapshot_time = time.time() # system_mem = psutil.virtual_memory() # # tracemalloc 스냅샷 (실행 중인 경우) # tracemalloc_snapshot = None # if tracemalloc.is_tracing(): # tracemalloc_snapshot = tracemalloc.take_snapshot() # # 워커 메모리 정보 # worker_mem = self._get_worker_memory_info() # snapshot = { # 'timestamp': snapshot_time, # 'label': label, # 'system_memory': { # 'total_mb': system_mem.total / 1024 / 1024, # 'used_mb': system_mem.used / 1024 / 1024, # 'free_mb': system_mem.free / 1024 / 1024, # 'percent': system_mem.percent # }, # 'worker_memory': worker_mem, # 'tracemalloc_snapshot': tracemalloc_snapshot, # 'gc_stats': { # 'collections': [gc.get_count()[i] for i in range(3)], # 'objects': len(gc.get_objects()) # } # } # # 스냅샷 저장 (최근 5개 유지) # if not hasattr(self, '_memory_snapshots'): # self._memory_snapshots = [] # self._memory_snapshots.append(snapshot) # # 오래된 스냅샷 정리 (최근 5개만 유지) # if len(self._memory_snapshots) > 5: # self._memory_snapshots.pop(0) # return snapshot # except Exception as e: # self.logger.log(f"메모리 스냅샷 생성 중 오류: {e}", level=logging.ERROR) # return None # def _compare_memory_snapshots(self, label1: str = "", label2: str = "") -> Optional[Dict]: # """두 메모리 스냅샷 비교 분석""" # try: # if not hasattr(self, '_memory_snapshots') or len(self._memory_snapshots) < 2: # return None # # 레이블로 스냅샷 찾기 또는 최근 2개 사용 # snapshots = self._memory_snapshots[-2:] # if label1 and label2: # labeled_snapshots = [s for s in self._memory_snapshots if s['label'] in [label1, label2]] # if len(labeled_snapshots) >= 2: # snapshots = labeled_snapshots[-2:] # if len(snapshots) < 2: # return None # snap1, snap2 = snapshots[0], snapshots[1] # # 시스템 메모리 비교 # sys_diff = { # 'used_mb_diff': snap2['system_memory']['used_mb'] - snap1['system_memory']['used_mb'], # 'percent_diff': snap2['system_memory']['percent'] - snap1['system_memory']['percent'] # } # # tracemalloc 비교 (가능한 경우) # tracemalloc_diff = None # if snap1.get('tracemalloc_snapshot') and snap2.get('tracemalloc_snapshot'): # try: # stats = snap2['tracemalloc_snapshot'].compare_to(snap1['tracemalloc_snapshot'], 'lineno') # total_diff = sum(stat.size_diff for stat in stats) # tracemalloc_diff = { # 'total_size_diff': total_diff, # 'total_size_diff_mb': total_diff / 1024 / 1024, # 'new_objects_count': len([s for s in stats if s.size_diff > 0]) # } # except Exception as e: # self.logger.log(f"tracemalloc 스냅샷 비교 중 오류: {e}", level=logging.DEBUG) # return { # 'time_diff_seconds': snap2['timestamp'] - snap1['timestamp'], # 'system_memory_diff': sys_diff, # 'tracemalloc_diff': tracemalloc_diff, # 'snapshot1_label': snap1.get('label', 'unknown'), # 'snapshot2_label': snap2.get('label', 'unknown') # } # except Exception as e: # self.logger.log(f"메모리 스냅샷 비교 중 오류: {e}", level=logging.ERROR) # return None # def _log_memory_analysis(self, context: str = ""): # """메모리 분석 결과 로깅""" # try: # context_str = f" [{context}]" if context else "" # # 현재 메모리 상태 로깅 # self._monitor_memory_usage() # # 워커 메모리 상태 로깅 # self._log_worker_memory_status(context) # # 스냅샷 비교 (가능한 경우) # comparison = self._compare_memory_snapshots() # if comparison: # sys_diff = comparison['system_memory_diff'] # self.logger.log( # f"메모리 분석{context_str}: 시스템 메모리 변화 {sys_diff['used_mb_diff']:+.1f}MB " # f"({sys_diff['percent_diff']:+.1f}%), 시간 경과: {comparison['time_diff_seconds']:.1f}초", # level=logging.INFO # ) # if comparison.get('tracemalloc_diff'): # trace_diff = comparison['tracemalloc_diff'] # self.logger.log( # f"객체 메모리 분석{context_str}: {trace_diff['total_size_diff_mb']:+.2f}MB, " # f"새 객체: {trace_diff['new_objects_count']}개", # level=logging.DEBUG # ) # except Exception as e: # self.logger.log(f"메모리 분석 로깅 중 오류: {e}", level=logging.ERROR)