feat: 새 파파고 CURL 번역기 및 관련 테스트와 설정 파일들을 추가하고 파파고 기기 ID를 관리합니다.
This commit is contained in:
parent
54854f3bf9
commit
1aaa1f355c
|
|
@ -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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
14b37dbe-4a71-4a8d-9afa-7de0eda627c3
|
||||
82
main.py
82
main.py
|
|
@ -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":
|
||||
|
|
|
|||
17
setup_ob2.py
17
setup_ob2.py
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;"> </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;"> </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;"> </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} | " if type2_label else ""
|
||||
names_html = " · ".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}. {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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 메서드가 없다면 아무 동작 안함.
|
||||
Binary file not shown.
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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()
|
||||
|
|
@ -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("완료")
|
||||
|
|
@ -10,6 +10,16 @@
|
|||
: 상품명기반생성은 모든 사용자가, AI태그생성과 렌즈기반태그는 VIP 전용으로 조정되었습니다.
|
||||
: VIP 사용자는 방식에 관계없이 상세 필터링 UI를 설정할 수 있습니다.
|
||||
|
||||
### 기능 개선
|
||||
- **심플모드 옵션 카드 리팩토링**
|
||||
: 기존 표현방식을 개선하여 가독성을 개선하였습니다.
|
||||
: 옵션타입2와 옵션타입3
|
||||
: 각 옵션명의 길이를 고려해 자동으로 2열,3열로 배치
|
||||
: 기본 퍼센티에서 지원하는 옵션표시를 개선 적용
|
||||
- **심플모드 시 '옵션 이미지 삽입' 체크박스 자동 해제**
|
||||
: 심플모드 활성화 시 상세페이지의 '옵션 이미지 삽입' 체크박스가 체크되어 있을 경우 자동으로 해제합니다.
|
||||
: 이미 해제된 상태라면 불필요한 클릭 없이 건너뜁니다.
|
||||
|
||||
### 오류 수정 및 안정화
|
||||
- 태그 수집방식이 렌즈방식일 경우 무한루프에 빠지는 오류 수정
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue