8825 lines
474 KiB
Python
8825 lines
474 KiB
Python
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'(?<![가-힣A-Za-z0-9]){re.escape(word)}(?![가-힣A-Za-z0-9])',
|
||
repl,
|
||
text
|
||
)
|
||
block["transText"] = text
|
||
|
||
# 조작된 데이터로 응답 생성
|
||
new_body = json.dumps(data)
|
||
|
||
# self.logger.log(f"조작된 데이터: {new_body}", level=logging.DEBUG)
|
||
|
||
await route.fulfill(
|
||
status=response.status,
|
||
headers=response.headers,
|
||
body=new_body
|
||
)
|
||
except asyncio.TimeoutError:
|
||
# API 타임아웃 발생 시 원본 요청을 그대로 통과시킴
|
||
self.logger.log(f"API 응답 타임아웃 발생, 원본 요청 통과: {request.url}", level=logging.WARNING)
|
||
await route.continue_()
|
||
except Exception as e:
|
||
# 기타 오류 발생 시 원본 요청을 그대로 통과시킴
|
||
self.logger.log(f"API 응답 처리 중 오류 발생: {e}, 원본 요청 통과", level=logging.WARNING)
|
||
await route.continue_()
|
||
else:
|
||
# 나머지는 기본 패스
|
||
await route.continue_()
|
||
|
||
|
||
def get_page(self):
|
||
return self.page
|
||
|
||
def get_parsing_page(self):
|
||
return self.parsing_page
|
||
|
||
def get_whale(self):
|
||
return self.whale_translator
|
||
|
||
def generate_random_suffix(self, length=4):
|
||
"""랜덤한 영문+숫자 조합 문자열 생성"""
|
||
chars = string.ascii_uppercase + string.digits
|
||
return ''.join(random.choices(chars, k=length))
|
||
|
||
|
||
# async def monitor_browser_logs(self, page):
|
||
# """브라우저 콘솔 로그를 Python으로 캡처하여 출력"""
|
||
# page.on("console", lambda msg: self.logger.info(f"JS 콘솔 로그: {msg.type}: {msg.text}"))
|
||
|
||
# async def monitor_browser_logs(self, page):
|
||
# """브라우저 콘솔 로그를 Python으로 캡처하여 출력"""
|
||
# level_map = {
|
||
# "log": logging.INFO,
|
||
# "info": logging.INFO,
|
||
# "warning": logging.WARNING,
|
||
# "error": logging.ERROR,
|
||
# "debug": logging.DEBUG,
|
||
# }
|
||
|
||
# page.on(
|
||
# "console",
|
||
# lambda msg: self.logger.log(
|
||
# f"JS 콘솔 로그: {msg.type}: {msg.text}",
|
||
# level=level_map.get(msg.type, logging.INFO)
|
||
# )
|
||
# )
|
||
|
||
# def get_base_dir(self):
|
||
# """
|
||
# 실행 환경에 따라 base_dir을 설정하는 메서드.
|
||
# cx_Freeze로 패키징된 경우 실행 파일의 경로, 일반 Python 환경일 경우 __file__을 기준으로 설정.
|
||
# """
|
||
# if getattr(sys, 'frozen', False): # 패키징된 경우
|
||
# base_dir = os.path.dirname(sys.executable)
|
||
# internal_dir = os.path.join(base_dir, 'lib', 'src') # lib 디렉토리 포함
|
||
# if os.path.exists(internal_dir): # lib 디렉토리가 존재하면 base_dir로 설정
|
||
# return internal_dir
|
||
|
||
# else: # 일반 Python 실행 환경
|
||
# base_dir = os.path.dirname(os.path.abspath(__file__))
|
||
# debug_dir = os.path.join(base_dir, 'src') # lib 디렉토리 포함
|
||
|
||
# return debug_dir
|
||
|
||
async def close_welcome_popup_if_exists(self, page, timeout_sec: int = 1):
|
||
"""
|
||
로그인 직후 간헐적으로 뜨는 '환영합니다' 팝업이 있을 경우 닫아준다.
|
||
일정 시간 동안 팝업이 감지되지 않으면 아무것도 하지 않고 종료한다.
|
||
|
||
Args:
|
||
page (Page): Playwright의 page 객체
|
||
timeout_sec (int): 팝업을 최대 몇 초 동안 기다릴지 (기본값 1초)
|
||
"""
|
||
# shadow-root 내부의 닫기 버튼을 가리키는 선택자
|
||
# self.welcome_popup_closeBTN_selector = '#ch-plugin >>> 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 콘텐츠에서 모든 <img> 태그의 URL을 추출하는 함수.
|
||
# <figure> 안의 <img> 태그와 독립된 <img> 태그 모두 처리.
|
||
# """
|
||
# soup = BeautifulSoup(html_content, 'html.parser')
|
||
|
||
# # 모든 <img> 태그를 찾기
|
||
# 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 = '<p> </p>'
|
||
# for url in urls:
|
||
# html_content += f'<figure class="image"><img src="{url}" style="aspect-ratio:1/1;"></figure>\n'
|
||
# return html_content
|
||
|
||
|
||
def generate_restored_html(self, urls):
|
||
"""이미지 URL 목록을 HTML 형식으로 변환하는 메서드, 각 이미지의 가로세로 비율 추가"""
|
||
html_content = '<p> </p>\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'<figure class="image">'
|
||
f'<img style="aspect-ratio:{aspect_ratio};" src="{url}" width="{width}" height="{height}">'
|
||
f'</figure>\n'
|
||
)
|
||
else:
|
||
# 이미지 크기를 확인할 수 없을 경우 기본 형식으로 추가
|
||
html_content += f'<figure class="image"><img src="{url}"></figure>\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)
|
||
|