feat: 새 파파고 CURL 번역기 및 관련 테스트와 설정 파일들을 추가하고 파파고 기기 ID를 관리합니다.

This commit is contained in:
9700X_PC 2026-03-05 07:20:51 +09:00
parent 54854f3bf9
commit 1aaa1f355c
16 changed files with 1764 additions and 296 deletions

318
check_setup.py Normal file
View File

@ -0,0 +1,318 @@
#!/usr/bin/env python
"""
check_setup.py - 빌드 모듈 커버리지 검증 도구
프로젝트 루트의 모든 .py 파일을 스캔해서,
setup_ob2.py CYTHON_MODULES / BASE_INCLUDES / BASE_EXCLUDES_RAW
포함되지 않은 "로컬 모듈" 있으면 경고합니다.
사용법:
python check_setup.py # 검사만 (경고 있어도 계속)
python check_setup.py --strict # 누락 발견 시 exit(1) (빌드 중단)
python check_setup.py --fix # 누락 모듈을 CYTHON_MODULES 추가 안내 출력
"""
import ast
import os
import sys
from pathlib import Path
from typing import Set, List, Tuple
# ============================================================
# 설정
# ============================================================
ROOT = Path(__file__).parent.resolve()
SETUP_FILE = ROOT / "setup_ob2.py"
# 검사 대상에서 제외할 디렉토리
SKIP_DIRS: Set[str] = {
# 빌드 결과물
"build", "dist", ".git", "__pycache__",
"Lib", "lib", "Scripts", "Include",
".venv", "venv", ".mypy_cache", ".pytest_cache",
"node_modules",
# 별도 관리 컴포넌트
"updateManager",
# 제3자 / OCR / AI 모델 라이브러리
"ppocr", "PP_Models", "paddle", "paddleocr",
# 개발용 유틸리티 및 레거시
"tools", "old",
# 로그 분석 / 테스트
"loggerModules", "logs",
# 사용안하는 모듈
"ai_modules", "ocr_backends", "limited_contents",
# pyu-data 등 파일명에 점이 있는 특수 디렉토리
"pyu-data",
}
# 검사 대상에서 제외할 루트 파일 (빌드 전용 스크립트 등)
SKIP_ROOT_FILES: Set[str] = {
# 빌드 관련
"setup_ob2.py", "setup_clean.py", "setup.py",
"generate_iss.py", "check_setup.py",
"conftest.py",
# 일회성 개발/테스트 스크립트
"vertexAI.py", "update_ai_client.py", "upload_categories.py",
"user_manual_dialog.py", "whale_new.py", "whale_test.py",
"whale_translator.py", "whale_translator2.py", "widgetmap.py",
"title.py", "toggleSwitch_bak.py", "release_note_dialog.py",
"recovery_log.py", "settings_dialog.py", "signup_dialog.py",
"simple_test.py", "simple_translate.py",
"setup1.py", "setup_non_img.py", "setup_onnx.py", "setup_paddle.py",
}
# test_ 로 시작하는 루트 파일은 테스트 스크립트이므로 자동 제외
SKIP_ROOT_PREFIX: Set[str] = {"test_"}
# 알려진 표준 라이브러리 최상위 패키지 (Python 3.10+ 내장, 이하 버전은 추정)
try:
_STDLIB: Set[str] = sys.stdlib_module_names # type: ignore[attr-defined]
except AttributeError:
_STDLIB: Set[str] = set(sys.builtin_module_names) | {
"abc", "ast", "asyncio", "atexit", "base64", "collections",
"concurrent", "contextlib", "copy", "csv", "dataclasses",
"datetime", "decimal", "enum", "functools", "glob", "hashlib",
"http", "importlib", "inspect", "io", "itertools", "json",
"logging", "math", "multiprocessing", "operator", "os",
"pathlib", "pickle", "platform", "pprint", "queue", "random",
"re", "shutil", "signal", "socket", "sqlite3", "string",
"struct", "subprocess", "sys", "tempfile", "threading",
"time", "traceback", "typing", "unittest", "urllib", "uuid",
"warnings", "weakref", "xml", "zipfile", "zlib",
}
# 알려진 서드파티 최상위 패키지 (로컬 모듈로 오탐되지 않도록 제외)
_KNOWN_THIRD_PARTY: Set[str] = {
"PySide6", "shiboken6", "PIL", "cv2", "numpy", "pandas",
"requests", "httpx", "curl_cffi", "supabase", "gotrue",
"openai", "playwright", "psutil", "pyperclip", "comtypes",
"win32api", "win32com", "win32event", "win32gui", "win32con",
"winerror", "pythoncom", "pydantic", "pydantic_core", "greenlet",
"translatepy", "bs4", "markdown", "packaging", "tqdm",
"Cython", "cx_Freeze", "setuptools", "pkg_resources",
"google", "anthropic", "aiohttp", "httpcore",
"storage3", "postgrest", "supafunc", "realtime",
"ctypes", "sleep_control",
}
# ============================================================
# setup_ob2.py AST 파싱 헬퍼
# ============================================================
def _parse_string_list_from_ast(tree: ast.Module, var_name: str) -> List[str]:
"""AST 트리에서 특정 변수에 할당된 문자열 리스트를 추출합니다."""
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if isinstance(target, ast.Name) and target.id == var_name:
if isinstance(node.value, ast.List):
result = []
for elt in node.value.elts:
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
result.append(elt.value)
return result
return []
def load_setup_config() -> Tuple[Set[str], Set[str], Set[str]]:
"""
setup_ob2.py AST 파싱해서
(cython_module_names, base_include_names, base_exclude_names) 반환합니다.
모두 표기법(dot notation) 모듈명 집합입니다.
"""
if not SETUP_FILE.exists():
print("[check_setup] ERROR: {} 를 찾을 수 없습니다.".format(SETUP_FILE))
sys.exit(1)
src = SETUP_FILE.read_text(encoding="utf-8")
tree = ast.parse(src, filename=str(SETUP_FILE))
# CYTHON_MODULES: 경로 형식("src/contents/option") -> 점 표기법
raw_cython = _parse_string_list_from_ast(tree, "CYTHON_MODULES")
cython_names: Set[str] = set()
for p in raw_cython:
clean = os.path.splitext(p)[0].replace("/", ".").replace("\\", ".")
cython_names.add(clean)
# BASE_INCLUDES: 이미 점 표기법
raw_includes = _parse_string_list_from_ast(tree, "BASE_INCLUDES")
include_names: Set[str] = set(raw_includes)
# BASE_EXCLUDES_RAW: 의도적으로 제외하는 로컬 모듈
raw_excludes = _parse_string_list_from_ast(tree, "BASE_EXCLUDES_RAW")
exclude_names: Set[str] = set(raw_excludes)
return cython_names, include_names, exclude_names
# ============================================================
# 프로젝트 파일 스캔
# ============================================================
def get_project_py_files() -> List[Path]:
"""프로젝트 루트의 모든 .py 파일을 반환합니다. (제외 디렉토리 필터링 포함)"""
result: List[Path] = []
for path in sorted(ROOT.rglob("*.py")):
rel = path.relative_to(ROOT)
parts = rel.parts
# 제외 디렉토리 체크
if any(part in SKIP_DIRS for part in parts[:-1]):
continue
# 루트 파일 제외 (정확한 파일명)
if len(parts) == 1 and path.name in SKIP_ROOT_FILES:
continue
# 루트 파일 제외 (prefix 패턴, e.g. test_*)
if len(parts) == 1 and any(path.name.startswith(p) for p in SKIP_ROOT_PREFIX):
continue
result.append(path)
return result
def path_to_module_name(path: Path) -> str:
"""
절대 경로 -> 표기법 모듈명.
: ROOT/src/contents/option.py -> src.contents.option
ROOT/src/contents/__init__.py -> src.contents
"""
rel = path.relative_to(ROOT)
parts = list(rel.parts)
if parts[-1] == "__init__.py":
parts = parts[:-1]
else:
parts[-1] = parts[-1][:-3] # .py 제거
return ".".join(parts)
# ============================================================
# 커버리지 판정
# ============================================================
def is_covered(module_name: str, cython: Set[str], includes: Set[str], excludes: Set[str]) -> bool:
"""
모듈이 setup_ob2.py 의해 커버되는지 확인합니다.
커버 조건 (하나라도 해당 OK):
1) CYTHON_MODULES 포함
2) BASE_INCLUDES 포함
3) BASE_EXCLUDES_RAW 포함 (의도적 제외 = 문제 없음)
4) 상위 패키지가 하나에 포함
5) 표준 라이브러리 or 알려진 서드파티
"""
all_covered = cython | includes | excludes
parts = module_name.split(".")
for depth in range(len(parts), 0, -1):
candidate = ".".join(parts[:depth])
if candidate in all_covered:
return True
top = parts[0]
if top in _STDLIB or top in _KNOWN_THIRD_PARTY:
return True
return False
def is_only_third_party(module_name: str) -> bool:
"""최상위 패키지가 서드파티 or 표준 라이브러리인지 확인합니다."""
top = module_name.split(".")[0]
return top in _STDLIB or top in _KNOWN_THIRD_PARTY
# ============================================================
# 메인 검사 함수
# ============================================================
def run_check(strict: bool = False, show_fix: bool = False) -> List[Tuple[str, Path]]:
"""
누락 모듈을 검사하고 결과를 반환합니다.
Args:
strict: True 이면 누락 발견 sys.exit(1)
show_fix: True 이면 CYTHON_MODULES / BASE_INCLUDES 추가 안내 출력
Returns:
[(module_name, file_path), ...] 누락 목록
"""
print("\n" + "=" * 60)
print(" [check_setup] 빌드 전 모듈 커버리지 검사 시작")
print("=" * 60)
# 1. setup_ob2.py 파싱
cython_names, include_names, exclude_names = load_setup_config()
print(" CYTHON_MODULES : {}".format(len(cython_names)))
print(" BASE_INCLUDES : {}".format(len(include_names)))
print(" BASE_EXCLUDES : {}".format(len(exclude_names)))
# 2. 프로젝트 파일 스캔
py_files = get_project_py_files()
print(" 스캔 대상 파일 : {}\n".format(len(py_files)))
# 3. 커버리지 판정
uncovered: List[Tuple[str, Path]] = []
for path in py_files:
module = path_to_module_name(path)
if is_only_third_party(module):
continue
if not is_covered(module, cython_names, include_names, exclude_names):
uncovered.append((module, path))
# 4. 결과 출력
if not uncovered:
print(" [OK] 모든 모듈이 setup_ob2.py 에 등록되어 있습니다.\n")
return []
print(" [WARN] 누락된 모듈 {}개 발견!\n".format(len(uncovered)))
print(" {:<50} 파일 경로".format("모듈명"))
print(" {} {}".format("-" * 50, "-" * 30))
for module, path in uncovered:
rel_path = path.relative_to(ROOT)
print(" {:<50} {}".format(module, rel_path))
# 5. --fix 안내
if show_fix:
print("\n" + "-" * 60)
print(" [추천] CYTHON_MODULES 에 추가할 항목 (경로 형식):")
print("-" * 60)
for module, path in uncovered:
rel_path = path.relative_to(ROOT).as_posix().replace(".py", "")
print(' "{}",'.format(rel_path))
print("\n 또는 BASE_INCLUDES 에 추가할 항목 (점 표기법):")
print("-" * 60)
for module, _ in uncovered:
print(' "{}",'.format(module))
print()
# 6. strict 모드: 빌드 중단
if strict:
print(" [ERROR] --strict 모드: 누락 모듈이 있어 빌드를 중단합니다.")
print(" 위 모듈을 setup_ob2.py 에 추가한 후 다시 빌드하세요.\n")
sys.exit(1)
else:
print(" [WARN] 경고만 표시합니다. (빌드는 계속)")
print(" --strict 옵션으로 실행하면 빌드를 자동 중단할 수 있습니다.\n")
return uncovered
# ============================================================
# 진입점
# ============================================================
if __name__ == "__main__":
strict_mode = "--strict" in sys.argv
fix_mode = "--fix" in sys.argv
run_check(strict=strict_mode, show_fix=fix_mode)

View File

@ -0,0 +1 @@
14b37dbe-4a71-4a8d-9afa-7de0eda627c3

82
main.py
View File

@ -13,11 +13,14 @@ from mainUI_SP import MAIN_GUI
# from limited_gui import shuffleMAIN_GUI
from src.modules.settings_manager import SettingsManager
from loggerModule import Logger
from startup_guard import safe_create_log_dir, guard_startup, show_startup_error
# from src.loggerModules.loggerModule_structlog import StructlogImprovedLogger
from updateManager.__version__ import __file_log_level__, __gui_log_level__, __version__
from src.ui.global_style import apply_global_theme, toggle_global_theme
import socket
import re
import traceback as _traceback_mod
# Windows DPI Awareness Constants
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4
@ -25,6 +28,53 @@ DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4
# 윈도우 플랫폼 여부 확인
IS_WIN = sys.platform.startswith('win')
# ============================================================
# [보안] 패키징 환경에서 개발 PC 경로 노출 방지
# .pyc 파일에 빌드 시 개발 PC 경로가 co_filename 으로 박혀 있어,
# 예외 발생 시 traceback 에 해당 경로가 그대로 출력됩니다.
# 아래 핸들러는 유저에게 보이는 메시지에서 경로를 <app> 으로 치환하고,
# 로그 파일에는 원본 전체 내용을 남겨 디버깅이 가능하게 합니다.
# ============================================================
_DEV_PATH_PATTERN = re.compile(
r'(?:[A-Za-z]:\\|/)(?:[^"\n]+?[\\/])*([A-Za-z]:\\[^"\n]+|/[^"\n]+)',
re.IGNORECASE
)
def _sanitize_path(text: str) -> str:
"""traceback 문자열에서 절대 경로를 <app> 으로 치환합니다."""
return re.sub(
r'(?i)(?:[A-Za-z]:\\|/)(?:[^"\n<>|?*]+[\\/])+',
'<app>\\\\',
text
)
_original_excepthook = sys.excepthook
_app_logger_ref = None # main() 에서 logger 참조를 주입받음
def _safe_excepthook(exc_type, exc_value, exc_tb):
"""패키징 환경용 전역 예외 핸들러."""
lines = _traceback_mod.format_exception(exc_type, exc_value, exc_tb)
full_tb = ''.join(lines) # 원본 (경로 포함)
# 1) 로그 파일에는 원본 전체 기록
if _app_logger_ref is not None:
try:
_app_logger_ref.log(
f"[UnhandledException]\n{full_tb}",
level=logging.ERROR
)
except Exception:
pass
# 2) 콘솔/화면에는 경로를 가려서 출력
sanitized = _sanitize_path(full_tb)
print(sanitized, file=sys.stderr)
def install_safe_excepthook():
"""패키징 환경(frozen)일 때만 안전한 예외 핸들러를 설치합니다."""
if getattr(sys, 'frozen', False):
sys.excepthook = _safe_excepthook
def is_already_running(port=56387):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
@ -91,32 +141,28 @@ def get_application_path():
def setup_logging():
"""
로그 디렉토리와 파일을 설정하고 경로를 반환합니다.
- 1순위: 설치 경로 logs/ 폴더
- 2순위: %LOCALAPPDATA%\\Edit_PartTimer3\\logs\ (권한 없을 자동 폴백)
- 모두 실패: 관리자 권한 안내 메시지 표시 종료
"""
base_path = get_application_path()
logs_dir = os.path.join(base_path, "logs")
# 로그 디렉토리 생성 (존재하지 않는 경우)
try:
if not os.path.exists(logs_dir):
os.makedirs(logs_dir)
print(f"로그 디렉토리 생성됨: {logs_dir}")
except Exception as e:
print(f"로그 디렉토리 생성 실패: {e}")
logs_dir = base_path # 실패 시 기본 경로 사용
preferred_logs_dir = os.path.join(base_path, "logs")
# startup_guard 를 통해 안전하게 디렉토리 생성 (폴백 + 권한 오류 안내 포함)
logs_dir = safe_create_log_dir(preferred_logs_dir)
log_file_path = os.path.join(logs_dir, "Edit_PartTimer3.log")
# # 로그 파일 경로 출력 (디버깅용)
# print(f"로그 파일 경로: {log_file_path}")
return {
"logs_dir": logs_dir,
"log_file_path": log_file_path
}
@guard_startup
def main():
# 패키징 환경 경로 노출 방지 핸들러 설치 (frozen 일 때만 작동)
install_safe_excepthook()
# 로그 설정
log_paths = setup_logging()
@ -126,6 +172,10 @@ def main():
logger_name="EP3_Logger",
file_log_level=__file_log_level__
)
# 예외 핸들러에 로거 주입 (로그 파일에 원본 traceback 기록용)
global _app_logger_ref
_app_logger_ref = logger
# try:
# if mp.current_process().name == "MainProcess":

View File

