브라우저 컨트롤러 개선: ED 모드에 따라 서버 URL 상태 체크 로직을 수정하고, 상품 수정 작업 시 버튼 활성 상태 확인 로직을 개선하였습니다. 또한, 썸네일 처리 및 이미지 변형 기능을 추가하여 다양한 변형 기법을 적용할 수 있도록 하였습니다. UI 요소의 가시성을 조정하고, 관련 로그 메시지를 개선하여 디버깅 용이성을 높였습니다. 데이터베이스 파일이 업데이트되었습니다.

This commit is contained in:
9700X_PC 2025-09-16 23:45:26 +09:00
parent 52f74ed031
commit 082fd0cbd2
5 changed files with 616 additions and 147 deletions

View File

@ -2531,6 +2531,7 @@ class BrowserController(QThread):
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)
@ -4133,22 +4134,27 @@ class BrowserController(QThread):
async def start_Percenty_task(self):
self.logger.log('퍼센티 상품수정 작업을 시작합니다...', level=logging.DEBUG)
is_ed_mode = self.toggle_states.get('ed_mode', False)
self.running = True # 번역 작업이 시작됨
self.translation_started.emit()
self.gpt_client.update_gpt_model(self.toggle_states['gpt_model'])
# 서버 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)
# 체크 실패해도 작업은 계속 진행
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:
# 금지어 목록 업데이트
@ -4199,13 +4205,13 @@ class BrowserController(QThread):
await self.scroll_page_to_top()
self.logger.log(f'동적로딩을 위해 휠 스크롤 업', level=logging.DEBUG)
if not self.toggle_states['ed_mode']:
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=self.toggle_states['ed_mode'])
product_infos, product_name_elements = await self.collect_product_info(items_per_page, ed_mode=is_ed_mode)
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]
@ -4236,29 +4242,31 @@ class BrowserController(QThread):
# 그 외 임시 변수 초기화
self.current_options_info = None
# 상품명 수집 오류 처리
self.logger.log(f'{index}/{len(product_buttons)} 버튼의 활성상태 확인 중...', level=logging.DEBUG)
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
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() # 일시중지 상태 확인
# self.check_pause() # 일시중지 상태 확인
# if not is_ed_mode:
title_infos = await self.titleGenerator.get_initial_info(self.price_setting_diag)
self.logger.log(f"title_infos : {title_infos}", level=logging.DEBUG)
# 금지카테고리 여부가 True이면, 금지카테고리 제목 설정 후 저장하고 다음 상품으로 넘어감.
# if title_infos.get("is_banned_category", False):
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)
@ -4285,8 +4293,8 @@ class BrowserController(QThread):
self.logger.log("금지카테고리 상품 처리 실패.", level=logging.ERROR)
continue # 다음 상품으로 넘어감
await self.random_human_behavior(self.page)
if not is_ed_mode:
await self.random_human_behavior(self.page)
# 정상상품이면 상품명 수정과 카테고리 수집
is_title = self.toggle_states['title']
@ -4298,26 +4306,26 @@ class BrowserController(QThread):
title_infos["generated_name"] = await self.titleGenerator.process_title()
self.complete_stage_signal.emit(0)
# 옵션 수정
self.start_stage_signal.emit(1)
await self.random_human_behavior(self.page)
is_optionTrnas = self.toggle_states.get('optionTrnas')
is_optionIMGTrans = self.toggle_states.get('optionIMGTrans')
is_optionAutoSelect = self.toggle_states.get('optionAutoSelect')
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 # 다음 상품으로 넘어감
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)
await self.random_human_behavior(self.page)
self.complete_stage_signal.emit(1)
# 가격 수정
self.start_stage_signal.emit(2)
@ -4352,43 +4360,45 @@ class BrowserController(QThread):
continue # 다음 상품으로 넘어감
self.complete_stage_signal.emit(3)
# 태그 수정
tag = self.toggle_states.get('tag')
tag_ai = self.toggle_states.get('tag_ai')
if not is_ed_mode:
# 태그 수정
tag = self.toggle_states.get('tag')
tag_ai = self.toggle_states.get('tag_ai')
await self.random_human_behavior(self.page)
if tag or tag_ai:
self.check_pause() # 일시중지 상태 확인
self.logger.log(f"키워드태그 수정 : {tag} + {tag_ai} ", level=logging.DEBUG)
self.start_stage_signal.emit(4)
tag_result = await self.edit_tags(title_infos, tag, tag_ai)
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')
if detail_Option or detail_IMGTrans:
self.check_pause() # 일시중지 상태 확인
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)
if tag or tag_ai:
self.check_pause() # 일시중지 상태 확인
self.logger.log(f"키워드태그 수정 : {tag} + {tag_ai} ", level=logging.DEBUG)
self.start_stage_signal.emit(4)
tag_result = await self.edit_tags(title_infos, tag, tag_ai)
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)
if not is_ed_mode:
# 상세페이지 수정 수정
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')
if detail_Option or detail_IMGTrans:
self.check_pause() # 일시중지 상태 확인
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)
@ -4410,7 +4420,7 @@ class BrowserController(QThread):
# save_result == "SAVED"인 경우는 정상 저장이므로 그대로 진행
# 메모 입력: 저장이 정상 완료(SAVED) 또는 중복 처리 완료(DUPLICATE_HANDLED)된 경우에만 진행
if save_result in ("SAVED", "DUPLICATE_HANDLED") and memo_button and self.toggle_states['memo']:
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)
await self.insert_product_infos_to_memo(memo_button, title_infos)
@ -4459,18 +4469,6 @@ class BrowserController(QThread):
# self.logger.log(f"디버그 이미지 워커 재시작", level=logging.DEBUG)
# # --- (추가) N개마다 컨텍스트 재시작 ---
# if completed_count > 0 and (completed_count % self.products_per_context_restart) == 0:
# # 5분 내 중복 방지
# self.logger.log(f"5분이내 중복 재실행 방지 - last_restart_ts: {self.last_restart_ts}", level=logging.DEBUG)
# if time.time() - self.last_restart_ts > 300: # 5분 내 중복 방지
# self.logger.log(f"{self.products_per_context_restart}개 상품 단위로 컨텍스트 재시작", level=logging.INFO)
# await self.restart_main_context()
# await self.resume_after_restart()
# self.last_restart_ts = time.time()
# break # for-loop 탈출 → while 처음으로
title_infos.clear() # 메모 입력 후 초기화
self.logger.log(f"title_infos 초기화", level=logging.DEBUG)

View File

@ -488,9 +488,9 @@ class MAIN_GUI(QMainWindow):
"off": []
}
},
"option_shuffle_toggle": {
"option_numbering_shuffle_toggle": {
"key": "option/shuffle_names",
"state_key": "option_shuffle",
"state_key": "option_numbering_shuffle",
"dependents": {
"on": [],
"off": []
@ -878,7 +878,7 @@ class MAIN_GUI(QMainWindow):
}
},
"optionNumbering_method_label": {
"key": "option/option_numbering_method_label", # 옵션 번호 번역 사용 여부
"key": "option/option_numbering_method_label", # 옵션 번호 사용 여부
"dependents": { # ON/OFF별 enable 위젯 리스트
"on": [],
"off": []
@ -1394,6 +1394,7 @@ class MAIN_GUI(QMainWindow):
'optionIMGTrans': False,
'optionIMGTrans_type': None,
'optionAutoSelect': False,
'option_numbering_shuffle': False,
'first_option_img_to_thumb': False,
'price': False,
'price_range_percent': 1,
@ -3705,7 +3706,7 @@ class MAIN_GUI(QMainWindow):
if option_container_layout is None:
option_container_layout = getattr(self, 'option_toggle_layout', None)
if option_container_layout is not None:
self._apply_whitelist_to_layout(option_container_layout, ['option_shuffle_widget'])
self._apply_whitelist_to_layout(option_container_layout, ['option_numbering_shuffle_widget'])
if hasattr(self, 'thumbnail_toggle_layout') and self.thumbnail_toggle_layout is not None:
self._apply_whitelist_to_layout(self.thumbnail_toggle_layout, ['thumb_represent_widget'])
@ -3818,7 +3819,7 @@ class MAIN_GUI(QMainWindow):
try:
register_only = [
# 옵션 탭: 옵션명 셔플만
'option_shuffle_widget',
'option_numbering_shuffle_widget',
# 썸네일 탭: 대표썸네일 변경만
'thumb_represent_widget',
]
@ -3861,8 +3862,8 @@ class MAIN_GUI(QMainWindow):
"""옵션 탭에서 옵션명 셔플 외 모든 위젯 숨김"""
try:
# 옵션명 셔플은 보이게
if hasattr(self, 'option_shuffle_widget'):
self.option_shuffle_widget.setVisible(True)
if hasattr(self, 'option_numbering_shuffle_widget'):
self.option_numbering_shuffle_widget.setVisible(True)
# 다른 옵션 관련 위젯들 숨김 (옵션명 셔플 제외)
option_widgets_to_hide = [
@ -3875,7 +3876,7 @@ class MAIN_GUI(QMainWindow):
for widget_name in option_widgets_to_hide:
if hasattr(self, widget_name):
widget = getattr(self, widget_name)
if widget and widget != self.option_shuffle_widget:
if widget and widget != self.option_numbering_shuffle_widget:
widget.setVisible(False)
except Exception as e:
@ -3885,7 +3886,7 @@ class MAIN_GUI(QMainWindow):
"""옵션 탭의 모든 위젯 보이게"""
try:
option_widgets = [
'option_shuffle_widget', 'option_name_toggle_widget', 'option_name_shuffle_widget',
'option_numbering_shuffle_widget', 'option_name_toggle_widget', 'option_name_shuffle_widget',
'option_price_toggle_widget', 'option_price_shuffle_widget',
'option_img_toggle_widget', 'option_img_trans_widget',
'option_img_remove_bg_widget', 'option_img_inpaint_widget'
@ -5288,15 +5289,15 @@ class MAIN_GUI(QMainWindow):
o_layout.addWidget(self.forbidden_match_option_widget)
# 등록상품모드: 옵션명 셔플 토글 추가
self.option_shuffle_widget = QWidget()
self.option_shuffle_layout = QHBoxLayout(self.option_shuffle_widget)
self.option_shuffle_toggle_label = QLabel("옵션 셔플", self)
self.option_shuffle_toggle = ToggleSwitch(self)
self.option_shuffle_toggle.setObjectName("option_shuffle_toggle")
self.option_shuffle_toggle.clicked.connect(lambda checked: self.universal_input_handler(self.option_shuffle_toggle, checked))
self.option_shuffle_layout.addWidget(self.option_shuffle_toggle_label)
self.option_shuffle_layout.addWidget(self.option_shuffle_toggle)
o_layout.addWidget(self.option_shuffle_widget)
self.option_numbering_shuffle_widget = QWidget()
self.option_shuffle_layout = QHBoxLayout(self.option_numbering_shuffle_widget)
self.option_numbering_shuffle_toggle_label = QLabel("옵션 넘버링 셔플", self)
self.option_numbering_shuffle_toggle = ToggleSwitch(self)
self.option_numbering_shuffle_toggle.setObjectName("option_numbering_shuffle_toggle")
self.option_numbering_shuffle_toggle.clicked.connect(lambda checked: self.universal_input_handler(self.option_numbering_shuffle_toggle, checked))
self.option_shuffle_layout.addWidget(self.option_numbering_shuffle_toggle_label)
self.option_shuffle_layout.addWidget(self.option_numbering_shuffle_toggle)
o_layout.addWidget(self.option_numbering_shuffle_widget)
# 스크롤 영역 조립
if self.option_scroll:

View File

@ -4,9 +4,11 @@ import logging
import asyncio
import glob
from typing import Optional
import urllib.request
try:
from PIL import Image, ImageOps
from PIL import Image, ImageOps, ImageEnhance, ImageFilter
import numpy as np
_PIL_AVAILABLE = True
except Exception:
_PIL_AVAILABLE = False
@ -116,6 +118,351 @@ class ThumbnailHandler:
except Exception as e:
self.logger.log(f"썸네일 임시 파일 처리 중 오류 발생: {e}", level=logging.ERROR)
def _apply_color_variation(self, img: Image.Image, brightness_range: float = 0.05, saturation_range: float = 0.1) -> Image.Image:
"""색상/밝기/채도 변형을 적용합니다."""
# 밝기 조정
brightness_factor = 1 + random.uniform(-brightness_range, brightness_range)
enhancer = ImageEnhance.Brightness(img)
img = enhancer.enhance(brightness_factor)
# 채도 조정
saturation_factor = 1 + random.uniform(-saturation_range, saturation_range)
enhancer = ImageEnhance.Color(img)
img = enhancer.enhance(saturation_factor)
self.logger.log(f"색상 변형 적용 - 밝기: {brightness_factor:.3f}, 채도: {saturation_factor:.3f}", level=logging.DEBUG)
return img
def _apply_gaussian_noise(self, img: Image.Image, noise_strength: float = 3.0) -> Image.Image:
"""가우시안 노이즈를 추가합니다."""
try:
# PIL 이미지를 numpy 배열로 변환
img_array = np.array(img)
# 가우시안 노이즈 생성
noise = np.random.normal(0, noise_strength, img_array.shape).astype(np.int16)
# 노이즈 추가 (클리핑으로 0-255 범위 유지)
noisy_array = np.clip(img_array.astype(np.int16) + noise, 0, 255).astype(np.uint8)
# numpy 배열을 다시 PIL 이미지로 변환
noisy_img = Image.fromarray(noisy_array)
self.logger.log(f"가우시안 노이즈 적용 - 강도: {noise_strength}", level=logging.DEBUG)
return noisy_img
except Exception as e:
self.logger.log(f"가우시안 노이즈 적용 실패: {e}", level=logging.WARNING)
return img
def _apply_crop_resize(self, img: Image.Image, crop_pixels: int = 2) -> Image.Image:
"""테두리 크롭 후 원래 크기로 리사이즈합니다."""
original_size = img.size
# 크롭 영역 계산 (각 방향에서 crop_pixels만큼 자름)
left = crop_pixels
top = crop_pixels
right = original_size[0] - crop_pixels
bottom = original_size[1] - crop_pixels
# 크롭
cropped = img.crop((left, top, right, bottom))
# 원래 크기로 리사이즈
resized = cropped.resize(original_size, Image.LANCZOS)
self.logger.log(f"크롭&리사이즈 적용 - 크롭: {crop_pixels}px", level=logging.DEBUG)
return resized
def _apply_scale_variation(self, img: Image.Image, scale_range: float = 0.02) -> Image.Image:
"""미세한 스케일 조정을 적용합니다."""
original_size = img.size
# 스케일 팩터 (98~102%)
scale_factor = 1 + random.uniform(-scale_range, scale_range)
# 임시 스케일링
temp_size = (int(original_size[0] * scale_factor), int(original_size[1] * scale_factor))
scaled = img.resize(temp_size, Image.LANCZOS)
# 원래 크기로 되돌리기
final = scaled.resize(original_size, Image.LANCZOS)
self.logger.log(f"스케일 변형 적용 - 팩터: {scale_factor:.3f}", level=logging.DEBUG)
return final
def _apply_blur_sharpen(self, img: Image.Image, blur_radius: float = 0.4) -> Image.Image:
"""미세한 블러 후 샤프닝을 적용합니다."""
# 가우시안 블러 적용
blurred = img.filter(ImageFilter.GaussianBlur(radius=blur_radius))
# 언샤프 마스크로 샤프닝
sharpened = blurred.filter(ImageFilter.UnsharpMask(radius=2, percent=150, threshold=3))
self.logger.log(f"블러&샤프닝 적용 - 블러 반경: {blur_radius}", level=logging.DEBUG)
return sharpened
def _get_random_background_color(self) -> tuple:
"""위화감 없는 연한 배경색을 랜덤으로 선택합니다."""
background_colors = [
# 연한 회색 계열
(248, 248, 248), # 매우 연한 회색
(250, 250, 250), # 거의 흰색에 가까운 회색
(245, 245, 245), # 연한 회색
(252, 252, 252), # 아주 연한 회색
# 살구/복숭아 계열
(255, 250, 240), # 플로럴 화이트
(255, 248, 245), # 시쉘
(254, 245, 230), # 연한 살구색
(255, 245, 238), # 연한 복숭아색
# 베이지/아이보리 계열
(255, 253, 245), # 연한 베이지
(252, 248, 240), # 베이지
(255, 255, 240), # 아이보리
(255, 254, 245), # 연한 아이보리
# 연한 크림 계열
(255, 252, 240), # 크림
(250, 245, 235), # 연한 크림
(255, 248, 235), # 옅은 크림
# 흰색 (기본)
(255, 255, 255), # 순백색
]
selected_color = random.choice(background_colors)
self.logger.log(f"랜덤 배경색 선택: RGB{selected_color}", level=logging.DEBUG)
return selected_color
def _apply_random_background_color(self, img: Image.Image) -> Image.Image:
"""투명 배경을 랜덤한 연한 배경색으로 변경합니다."""
if img.mode not in ("RGBA", "LA"):
return img
background_color = self._get_random_background_color()
colored_bg = Image.new('RGB', img.size, background_color)
if img.mode == "RGBA":
colored_bg.paste(img, mask=img.split()[3]) # alpha 채널을 마스크로 사용
else: # LA mode
colored_bg.paste(img, mask=img.split()[1]) # alpha 채널을 마스크로 사용
self.logger.log(f"랜덤 배경색 적용: RGB{background_color}", level=logging.DEBUG)
return colored_bg
def _apply_subtle_texture(self, img: Image.Image, intensity: float = 0.008) -> Image.Image:
"""미세한 텍스쳐를 적용합니다."""
try:
img_array = np.array(img)
height, width = img_array.shape[:2]
# 미세한 노이즈 패턴 생성 (더 자연스러운 텍스쳐)
texture_patterns = [
# 세밀한 격자 패턴
lambda h, w: np.sin(np.arange(w) * 0.5) * np.sin(np.arange(h).reshape(-1, 1) * 0.5),
# 원형 패턴
lambda h, w: np.sin(np.sqrt((np.arange(w) - w//2)**2 + (np.arange(h).reshape(-1, 1) - h//2)**2) * 0.01),
# 대각선 패턴
lambda h, w: np.sin((np.arange(w) + np.arange(h).reshape(-1, 1)) * 0.03),
# 랜덤 노이즈 패턴
lambda h, w: np.random.normal(0, 1, (h, w)),
]
# 랜덤 패턴 선택
pattern_func = random.choice(texture_patterns)
texture = pattern_func(height, width)
# 텍스쳐 강도 조정 및 정규화
texture = (texture / np.max(np.abs(texture))) * intensity * 255
# 각 채널에 텍스쳐 적용
if len(img_array.shape) == 3: # RGB
for channel in range(3):
img_array[:, :, channel] = np.clip(
img_array[:, :, channel].astype(np.float32) + texture, 0, 255
).astype(np.uint8)
else: # Grayscale
img_array = np.clip(
img_array.astype(np.float32) + texture, 0, 255
).astype(np.uint8)
textured_img = Image.fromarray(img_array)
self.logger.log(f"미세 텍스쳐 적용 - 강도: {intensity}", level=logging.DEBUG)
return textured_img
except Exception as e:
self.logger.log(f"텍스쳐 적용 실패: {e}", level=logging.WARNING)
return img
def _apply_subtle_gradient(self, img: Image.Image, intensity: float = 0.015) -> Image.Image:
"""미세한 그라데이션을 적용합니다."""
try:
img_array = np.array(img)
height, width = img_array.shape[:2]
# 다양한 그라데이션 방향
gradient_types = [
# 수직 그라데이션 (위→아래)
lambda h, w: np.linspace(-intensity, intensity, h).reshape(-1, 1) * np.ones(w),
# 수평 그라데이션 (좌→우)
lambda h, w: np.ones((h, 1)) * np.linspace(-intensity, intensity, w),
# 대각선 그라데이션 (좌상→우하)
lambda h, w: (np.arange(h).reshape(-1, 1) + np.arange(w)) / (h + w) * intensity * 2 - intensity,
# 원형 그라데이션 (중심→외곽)
lambda h, w: (np.sqrt((np.arange(w) - w//2)**2 + (np.arange(h).reshape(-1, 1) - h//2)**2) /
max(h, w) * intensity * 2 - intensity),
# 역원형 그라데이션 (외곽→중심)
lambda h, w: -(np.sqrt((np.arange(w) - w//2)**2 + (np.arange(h).reshape(-1, 1) - h//2)**2) /
max(h, w) * intensity * 2 - intensity),
]
# 랜덤 그라데이션 선택
gradient_func = random.choice(gradient_types)
gradient = gradient_func(height, width)
# 그라데이션 강도를 픽셀값으로 변환
gradient_effect = gradient * 255
# 각 채널에 그라데이션 적용
if len(img_array.shape) == 3: # RGB
for channel in range(3):
img_array[:, :, channel] = np.clip(
img_array[:, :, channel].astype(np.float32) + gradient_effect, 0, 255
).astype(np.uint8)
else: # Grayscale
img_array = np.clip(
img_array.astype(np.float32) + gradient_effect, 0, 255
).astype(np.uint8)
gradient_img = Image.fromarray(img_array)
self.logger.log(f"미세 그라데이션 적용 - 강도: {intensity}", level=logging.DEBUG)
return gradient_img
except Exception as e:
self.logger.log(f"그라데이션 적용 실패: {e}", level=logging.WARNING)
return img
def process_thumbnail_image(self,
input_path: str,
output_path: str,
target_size: tuple = (1000, 1000),
target_dpi: tuple = (72, 72),
mirror: bool = True,
rotation_range: tuple = (-5, 5),
apply_color_variation: bool = True,
apply_gaussian_noise: bool = True,
apply_crop_resize: bool = True,
apply_scale_variation: bool = True,
apply_blur_sharpen: bool = True,
apply_random_background: bool = True,
apply_subtle_texture: bool = True,
apply_subtle_gradient: bool = True) -> bool:
"""
썸네일 이미지에 다양한 변형 감지회피 기법을 적용합니다.
Args:
input_path: 입력 이미지 경로
output_path: 출력 이미지 경로
target_size: 목표 이미지 크기 (기본: 1000x1000)
target_dpi: 목표 DPI (기본: 72x72)
mirror: 좌우 반전 적용 여부 (기본: True)
rotation_range: 회전 각도 범위 (기본: -5~5)
apply_color_variation: 색상/밝기/채도 변형 적용 여부
apply_gaussian_noise: 가우시안 노이즈 적용 여부
apply_crop_resize: 크롭&리사이즈 적용 여부
apply_scale_variation: 스케일 변형 적용 여부
apply_blur_sharpen: 블러&샤프닝 적용 여부
apply_random_background: 랜덤 배경색 적용 여부 (연한 회색, 살구색 )
apply_subtle_texture: 미세한 텍스쳐 적용 여부
apply_subtle_gradient: 미세한 그라데이션 적용 여부
Returns:
bool: 처리 성공 여부
"""
try:
with Image.open(input_path) as im:
# 원본 정보 로그
current_dpi = im.info.get('dpi', (72, 72))
current_size = im.size
self.logger.log(f"원본 이미지 정보 - 크기: {current_size}, DPI: {current_dpi}", level=logging.DEBUG)
# 1. DPI 및 사이즈 조정
if current_dpi != target_dpi or current_size != target_size:
if current_size != target_size:
im = im.resize(target_size, Image.LANCZOS)
self.logger.log(f"이미지 크기 조정: {current_size} -> {target_size}", level=logging.INFO)
if current_dpi != target_dpi:
self.logger.log(f"이미지 DPI 조정: {current_dpi} -> {target_dpi}", level=logging.INFO)
# 2. 좌우 반전
if mirror:
im = ImageOps.mirror(im)
self.logger.log("좌우 반전 적용", level=logging.DEBUG)
# 3. 투명도 처리 (배경색 적용)
applied_techniques = []
if apply_random_background:
im = self._apply_random_background_color(im)
applied_techniques.append("랜덤배경")
elif im.mode in ("RGBA", "LA"):
# 랜덤 배경 비활성화시 기본 흰색 배경
white_bg = Image.new('RGB', im.size, (255, 255, 255))
if im.mode == "RGBA":
white_bg.paste(im, mask=im.split()[3])
else: # LA mode
white_bg.paste(im, mask=im.split()[1])
im = white_bg
self.logger.log("투명 배경을 흰색으로 변환", level=logging.DEBUG)
# 4. 감지회피 기법들 적용
if apply_color_variation:
im = self._apply_color_variation(im)
applied_techniques.append("색상변형")
if apply_gaussian_noise:
im = self._apply_gaussian_noise(im)
applied_techniques.append("노이즈")
if apply_crop_resize:
im = self._apply_crop_resize(im)
applied_techniques.append("크롭리사이즈")
if apply_scale_variation:
im = self._apply_scale_variation(im)
applied_techniques.append("스케일변형")
if apply_blur_sharpen:
im = self._apply_blur_sharpen(im)
applied_techniques.append("블러샤프닝")
if apply_subtle_texture:
im = self._apply_subtle_texture(im)
applied_techniques.append("미세텍스쳐")
if apply_subtle_gradient:
im = self._apply_subtle_gradient(im)
applied_techniques.append("미세그라데이션")
# 5. 회전 (마지막에 적용)
if rotation_range and len(rotation_range) == 2:
angle = random.choice([rotation_range[0], rotation_range[1]])
im = im.rotate(angle, expand=False, resample=Image.BICUBIC, fillcolor=(255, 255, 255))
applied_techniques.append(f"회전{angle}")
# 6. 최종 저장
im.save(output_path, format="PNG", dpi=target_dpi)
techniques_str = ", ".join(applied_techniques) if applied_techniques else "없음"
self.logger.log(f"썸네일 변형 완료 - 적용기법: [{techniques_str}], DPI: {target_dpi}, 크기: {target_size}", level=logging.INFO)
return True
except Exception as e:
self.logger.log(f"썸네일 이미지 처리 실패: {e}", level=logging.ERROR)
return False
async def process_thumbnails(self, toggle_states):
try:
@ -164,7 +511,6 @@ class ThumbnailHandler:
# 원본 이미지 다운로드
# 표준 라이브러리로 다운로드
import urllib.request
try:
urllib.request.urlretrieve(src, self.ed_original_path)
self.logger.log(f"원본 썸네일 다운로드 완료: {self.ed_original_path}", level=logging.DEBUG)
@ -173,37 +519,47 @@ class ThumbnailHandler:
self.set_progress_visible_signal.emit(False)
return
# 좌우 반전 및 ±15도 회전 수행
try:
with Image.open(self.ed_original_path) as im:
# 좌우 반전
mirrored = ImageOps.mirror(im)
# 회전 각도: 좌/우 15도 중 랜덤 선택
angle = random.choice([-15, 15])
# 중심 기준 회전, 크기 유지(expand=False), 배경 투명 유지 시도
rotated = mirrored.rotate(angle, expand=False, resample=Image.BICUBIC, fillcolor=(255, 255, 255, 0) if im.mode in ("RGBA", "LA") else None)
# 저장 (PNG 권장)
rotated.save(self.ed_transformed_path, format="PNG")
self.logger.log(f"썸네일 변형 완료(좌우반전, 회전 {angle}도): {self.ed_transformed_path}", level=logging.INFO)
except Exception as e:
self.logger.log(f"썸네일 변형 처리 실패: {e}", level=logging.ERROR)
# 이미지 변형 및 감지회피 기법 적용
success = self.process_thumbnail_image(
input_path=self.ed_original_path,
output_path=self.ed_transformed_path,
target_size=(1000, 1000),
target_dpi=(72, 72),
mirror=True,
rotation_range=(-5, 5),
apply_color_variation=True,
apply_gaussian_noise=True,
apply_crop_resize=True,
apply_scale_variation=True,
apply_blur_sharpen=True,
apply_random_background=True,
apply_subtle_texture=True,
apply_subtle_gradient=True
)
if not success:
self.logger.log("썸네일 변형 처리 실패", level=logging.ERROR)
self.set_progress_visible_signal.emit(False)
return
# 기존 첫 번째 썸네일 삭제
try:
delete_btn = await first_card.query_selector(self.delete_buttons_selector)
# 1번 썸네일 카드의 삭제 버튼 클릭
thumbnail_cards = await self.page.query_selector_all(self.thumbnail_cards)
if not thumbnail_cards or len(thumbnail_cards) < 1:
self.logger.log("삭제할 썸네일 카드가 없습니다.", level=logging.ERROR)
return
first_card = thumbnail_cards[0]
delete_btn = await first_card.query_selector("span:has-text('삭제')")
if delete_btn:
await delete_btn.click()
self.logger.log("첫 번째 썸네일 삭제 버튼 클릭", level=logging.DEBUG)
self.logger.log("1번 썸네일 삭제 버튼 클릭", level=logging.DEBUG)
await asyncio.sleep(1)
else:
self.logger.log("첫 번째 썸네일 삭제 버튼을 찾을 수 없습니다.", level=logging.ERROR)
self.set_progress_visible_signal.emit(False)
self.logger.log("삭제 버튼을 찾을 수 없습니다.", level=logging.ERROR)
return
except Exception as e:
self.logger.log(f"첫 번째 썸네일 삭제 중 오류: {e}", level=logging.ERROR, exc_info=True)
self.set_progress_visible_signal.emit(False)
self.logger.log(f"삭제 버튼 클릭 중 오류 발생: {e}", level=logging.ERROR, exc_info=True)
return
# 업로드 카드에서 업로드 실행
@ -247,10 +603,16 @@ class ThumbnailHandler:
# 업로드 직후 썸네일을 맨 앞으로 이동
try:
moved = await self.move_last_thumbnail_to_front()
self.logger.log(f"업로드 썸네일 맨 앞으로 이동 결과: {moved}", level=logging.INFO)
# 동적 대기: 썸네일 구조가 완전히 업데이트될 때까지 대기
structure_ready = await self.wait_for_thumbnail_structure_ready(timeout=5.0)
if structure_ready:
self.logger.log("[ed_mode] 썸네일 이동 시작 - 구조 업데이트 완료", level=logging.INFO)
moved = await self.move_last_thumbnail_to_front()
self.logger.log(f"[ed_mode] 업로드 썸네일 맨 앞으로 이동 결과: {moved}", level=logging.INFO)
else:
self.logger.log("[ed_mode] 썸네일 구조 대기 타임아웃 - 이동 생략", level=logging.WARNING)
except Exception as e:
self.logger.log(f"썸네일 이동 중 오류(ed_mode): {e}", level=logging.WARNING)
self.logger.log(f"[ed_mode] 썸네일 이동 중 오류: {e}", level=logging.WARNING)
# 진행률 및 마무리
self.update_detail_progress_signal.emit(1, 1)
@ -388,12 +750,19 @@ class ThumbnailHandler:
self.logger.log("이미지 삽입 버튼 클릭 완료", level=logging.DEBUG)
# 누끼 전용 모드일 때, 방금 추가된 썸네일을 맨 앞으로 이동
try:
await asyncio.sleep(0.5)
if thumb_nukki and not thumb:
moved = await self.move_last_thumbnail_to_front()
self.logger.log(f"방금 추가된 썸네일 맨 앞으로 이동 결과: {moved}", level=logging.INFO)
# 동적 대기: 썸네일 구조가 완전히 업데이트될 때까지 대기
structure_ready = await self.wait_for_thumbnail_structure_ready(timeout=5.0)
if structure_ready:
self.logger.log("[일반모드] 썸네일 이동 시작 - 누끼 전용 모드, 구조 업데이트 완료", level=logging.INFO)
moved = await self.move_last_thumbnail_to_front()
self.logger.log(f"[일반모드] 방금 추가된 썸네일 맨 앞으로 이동 결과: {moved}", level=logging.INFO)
else:
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)
self.logger.log(f"[일반모드] 썸네일 순서 이동 중 오류: {e}", level=logging.WARNING)
else:
self.logger.log("업로드 버튼을 찾을 수 없습니다.", level=logging.ERROR)
@ -693,6 +1062,69 @@ class ThumbnailHandler:
self.logger.log(f"버튼 클릭 또는 대기 중 예외 발생: {e}", level=logging.ERROR, exc_info=True)
return False
async def wait_for_thumbnail_structure_ready(self, timeout: float = 5.0) -> bool:
"""
썸네일 카드 구조가 완전히 업데이트될 때까지 동적으로 대기합니다.
기대 구조:
- 최소 2 카드 (썸네일 + 업로드)
- 번째 카드: thumbnail_card 타입
- 마지막 카드: upload_card 타입
Returns:
bool: 구조가 완료되면 True, 타임아웃시 False
"""
start_time = asyncio.get_event_loop().time()
check_interval = 0.1 # 100ms마다 체크
self.logger.log("[동적대기] 썸네일 카드 구조 완료 대기 시작", level=logging.DEBUG)
while True:
try:
cards = await self.page.query_selector_all(self.thumbnail_cards)
current_time = asyncio.get_event_loop().time()
# 타임아웃 체크
if current_time - start_time > timeout:
self.logger.log(f"[동적대기] 타임아웃 ({timeout}초) - 현재 카드 개수: {len(cards)}", level=logging.WARNING)
return False
# 기본 조건: 최소 2개 카드 필요
if len(cards) < 2:
self.logger.log(f"[동적대기] 카드 개수 부족: {len(cards)}개 (최소 2개 필요)", level=logging.DEBUG)
await asyncio.sleep(check_interval)
continue
# 첫 번째 카드 검사 (썸네일 카드여야 함)
first_card = cards[0]
first_upload_btn = await first_card.query_selector("span:has-text('Upload')")
first_img = await first_card.query_selector("img")
first_delete = await first_card.query_selector("span:has-text('삭제')")
is_first_thumbnail = (not first_upload_btn) and first_img and first_delete
# 마지막 카드 검사 (업로드 카드여야 함)
last_card = cards[-1]
last_upload_btn = await last_card.query_selector("span:has-text('Upload')")
last_img = await last_card.query_selector("img")
is_last_upload = last_upload_btn and (not last_img)
# 구조 완료 조건 확인
if is_first_thumbnail and is_last_upload:
elapsed = current_time - start_time
self.logger.log(f"[동적대기] 썸네일 구조 완료 확인 ({elapsed:.2f}초) - 카드 {len(cards)}", level=logging.DEBUG)
return True
else:
self.logger.log(f"[동적대기] 구조 미완료 - 첫번째카드: {is_first_thumbnail}, 마지막카드: {is_last_upload}", level=logging.DEBUG)
await asyncio.sleep(check_interval)
continue
except Exception as e:
self.logger.log(f"[동적대기] 구조 확인 중 오류: {e}", level=logging.WARNING)
await asyncio.sleep(check_interval)
continue
async def move_last_thumbnail_to_front(self) -> bool:
"""
업로드 직후 마지막(실제) 썸네일 카드를 드래그하여 번째 위치로 이동합니다.
@ -703,22 +1135,55 @@ class ThumbnailHandler:
try:
# 현재 썸네일 카드 재조회 (마지막은 업로드 카드로 가정)
cards = await self.page.query_selector_all(self.thumbnail_cards)
# self.logger.log(f"[드래그디버그] 총 카드 개수: {len(cards)}", level=logging.DEBUG)
if not cards or len(cards) < 2:
self.logger.log("이동할 썸네일 카드가 충분하지 않습니다.", level=logging.WARNING)
return False
# 각 카드의 내용 확인 (디버깅)
for i, card in enumerate(cards):
try:
# Upload 버튼이 있는지 확인
upload_btn = await card.query_selector("span:has-text('Upload')")
img_elem = await card.query_selector("img")
delete_btn = await card.query_selector("span:has-text('삭제')")
card_type = "unknown"
if upload_btn:
card_type = "upload_card"
elif img_elem and delete_btn:
card_type = "thumbnail_card"
elif img_elem:
card_type = "image_only"
# self.logger.log(f"[드래그디버그] 카드 {i}: {card_type} (upload_btn: {upload_btn is not None}, img: {img_elem is not None}, delete: {delete_btn is not None})", level=logging.DEBUG)
except Exception as e:
self.logger.log(f"[드래그디버그] 카드 {i} 분석 중 오류: {e}", level=logging.WARNING)
# 업로드 카드가 마지막에 위치한다고 가정하면, 실제 마지막 썸네일은 -2
if len(cards) >= 2:
source_card = cards[-2] if len(cards) >= 2 else None
target_card = cards[0]
else:
self.logger.log("썸네일 카드 목록이 예상과 다릅니다.", level=logging.WARNING)
return False
# 하지만 업로드 직후에는 구조가 다를 수 있으므로 동적으로 확인
source_card = None
target_card = cards[0] if len(cards) > 0 else None
# 뒤에서부터 첫 번째 썸네일 카드(Upload 버튼이 없는 카드) 찾기
for i in range(len(cards) - 1, -1, -1):
try:
upload_btn = await cards[i].query_selector("span:has-text('Upload')")
if not upload_btn: # Upload 버튼이 없으면 썸네일 카드
source_card = cards[i]
# self.logger.log(f"[드래그디버그] source_card 선택: 인덱스 {i} (뒤에서 {len(cards)-i}번째)", level=logging.DEBUG)
break
except Exception as e:
# self.logger.log(f"[드래그디버그] 카드 {i} Upload 버튼 확인 중 오류: {e}", level=logging.WARNING)
continue
if source_card is None or target_card is None:
self.logger.log("드래그 대상 요소를 찾지 못했습니다.", level=logging.WARNING)
# self.logger.log(f"[드래그디버그] 드래그 대상 요소를 찾지 못했습니다. source: {source_card is not None}, target: {target_card is not None}", level=logging.WARNING)
return False
# self.logger.log("[드래그디버그] 드래그 시작 - source_card와 target_card 확인 완료", level=logging.DEBUG)
# drag_and_drop 시도 (가능한 경우)
try:
await source_card.drag_to(target_card)

View File

@ -20,9 +20,11 @@
- 크롬확장 1.1.185_0 업데이트 적용
- 상품편집 시작시 편집설정 재반영 적용(알바생 실행 이후 편집설정을 변경해도 반영되도록)
- 누끼제거 모듈 변경 및 최적화(기존 10초 -> 0.3초)
- 누끼이미지 최적화(비율고정/마진크기축소/DPI-72고정)
- 누끼를 따면 무조건 첫 이미지로
- 썸네일 번역없이 배경제거만 할 경우, 해당 이미지를 맨 앞으로 배치
- OCR 모듈 변경으로 avx등 구형PC 지원확대
- GPU 가속모듈 변경으로 범용성 확대 (Nvidia->내장그래픽)
- 누끼이미지 최적화(비율고정/마진크기축소/DPI-72고정)
- 금지어 추가시 기본값을 '금지'▷'비허용' 변경
- 금지어 필터링 방식 옵션 제공 ['일치', '포함']
금지어: '샤넬'
@ -31,13 +33,16 @@
포함방식 : '샤넬'과 '샤넬가방' 모두 삭제
- 중지버튼 삭제
- 첫번째 옵션이미지의 대표썸네일 설정 기능 추가
- 썸네일 번역없이 배경제거만 할 경우, 해당 이미지를 맨 앞으로 배치
- 등록상품모드 추가
- 등록상품모드에서 등급별 편집가능한 범위
Basic - 상품명 편집
Premium - 상품명 편집 + 가격 편집
VIP - 상품명 편집 + 썸네일 편집 + 가격 편집 + 옵션편집
상품명 : 기존 키고정 여부와 셔플 or 생성을 모두 적용 가능
썸네일 : 동일이미지 판단 해시우회를 위해 1.좌우반전, 2.미세한각도조정, 3.채도/밝기 미세조정, 4.스케일조정, 5.가우시안노이즈조정, 6.미세블러적용 7.배경미세텍스쳐 8.배경색랜덤변경 등으로 사용자눈에는 차이가 없으나 해시값의 변화를 불러오는 내용 적용.
옵션 : 다양한 넘버링 적용.
# 3.12.0 마이너 업데이트 로그
### 오류수정