@ -17,6 +17,9 @@ from cx_Freeze.command.build_exe import build_exe as _build_exe
from setuptools import setup as cy_setup, Extension
from Cython.Build import cythonize
# 빌드 전 모듈 커버리지 검증 도구
from check_setup import run_check as _run_module_check
# 버전 및 메타데이터 가져오기
@ -163,6 +166,7 @@ BASE_EXCLUDES = [pkg for pkg in BASE_EXCLUDES_RAW if pkg not in CYTHON_MODULE_NA
BASE_INCLUDES = [
'browser_control',
'loggerModule', 'toggleSwitch',
'startup_guard', # 시작 오류 처리 모듈
'src.cmdDiag', 'src.inputDiag', 'src.keyword', 'src.priceSetDiag',
# 'src.titleManager',
# 'src.titleManager.naver_parser', 'src.titleManager.naverAPI', 'src.titleManager.gpt_client', 'src.titleManager.grok_client',
@ -394,8 +398,13 @@ def collect_include_files():
# [Main Logic] 실행 로직
# ============================================================================
# 0. 기존 빌드찌꺼기 제거
# 0-1. 빌드 전 모듈 커버리지 사전 검사
# --strict 콘솔 인자가 있으면 누락 발견 시 빌드 중단, 없으면 경고만 표시
_strict_mode = "--strict" in sys.argv
_fix_mode = "--fix" in sys.argv
_run_module_check(strict=_strict_mode, show_fix=_fix_mode)
# 0-2. 기존 빌드찌꺼기 제거
run_setup_clean()
# 1. Cython 빌드 실행
@ -424,7 +433,7 @@ build_options = {
'excludes': final_excludes,
'include_files': final_include_files,
'zip_include_packages': [],
'optimize': 0,
'optimize': 1, # 1=docstring 제거(경량화), 2=assert 제거(부작용 위험 있음)
'silent': True,
'include_msvcr': True,
}
@ -440,6 +449,10 @@ class CustomBuildExe(_build_exe):
print("\n" + "="*60)
print(" AutoPercenty3 통합 빌드 시스템 시작")
print("="*60 + "\n")
# 빌드 전 모듈 커버리지 재검사 (엄격 모드: 누락 발견 시 빌드 중단)
_run_module_check(strict=True, show_fix=True)
try:
# 총 6단계로 구성 (Cython은 이미 완료됨 -> 10%)
with tqdm(total=100, initial=10, unit="pct",

View File

@ -376,10 +376,252 @@ class DetailHandler:
lines += ["", "### 나열된 옵션목록 이외의 옵션이 필요하실 경우 고객센터로 연락주세요.", "---", ""]
await input_field.fill("\n".join(lines))
async def _uncheck_option_image_insert(self):
"""
심플모드 상세페이지의 '옵션 이미지 삽입' 체크박스를 해제합니다.
이미 해제된 상태라면 아무 동작도 하지 않습니다.
"""
try:
checkbox_input = self.page.locator(
'label.ant-checkbox-wrapper:has(span:text("옵션 이미지 삽입")) input.ant-checkbox-input'
).first
# 체크박스가 DOM에 존재하는지 확인
if await checkbox_input.count() == 0:
self.logger.log("'옵션 이미지 삽입' 체크박스를 찾을 수 없습니다.", level=logging.DEBUG)
return
is_checked = await checkbox_input.is_checked()
if is_checked:
await checkbox_input.click()
self.logger.log("[심플모드] '옵션 이미지 삽입' 체크박스 해제 완료", level=logging.INFO)
else:
self.logger.log("[심플모드] '옵션 이미지 삽입' 체크박스가 이미 해제 상태", level=logging.DEBUG)
except Exception as e:
self.logger.log(f"'옵션 이미지 삽입' 체크박스 해제 중 오류: {e}", level=logging.WARNING)
def _build_simple_type2_html(self, type2_items: list, type2_label: str, is_excluded: bool = False) -> str:
"""
심플모드 서브옵션(Type2) HTML 블록을 생성합니다.
글자 평균에 따라 레이아웃을 자동 분기합니다:
- 케이스 A: 항목 많고 글자 2 압축형
- 케이스 B: 글자가 중간 길이 1 세로 나열형
- 케이스 C: 글자가 짧을 (S/M/L ) 가로 버튼형
"""
if not type2_items:
return ""
avg_len = sum(len(it['trans']) for it in type2_items) / max(1, len(type2_items))
tag_colors = ['#eeeeee'] if is_excluded else ['#fff9c4', '#e3f2fd', '#ffe0b2', '#e8f5e9', '#f3e5f5']
txt_color = "#777777" if is_excluded else "#333333"
t2_html = f'<p style="margin:0 0 10px 0;"><span style="color:#888888; font-size:13px;">📏 {type2_label}</span></p>'
# 케이스 A: 항목이 많고 글자도 길 때 (2단 표 압축형)
if avg_len >= 8 and len(type2_items) >= 4:
t2_html += '<table style="width:100%; border:none; border-collapse:collapse;"><tbody><tr>'
half = (len(type2_items) + 1) // 2
for col_idx in range(2):
t2_html += '<td style="width:50%; vertical-align:top; border:none; padding:0;"><p style="margin:0; line-height:2.0;">'
chunk = type2_items[col_idx * half: (col_idx + 1) * half]
for idx, it in enumerate(chunk):
c = tag_colors[(col_idx * half + idx) % len(tag_colors)]
t2_html += (
f'<span style="background-color:{c}; color:{txt_color}; font-size:13px; '
f'padding:3px 8px; margin-bottom:4px; display:inline-block;">'
f'<strong>{it["trans"]}</strong></span><br>'
)
t2_html += '</p></td>'
t2_html += '</tr></tbody></table>'
# 케이스 B: 글자가 길 때 (1단 세로 나열형)
elif avg_len > 6:
t2_html += '<p style="margin:0; line-height:2.2;">'
for idx, it in enumerate(type2_items):
c = tag_colors[idx % len(tag_colors)]
t2_html += (
f'<span style="background-color:{c}; color:{txt_color}; font-size:13px; '
f'padding:4px 10px; margin-bottom:4px; display:inline-block;">'
f'<strong>{it["trans"]}</strong></span><br>'
)
t2_html += '</p>'
# 케이스 C: 글자가 짧을 때 (가로 버튼형 - S/M/L 등)
else:
t2_html += '<p style="margin:0; line-height:2.2;">'
for idx, it in enumerate(type2_items):
c = tag_colors[idx % len(tag_colors)]
t2_html += (
f'<span style="background-color:{c}; color:{txt_color}; font-size:14px; '
f'padding:4px 12px; margin:2px;">'
f'<strong>{it["trans"]}</strong></span>'
)
t2_html += '</p>'
return t2_html
def _build_simple_card_grid(
self,
items: list,
num_cols: int,
type2_items: list,
type2_label: str,
is_excluded: bool = False
) -> str:
"""
심플모드 카드 그리드 HTML을 생성합니다.
Args:
items: 카드 아이템 목록 (type1_selected 또는 type1_excluded)
num_cols: (column) 개수
type2_items: 서브옵션 아이템 목록
type2_label: 서브옵션 레이블 텍스트
is_excluded: True이면 제외(회색) 스타일 적용
"""
if not items:
return ""
border_col = "#777777" if is_excluded else "#1976d2"
head_bg = "#f5f5f5" if is_excluded else "#e3f2fd"
num_col = "#777777" if is_excluded else "#1976d2"
txt_col = "#555555" if is_excluded else "#333333"
opacity = "0.7" if is_excluded else "1.0"
gap_pct = 2 if num_cols == 3 else 4
cell_pct = (100 - (gap_pct * (num_cols - 1))) // num_cols
# 서브옵션 HTML 블록 (모든 카드에 공통으로 삽입)
t2_html_block = self._build_simple_type2_html(type2_items, type2_label, is_excluded)
# 바깥쪽 테이블 (table-layout: fixed 로 가로폭 강제 고정)
html = '<figure class="table" style="width:100%; margin-bottom:40px;">'
html += '<table style="width:100%; border-collapse:collapse; border:0px none transparent; table-layout:fixed;"><tbody>'
for i in range(0, len(items), num_cols):
row_items = items[i:i + num_cols]
html += '<tr>'
for j in range(num_cols):
# 열 사이 스페이서
if j > 0:
html += f'<td style="width:{gap_pct}%; border:0px none transparent; padding:0;">&nbsp;</td>'
if j < len(row_items):
it = row_items[j]
num_text = it.get("new_num", it.get("num", ""))
name = it["trans"]
img_url = it.get("img", "")
badge = " (1:1 문의)" if is_excluded else ""
# 이미지 없음 방어 코드
if not img_url or "ic_image_add" in img_url or img_url.endswith(".svg"):
img_url = "https://placehold.co/400x400/f8f9fa/a1a1aa?text=No+Image"
html += f'<td style="width:{cell_pct}%; padding:0; border:0px none transparent; vertical-align:top;">'
html += f'<table style="width:100%; height:100%; border:2px solid {border_col}; border-collapse:collapse;"><tbody>'
# [카드 헤더]
html += f'<tr><td style="background-color:{head_bg}; padding:15px 10px; text-align:center;">'
if num_text:
html += f'<span style="color:{num_col}; font-size:16px;"><strong>{num_text}</strong></span> '
html += f'<span style="color:{txt_col}; font-size:18px;"><strong>{name}{badge}</strong></span>'
html += '</td></tr>'
# [카드 이미지] (서브옵션 없으면 height:100% 부여)
img_td_style = 'padding:0; background-color:#ffffff;'
if not t2_html_block:
img_td_style += ' height:100%; vertical-align:top;'
html += f'<tr><td style="{img_td_style}">'
html += (
f'<figure class="image" style="margin:0;">'
f'<img src="{img_url}" style="width:100%; display:block; aspect-ratio:1/1; object-fit:cover; opacity:{opacity};">'
f'</figure>'
)
html += '</td></tr>'
# [카드 서브옵션] (서브옵션 있으면 height:100% 부여)
if t2_html_block:
html += '<tr><td style="padding:20px 15px; background-color:#ffffff; text-align:center; vertical-align:top; height:100%;">'
html += t2_html_block
html += '</td></tr>'
html += '</tbody></table></td>'
else:
# 행 마지막 빈 칸 채우기 (레이아웃 붕괴 방지)
html += f'<td style="width:{cell_pct}%; border:0px none transparent; padding:0;">&nbsp;</td>'
html += '</tr>'
# 행 사이 투명 여백(Gap) 생성
if i + num_cols < len(items):
html += f'<tr><td colspan="{num_cols * 2 - 1}" style="height:30px; border:0px none transparent;">&nbsp;</td></tr>'
html += '</tbody></table></figure>'
return html
def _build_simple_options_block(self, optionHandler) -> tuple[str, dict]:
"""
심플모드 옵션 블록 HTML을 생성합니다.
Returns:
(options_block: str, option_data_for_validation: dict)
"""
structured = getattr(optionHandler, 'new_options_structured', None)
is_single = optionHandler.option_info.get("is_single_option", False)
option_data_for_validation = optionHandler.get_new_options_info()
if not structured or is_single:
return "", option_data_for_validation
t1_selected = structured.get("type1_selected", [])
t1_excluded = structured.get("type1_excluded", [])
type2_items = structured.get("type2_items", [])
type2_label = structured.get("type2_label", "상세 옵션")
if not (t1_selected or t1_excluded):
return "", option_data_for_validation
# 열(Column) 수 자동 결정
n = len(t1_selected)
if n <= 2:
cols = n if n > 0 else 1
elif n == 3:
cols = 3
elif n == 4:
cols = 2
else:
cols = 3
selected_cards_html = self._build_simple_card_grid(t1_selected, cols, type2_items, type2_label)
excluded_section = ""
if t1_excluded:
excluded_section = self._build_simple_card_grid(t1_excluded, 3, type2_items, type2_label, is_excluded=True)
options_block = (
"\n\n---\n\n"
'<p style="text-align:center; margin-bottom:25px;">'
'<span style="background-color:#000000; color:#ffffff; font-size:20px; padding:8px 30px; border-radius:30px;">'
'<strong>옵션 선택</strong></span></p>\n'
'<hr style="border-top:2px solid #333333; margin-bottom:30px;">\n\n'
f"{selected_cards_html}\n"
f"{excluded_section}\n\n"
"---\n\n<br><br>\n"
)
self.logger.log(
f"[심플모드] 동적 카드(HTML {cols}열 그리드) 생성 완료: "
f"선택 {len(t1_selected)}개 + 제외 {len(t1_excluded)}",
level=logging.INFO
)
return options_block, option_data_for_validation
async def input_detail_text(self, optionHandler, input_field):
"""
상세페이지에 소개글과 옵션 데이터를 입력하는 메서드
(일반 모드와 심플 모드 분기 처리 변수 스코프 안전성 확보)
상세페이지에 소개글과 옵션 데이터를 입력하는 메서드.
일반 모드와 심플 모드분기 처리합니다.
"""
try:
if not input_field:
@ -390,8 +632,8 @@ class DetailHandler:
# 1. VIP 등록모드 랜덤 소개 문구 (맨 윗줄)
# --------------------------------------------------
vip_intro_block = ""
if (self.toggle_states.get('ed_mode', False) and
self.toggle_states.get('vip_detail_edit', False)):
if (self.toggle_states.get('ed_mode', False) and
self.toggle_states.get('vip_detail_edit', False)):
try:
random_intro = self._get_random_biz_intro()
vip_intro_block = f"{random_intro}\n\n---\n"
@ -406,180 +648,40 @@ class DetailHandler:
leading_block = "\n".join(leading_lines)
# --------------------------------------------------
# 3. 옵션 목록 (★ 심플모드 / 일반모드 분기)
# 3. 옵션 목록 생성
# --------------------------------------------------
is_simple_mode = self.toggle_states.get('optionNumbering_only', False)
options_block = ""
# 스코프 오류 방지를 위한 변수 사전 선언
option_data_for_validation = {}
if is_simple_mode:
structured = getattr(optionHandler, 'new_options_structured', None)
is_single = optionHandler.option_info.get("is_single_option", False)
option_data_for_validation = optionHandler.get_new_options_info()
if structured and not is_single:
t1_selected = structured.get("type1_selected", [])
t1_excluded = structured.get("type1_excluded", [])
type2_items = structured.get("type2_items", [])
type2_label = structured.get("type2_label", "")
has_options = bool(t1_selected or t1_excluded)
if has_options:
# ── 열 수 결정 (카드 개수 기반) ─────────────────
n = len(t1_selected)
if n <= 2:
cols = n if n > 0 else 1 # 1→1열, 2→2열
elif n == 3:
cols = 3 # 3→3열
elif n == 4:
cols = 2 # 4→2열 2행
else:
cols = 3 # 5이상→3열
# flex-basis: (100% / cols) - gap 보정
# gap=12px, cols개라면 각 카드 = calc(100%/cols - 12px*(cols-1)/cols)
gap_px = 12
card_basis = f"calc({100 // cols}% - {gap_px * (cols - 1) // cols}px)"
# type2 서브옵션 인라인 텍스트
type2_row = ""
if type2_items:
label_text = f"{type2_label}&nbsp;|&nbsp;" if type2_label else ""
names_html = "&nbsp;&middot;&nbsp;".join(
f"<b>{it['trans']}</b>" for it in type2_items
)
type2_row = (
f'<div style="font-size:12px;color:#555;margin-top:6px;">'
f'{label_text}{names_html}</div>'
)
# ── HTML 카드 빌더 ──────────────────────────────
def _build_card(num_text, name, img_url, basis, is_excluded=False):
border_color = "#aaaaaa" if is_excluded else "#4a90d9"
bg_color = "#f5f5f5" if is_excluded else "#ffffff"
badge = (
'<span style="display:inline-block;font-size:10px;'
'background:#dddddd;color:#555555;padding:2px 6px;'
'border-radius:4px;margin-left:4px;">문의주문</span>'
) if is_excluded else ""
img_tag = ""
if img_url and "ic_image_add" not in img_url and not img_url.endswith(".svg"):
img_tag = (
f'<div style="width:100%;aspect-ratio:1/1;overflow:hidden;'
f'border-radius:6px;margin-bottom:8px;">'
f'<img src="{img_url}" style="width:100%;height:100%;'
f'object-fit:cover;display:block;">'
f'</div>'
)
num_line = f'<b>{num_text}.&nbsp;{name}</b>{badge}' if num_text else f'<b>{name}</b>{badge}'
return (
f'<div style="'
f'border:2px solid {border_color};'
f'border-radius:10px;'
f'padding:12px;'
f'background-color:{bg_color};'
f'box-shadow:0 2px 6px rgba(0,0,0,0.10);'
f'flex:0 0 {basis};'
f'box-sizing:border-box;'
f'">'
f'{img_tag}'
f'<div style="font-size:13px;margin-bottom:4px;">'
f'{num_line}</div>'
f'{type2_row}'
f'</div>'
)
# ── 선택된 옵션 카드 목록 ──────────────────────
selected_cards_html = "".join(
_build_card(it["num"], it["trans"], it.get("img", ""), card_basis)
for it in t1_selected
)
grid_wrap = (
f'<div style="display:flex;flex-wrap:wrap;gap:{gap_px}px;'
f'margin:16px 0;">'
f'{selected_cards_html}'
f'</div>'
)
# ── 제외 옵션 카드 목록 (항상 3열) ──────────────
excluded_section = ""
if t1_excluded:
ex_basis = f"calc(33% - {gap_px * 2 // 3}px)"
ex_cards_html = "".join(
_build_card(
it.get("new_num", it.get("num", "")),
it["trans"], "", ex_basis, is_excluded=True
)
for it in t1_excluded
)
excluded_section = (
f'<hr style="margin:20px 0;border:none;border-top:1px solid #ddd;">'
f'<div style="font-size:13px;font-weight:bold;'
f'color:#888888;margin-bottom:10px;">📌 아래 옵션은 1:1 문의 주문 가능</div>'
f'<div style="display:flex;flex-wrap:wrap;gap:{gap_px}px;">'
f'{ex_cards_html}'
f'</div>'
)
options_block = (
"\n\n---\n\n"
"### 📋 **옵션 상세 안내**\n\n"
f"{grid_wrap}\n"
f"{excluded_section}\n\n"
"---\n\n<br><br>\n"
)
self.logger.log(
f"[심플모드] 옵션 카드(HTML {cols}열 그리드) 생성 완료: "
f"선택 {len(t1_selected)}개 + 제외 {len(t1_excluded)}"
f"+ 서브옵션 {len(type2_items)}",
level=logging.INFO
)
# 심플모드: '옵션 이미지 삽입' 체크박스 해제
await self._uncheck_option_image_insert()
# 심플모드: HTML 카드 그리드 생성
options_block, option_data_for_validation = self._build_simple_options_block(optionHandler)
else:
# 일반모드: 텍스트 옵션 목록 생성
option_data = optionHandler.get_all_translated_options()
is_single = optionHandler.option_info.get("is_single_option", True)
option_data_for_validation = option_data # 레거시 정리 메서드 전달용
is_single = optionHandler.option_info.get("is_single_option", True)
option_data_for_validation = option_data
if option_data and (len(option_data) > 1 or not is_single):
lines = [
"",
"---",
"### 📋 **옵션 목록**",
"",
""
]
# 이미 넘버링이 적용된 옵션명을 일관된 이모지와 함께 사용 (마크다운 자동 넘버링 방지)
# 상품별로 하나의 이모지를 선택하여 모든 옵션에 동일하게 사용
selected_emoji = self._get_option_emoji(0, style="random")
for i, option in enumerate(option_data.keys()):
lines = ["", "---", "### 📋 **옵션 목록**", "", ""]
for option in option_data.keys():
lines.append(f"{selected_emoji} **{option}**")
lines.extend([
"",
"---",
"",
"",
"<br>",
"<br>",
""
])
lines.extend(["", "---", "", "", "<br>", "<br>", ""])
options_block = "\n".join(lines)
self.logger.log("일반모드 옵션 목록 생성 완료", level=logging.INFO)
# --------------------------------------------------
# 4. 과거 형식 옵션 목록 자동 정리 (선택적)
# --------------------------------------------------
if leading_block and (options_block != ""):
cleaned_content = self._replace_or_append_options_section(leading_block, options_block, option_data_for_validation)
if leading_block and options_block:
cleaned_content = self._replace_or_append_options_section(
leading_block, options_block, option_data_for_validation
)
if cleaned_content and cleaned_content != leading_block:
leading_block = cleaned_content
self.logger.log("기존 소개글 내의 과거 옵션 목록을 정리했습니다.", level=logging.INFO)
@ -598,9 +700,7 @@ class DetailHandler:
bulk_text = "\n".join(text_blocks).strip()
try:
# 마크다운 → HTML 변환 후 직접 입력 (빠른 방식)
await self.input_detail_text_chunked(input_field, bulk_text)
except Exception as e:
self.logger.log(f"CKEditor HTML 입력 실패: {e}", level=logging.ERROR, exc_info=True)
@ -609,7 +709,7 @@ class DetailHandler:
except Exception as e:
self.logger.log(f"상세페이지 텍스트 입력 중 오류 발생: {e}", level=logging.ERROR, exc_info=True)
def convert_markdown_to_html(self, markdown_text: str) -> str:
"""마크다운 텍스트를 HTML로 변환합니다."""
try:

View File

@ -359,10 +359,7 @@ class OptionHandler:
# 번역 적용
if validated_translations:
if toggle_states.get('optionNumbering_only', False):
await self.apply_option_for_numbering_only(validated_translations, toggle_states)
else:
await self.apply_translated_options(validated_translations, forbidden_manager, toggle_states)
await self.apply_translated_options(validated_translations, forbidden_manager, toggle_states)
self.is_gpt_success = True
self.logger.log(f"[{'USE_AI' if toggle_states.get('optionTrnas_method') else 'Papago + AI'}] 옵션 일괄 번역 및 적용 성공", level=logging.INFO)
@ -394,9 +391,13 @@ class OptionHandler:
await self.low_order_click()
# 6. 가격 정렬 후 넘버링 적용
if optionTrnas and not toggle_states.get('optionNumbering_only'): # 번역이 활성화되고 심플모드가 아닐 경우에만 넘버링 적용
self.logger.log("가격 정렬 완료 - 넘버링을 적용합니다.", level=logging.DEBUG)
await self.apply_numbering_to_sorted_options(toggle_states)
if optionTrnas: # 번역이 활성화된 경우
if toggle_states.get('optionNumbering_only'):
self.logger.log("가격 정렬 완료 - 심플모드 넘버링을 적용합니다.", level=logging.DEBUG)
await self.apply_simple_numbering_after_sort(toggle_states)
else:
self.logger.log("가격 정렬 완료 - 넘버링을 적용합니다.", level=logging.DEBUG)
await self.apply_numbering_to_sorted_options(toggle_states)
# 7. 옵션 이미지 업데이트 (옵션 이미지가 있는 경우)
optionIMGTrans_type = toggle_states.get('optionIMGTrans_type', 'CPU')
@ -3341,75 +3342,89 @@ class OptionHandler:
self.logger.log(f"번역 프로세스 오류: {e}", level=logging.ERROR, exc_info=True)
return {}
async def apply_option_for_numbering_only(self, validated_translations, toggle_states):
async def apply_simple_numbering_after_sort(self, toggle_states):
"""
[심플모드] 옵션타입 1에만 넘버링(A, B, C... or 1, 2, 3...) 적용.
옵션타입 2, 3 등은 번역된 이름 그대로 이용.
[심플모드] 가격 정렬 , 옵션타입 1 선택된 옵션들에 넘버링 부여 (A, B, C...)
이후에 제외된 옵션들에게 이어서 넘버링 부여 (E, F...)
입력 필드에는 넘버링만 기입함.
"""
self.logger.log("✨ [심플모드] 옵션타입 1에만 넘버링을 적용합니다.", level=logging.INFO)
self.logger.log("✨ [심플모드] 가격 정렬 후 넘버링을 적용합니다.", level=logging.INFO)
numbering_method = toggle_states.get('optionNumbering_method_type', 'alphabetic_upper')
self.numbering_map = {}
numbering_method = toggle_states.get('optionNumbering_method_type', 'alphabetic_upper') # alphabetic_upper or numeric
self.numbering_map = {} # { "A": "번역된옵션명", ... }
current_index = 0
for option_type_key, option_type_dict in self.option_info.get("option_types", {}).items():
for item in option_type_dict.get("items", []):
original_name = item.get("original_name")
name_input_elem = item.get("name_input_elem")
if not original_name: continue
# 번역된 이름 가져오기
translated_name = validated_translations.get(original_name, original_name)
# [수정] 옵션타입 1만 넘버링, 나머지 타입은 번역명 그대로 사용
if option_type_key == "option_type_1":
numbering_text = self.generate_numbering(numbering_method, current_index)
# 매핑 저장
self.numbering_map[numbering_text] = translated_name
item["_assigned_num"] = numbering_text
item["_assigned_trans"] = translated_name
# 입력 요소에 넘버링 적용
if name_input_elem:
try:
try:
await name_input_elem.get_property("tagName")
except:
parent_elem = item.get("option_card_elem")
if parent_elem:
name_input_elem = await parent_elem.query_selector("input.ant-input")
item["name_input_elem"] = name_input_elem
if name_input_elem:
await name_input_elem.fill(numbering_text)
except Exception as e:
self.logger.log(f"넘버링 입력 실패 [{original_name}]: {e}", level=logging.WARNING)
current_index += 1
try:
# DOM 정보를 실시간으로 다시 수집 (정렬된 순서대로)
option_type_selectors = await self.page.query_selector_all("div.ant-collapse-content-box")
if not option_type_selectors: return
main_option_box = option_type_selectors[0]
option_items = await main_option_box.query_selector_all("li.ant-list-item")
selected_items = []
excluded_items = []
for option_item in option_items:
excluded_span = await option_item.query_selector("span:has-text('제외된 옵션')")
if excluded_span:
excluded_items.append(option_item)
else:
# 타입 2, 3 등: 번역명을 그대로 입력 (넘버링 미적용)
item["_assigned_num"] = "" # 븈 문자열
item["_assigned_trans"] = translated_name
selected_items.append(option_item)
current_index = 0
# 1) 선택된 옵션 넘버링 부여 (순차적으로)
for option_item in selected_items:
original_name_elem = await option_item.query_selector("span.Body3Regular14.CharacterSecondary45")
original_name = await original_name_elem.inner_text() if original_name_elem else None
name_input_elem = await option_item.query_selector("input.ant-input")
if original_name and name_input_elem:
numbering_text = self.generate_numbering(numbering_method, current_index)
try:
# Attempt standard fill with a short timeout.
if not await name_input_elem.is_disabled():
await name_input_elem.fill(numbering_text, timeout=2000)
else:
# Force value update for disabled inputs
await name_input_elem.evaluate(f"(node) => {{ node.value = '{numbering_text}'; node.dispatchEvent(new Event('input', {{ bubbles: true }})); }}")
except Exception as e:
self.logger.log(f"선택옵션 넘버링 입력 경고(JS대체 시도): {e}", level=logging.DEBUG)
await name_input_elem.evaluate(f"(node) => {{ node.value = '{numbering_text}'; node.dispatchEvent(new Event('input', {{ bubbles: true }})); }}")
translated_name = self.option_info.get("translated_names", {}).get(original_name, original_name)
self.numbering_map[numbering_text] = translated_name
if name_input_elem:
try:
try:
await name_input_elem.get_property("tagName")
except:
parent_elem = item.get("option_card_elem")
if parent_elem:
name_input_elem = await parent_elem.query_selector("input.ant-input")
item["name_input_elem"] = name_input_elem
current_index += 1
await asyncio.sleep(0.05)
if name_input_elem:
await name_input_elem.fill(translated_name)
except Exception as e:
self.logger.log(f"코주 옵션 입력 실패 [{original_name}]: {e}", level=logging.WARNING)
self.logger.log(f"넘버링 적용 완료: 타입1={current_index}", level=logging.DEBUG)
# 2) 제외된 옵션 넘버링 부여 (이어서)
for option_item in excluded_items:
original_name_elem = await option_item.query_selector("span.Body3Regular14.CharacterSecondary45")
original_name = await original_name_elem.inner_text() if original_name_elem else None
name_input_elem = await option_item.query_selector("input.ant-input")
if original_name and name_input_elem:
numbering_text = self.generate_numbering(numbering_method, current_index)
try:
# Attempt standard fill with a short timeout.
if not await name_input_elem.is_disabled():
await name_input_elem.fill(numbering_text, timeout=2000)
else:
# Force value update for disabled inputs
await name_input_elem.evaluate(f"(node) => {{ node.value = '{numbering_text}'; node.dispatchEvent(new Event('input', {{ bubbles: true }})); }}")
except Exception as e:
self.logger.log(f"제외옵션 넘버링 입력 경고(JS대체 시도): {e}", level=logging.DEBUG)
await name_input_elem.evaluate(f"(node) => {{ node.value = '{numbering_text}'; node.dispatchEvent(new Event('input', {{ bubbles: true }})); }}")
translated_name = self.option_info.get("translated_names", {}).get(original_name, original_name)
self.numbering_map[numbering_text] = translated_name
current_index += 1
await asyncio.sleep(0.05)
self.logger.log(f"심플모드 넘버링 적용 완료: 타입1={current_index}", level=logging.DEBUG)
except Exception as e:
self.logger.log(f"심플모드 넘버링 적용 중 오류: {e}", level=logging.ERROR)
async def collect_new_options_info(self):
"""
@ -3431,56 +3446,39 @@ class OptionHandler:
numbering_method = self.toggle_states.get('optionNumbering_method_type', 'alphabetic_upper')
option_types = self.option_info.get("option_types", {})
# ── 제외된 옵션 넘버값을 DOM에서 수집 ────────────────────────
excluded_nums: set = set()
try:
all_cards = await self.page.query_selector_all(
"div#productMainContentContainerId li.ant-list-item"
)
for card in all_cards:
if await card.query_selector("span:has-text('제외된 옵션')"):
inp = await card.query_selector("input.ant-input")
if inp:
v = (await inp.input_value()).strip()
if v:
excluded_nums.add(v)
except Exception as e:
self.logger.log(f"제외 옵션 감지 실패(무시): {e}", level=logging.DEBUG)
# ── type1 items 수집 ──────────────────────────────────────────
# 실시간 순서로 옵션 정보를 재수집합니다
t1_selected: list = []
t1_excluded: list = []
t1_items = option_types.get("option_type_1", {}).get("items", [])
for item in t1_items:
if not item.get("original_name"):
continue
num = item.get("_assigned_num", "")
trans = item.get("_assigned_trans", item.get("original_name", ""))
# [버그2 수정] image_url은 수집 시점의 원본 URL → 번역 후 DOM에서 현재 src 재수집
img = ""
try:
card_elem = item.get("option_card_elem")
if card_elem:
img_elem = await card_elem.query_selector("img")
option_type_selectors = await self.page.query_selector_all("div.ant-collapse-content-box")
if option_type_selectors:
main_option_box = option_type_selectors[0]
option_items = await main_option_box.query_selector_all("li.ant-list-item")
for option_item in option_items:
original_name_elem = await option_item.query_selector("span.Body3Regular14.CharacterSecondary45")
original_name = await original_name_elem.inner_text() if original_name_elem else ""
inp = await option_item.query_selector("input.ant-input")
num = (await inp.input_value()).strip() if inp else ""
trans = self.option_info.get("translated_names", {}).get(original_name, original_name)
img = ""
try:
img_elem = await option_item.query_selector("img")
if img_elem:
src = (await img_elem.get_attribute("src")) or ""
# SVG(기본 아이콘) 제외 → 실제 이미지 URL만
if src and not src.endswith(".svg") and "ic_image_add" not in src:
img = src
except Exception:
img = item.get("image_url", "") or ""
if num in excluded_nums or not num:
t1_excluded.append({"num": num, "trans": trans})
else:
t1_selected.append({"num": num, "trans": trans, "img": img})
# 제외된 type1 옵션에 새 넘버링 부여 (선택된 수 이후 순차)
next_idx = len(t1_selected)
for i, ex in enumerate(t1_excluded):
ex["new_num"] = self.generate_numbering(numbering_method, next_idx + i)
except Exception:
pass
excluded_span = await option_item.query_selector("span:has-text('제외된 옵션')")
if excluded_span:
t1_excluded.append({"num": num, "trans": trans, "new_num": num})
else:
t1_selected.append({"num": num, "trans": trans, "img": img})
# ── type2 items 수집 ──────────────────────────────────────────
type2_items: list = []
@ -3504,7 +3502,7 @@ class OptionHandler:
for item in type_dict.get("items", []):
if not item.get("original_name"):
continue
trans = item.get("_assigned_trans", item.get("original_name", ""))
trans = self.option_info.get("translated_names", {}).get(item.get("original_name"), item.get("original_name"))
type2_items.append({"trans": trans})
# ── new_options_structured 저장 ───────────────────────────────
@ -3523,7 +3521,7 @@ class OptionHandler:
self.new_options_info[f"option_image_url_{count}"] = item.get("img", "")
count += 1
for item in t1_excluded:
self.new_options_info[f"option_number_{count}"] = item["new_num"]
self.new_options_info[f"option_number_{count}"] = item.get("new_num", item["num"])
self.new_options_info[f"option_name_{count}"] = item["trans"]
self.new_options_info[f"option_image_url_{count}"] = ""
count += 1

View File

@ -0,0 +1,349 @@
# -*- coding: utf-8 -*-
"""
파파고 번역기 - 하이브리드 구현 (curl_cffi + Playwright 폴백)
================================================================
curl_cffi 기반 HTTP 직접 요청 방식으로 번역을 수행하고,
1회라도 타임아웃이나 오류 등으로 정상 동작하지 않으면
이후 기존 playwright 기반 번역기로 영구 폴백하여 동작합니다.
탐지 우회 전략 (curl_cffi ):
1. deviceId 고정 - 프로그램 1 실행/인스턴스 생성 고정 UUID 부여
2. Session 재사용 - 동일 Session 객체로 쿠키·TLS 핸드셰이크 유지
3. 브라우저 위장 - curl_cffi impersonate(Chrome 124 지문)
4. 워밍업 방문 - 요청 메인 페이지 방문하여 쿠키 자연 획득
5. 최소 요청 간격 - 너무 빠른 연속 요청 방지용 딜레이
입출력 인터페이스:
기존 PapagoTranslator와 동일 메서드 시그니처를 유지합니다.
"""
import asyncio
import hmac
import hashlib
import base64
import logging
import time
import uuid
from typing import List, Optional
try:
from curl_cffi.requests import AsyncSession
HAS_CURL_CFFI = True
except ImportError:
HAS_CURL_CFFI = False
AsyncSession = None
from src.translator.papago_translator import PapagoTranslator
# ─────────────────────────────────────────────────────────────────────────────
# 상수 및 내부 유틸리티
# ─────────────────────────────────────────────────────────────────────────────
PAPAGO_BASE_URL = "https://papago.naver.com"
PAPAGO_TRANSLATE_URL = f"{PAPAGO_BASE_URL}/apis/n2mt/translate"
# 파파고 JS 소스에서 추출한 HMAC-MD5 서명 키 (빌드 변경 시 업데이트 필요)
PAPAGO_HMAC_KEY = "v1.0.0"
_LANG_MAP = {
"zh": "zh-CN",
"zh-tw": "zh-TW",
}
_MIN_REQUEST_INTERVAL = 0.3
def _normalize_lang(lang: str) -> str:
"""기존 코드의 언어 코드를 파파고 API 형식으로 변환"""
return _LANG_MAP.get(lang.lower(), lang)
def _build_authorization(device_id: str, timestamp: int, key: str = PAPAGO_HMAC_KEY) -> str:
"""PPG Authorization 헤더 값 생성 (HMAC-MD5 + Base64)"""
message = f"{device_id}\n{timestamp}"
sig = hmac.new(
key.encode("utf-8"),
message.encode("utf-8"),
hashlib.md5,
).digest()
return f"PPG {device_id}:{base64.b64encode(sig).decode('utf-8')}"
def _build_headers(device_id: str, timestamp: int) -> dict:
"""번역 API 요청 헤더 구성"""
auth = _build_authorization(device_id, timestamp)
return {
"accept": "application/json",
"accept-encoding": "gzip, deflate, br, zstd",
"accept-language": "ko",
"authorization": auth,
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"device-type": "pc",
"origin": PAPAGO_BASE_URL,
"referer": f"{PAPAGO_BASE_URL}/",
"sec-ch-ua": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"timestamp": str(timestamp),
"user-agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"x-apigw-partnerid": "papago",
"x-ppg-ctype": "WEB_PC",
}
# ─────────────────────────────────────────────────────────────────────────────
# 메인 하이브리드 번역기 클래스
# ─────────────────────────────────────────────────────────────────────────────
class PapagoTranslatorByCurl:
"""
curl_cffi 기존 Playwright 번역기를 래핑하는 하이브리드 번역기.
"""
def __init__(
self,
logger,
browser_control=None,
page=None,
timeout: int = 60000,
hmac_key: str = PAPAGO_HMAC_KEY,
impersonate: str = "chrome124",
):
self.logger = logger
self.hmac_key = hmac_key
self.timeout_sec = timeout / 1000 # ms → s
# 봇 탐지 우회: 파일 저장이 아닌 인스턴스 단위 UUID 부여 (한 프로세스 생명주기 동안만 고정)
self.device_id: str = str(uuid.uuid4())
self.use_curl: bool = HAS_CURL_CFFI
self.retry_wrong_lang = 3
self._last_request_at: float = 0.0
# curl_cffi 세션 관련 초기화
if self.use_curl:
self._session: Optional[AsyncSession] = AsyncSession(impersonate=impersonate)
self._curl_initialized: bool = False
# 기존 Playwright 기반 번역기를 인스턴스로 생성 (Fallback용)
self.playwright_fallback = PapagoTranslator(
logger=logger,
browser_control=browser_control,
page=page,
timeout=timeout
)
# 구글 번역 등도 이 인스턴스 안의 함수 활용
self.logger.log(
f"하이브리드 번역기 초기화 (deviceId={self.device_id}, curl_cffi={self.use_curl})",
level=logging.INFO,
)
# ── 호환성 및 기존 위임 메서드 ──────────────────────────────────────────
def update_page(self, page):
"""playwright 호환성 유지용"""
self.playwright_fallback.update_page(page)
def translate_product_name_from_google(self, original_name: str) -> str:
"""단일 제품명 구글 번역 (기존과 동일하게 위임)"""
return self.playwright_fallback.translate_product_name_from_google(original_name)
# ── curl_cffi 내부 로직 ─────────────────────────────────────────────────
async def _curl_warmup(self):
"""
번역 메인 페이지를 방문해 세션 워밍업 쿠키 획득.
만약 워밍업 에러가 나더라도, 번역 시도는 해봅니다 (실패시 폴백됨).
"""
max_retries = 3
for attempt in range(1, max_retries + 1):
try:
self.logger.log(
f"파파고 (curl) 세션 워밍업 시도 {attempt}/{max_retries}...",
level=logging.INFO,
)
resp = await self._session.get(
PAPAGO_BASE_URL,
headers={
"user-agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
"accept": (
"text/html,application/xhtml+xml,"
"application/xml;q=0.9,*/*;q=0.8"
),
},
timeout=self.timeout_sec,
)
if resp.status_code == 200:
self._curl_initialized = True
self.logger.log(
f"파파고 (curl) 워밍업 완료 (쿠키 {len(self._session.cookies)}개)",
level=logging.INFO,
)
return
else:
self.logger.log(
f"워밍업 응답 HTTP {resp.status_code}", level=logging.WARNING
)
except Exception as e:
self.logger.log(f"워밍업 실패: {e}", level=logging.WARNING)
if attempt < max_retries:
await asyncio.sleep(2)
else:
# 워밍업 실패해도 일단 통과는 시킴.
# 실제 API 호출에서 실패하면 어차피 fallback 처리.
self._curl_initialized = True
async def _throttle(self):
"""연속 요청 딜레이 적용 방어"""
elapsed = time.monotonic() - self._last_request_at
if elapsed < _MIN_REQUEST_INTERVAL:
await asyncio.sleep(_MIN_REQUEST_INTERVAL - elapsed)
async def _request_translate_via_curl(self, text: str, source_lang: str, target_lang: str) -> Optional[str]:
"""단일 HTTP POST 번역 요청"""
await self._throttle()
src = _normalize_lang(source_lang)
tgt = _normalize_lang(target_lang)
timestamp = int(time.time() * 1000)
headers = _build_headers(self.device_id, timestamp)
payload = {
"deviceId": self.device_id,
"locale": "ko",
"dict": "true",
"dictDisplay": "30",
"honorific": "true",
"instant": "false",
"paging": "false",
"source": src,
"target": tgt,
"text": text,
"usageAgreed": "true",
}
resp = await self._session.post(
PAPAGO_TRANSLATE_URL, headers=headers, data=payload, timeout=self.timeout_sec
)
self._last_request_at = time.monotonic()
if resp.status_code == 200:
data = resp.json()
return data.get("translatedText", "")
else:
self.logger.log(f"파파고 API 오류: HTTP {resp.status_code} | {resp.text[:200]}", level=logging.WARNING)
return None
async def _papago_translate_curl(self, text: str, source_lang: str, target_lang: str, expected_lines: int) -> Optional[List[str]]:
"""curl 기반 Papago 번역 처리 루틴 (예외 발생 시 상위로 전파)"""
if not self._curl_initialized:
await self._curl_warmup()
for attempt in range(1, self.retry_wrong_lang + 1):
try:
translated_text = await self._request_translate_via_curl(text, source_lang, target_lang)
if not translated_text:
raise RuntimeError("번역 응답 결과가 비어있음 또는 HTTP 에러")
translated_lines = translated_text.split("\n")
# 줄 수 및 한글 포함 검증 (playwright 내 함수 재활용)
if len(translated_lines) == expected_lines and self.playwright_fallback._is_korean(translated_lines):
self.logger.log(f"curl 번역 성공 ({len(translated_lines)}줄)", level=logging.DEBUG)
return translated_lines
self.logger.log(
f"curl 번역 결과 이상(한글 누락 등). 재시도 {attempt}/{self.retry_wrong_lang}",
level=logging.WARNING
)
except Exception as e:
self.logger.log(f"curl 번역 시도 {attempt} 실패: {e}", level=logging.WARNING)
if attempt == self.retry_wrong_lang:
raise e # 재시도 횟수 초과 시 예외를 던져 fallback 트리거
# 재시도 전 연결 재확인 (워밍업 재수행)
await asyncio.sleep(2)
await self._curl_warmup()
return None
# ── 공용 번역 메서드 ────────────────────────────────────────────────────
async def translate(
self,
text: str,
source_lang: str = "zh",
target_lang: str = "ko",
engine: str = "papago",
fallback: bool = True,
) -> List[str]:
"""
메인 번역 진입점.
engine="google" 이거나 curl 사용 불가상태라면 기존 playwright를 호출합니다.
curl 방식 시도 네트워크 통신 타임아웃/에러 등이 1회라도 발생하면
이후 영구적으로 playwright 방식으로 폴백합니다.
"""
# 구글을 명시한 경우 curl과 무관하므로 위임
if engine == "google":
self.logger.log("구글 번역 위임 (Playwright 측 사용)", level=logging.INFO)
return await self.playwright_fallback.translate(text, source_lang, target_lang, engine, fallback)
lines = text.split("\n") if "\n" in text else [text]
if self.use_curl and engine == "papago":
try:
self.logger.log("파파고 번역 시작 (curl_cffi)", level=logging.INFO)
papago_result = await self._papago_translate_curl(text, source_lang, target_lang, len(lines))
if papago_result is not None:
self.logger.log("파파고 (curl) 번역 완료", level=logging.INFO)
# 톤 조정도 기존 playwright_fallback에 내장된 함수 재사용
return self.playwright_fallback._adjust_tone_to_marketing(papago_result)
else:
# 올바른 번역 결과를 끝내 못 얻음 (예: 중국어로만 나와서 재시도 3번 실패)
self.logger.log("curl 모드 결과가 None 입니다. Playwright로 폴백 설정합니다.", level=logging.WARNING)
self.use_curl = False
except Exception as e:
# 1회라도 오류가 던져졌다면 (타임아웃 등)
self.logger.log(f"curl 모드 예외 발생: {e}. 영구적으로 Playwright로 폴백 설정합니다.", level=logging.WARNING)
self.use_curl = False
# self.use_curl == False 여서 원래부터 폴백이거나,
# 방금 시도하다가 실패해서 플래그가 바뀐 경우
self.logger.log("파파고 번역 시작 (Playwright Fallback)", level=logging.INFO)
return await self.playwright_fallback.translate(text, source_lang, target_lang, engine, fallback)
async def papago_translate(self, text: str, source_lang: str = "zh", target_lang: str = "ko") -> Optional[List[str]]:
"""직접 papago_translate 호출 시 (내부적으로 거의 쓰지 않으나 호환성용)"""
# 이 메서드는 폴백 기능을 제공하지 않고 호출 측이 알아서 한다는 원칙이므로, translate를 씀.
# 호환성을 위해 아래와 같이 간단 위임
return await self.translate(text, source_lang, target_lang, engine="papago", fallback=False)
async def google_translate_lines(self, lines: List[str], source_lang: str = "zh", target_lang: str = "ko") -> List[str]:
"""직접 구글 번역 호출 시 (호환성용 위임)"""
return await self.playwright_fallback.google_translate_lines(lines, source_lang, target_lang)
async def close(self):
"""세션/브라우저 정리"""
try:
if self.use_curl and hasattr(self, "_session") and self._session:
await self._session.close()
self.logger.log("파파고 curl 세션 종료", level=logging.INFO)
except Exception:
pass
# 필요하다면 playwright 측 브라우저도 정리가 가능하지만, 기존 코드 구조상 위임 가능 여부 체크.
# 기존 PapagoTranslator에 별도 close 메서드가 없다면 아무 동작 안함.

201
startup_guard.py Normal file
View File

@ -0,0 +1,201 @@
# startup_guard.py
"""
시작 발생하는 권한/환경 오류를 중앙에서 처리하는 모듈.
cx_Freeze 패키징 Program Files 설치된 경우,
관리자 권한 없이 로그 파일 생성 등에서 PermissionError 발생할 있습니다.
주요 기능:
- 로그 디렉토리 안전 생성 (Program Files LOCALAPPDATA 폴백)
- QApplication 없이도 Win32 MessageBox 오류 안내
- 시작 단계 예외 포착 데코레이터
"""
import sys
import os
import ctypes
import traceback
APP_NAME = "Edit_PartTimer3"
# ============================================================
# Win32 MessageBox 래퍼 (QApplication 없이 사용 가능)
# ============================================================
_MB_OK = 0x00000000
_MB_ICONERROR = 0x00000010
_MB_ICONWARNING = 0x00000030
_MB_ICONINFO = 0x00000040
_MB_TOPMOST = 0x00040000
_MB_SETFOREGROUND = 0x00010000
def _win32_msgbox(title: str, message: str, icon: int = _MB_ICONERROR) -> None:
"""Win32 API MessageBoxW 를 직접 호출합니다. (QApplication 불필요)"""
if sys.platform.startswith("win"):
ctypes.windll.user32.MessageBoxW(
0,
message,
title,
_MB_OK | icon | _MB_TOPMOST | _MB_SETFOREGROUND
)
# ============================================================
# 관리자 권한 여부 확인
# ============================================================
def is_admin() -> bool:
"""현재 프로세스가 관리자 권한으로 실행 중인지 확인합니다."""
try:
return ctypes.windll.shell32.IsUserAnAdmin() != 0
except Exception:
return False
# ============================================================
# 관리자 권한 요청 메시지 표시
# ============================================================
def show_admin_required(detail: str = "") -> None:
"""
관리자 권한 요구 메시지를 Win32 MessageBox 표시하고 프로세스를 종료합니다.
Args:
detail: 추가로 표시할 오류 상세 내용 (선택)
"""
message = (
"프로그램을 시작하는 중 권한 오류가 발생했습니다.\n\n"
"프로그램 파일 폴더에 쓰기 권한이 없습니다.\n"
"아래 방법으로 다시 실행해 주세요:\n\n"
" ▶ 프로그램 아이콘 우클릭 → '관리자 권한으로 실행'\n"
)
if detail:
message += f"\n[오류 내용]\n{detail}"
_win32_msgbox(
title=f"{APP_NAME} - 권한 오류",
message=message,
icon=_MB_ICONERROR
)
sys.exit(1)
def show_startup_error(title: str, message: str, exit_after: bool = True) -> None:
"""
일반 시작 오류를 Win32 MessageBox 표시합니다.
Args:
title: 다이얼로그 제목
message: 표시할 메시지
exit_after: True 이면 표시 sys.exit(1) 호출
"""
_win32_msgbox(title=f"{APP_NAME} - {title}", message=message, icon=_MB_ICONERROR)
if exit_after:
sys.exit(1)
# ============================================================
# 로그 디렉토리 안전 생성
# ============================================================
def _try_create_and_write(directory: str) -> bool:
"""
디렉토리 생성 쓰기 테스트를 수행합니다.
성공하면 True, 실패(권한 오류 )하면 False 반환합니다.
"""
try:
os.makedirs(directory, exist_ok=True)
test_file = os.path.join(directory, ".write_test")
with open(test_file, "w", encoding="utf-8") as f:
f.write("test")
os.remove(test_file)
return True
except (PermissionError, OSError):
return False
def get_fallback_log_dir(app_name: str = APP_NAME) -> str:
"""
LOCALAPPDATA 기반의 폴백 로그 경로를 반환합니다.
(항상 일반 사용자도 쓰기 가능한 경로)
"""
base = (
os.environ.get("LOCALAPPDATA")
or os.environ.get("APPDATA")
or os.path.expanduser("~")
)
return os.path.join(base, app_name, "logs")
def safe_create_log_dir(preferred_dir: str, app_name: str = APP_NAME) -> str:
"""
로그 디렉토리를 안전하게 생성합니다.
시도 순서:
1단계: preferred_dir (설치 경로 logs/)
2단계: %LOCALAPPDATA%\\{app_name}\\logs\\ (폴백)
3단계: 모두 실패 관리자 권한 안내 종료
Returns:
str: 최종 사용 가능한 로그 디렉토리 경로
Raises:
SystemExit: 모든 경로에 쓰기 불가 종료
"""
# 1단계: 원래 경로
if _try_create_and_write(preferred_dir):
return preferred_dir
# 2단계: LOCALAPPDATA 폴백
fallback_dir = get_fallback_log_dir(app_name)
if _try_create_and_write(fallback_dir):
print(f"[startup_guard] 로그 경로 폴백: {fallback_dir}", flush=True)
return fallback_dir
# 3단계: 모두 실패 → 관리자 권한 안내
show_admin_required(
detail=(
f"선호 경로: {preferred_dir}\n"
f"폴백 경로: {fallback_dir}\n"
"두 경로 모두 쓰기에 실패했습니다."
)
)
# show_admin_required 가 sys.exit 하므로 여기에 도달하지 않음
raise RuntimeError("unreachable") # type checker 용
# ============================================================
# 시작 단계 예외 포착 데코레이터
# ============================================================
def guard_startup(func):
"""
함수 실행 PermissionError / OSError 시작 오류를 포착하여
사용자 친화적인 메시지로 안내하는 데코레이터.
사용 :
@guard_startup
def main():
...
"""
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except PermissionError as e:
tb = traceback.format_exc()
show_admin_required(detail=str(e))
except SystemExit:
raise # sys.exit() 는 그대로 통과
except Exception as e:
tb = traceback.format_exc()
show_startup_error(
title="시작 오류",
message=(
f"프로그램 시작 중 예기치 못한 오류가 발생했습니다.\n\n"
f"{type(e).__name__}: {e}\n\n"
f"로그 파일을 개발자에게 전달해 주세요."
),
exit_after=True
)
return wrapper

34
test/t1.html Normal file
View File

@ -0,0 +1,34 @@
<div style="background-color: #f0f8ff; border: 2px solid #333; padding: 15px; margin-bottom: 20px;">
<span style="font-size: 16px; font-weight: bold; color: #ff5722;">테스트 1: 기본 스타일</span><br>
이 박스에 <b>파란색 배경, 검은색 테두리, 그리고 안쪽 여백(padding)</b>이 유지되고 있다면 기본적인 인라인 CSS는 허용되는 상태입니다.
</div>
<div style="font-weight: bold; margin-bottom: 5px;">테스트 2: Flexbox 레이아웃 (기존 방식)</div>
<div style="display: flex; gap: 15px; margin-bottom: 20px;">
<div
style="flex: 1; border: 2px solid #4a90d9; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2); padding: 15px; background-color: #ffffff;">
<b>카드 A</b><br>
이 카드가 오른쪽 카드와 <b>가로로 나란히</b> 배치되고, <b>모서리가 둥글고 그림자</b>가 있다면 Flex 레이아웃이 완벽히 지원되는 것입니다.
</div>
<div style="flex: 1; border: 2px solid #aaaaaa; border-radius: 10px; padding: 15px; background-color: #f5f5f5;">
<b>카드 B</b><br>
만약 이 카드가 카드 A 아래로 줄바꿈 되어 나타난다면 <code>display: flex</code>가 필터링된 것입니다.
</div>
</div>
<div style="font-weight: bold; margin-bottom: 5px;">테스트 3: Table 레이아웃 (대안 방식)</div>
<table
style="width: 100%; border-collapse: separate; border-spacing: 15px; background-color: #fafafa; border: 1px dashed #ccc;">
<tr>
<td
style="width: 50%; border: 2px solid #4a90d9; border-radius: 10px; padding: 15px; background-color: #ffffff; vertical-align: top;">
<b>테이블 카드 A</b><br>
테스트 2(Flex)가 깨지더라도, 이 테이블 구조가 <b>가로로 예쁘게 두 칸</b>으로 나뉘어 보인다면 앞으로 옵션 카드는 Table 태그로 생성해야 합니다.
</td>
<td
style="width: 50%; border: 2px solid #aaaaaa; border-radius: 10px; padding: 15px; background-color: #f5f5f5; vertical-align: top;">
<b>테이블 카드 B</b><br>
테이블의 테두리(border)나 여백(padding)이 잘 적용되는지 확인해 보세요.
</td>
</tr>
</table>

36
test/t2.html Normal file
View File

@ -0,0 +1,36 @@
<p>
<span style="color:#ff5722;font-size:16px;"><strong>테스트 1: 기본 스타일</strong></span><br>
이 박스에 <strong>파란색 배경, 검은색 테두리, 그리고 안쪽 여백(padding)</strong>이 유지되고 있다면 기본적인 인라인 CSS는 허용되는 상태입니다.
</p>
<p>
<strong>테스트 2: Flexbox 레이아웃 (기존 방식)</strong>
</p>
<p>
<strong>카드 A</strong><br>
이 카드가 오른쪽 카드와 <strong>가로로 나란히</strong> 배치되고, <strong>모서리가 둥글고 그림자</strong>가 있다면 Flex 레이아웃이 완벽히 지원되는 것입니다.
</p>
<p>
<strong>카드 B</strong><br>
만약 이 카드가 카드 A 아래로 줄바꿈 되어 나타난다면 display: flex가 필터링된 것입니다.
</p>
<p>
<strong>테스트 3: Table 레이아웃 (대안 방식)</strong>
</p>
<figure class="table" style="width:100%;">
<table style="background-color:#fafafa;border:1px dashed #ccc;">
<tbody>
<tr>
<td
style="background-color:#ffffff;border:2px solid #4a90d9;padding:15px;vertical-align:top;width:50%;">
<strong>테이블 카드 A</strong><br>
테스트 2(Flex)가 깨지더라도, 이 테이블 구조가 <strong>가로로 예쁘게 두 칸</strong>으로 나뉘어 보인다면 앞으로 옵션 카드는 Table 태그로 생성해야 합니다.
</td>
<td
style="background-color:#f5f5f5;border:2px solid #aaaaaa;padding:15px;vertical-align:top;width:50%;">
<strong>테이블 카드 B</strong><br>
테이블의 테두리(border)나 여백(padding)이 잘 적용되는지 확인해 보세요.
</td>
</tr>
</tbody>
</table>
</figure>

31
tests/test_d.py Normal file
View File

@ -0,0 +1,31 @@
import requests
import json
# 리눅스 서버의 IP 주소로 변경해 주세요. (같은 서버에서 돌리면 localhost)
SERVER_IP = "192.168.0.146"
url = f"http://{SERVER_IP}:1188/translate"
# 번역할 내용 및 언어 설정
payload = {
"text": "Hello, this is a test for network monitoring translation.",
"source_lang": "EN", # 출발 언어 (자동 감지하려면 "auto" 또는 생략)
"target_lang": "KO" # 도착 언어 (한국어)
}
headers = {
"Content-Type": "application/json"
}
try:
response = requests.post(url, data=json.dumps(payload), headers=headers)
response.raise_for_status() # HTTP 에러 발생 시 예외 처리
result = response.json()
if result.get("code") == 200:
print("✅ 번역 성공:", result.get("data"))
else:
print("⚠️ 번역 실패:", result)
except Exception as e:
print("🚨 요청 중 에러 발생:", e)

14
tests/test_deepl.py Normal file
View File

@ -0,0 +1,14 @@
from deepl import DeepLCLI
import time
deepl = DeepLCLI("zh", "ko")
start_time = time.time()
print(deepl.translate("你好 每小时的吞吐量是多少?")) #=> "안녕하세요 시간당 처리량은 얼마입니까?"
end_time = time.time()
print(deepl.translate("'超级工厂'\n'吉盛科'\n'厂家'\n'定时|亮度|色温调节'\n'直销'\n'BSCI/ISO验厂/自有海外工厂'"))
end_time2 = time.time()
print(deepl.translate("'木屋'\n'木滑梯'"))
end_time3 = time.time()
print(f"소요 시간: {end_time - start_time}")
print(f"소요 시간: {end_time2 - end_time}")
print(f"소요 시간: {end_time3 - end_time2}")

299
tests/test_papago_curl.py Normal file
View File

@ -0,0 +1,299 @@
# -*- coding: utf-8 -*-
"""
PapagoTranslatorByCurl 통합 테스트
=====================================
기존 playwright 방식을 대체하는 curl_cffi 번역기 테스트.
실행:
python tests/test_papago_curl.py
python -m pytest tests/test_papago_curl.py -v -s
"""
import sys
import io
import os
import asyncio
import hmac
import hashlib
import base64
import logging
from pathlib import Path
# 프로젝트 루트를 sys.path에 추가 (tests/ 폴더에서 실행 시 src 모듈 검색 가능)
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
# Windows 터미널 UTF-8 출력 강제
if hasattr(sys.stdout, "buffer"):
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "buffer"):
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
try:
from curl_cffi import requests as cffi_requests
HAS_CURL_CFFI = True
except ImportError:
HAS_CURL_CFFI = False
print("[경고] curl_cffi 미설치: pip install curl_cffi")
# ─────────────────────────────────────────────────────────────────────────────
# 테스트용 간이 Logger (기존 프로젝트 logger 인터페이스 모방)
# ─────────────────────────────────────────────────────────────────────────────
class _SimpleLogger:
"""PapagoTranslatorByCurl에 넘길 간이 로거"""
def log(self, msg: str, level: int = logging.INFO, exc_info: bool = False):
label = {
logging.DEBUG: "DEBUG",
logging.INFO: "INFO",
logging.WARNING: "WARN",
logging.ERROR: "ERROR",
}.get(level, "INFO")
print(f" [{label}] {msg}")
if exc_info:
import traceback
traceback.print_exc()
# ─────────────────────────────────────────────────────────────────────────────
# 유닛 테스트: Authorization 헤더 생성 (네트워크 불필요)
# ─────────────────────────────────────────────────────────────────────────────
def test_generate_authorization():
"""PPG Authorization 헤더 포맷 검증"""
from src.translator.papago_translator_by_curl import (
_build_authorization,
PAPAGO_HMAC_KEY,
)
device_id = "f6905385-5f47-4ea3-87ea-f6459c55bb33"
timestamp = 1772288157276
auth = _build_authorization(device_id, timestamp, PAPAGO_HMAC_KEY)
print(f"\n[테스트] Authorization 헤더 생성:")
print(f" deviceId = {device_id}")
print(f" timestamp = {timestamp}")
print(f" 결과 = {auth}")
assert auth.startswith("PPG "), f"PPG 접두사 누락: {auth}"
parts = auth[4:].split(":")
assert len(parts) == 2, f"형식 불일치: {auth}"
assert parts[0] == device_id
assert len(base64.b64decode(parts[1])) == 16, "MD5 서명 길이 불일치"
print(" [OK] 형식 검증 통과")
def test_lang_normalization():
"""언어 코드 정규화 검증 (zh → zh-CN 등)"""
from src.translator.papago_translator_by_curl import _normalize_lang
cases = [
("zh", "zh-CN"),
("zh-CN", "zh-CN"),
("ko", "ko"),
("en", "en"),
("zh-tw", "zh-TW"),
]
print("\n[테스트] 언어 코드 정규화:")
for inp, expected in cases:
result = _normalize_lang(inp)
print(f" {inp!r} -> {result!r} ({'OK' if result == expected else 'FAIL'})")
assert result == expected, f"{inp} -> {result} (expected {expected})"
print(" [OK] 정규화 검증 통과")
# ─────────────────────────────────────────────────────────────────────────────
# 통합 테스트: 실제 번역 API 호출 (네트워크 필요)
# ─────────────────────────────────────────────────────────────────────────────
async def _async_test_simple_translate():
"""단순 번역 테스트 (중국어→한국어)"""
if not HAS_CURL_CFFI:
print(" [SKIP] curl_cffi 미설치")
return
from src.translator.papago_translator_by_curl import PapagoTranslatorByCurl
logger = _SimpleLogger()
translator = PapagoTranslatorByCurl(logger=logger)
text = "木屋"
print(f"\n[테스트] 단순 번역: '{text}'")
result = await translator.translate(text, source_lang="zh", target_lang="ko")
print(f" 번역 결과: {result}")
assert result, "번역 결과가 비어있음"
assert any(ch for line in result for ch in line if "\uac00" <= ch <= "\ud7a3"), \
"번역 결과에 한글이 없음"
print(" [OK] 한글 번역 결과 확인")
await translator.close()
async def _async_test_multiline_translate():
"""여러 줄 번역 테스트 (기존 playwright 방식과 동일한 인터페이스)"""
if not HAS_CURL_CFFI:
print(" [SKIP] curl_cffi 미설치")
return
from src.translator.papago_translator_by_curl import PapagoTranslatorByCurl
logger = _SimpleLogger()
translator = PapagoTranslatorByCurl(logger=logger)
# 기존 papago_translator.py 호출 방식 그대로
texts = ["'木屋'", "'木滑梯'", "'铝合金'"]
combined = "\n".join(texts)
print(f"\n[테스트] 여러 줄 번역 ({len(texts)}줄):")
for t in texts:
print(f" 원문: {t}")
result = await translator.translate(combined, source_lang="zh", target_lang="ko")
print(f" 번역 결과 ({len(result)}줄):")
for i, line in enumerate(result):
print(f" [{i}] {line}")
assert len(result) == len(texts), \
f"줄 수 불일치: {len(result)} != {len(texts)}"
print(" [OK] 줄 수 일치, 번역 성공")
await translator.close()
async def _async_test_papago_translate_method():
"""papago_translate() 메서드 직접 호출 테스트"""
if not HAS_CURL_CFFI:
print(" [SKIP] curl_cffi 미설치")
return
from src.translator.papago_translator_by_curl import PapagoTranslatorByCurl
logger = _SimpleLogger()
translator = PapagoTranslatorByCurl(logger=logger)
print("\n[테스트] papago_translate() 직접 호출:")
result = await translator.papago_translate(
"木屋\n木滑梯", source_lang="zh", target_lang="ko"
)
print(f" 결과: {result}")
assert result is not None, "papago_translate()가 None 반환"
print(" [OK] papago_translate() 정상 반환")
await translator.close()
async def _async_test_google_fallback():
"""Google 번역 폴백 테스트"""
if not HAS_CURL_CFFI:
print(" [SKIP] curl_cffi 미설치")
return
from src.translator.papago_translator_by_curl import PapagoTranslatorByCurl
logger = _SimpleLogger()
translator = PapagoTranslatorByCurl(logger=logger)
print("\n[테스트] Google 번역 engine 직접 선택:")
lines = ["木屋", "木滑梯"]
result = await translator.google_translate_lines(lines, source_lang="zh", target_lang="ko")
print(f" 결과: {result}")
assert isinstance(result, list), "리스트 반환 필요"
print(" [OK] Google 번역 폴백 정상 동작")
await translator.close()
async def _async_test_session_reuse():
"""세션 재사용 확인 (deviceId 일관성)"""
if not HAS_CURL_CFFI:
print(" [SKIP] curl_cffi 미설치")
return
from src.translator.papago_translator_by_curl import PapagoTranslatorByCurl
logger = _SimpleLogger()
translator = PapagoTranslatorByCurl(logger=logger)
print(f"\n[테스트] 세션 재사용 (deviceId 일관성):")
print(f" deviceId = {translator.device_id}")
# 3번 연속 번역 - 동일 deviceId 사용 확인
words = ["木屋", "铁门", "玻璃"]
for word in words:
r = await translator.translate(word, source_lang="zh", target_lang="ko")
first = r[0] if r else "?"
print(f" '{word}' -> '{first}' (deviceId: {translator.device_id})")
print(" [OK] 동일 deviceId로 연속 번역 완료")
await translator.close()
# ─────────────────────────────────────────────────────────────────────────────
# 테스트 러너
# ─────────────────────────────────────────────────────────────────────────────
def run_all_tests():
print("=" * 60)
print("PapagoTranslatorByCurl 테스트")
print("=" * 60)
# 동기 유닛 테스트
sync_tests = [
("[1] Authorization 헤더 생성", test_generate_authorization),
("[2] 언어 코드 정규화", test_lang_normalization),
]
# 비동기 통합 테스트
async_tests = [
("[4] 단순 번역 (zh->ko)", _async_test_simple_translate),
("[5] 여러 줄 번역", _async_test_multiline_translate),
("[6] papago_translate() 직접", _async_test_papago_translate_method),
("[7] Google 폴백", _async_test_google_fallback),
("[8] 세션 재사용 확인", _async_test_session_reuse),
]
passed, failed = 0, 0
for name, func in sync_tests:
print(f"\n{'-'*40}")
print(f">> {name}")
try:
func()
passed += 1
except AssertionError as e:
print(f" [FAIL] 검증 실패: {e}")
failed += 1
except Exception as e:
import traceback
print(f" [ERROR] 예외 발생: {e}")
traceback.print_exc()
failed += 1
for name, coro_func in async_tests:
print(f"\n{'-'*40}")
print(f">> {name}")
try:
asyncio.run(coro_func())
passed += 1
except AssertionError as e:
print(f" [FAIL] 검증 실패: {e}")
failed += 1
except Exception as e:
import traceback
print(f" [ERROR] 예외 발생: {e}")
traceback.print_exc()
failed += 1
print(f"\n{'='*60}")
print(f"결과: {passed}개 통과, {failed}개 실패")
print(f"{'='*60}")
if __name__ == "__main__":
run_all_tests()

14
tmp_fix_test.py Normal file
View File

@ -0,0 +1,14 @@
# fix_test.py - 임시 수정 스크립트
content = open('tests/test_papago_curl.py', encoding='utf-8').read()
lines = content.splitlines(keepends=True)
for i, line in enumerate(lines):
if 'r[0] if r else' in line and 'deviceId' in line:
print(f"수정 대상 라인 {i+1}: {repr(line)}")
lines[i] = (
" first = r[0] if r else \"?\"\n"
" print(f\" '{word}' -> '{first}' (deviceId: {translator.device_id})\")\n"
)
print(f"수정 후: {repr(lines[i])}")
break
open('tests/test_papago_curl.py', 'w', encoding='utf-8').writelines(lines)
print("완료")

View File

@ -10,6 +10,16 @@
: 상품명기반생성은 모든 사용자가, AI태그생성과 렌즈기반태그는 VIP 전용으로 조정되었습니다.
: VIP 사용자는 방식에 관계없이 상세 필터링 UI를 설정할 수 있습니다.
### 기능 개선
- **심플모드 옵션 카드 리팩토링**
: 기존 표현방식을 개선하여 가독성을 개선하였습니다.
: 옵션타입2와 옵션타입3
: 각 옵션명의 길이를 고려해 자동으로 2열,3열로 배치
: 기본 퍼센티에서 지원하는 옵션표시를 개선 적용
- **심플모드 시 '옵션 이미지 삽입' 체크박스 자동 해제**
: 심플모드 활성화 시 상세페이지의 '옵션 이미지 삽입' 체크박스가 체크되어 있을 경우 자동으로 해제합니다.
: 이미 해제된 상태라면 불필요한 클릭 없이 건너뜁니다.
### 오류 수정 및 안정화
- 태그 수집방식이 렌즈방식일 경우 무한루프에 빠지는 오류 수정