From 71947fca839fc5f7370b476b21a9718a59f597e9 Mon Sep 17 00:00:00 2001 From: 9700X_PC <9700X_PC@gmail.com> Date: Sun, 28 Sep 2025 00:26:53 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95:?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=A1=B0=EA=B1=B4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B3=80=EC=88=98=EB=A5=BC=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=ED=95=98=EA=B3=A0,=20=EA=B4=80=EB=A0=A8=20=EC=8B=9C?= =?UTF-8?q?=EA=B7=B8=EB=84=90=EC=9D=84=20=EC=97=B0=EA=B2=B0=ED=95=98?= =?UTF-8?q?=EC=97=AC=20UI=EC=99=80=EC=9D=98=20=EC=97=B0=EB=8F=99=EC=9D=84?= =?UTF-8?q?=20=EA=B0=95=ED=99=94=ED=95=98=EC=98=80=EC=8A=B5=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.=20=EB=98=90=ED=95=9C,=20ONNX=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EB=B0=8F=20GPU=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=95=88=EC=A0=84=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=EC=9D=84=20=EC=9C=84=ED=95=9C=20=ED=8F=B4=EB=B0=B1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=98?= =?UTF-8?q?=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4.=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=B2=A0=EC=9D=B4=EC=8A=A4=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=9D=B4=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=EB=90=98?= =?UTF-8?q?=EC=97=88=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AutoPercenty_20250927_210832.iss | 378 ++++++++++ AutoPercenty_20250928_000831.iss | 378 ++++++++++ bizinfo.db | Bin 24576 -> 24576 bytes browser_control.py | 697 ++++++++++++++---- mainUI_SP.py | 184 ++++- src/modules/bria_background_removal_module.py | 152 ++-- src/modules/gpu_utils.py | 66 +- src/modules/image_worker.py | 89 ++- .../onnx_ocr_module/src/onnx_ocr_wrapper.py | 86 ++- ...ta_909d2ef8-7053-4006-ab40-49eb49f20383.db | Bin 3678208 -> 3678208 bytes updateManager/updateLog.md | 12 +- 11 files changed, 1781 insertions(+), 261 deletions(-) create mode 100644 AutoPercenty_20250927_210832.iss create mode 100644 AutoPercenty_20250928_000831.iss diff --git a/AutoPercenty_20250927_210832.iss b/AutoPercenty_20250927_210832.iss new file mode 100644 index 00000000..1f8cf390 --- /dev/null +++ b/AutoPercenty_20250927_210832.iss @@ -0,0 +1,378 @@ +; AutoPercenty3 Inno Setup Script +; 이 스크립트는 cx_Freeze로 빌드된 결과물이 있는 "build\exe.win-amd64-3.11" 폴더를 기반으로 인스톨러를 제작합니다. +; 20250927_210832에 생성됨 + +#define AppId "autopercenty" +#define MyAppName "Edit_PartTimer" +#define MyAppVersion "3.12.1" +#define MyAppPublisher "WhenRideMyCar" +#define MyAppProgramName "편집알바생" +#define MyAppDescription "편집알바생" +#define MyAppCopyright "Copyright 2024" +#define MyAppExeName "Edit_PartTimer3" +#define MySetupName "Edit_PartTimer Setup" +#define MySetupIcon "src/Edit_PartTimer3.ico" +#define MySetupOutputDir "dist/installer" + +[Setup] +; 기본 설정 +AppId={#AppId} +AppName={#MyAppProgramName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppPublisher} +OutputDir={#MySetupOutputDir} +OutputBaseFilename={#MySetupName} +SetupIconFile={#MySetupIcon} +Compression=lzma +SolidCompression=yes + +; 업데이트 관련 설정 - 권한 최적화 +PrivilegesRequired=admin +PrivilegesRequiredOverridesAllowed=dialog +UpdateUninstallLogAppName=yes +AppMutex={#MyAppName} +CloseApplications=yes +RestartApplications=no + +; 보안 및 호환성 설정 +ArchitecturesAllowed=x64 +ArchitecturesInstallIn64BitMode=x64 +AllowNoIcons=yes + +; 버전 정보 +VersionInfoVersion={#MyAppVersion} +VersionInfoCompany={#MyAppPublisher} +VersionInfoDescription={#MyAppDescription} +VersionInfoCopyright={#MyAppCopyright} +VersionInfoProductName={#MyAppProgramName} +VersionInfoProductVersion={#MyAppVersion} + +[Languages] +Name: "korean"; MessagesFile: "compiler:Languages\Korean.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" + +[Dirs] +; 설치 시 {app}\logs 폴더를 생성하고, +; Users 그룹에 'modify' 권한(=쓰기 가능)을 부여 +Name: "{app}\logs"; Permissions: users-modify +; 설치 시 {app}\user_data 폴더를 생성하고, +; Users 그룹에 'modify' 권한(=쓰기 가능)을 부여 +Name: "{app}\user_data"; Permissions: users-modify +; Playwright 브라우저 폴더를 Program Files 내부에 생성 +Name: "{app}\lib\src\browsers\chromium-1155"; Permissions: users-modify +; Playwright 브라우저 사용자폴더를 Program Files 내부에 생성 +Name: "{app}\lib\src\browsers\user_data"; Permissions: users-modify + +[Files] +; 프로그램 파일만 설치 (항상 덮어쓰기) +Source: "build\exe.win-amd64-3.11\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; VC++ 재배포 패키지 파일을 임시 폴더({tmp})에 복사 +Source: "VC_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall + +[Registry] +; Playwright 브라우저 경로를 Program Files 내부로 설정 +Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "PLAYWRIGHT_BROWSERS_PATH"; ValueData: "{app}\lib\src\browsers"; Flags: preservestringtype + +[Icons] +; 시작 메뉴 바로가기 +Name: "{group}\{#MyAppProgramName}"; Filename: "{app}\{#MyAppExeName}.exe" +; 바탕화면 바로가기 +Name: "{autodesktop}\{#MyAppProgramName}"; Filename: "{app}\{#MyAppExeName}.exe"; Tasks: desktopicon +; 프로그램 제거 바로가기 +Name: "{group}\{#MyAppProgramName} 제거"; Filename: "{uninstallexe}" + +[Run] +; VC++ 재배포 패키지 설치 (필요할 경우) +Filename: "{tmp}\VC_redist.x64.exe"; Parameters: "/install /passive /norestart"; StatusMsg: "VC++ 재배포 패키지 설치 중..."; Check: NeedsVCredist +; 설치 후 프로그램 실행 (원할 경우) +Filename: "{app}\{#MyAppExeName}.exe"; Description: "{cm:LaunchProgram,{#MyAppProgramName}}"; Flags: nowait postinstall skipifsilent + +[Code] +function CompareVersion(V1, V2: string): Integer; +var + P1, P2, N1, N2: Integer; +begin + P1 := 1; + P2 := 1; + Result := 0; + while (Result = 0) and ((P1 <= Length(V1)) or (P2 <= Length(V2))) do begin + while (P1 <= Length(V1)) and (V1[P1] = '.') do Inc(P1); + while (P2 <= Length(V2)) and (V2[P2] = '.') do Inc(P2); + if (P1 <= Length(V1)) and (P2 <= Length(V2)) then begin + N1 := 0; while (P1 <= Length(V1)) and (V1[P1] >= '0') and (V1[P1] <= '9') do begin N1 := N1 * 10 + Ord(V1[P1]) - Ord('0'); Inc(P1); end; + N2 := 0; while (P2 <= Length(V2)) and (V2[P2] >= '0') and (V2[P2] <= '9') do begin N2 := N2 * 10 + Ord(V2[P2]) - Ord('0'); Inc(P2); end; + if N1 < N2 then Result := -1 else if N1 > N2 then Result := 1; + end else begin + if P1 <= Length(V1) then Result := 1 else if P2 <= Length(V2) then Result := -1; + end; + while (P1 <= Length(V1)) and (V1[P1] <> '.') do Inc(P1); + while (P2 <= Length(V2)) and (V2[P2] <> '.') do Inc(P2); + end; +end; + +// 파일 또는 폴더 복사 함수 +procedure CopyDir(const SourcePath, DestPath: string); +var + FindRec: TFindRec; + SourceFilePath: string; + DestFilePath: string; +begin + ForceDirectories(DestPath); + + if FindFirst(SourcePath + '\*', FindRec) then + begin + try + repeat + if (FindRec.Name <> '.') and (FindRec.Name <> '..') then + begin + SourceFilePath := SourcePath + '\' + FindRec.Name; + DestFilePath := DestPath + '\' + FindRec.Name; + + if FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY = 0 then + begin + if FileCopy(SourceFilePath, DestFilePath, False) then + Log('파일 복사 성공: ' + SourceFilePath + ' -> ' + DestFilePath) + else + Log('파일 복사 실패: ' + SourceFilePath); + end + else + CopyDir(SourceFilePath, DestFilePath); + end; + until not FindNext(FindRec); + finally + FindClose(FindRec); + end; + end; +end; + +// 디렉토리 삭제 함수 +procedure DeleteDir(const DirPath: string); +var + FindRec: TFindRec; + FilePath: string; +begin + if not DirExists(DirPath) then Exit; + + if FindFirst(DirPath + '\*', FindRec) then + begin + try + repeat + if (FindRec.Name <> '.') and (FindRec.Name <> '..') then + begin + FilePath := DirPath + '\' + FindRec.Name; + + if FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY = 0 then + begin + if DeleteFile(FilePath) then + Log('파일 삭제 성공: ' + FilePath) + else + Log('파일 삭제 실패: ' + FilePath); + end + else + DeleteDir(FilePath); + end; + until not FindNext(FindRec); + finally + FindClose(FindRec); + end; + end; + + if RemoveDir(DirPath) then + Log('디렉토리 삭제 성공: ' + DirPath) + else + Log('디렉토리 삭제 실패: ' + DirPath); +end; + +// 프로그램 실행 여부 확인 +function IsAppRunning(const FileName: string): Boolean; +var + Handle: THandle; +begin + Handle := FindWindowByWindowName('{#MyAppProgramName}'); // 프로그램의 윈도우 타이틀로 찾기 + Result := (Handle <> 0); +end; + +// 프로그램 종료 +procedure CloseApplication(const FileName: string); +var + Handle: THandle; +begin + Handle := FindWindowByWindowName('{#MyAppProgramName}'); + if Handle <> 0 then + begin + PostMessage(Handle, 18, 0, 0); // WM_QUIT + Sleep(1000); // 종료 대기 + end; +end; + +// VC++ 재배포 패키지 필요 여부 확인 +function NeedsVCredist: Boolean; +begin + if RegKeyExists(HKEY_LOCAL_MACHINE, 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64') then + Result := False // 이미 설치됨 + else + Result := True; // 미설치 -> 설치 필요 +end; + +// 설치 완료 후 실행 여부 확인 +function InitializeFinish(): Boolean; +var + ResultCode: Integer; +begin + Result := True; + if MsgBox('설치가 완료되었습니다. 프로그램을 실행하시겠습니까?' + #13#10 + + '(실행 시 서버와 동기화하여 설정이 업데이트됩니다)', + mbConfirmation, MB_YESNO) = IDYES then + begin + Exec(ExpandConstant('{app}\{#MyAppExeName}.exe'), '', '', SW_SHOW, ewNoWait, ResultCode); + end; +end; + +function InitializeSetup(): Boolean; +var + OldVersion: String; + NewVersion: String; + OldAppPath: String; + UserDataSourcePath, UserDataBackupPath: String; + LibSrcPath: String; + ResultCode: Integer; +begin + Result := True; + NewVersion := '{#MyAppVersion}'; + UserDataBackupPath := ExpandConstant('{tmp}\user_data_backup'); + + // 현재 프로그램 버전 확인 + if RegQueryStringValue(HKLM, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppName}_is1', + 'DisplayVersion', OldVersion) then + begin + // 같은 버전이거나 더 높은 버전이 설치되어 있는 경우 + if CompareVersion(OldVersion, NewVersion) >= 0 then + begin + MsgBox('현재 설치된 버전(' + OldVersion + ')이 이 설치 프로그램의 버전(' + + NewVersion + ')과 같거나 더 높습니다.' + #13#10 + + '설치를 계속할 수 없습니다.', mbInformation, MB_OK); + Result := False; + exit; + end; + + // 이전 버전이 설치되어 있는 경우 업데이트 진행 + if CompareVersion(OldVersion, NewVersion) < 0 then + begin + Log('업데이트 설치 진행: ' + OldVersion + ' -> ' + NewVersion); + + // 프로그램이 실행 중인지 확인하고 종료 요청 + if IsAppRunning('{#MyAppExeName}.exe') then + begin + if MsgBox('프로그램을 업데이트하기 위해 실행 중인 프로그램을 종료해야 합니다.' + #13#10 + + '계속하시겠습니까?', mbConfirmation, MB_YESNO) = IDNO then + begin + Result := False; + exit; + end; + CloseApplication('{#MyAppExeName}.exe'); + Sleep(2000); // 프로세스 종료 대기 + + // 프로세스가 여전히 실행 중이면 강제 종료 + Log('프로세스 강제 종료 시도: {#MyAppExeName}.exe'); + Exec('taskkill', '/f /im {#MyAppExeName}.exe /t', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Sleep(3000); // 강제 종료 후 추가 대기 + end; + + // 레지스트리에서 설치 경로 확인 + if RegQueryStringValue(HKLM, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppName}_is1', + 'InstallLocation', OldAppPath) then + begin + Log('기존 설치 경로: ' + OldAppPath); + + // lib/src/user_data 폴더 백업 + UserDataSourcePath := OldAppPath + '\lib\src\user_data'; + if DirExists(UserDataSourcePath) then + begin + Log('사용자 데이터 백업 중: ' + UserDataSourcePath + ' -> ' + UserDataBackupPath); + ForceDirectories(UserDataBackupPath); + CopyDir(UserDataSourcePath, UserDataBackupPath); + end + else + begin + Log('사용자 데이터 폴더가 존재하지 않음: ' + UserDataSourcePath); + end; + + // 기존 설치 폴더 완전 삭제 (사용자 데이터는 이미 백업됨) + if DirExists(OldAppPath) then + begin + Log('기존 설치 폴더 삭제 중: ' + OldAppPath); + + // 1차 시도: Inno Setup 내장 함수 + DeleteDir(OldAppPath); + + // 삭제 확인 및 재시도 + Sleep(2000); // 2초 대기 + if DirExists(OldAppPath) then + begin + Log('설치 폴더 삭제 재시도 (Windows 명령어 사용): ' + OldAppPath); + // Windows rmdir 명령어로 강제 삭제 시도 + Exec('cmd.exe', '/c rmdir /s /q "' + OldAppPath + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Sleep(2000); // 추가 대기 + end; + + // 3차 시도: PowerShell로 강제 삭제 + if DirExists(OldAppPath) then + begin + Log('설치 폴더 삭제 3차 시도 (PowerShell 사용): ' + OldAppPath); + Exec('powershell.exe', '-Command "Remove-Item -Path ''' + OldAppPath + ''' -Recurse -Force -ErrorAction SilentlyContinue"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Sleep(2000); + end; + + // 여전히 존재하면 사용자에게 알림 + if DirExists(OldAppPath) then + begin + Log('경고: 설치 폴더 삭제 실패 - 일부 파일이 사용 중이거나 권한 문제일 수 있음: ' + OldAppPath); + if MsgBox('기존 설치 폴더를 완전히 삭제하지 못했습니다.' + #13#10 + + '이전 버전의 파일들이 남아있어 새 버전과 충돌할 수 있습니다.' + #13#10 + #13#10 + + '폴더: ' + OldAppPath + #13#10 + #13#10 + + '설치를 계속하시겠습니까?' + #13#10 + + '(아니오를 선택하면 수동으로 폴더를 삭제한 후 다시 설치하세요)', + mbConfirmation, MB_YESNO) = IDNO then + begin + Result := False; + exit; + end; + end + else + begin + Log('기존 설치 폴더 삭제 완료: ' + OldAppPath); + end; + end + else + begin + Log('삭제할 설치 폴더가 존재하지 않음: ' + OldAppPath); + end; + end; + end; + end; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + UserDataBackupPath, UserDataDestPath: String; +begin + // 설치 완료 후 + if CurStep = ssPostInstall then + begin + UserDataBackupPath := ExpandConstant('{tmp}\user_data_backup'); + UserDataDestPath := ExpandConstant('{app}\lib\src\user_data'); + + // 백업한 사용자 데이터 폴더가 있으면 복원 + if DirExists(UserDataBackupPath) then + begin + Log('사용자 데이터 복원 중: ' + UserDataBackupPath + ' -> ' + UserDataDestPath); + ForceDirectories(UserDataDestPath); + CopyDir(UserDataBackupPath, UserDataDestPath); + Log('사용자 데이터 복원 완료'); + end; + end; +end; diff --git a/AutoPercenty_20250928_000831.iss b/AutoPercenty_20250928_000831.iss new file mode 100644 index 00000000..7ec94089 --- /dev/null +++ b/AutoPercenty_20250928_000831.iss @@ -0,0 +1,378 @@ +; AutoPercenty3 Inno Setup Script +; 이 스크립트는 cx_Freeze로 빌드된 결과물이 있는 "build\exe.win-amd64-3.11" 폴더를 기반으로 인스톨러를 제작합니다. +; 20250928_000831에 생성됨 + +#define AppId "autopercenty" +#define MyAppName "Edit_PartTimer" +#define MyAppVersion "3.12.1" +#define MyAppPublisher "WhenRideMyCar" +#define MyAppProgramName "편집알바생" +#define MyAppDescription "편집알바생" +#define MyAppCopyright "Copyright 2024" +#define MyAppExeName "Edit_PartTimer3" +#define MySetupName "Edit_PartTimer Setup" +#define MySetupIcon "src/Edit_PartTimer3.ico" +#define MySetupOutputDir "dist/installer" + +[Setup] +; 기본 설정 +AppId={#AppId} +AppName={#MyAppProgramName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppPublisher} +OutputDir={#MySetupOutputDir} +OutputBaseFilename={#MySetupName} +SetupIconFile={#MySetupIcon} +Compression=lzma +SolidCompression=yes + +; 업데이트 관련 설정 - 권한 최적화 +PrivilegesRequired=admin +PrivilegesRequiredOverridesAllowed=dialog +UpdateUninstallLogAppName=yes +AppMutex={#MyAppName} +CloseApplications=yes +RestartApplications=no + +; 보안 및 호환성 설정 +ArchitecturesAllowed=x64 +ArchitecturesInstallIn64BitMode=x64 +AllowNoIcons=yes + +; 버전 정보 +VersionInfoVersion={#MyAppVersion} +VersionInfoCompany={#MyAppPublisher} +VersionInfoDescription={#MyAppDescription} +VersionInfoCopyright={#MyAppCopyright} +VersionInfoProductName={#MyAppProgramName} +VersionInfoProductVersion={#MyAppVersion} + +[Languages] +Name: "korean"; MessagesFile: "compiler:Languages\Korean.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" + +[Dirs] +; 설치 시 {app}\logs 폴더를 생성하고, +; Users 그룹에 'modify' 권한(=쓰기 가능)을 부여 +Name: "{app}\logs"; Permissions: users-modify +; 설치 시 {app}\user_data 폴더를 생성하고, +; Users 그룹에 'modify' 권한(=쓰기 가능)을 부여 +Name: "{app}\user_data"; Permissions: users-modify +; Playwright 브라우저 폴더를 Program Files 내부에 생성 +Name: "{app}\lib\src\browsers\chromium-1155"; Permissions: users-modify +; Playwright 브라우저 사용자폴더를 Program Files 내부에 생성 +Name: "{app}\lib\src\browsers\user_data"; Permissions: users-modify + +[Files] +; 프로그램 파일만 설치 (항상 덮어쓰기) +Source: "build\exe.win-amd64-3.11\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; VC++ 재배포 패키지 파일을 임시 폴더({tmp})에 복사 +Source: "VC_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall + +[Registry] +; Playwright 브라우저 경로를 Program Files 내부로 설정 +Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "PLAYWRIGHT_BROWSERS_PATH"; ValueData: "{app}\lib\src\browsers"; Flags: preservestringtype + +[Icons] +; 시작 메뉴 바로가기 +Name: "{group}\{#MyAppProgramName}"; Filename: "{app}\{#MyAppExeName}.exe" +; 바탕화면 바로가기 +Name: "{autodesktop}\{#MyAppProgramName}"; Filename: "{app}\{#MyAppExeName}.exe"; Tasks: desktopicon +; 프로그램 제거 바로가기 +Name: "{group}\{#MyAppProgramName} 제거"; Filename: "{uninstallexe}" + +[Run] +; VC++ 재배포 패키지 설치 (필요할 경우) +Filename: "{tmp}\VC_redist.x64.exe"; Parameters: "/install /passive /norestart"; StatusMsg: "VC++ 재배포 패키지 설치 중..."; Check: NeedsVCredist +; 설치 후 프로그램 실행 (원할 경우) +Filename: "{app}\{#MyAppExeName}.exe"; Description: "{cm:LaunchProgram,{#MyAppProgramName}}"; Flags: nowait postinstall skipifsilent + +[Code] +function CompareVersion(V1, V2: string): Integer; +var + P1, P2, N1, N2: Integer; +begin + P1 := 1; + P2 := 1; + Result := 0; + while (Result = 0) and ((P1 <= Length(V1)) or (P2 <= Length(V2))) do begin + while (P1 <= Length(V1)) and (V1[P1] = '.') do Inc(P1); + while (P2 <= Length(V2)) and (V2[P2] = '.') do Inc(P2); + if (P1 <= Length(V1)) and (P2 <= Length(V2)) then begin + N1 := 0; while (P1 <= Length(V1)) and (V1[P1] >= '0') and (V1[P1] <= '9') do begin N1 := N1 * 10 + Ord(V1[P1]) - Ord('0'); Inc(P1); end; + N2 := 0; while (P2 <= Length(V2)) and (V2[P2] >= '0') and (V2[P2] <= '9') do begin N2 := N2 * 10 + Ord(V2[P2]) - Ord('0'); Inc(P2); end; + if N1 < N2 then Result := -1 else if N1 > N2 then Result := 1; + end else begin + if P1 <= Length(V1) then Result := 1 else if P2 <= Length(V2) then Result := -1; + end; + while (P1 <= Length(V1)) and (V1[P1] <> '.') do Inc(P1); + while (P2 <= Length(V2)) and (V2[P2] <> '.') do Inc(P2); + end; +end; + +// 파일 또는 폴더 복사 함수 +procedure CopyDir(const SourcePath, DestPath: string); +var + FindRec: TFindRec; + SourceFilePath: string; + DestFilePath: string; +begin + ForceDirectories(DestPath); + + if FindFirst(SourcePath + '\*', FindRec) then + begin + try + repeat + if (FindRec.Name <> '.') and (FindRec.Name <> '..') then + begin + SourceFilePath := SourcePath + '\' + FindRec.Name; + DestFilePath := DestPath + '\' + FindRec.Name; + + if FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY = 0 then + begin + if FileCopy(SourceFilePath, DestFilePath, False) then + Log('파일 복사 성공: ' + SourceFilePath + ' -> ' + DestFilePath) + else + Log('파일 복사 실패: ' + SourceFilePath); + end + else + CopyDir(SourceFilePath, DestFilePath); + end; + until not FindNext(FindRec); + finally + FindClose(FindRec); + end; + end; +end; + +// 디렉토리 삭제 함수 +procedure DeleteDir(const DirPath: string); +var + FindRec: TFindRec; + FilePath: string; +begin + if not DirExists(DirPath) then Exit; + + if FindFirst(DirPath + '\*', FindRec) then + begin + try + repeat + if (FindRec.Name <> '.') and (FindRec.Name <> '..') then + begin + FilePath := DirPath + '\' + FindRec.Name; + + if FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY = 0 then + begin + if DeleteFile(FilePath) then + Log('파일 삭제 성공: ' + FilePath) + else + Log('파일 삭제 실패: ' + FilePath); + end + else + DeleteDir(FilePath); + end; + until not FindNext(FindRec); + finally + FindClose(FindRec); + end; + end; + + if RemoveDir(DirPath) then + Log('디렉토리 삭제 성공: ' + DirPath) + else + Log('디렉토리 삭제 실패: ' + DirPath); +end; + +// 프로그램 실행 여부 확인 +function IsAppRunning(const FileName: string): Boolean; +var + Handle: THandle; +begin + Handle := FindWindowByWindowName('{#MyAppProgramName}'); // 프로그램의 윈도우 타이틀로 찾기 + Result := (Handle <> 0); +end; + +// 프로그램 종료 +procedure CloseApplication(const FileName: string); +var + Handle: THandle; +begin + Handle := FindWindowByWindowName('{#MyAppProgramName}'); + if Handle <> 0 then + begin + PostMessage(Handle, 18, 0, 0); // WM_QUIT + Sleep(1000); // 종료 대기 + end; +end; + +// VC++ 재배포 패키지 필요 여부 확인 +function NeedsVCredist: Boolean; +begin + if RegKeyExists(HKEY_LOCAL_MACHINE, 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64') then + Result := False // 이미 설치됨 + else + Result := True; // 미설치 -> 설치 필요 +end; + +// 설치 완료 후 실행 여부 확인 +function InitializeFinish(): Boolean; +var + ResultCode: Integer; +begin + Result := True; + if MsgBox('설치가 완료되었습니다. 프로그램을 실행하시겠습니까?' + #13#10 + + '(실행 시 서버와 동기화하여 설정이 업데이트됩니다)', + mbConfirmation, MB_YESNO) = IDYES then + begin + Exec(ExpandConstant('{app}\{#MyAppExeName}.exe'), '', '', SW_SHOW, ewNoWait, ResultCode); + end; +end; + +function InitializeSetup(): Boolean; +var + OldVersion: String; + NewVersion: String; + OldAppPath: String; + UserDataSourcePath, UserDataBackupPath: String; + LibSrcPath: String; + ResultCode: Integer; +begin + Result := True; + NewVersion := '{#MyAppVersion}'; + UserDataBackupPath := ExpandConstant('{tmp}\user_data_backup'); + + // 현재 프로그램 버전 확인 + if RegQueryStringValue(HKLM, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppName}_is1', + 'DisplayVersion', OldVersion) then + begin + // 같은 버전이거나 더 높은 버전이 설치되어 있는 경우 + if CompareVersion(OldVersion, NewVersion) >= 0 then + begin + MsgBox('현재 설치된 버전(' + OldVersion + ')이 이 설치 프로그램의 버전(' + + NewVersion + ')과 같거나 더 높습니다.' + #13#10 + + '설치를 계속할 수 없습니다.', mbInformation, MB_OK); + Result := False; + exit; + end; + + // 이전 버전이 설치되어 있는 경우 업데이트 진행 + if CompareVersion(OldVersion, NewVersion) < 0 then + begin + Log('업데이트 설치 진행: ' + OldVersion + ' -> ' + NewVersion); + + // 프로그램이 실행 중인지 확인하고 종료 요청 + if IsAppRunning('{#MyAppExeName}.exe') then + begin + if MsgBox('프로그램을 업데이트하기 위해 실행 중인 프로그램을 종료해야 합니다.' + #13#10 + + '계속하시겠습니까?', mbConfirmation, MB_YESNO) = IDNO then + begin + Result := False; + exit; + end; + CloseApplication('{#MyAppExeName}.exe'); + Sleep(2000); // 프로세스 종료 대기 + + // 프로세스가 여전히 실행 중이면 강제 종료 + Log('프로세스 강제 종료 시도: {#MyAppExeName}.exe'); + Exec('taskkill', '/f /im {#MyAppExeName}.exe /t', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Sleep(3000); // 강제 종료 후 추가 대기 + end; + + // 레지스트리에서 설치 경로 확인 + if RegQueryStringValue(HKLM, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppName}_is1', + 'InstallLocation', OldAppPath) then + begin + Log('기존 설치 경로: ' + OldAppPath); + + // lib/src/user_data 폴더 백업 + UserDataSourcePath := OldAppPath + '\lib\src\user_data'; + if DirExists(UserDataSourcePath) then + begin + Log('사용자 데이터 백업 중: ' + UserDataSourcePath + ' -> ' + UserDataBackupPath); + ForceDirectories(UserDataBackupPath); + CopyDir(UserDataSourcePath, UserDataBackupPath); + end + else + begin + Log('사용자 데이터 폴더가 존재하지 않음: ' + UserDataSourcePath); + end; + + // 기존 설치 폴더 완전 삭제 (사용자 데이터는 이미 백업됨) + if DirExists(OldAppPath) then + begin + Log('기존 설치 폴더 삭제 중: ' + OldAppPath); + + // 1차 시도: Inno Setup 내장 함수 + DeleteDir(OldAppPath); + + // 삭제 확인 및 재시도 + Sleep(2000); // 2초 대기 + if DirExists(OldAppPath) then + begin + Log('설치 폴더 삭제 재시도 (Windows 명령어 사용): ' + OldAppPath); + // Windows rmdir 명령어로 강제 삭제 시도 + Exec('cmd.exe', '/c rmdir /s /q "' + OldAppPath + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Sleep(2000); // 추가 대기 + end; + + // 3차 시도: PowerShell로 강제 삭제 + if DirExists(OldAppPath) then + begin + Log('설치 폴더 삭제 3차 시도 (PowerShell 사용): ' + OldAppPath); + Exec('powershell.exe', '-Command "Remove-Item -Path ''' + OldAppPath + ''' -Recurse -Force -ErrorAction SilentlyContinue"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Sleep(2000); + end; + + // 여전히 존재하면 사용자에게 알림 + if DirExists(OldAppPath) then + begin + Log('경고: 설치 폴더 삭제 실패 - 일부 파일이 사용 중이거나 권한 문제일 수 있음: ' + OldAppPath); + if MsgBox('기존 설치 폴더를 완전히 삭제하지 못했습니다.' + #13#10 + + '이전 버전의 파일들이 남아있어 새 버전과 충돌할 수 있습니다.' + #13#10 + #13#10 + + '폴더: ' + OldAppPath + #13#10 + #13#10 + + '설치를 계속하시겠습니까?' + #13#10 + + '(아니오를 선택하면 수동으로 폴더를 삭제한 후 다시 설치하세요)', + mbConfirmation, MB_YESNO) = IDNO then + begin + Result := False; + exit; + end; + end + else + begin + Log('기존 설치 폴더 삭제 완료: ' + OldAppPath); + end; + end + else + begin + Log('삭제할 설치 폴더가 존재하지 않음: ' + OldAppPath); + end; + end; + end; + end; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + UserDataBackupPath, UserDataDestPath: String; +begin + // 설치 완료 후 + if CurStep = ssPostInstall then + begin + UserDataBackupPath := ExpandConstant('{tmp}\user_data_backup'); + UserDataDestPath := ExpandConstant('{app}\lib\src\user_data'); + + // 백업한 사용자 데이터 폴더가 있으면 복원 + if DirExists(UserDataBackupPath) then + begin + Log('사용자 데이터 복원 중: ' + UserDataBackupPath + ' -> ' + UserDataDestPath); + ForceDirectories(UserDataDestPath); + CopyDir(UserDataBackupPath, UserDataDestPath); + Log('사용자 데이터 복원 완료'); + end; + end; +end; diff --git a/bizinfo.db b/bizinfo.db index ff85cc27859a2bbb0480747b4231611918ca8ab3..f6ba21cca2f936b150815ec41a9b5f11a404192a 100644 GIT binary patch delta 363 zcmZoTz}Rqrae_2s{zMsP#{7*5OZd5%dH*u-ed7Jgw`8-R!h7Dye`Qk`-6pS>bMo_H z;bY(x zJo|W-ZWa_M=5ZC5;bCBu2Reb5gP)g|r6e&YySOC3DAiC@8Ym$Rm&nO4DM`)GGZdBr z3X8*q4GoJ+(hWDivTgl+ed7Jg`**XT!h7Dye`Qk`r%X2Dmo+c}>Q)yP=HTb$WiBpG&dASBO3VYojQrB#)V$<^c*Fdv V^5Wch14E!W#wMnlZ`-RX006uoJ2(IU diff --git a/browser_control.py b/browser_control.py index bb382169..5fac79c2 100644 --- a/browser_control.py +++ b/browser_control.py @@ -137,6 +137,9 @@ class BrowserController(QThread): self.image_processor = None self.is_image_processor_init = False self._restart_in_progress: bool = False + + # 마지막 선택된 그룹 기억하기 위한 변수 + self.last_selected_group = None self.curr_page_idx: int = 1 # 1부터 시작 self.curr_product_idx: int = 0 # 0-based, 현재 페이지 안에서 몇 번째 상품까지 끝냈는지 @@ -978,6 +981,247 @@ class BrowserController(QThread): self.browser_new_product_page_error.emit(str(e)) return + async def is_registered_product_page(self): + """현재 페이지가 등록상품 페이지인지 확인 (3초 타임아웃)""" + try: + # 등록상품 페이지 식별자 확인 (3초 타임아웃) + page_title_selector = "main.ant-layout-content div.ant-row-middle span.CharacterTitle85.H5Bold16" + + try: + # 3초 타임아웃으로 요소 대기 + element = await self.page.wait_for_selector(page_title_selector, timeout=3000) + text = (await element.inner_text()).strip() + is_registered_page = text == '등록 상품 목록' + self.logger.log(f"등록상품 페이지 확인: '{text}' = {is_registered_page}", level=logging.DEBUG) + return is_registered_page + except Exception: + # 타임아웃 또는 요소 없음 + self.logger.log("등록상품 페이지 식별자 요소를 찾을 수 없습니다. (3초 타임아웃)", level=logging.DEBUG) + return False + except Exception as e: + self.logger.log(f"등록상품 페이지 확인 중 오류: {e}", level=logging.DEBUG) + return False + + async def is_new_product_page(self): + """현재 페이지가 신규상품 페이지인지 확인 (3초 타임아웃)""" + try: + # 신규상품 페이지 식별자 확인 (3초 타임아웃) + page_title_selector = "main.ant-layout-content div.ant-row-middle[style='row-gap: 0px;'] span.Body1Bold14" + + try: + # 3초 타임아웃으로 요소 대기 + element = await self.page.wait_for_selector(page_title_selector, timeout=3000) + text = (await element.inner_text()).strip() + is_new_page = text == '수집 상품 목록' + self.logger.log(f"신규상품 페이지 확인: '{text}' = {is_new_page}", level=logging.DEBUG) + return is_new_page + except Exception: + # 타임아웃 또는 요소 없음 + self.logger.log("신규상품 페이지 식별자 요소를 찾을 수 없습니다. (3초 타임아웃)", level=logging.DEBUG) + return False + except Exception as e: + self.logger.log(f"신규상품 페이지 확인 중 오류: {e}", level=logging.DEBUG) + return False + + async def is_market_settings_page(self): + """현재 페이지가 마켓설정 페이지인지 확인 (메뉴 선택 상태로 판단, 3초 타임아웃)""" + try: + # 1. 메뉴 아이템의 선택 상태로 확인 (가장 정확한 방법) + # 선택된 마켓설정 메뉴 아이템 확인 + # 패턴: ul[role='menu'] li.ant-menu-item.ant-menu-item-selected[data-menu-id='rc-menu-uuid-11100-1-MARKET_SETTING'][role='menuitem'] + selected_market_menu_selector = "ul[role='menu'] li.ant-menu-item.ant-menu-item-selected[data-menu-id*='MARKET_SETTING'][role='menuitem']" + + try: + selected_element = await self.page.wait_for_selector(selected_market_menu_selector, timeout=3000) + if selected_element: + # 메뉴 ID 확인해서 로그 출력 + menu_id = await selected_element.get_attribute('data-menu-id') + self.logger.log(f"마켓설정 페이지 확인 (메뉴 선택됨): {menu_id}", level=logging.DEBUG) + return True + except Exception: + # 선택된 마켓설정 메뉴가 없음 + pass + + # 2. 백업 방법: 마켓설정 메뉴 아이템 존재하지만 선택되지 않은 경우 확인 + # 패턴: ul[role='menu'] li.ant-menu-item[data-menu-id='rc-menu-uuid-11100-1-MARKET_SETTING'][role='menuitem'] + unselected_market_menu_selector = "ul[role='menu'] li.ant-menu-item:not(.ant-menu-item-selected)[data-menu-id*='MARKET_SETTING'][role='menuitem']" + try: + unselected_element = await self.page.wait_for_selector(unselected_market_menu_selector, timeout=1000) + if unselected_element: + # 메뉴가 존재하지만 선택되지 않은 경우는 마켓설정 페이지가 아님 + menu_id = await unselected_element.get_attribute('data-menu-id') + self.logger.log(f"마켓설정 메뉴는 존재하지만 선택되지 않음: {menu_id}", level=logging.DEBUG) + return False + except Exception: + pass + + # 3. 백업 방법: URL로 확인 + current_url = self.page.url + if 'market' in current_url.lower() and 'setting' in current_url.lower(): + self.logger.log(f"마켓설정 페이지 확인 (URL): {current_url}", level=logging.DEBUG) + return True + + # 4. 최종 백업: 페이지 타이틀로 확인 + try: + page_title_selectors = [ + "main.ant-layout-content h1", + "main.ant-layout-content .ant-typography-title" + ] + + for selector in page_title_selectors: + try: + element = await self.page.wait_for_selector(selector, timeout=1000) + if element: + text = (await element.inner_text()).strip() + if any(keyword in text for keyword in ['마켓', '설정', 'market', 'setting']): + self.logger.log(f"마켓설정 페이지 확인 (제목): '{text}'", level=logging.DEBUG) + return True + except Exception: + continue + except Exception: + pass + + self.logger.log("마켓설정 페이지가 아닙니다.", level=logging.DEBUG) + return False + + except Exception as e: + self.logger.log(f"마켓설정 페이지 확인 중 오류: {e}", level=logging.DEBUG) + return False + + async def get_current_selected_group_name(self, ed_mode): + """현재 선택된 그룹 이름을 가져옴""" + try: + if ed_mode: + group_selector = self.selected_group_name_for_ed_locator + else: + group_selector = self.selected_group_name_locator + + group_name = (await self.page.inner_text(group_selector)).strip() + self.logger.log(f"현재 선택된 그룹: '{group_name}'", level=logging.DEBUG) + return group_name + except Exception as e: + self.logger.log(f"현재 그룹 이름 확인 실패: {e}", level=logging.DEBUG) + return None + + async def click_refresh_button(self): + """페이지 새로고침 버튼 클릭""" + try: + # 새로고침 버튼 - button 요소를 우선으로 시도 + refresh_button_selector = "main.ant-layout-content div.ant-row-middle button[type='button']:has(span[aria-label='reload'][role='img'])" + + # 새로고침 버튼 존재 확인 + refresh_button = await self.page.query_selector(refresh_button_selector) + if refresh_button: + await refresh_button.click() + self.logger.log("✅ 새로고침 버튼 클릭 완료", level=logging.INFO) + await asyncio.sleep(2) # 새로고침 완료 대기 + return True + else: + # 대안: span 요소 직접 클릭 + span_selector = "main.ant-layout-content div.ant-row-middle button[type='button'] span[aria-label='reload'][role='img']" + refresh_span = await self.page.query_selector(span_selector) + if refresh_span: + await refresh_span.click() + self.logger.log("✅ 새로고침 아이콘 클릭 완료", level=logging.INFO) + await asyncio.sleep(2) # 새로고침 완료 대기 + return True + else: + self.logger.log("❌ 새로고침 버튼을 찾을 수 없습니다.", level=logging.WARNING) + return False + except Exception as e: + self.logger.log(f"새로고침 버튼 클릭 중 오류: {e}", level=logging.ERROR, exc_info=True) + return False + + async def ensure_proper_page(self, ed_mode): + """ + ed_mode에 따라 적절한 페이지로 이동하고, 기존 선택 그룹도 복원 + - ed_mode=True: 등록상품 페이지로 이동 + - ed_mode=False: 신규상품 페이지로 이동 + - 마켓설정 페이지에서도 적절한 상품 페이지로 이동 + - 페이지 이동 후 이전에 선택했던 그룹으로 자동 선택 + """ + try: + current_url = self.page.url + self.logger.log(f"현재 페이지 확인 중... (ed_mode: {ed_mode}, URL: {current_url})", level=logging.DEBUG) + + # 먼저 마켓설정 페이지인지 확인 + is_market_settings = await self.is_market_settings_page() + need_page_change = False + current_group_name = None + + if is_market_settings: + self.logger.log("📋 현재 마켓설정 페이지입니다. 상품 관리 페이지로 이동합니다.", level=logging.INFO) + need_page_change = True + # 마켓설정 페이지에는 그룹이 없으므로 마지막 선택된 그룹 사용 + current_group_name = self.last_selected_group + if current_group_name: + self.logger.log(f"📋 마켓설정 페이지: 마지막 선택 그룹 '{current_group_name}' 사용", level=logging.INFO) + else: + self.logger.log("📋 마켓설정 페이지: 마지막 선택 그룹 정보 없음, 기본 그룹으로 시작", level=logging.INFO) + else: + # 상품 페이지에서 현재 선택된 그룹 이름 저장 (페이지 이동 전) + current_group_name = await self.get_current_selected_group_name(ed_mode) + + if ed_mode: + # 등록상품 모드 - 등록상품 페이지 확인 + if is_market_settings or not await self.is_registered_product_page(): + if not is_market_settings: + self.logger.log("❌ 등록상품 페이지가 아닙니다. 이동 중...", level=logging.INFO) + await self.go_to_registered_product_page() + need_page_change = True + + # 이동 후 확인 + await asyncio.sleep(2) # 페이지 로딩 대기 + if not await self.is_registered_product_page(): + self.logger.log("❌ 등록상품 페이지 이동 실패", level=logging.ERROR) + return False + self.logger.log("✅ 등록상품 페이지로 이동 완료", level=logging.INFO) + else: + self.logger.log("✅ 이미 등록상품 페이지에 있습니다.", level=logging.DEBUG) + else: + # 신규상품 모드 - 신규상품 페이지 확인 + if is_market_settings or not await self.is_new_product_page(): + if not is_market_settings: + self.logger.log("❌ 신규상품 페이지가 아닙니다. 이동 중...", level=logging.INFO) + await self.go_to_new_product_page() + need_page_change = True + + # 이동 후 확인 + await asyncio.sleep(2) # 페이지 로딩 대기 + if not await self.is_new_product_page(): + self.logger.log("❌ 신규상품 페이지 이동 실패", level=logging.ERROR) + return False + self.logger.log("✅ 신규상품 페이지로 이동 완료", level=logging.INFO) + else: + self.logger.log("✅ 이미 신규상품 페이지에 있습니다.", level=logging.DEBUG) + + # 페이지 이동이 발생했고, 이전에 선택된 그룹이 있으면 복원 + if need_page_change and current_group_name and current_group_name != "전체": + self.logger.log(f"🔄 이전 선택 그룹 '{current_group_name}' 복원 중...", level=logging.INFO) + try: + await self.select_group_by_name(current_group_name) + self.logger.log(f"✅ 그룹 '{current_group_name}' 복원 완료", level=logging.INFO) + except Exception as group_error: + self.logger.log(f"⚠️ 그룹 '{current_group_name}' 복원 실패: {group_error}", level=logging.WARNING) + # 그룹 선택 실패해도 페이지 이동은 성공했으므로 True 반환 + elif need_page_change and is_market_settings: + if current_group_name and current_group_name != "전체": + # 마켓설정 페이지에서 이동 후 마지막 선택 그룹 복원 + self.logger.log(f"📋 마켓설정에서 상품페이지로 이동 후 마지막 선택 그룹 '{current_group_name}' 복원 시도", level=logging.INFO) + try: + await self.select_group_by_name(current_group_name) + self.logger.log(f"✅ 마켓설정에서 이동 후 그룹 '{current_group_name}' 복원 완료", level=logging.INFO) + except Exception as group_error: + self.logger.log(f"⚠️ 마켓설정에서 이동 후 그룹 '{current_group_name}' 복원 실패: {group_error}", level=logging.WARNING) + else: + self.logger.log("📋 마켓설정에서 이동했지만 마지막 선택 그룹 정보가 없어 기본 그룹으로 시작합니다.", level=logging.INFO) + + return True + + except Exception as e: + self.logger.log(f"적절한 페이지 확인 및 이동 중 오류: {e}", level=logging.ERROR, exc_info=True) + return False + # async def start_browser(self): # """크롬 브라우저 실행 및 페이지 로딩""" @@ -1945,10 +2189,28 @@ class BrowserController(QThread): max_steps = 400 for step in range(max_steps): - # 현재 활성화 항목 텍스트 읽기 - active = self.page.locator(".ant-select-item-option-active") - if await active.count() > 0: - text = (await active.inner_text()).strip() + # 현재 활성화 항목 텍스트 읽기 - 더 구체적인 선택자 사용 + active_elements = self.page.locator(".ant-select-item-option-active") + active_count = await active_elements.count() + + if active_count > 0: + # 여러 개의 active 요소가 있을 경우, 그룹 관련 요소만 선택 + text = None + for i in range(active_count): + element = active_elements.nth(i) + element_text = (await element.inner_text()).strip() + + # 페이지당 상품수 드롭다운 항목은 무시 ("개씩 보기"가 포함된 것들) + if "개씩 보기" not in element_text: + text = element_text + break + + if text is None: + # 그룹 관련 요소를 찾지 못한 경우, 첫 번째 요소 사용 + text = (await active_elements.first().inner_text()).strip() + + self.logger.log(f"현재 활성 항목: '{text}' (전체 {active_count}개 중)", level=logging.DEBUG) + if text == group_name: await self.page.keyboard.press("Enter") option_found = True @@ -1971,6 +2233,8 @@ class BrowserController(QThread): if option_found: self.logger.log(f"✅ 그룹 '{group_name}' 선택 완료", level=logging.INFO) + # 마지막 선택된 그룹 기억 + self.last_selected_group = group_name # 총 상품 개수 가져오기 product_count_info = await self.get_total_product_count() total_products = product_count_info.get("total_count", 0) @@ -2809,7 +3073,7 @@ class BrowserController(QThread): self.logger.log("최대 스크롤 횟수에 도달했습니다.", level=logging.DEBUG) - async def collect_product_info(self, items_per_page, ed_mode): + async def collect_product_info(self, items_per_page, ed_mode, total_products=None): """ 상품 정보를 수집하는 메서드 """ @@ -2817,6 +3081,12 @@ class BrowserController(QThread): product_infos = [] product_name_elements = [] # product_name_element를 저장할 리스트 + # 실제 수집할 상품 개수 결정 (총 상품수와 페이지당 상품수 중 작은 값) + actual_items_to_collect = items_per_page + if total_products is not None: + actual_items_to_collect = min(items_per_page, total_products) + self.logger.log(f"실제 수집할 상품 개수: {actual_items_to_collect} (총 상품: {total_products}, 페이지당: {items_per_page})", level=logging.DEBUG) + # ed_mode에 따라 product_elements 설정 if ed_mode: # 각 상품의 이름, 가격, 이미지를 위한 선택자 리스트 구성 (index가 2부터 시작) @@ -2826,13 +3096,14 @@ class BrowserController(QThread): "price": self.product_price_for_ed_template.format(index=i), "image": self.product_image_for_ed_template.format(index=i) } - for i in range(2, items_per_page + 2) # index가 2부터 시작하도록 설정 + for i in range(2, actual_items_to_collect + 2) # 실제 상품 개수만큼만 생성 ] else: # ed_mode=False일 때는 각 상품의 부모 요소를 모두 선택 - product_elements = await self.page.query_selector_all(self.product_parent_locator) + all_product_elements = await self.page.query_selector_all(self.product_parent_locator) + product_elements = all_product_elements[:actual_items_to_collect] # 실제 상품 개수만큼만 선택 - for i, element in enumerate(product_elements[:items_per_page], start=1): + for i, element in enumerate(product_elements, start=1): try: if ed_mode: # ed_mode=True일 때는 각 상품의 개별 선택자 사용 @@ -3978,6 +4249,12 @@ class BrowserController(QThread): - 삭제 완료 후 성과 로그 추출 """ try: + # 적절한 페이지로 이동 확인 (ed_mode용이므로 True) + ed_mode = self.toggle_states.get('ed_mode', True) # ed_mode용 메서드이므로 기본값 True + if not await self.ensure_proper_page(ed_mode): + self.logger.log("❌ 적절한 페이지로 이동 실패", level=logging.ERROR) + return + is_already_ext = await self.off_on_ext_Percenty() if not is_already_ext: self.logger.log("확장 페이지 열기 실패", level=logging.ERROR) @@ -4167,6 +4444,11 @@ class BrowserController(QThread): self.complete_remove_market_info_job.emit(completion_msg) self.step_completed.emit("delete_upload_info", True) + # 업로드정보 삭제 완료 후 새로고침 버튼 클릭 + await asyncio.sleep(1) # 1초 대기 + self.logger.log("🔄 업로드정보 삭제 완료 후 새로고침 실행 중...", level=logging.INFO) + await self.click_refresh_button() + except Exception as e: self.logger.log(f"ed_bulk_delete_market_info 오류: {e}", level=logging.ERROR, exc_info=True) self.step_completed.emit("delete_upload_info", False) @@ -4187,6 +4469,13 @@ class BrowserController(QThread): else: self.logger.log("🔧 업로드조건 미설정 - 기본값 사용", level=logging.INFO) + # 적절한 페이지로 이동 확인 + ed_mode = self.toggle_states.get('ed_mode', False) + if not await self.ensure_proper_page(ed_mode): + self.logger.log("❌ 적절한 페이지로 이동 실패", level=logging.ERROR) + self.upload_log_message.emit("❌ 페이지 이동 실패") + return + # 업로드 시작 시그널 self.upload_step_changed.emit("상품 업로드 준비 중", 5) self.upload_log_message.emit("업로드 작업을 시작합니다...") @@ -4250,6 +4539,10 @@ class BrowserController(QThread): # 총 상품수 정보 전달 self.upload_progress_updated.emit(0, total_products) self.upload_log_message.emit(f"총 {total_products}개 상품, {total_pages}페이지를 처리합니다.") + + # 업로드 통계 추적 + total_upload_success = 0 + total_upload_fail = 0 async def ensure_select_all_checked(): """전체상품 체크박스가 항상 체크 상태가 되도록 보장""" @@ -4501,6 +4794,8 @@ class BrowserController(QThread): m = re.findall(r"(\d+)건 성공\s*/\s*(\d+)건 실패", text_content) if m: success_count, fail_count = m[0] + total_upload_success += int(success_count) + total_upload_fail += int(fail_count) msg = f"업로드 결과 - 성공: {success_count}건, 실패: {fail_count}건" self.logger.log(msg, level=logging.INFO) else: @@ -4566,10 +4861,14 @@ class BrowserController(QThread): self.logger.log("ed_mode 선택상품 일괄 업로드 완료", level=logging.INFO) - # 업로드 완료 시그널 + # 최종 업로드 통계를 포함한 완료 메시지 전달 + completion_msg = f"업로드 작업 완료 - 총 성공: {total_upload_success}건, 총 실패: {total_upload_fail}건" + self.logger.log(completion_msg, level=logging.INFO) + + # 업로드 완료 시그널 (통계 포함) self.upload_step_changed.emit("업로드 완료", 100) - self.upload_log_message.emit("모든 상품 업로드가 성공적으로 완료되었습니다!") - self.upload_completed.emit(True, "업로드 완료") + self.upload_log_message.emit(completion_msg) + self.upload_completed.emit(True, completion_msg) self.step_completed.emit("upload_products", True) # ✅ 모든 작업 후 1페이지로 돌아가기 @@ -4588,6 +4887,12 @@ class BrowserController(QThread): is_ed_mode = self.toggle_states.get('ed_mode', False) + # 적절한 페이지로 이동 확인 + if not await self.ensure_proper_page(is_ed_mode): + self.logger.log("❌ 적절한 페이지로 이동 실패", level=logging.ERROR) + self.translation_error.emit("페이지 이동 실패") + return + self.running = True # 번역 작업이 시작됨 self.translation_started.emit() @@ -4662,7 +4967,7 @@ class BrowserController(QThread): product_buttons = await self.get_product_edit_buttons_by_template() else: self.logger.log('상품정보 수집', level=logging.DEBUG) - product_infos, product_name_elements = await self.collect_product_info(items_per_page, ed_mode=is_ed_mode) + product_infos, product_name_elements = await self.collect_product_info(items_per_page, ed_mode=is_ed_mode, total_products=total_products) self.logger.log(f"product_infos : {product_infos}", level=logging.DEBUG) self.logger.log('수정모드이므로 상품명 elements를 수정버튼으로 활용합니다.', level=logging.DEBUG) product_buttons = [{"edit_button": name_element, "memo_button": None, "shipping_button": None} for name_element in product_name_elements] @@ -5332,16 +5637,6 @@ class BrowserController(QThread): self.logger.log("이벤트 루프가 초기화되지 않았거나 이미 종료되었습니다.", level=logging.ERROR) - def start_UploadSelectedMarketsJob_task(self): - """번역 작업을 이벤트 루프에서 실행""" - # 이벤트 루프가 없거나 닫혀 있으면 새로 생성 - # self.initialize_event_loop() - - if self.loop and not self.loop.is_closed(): - # 이미 실행 중인 이벤트 루프에 번역 작업 추가 - asyncio.run_coroutine_threadsafe(self.ed_bulk_upload_selected_markets(), self.loop) - else: - self.logger.log("이벤트 루프가 초기화되지 않았거나 이미 종료되었습니다.", level=logging.ERROR) def cancel_upload(self): """업로드 취소""" @@ -5644,7 +5939,7 @@ class BrowserController(QThread): if not self.toggle_states['ed_mode']: product_buttons = await self.get_product_edit_buttons_by_template() else: - product_infos, product_name_elements = await self.collect_product_info(items_per_page, ed_mode=self.toggle_states['ed_mode']) + product_infos, product_name_elements = await self.collect_product_info(items_per_page, ed_mode=self.toggle_states['ed_mode'], total_products=total_products) product_buttons = [{"edit_button": name_element, "memo_button": None, "shipping_button": None} for name_element in product_name_elements] return total_products, product_buttons @@ -6093,6 +6388,174 @@ class BrowserController(QThread): return False, response_time + + def start_UploadSelectedMarketsJob_task(self, upload_conditions=None): + """업로드 작업을 이벤트 루프에서 실행""" + if self.loop and not self.loop.is_closed(): + # 이미 실행 중인 이벤트 루프에 업로드 작업 추가 + asyncio.run_coroutine_threadsafe(self.ed_bulk_upload_selected_markets(upload_conditions), self.loop) + else: + self.logger.log("이벤트 루프가 초기화되지 않았거나 이미 종료되었습니다.", level=logging.ERROR) + + async def apply_upload_conditions_to_page(self, price_policy: int, smartstore_policy: bool): + """업로드조건을 웹페이지에 실제로 적용""" + try: + self.logger.log(f"🔧 웹페이지에 업로드조건 적용 시작 - 가격정책: {price_policy}, 스마트스토어정책: {smartstore_policy}", level=logging.INFO) + + # 1. 가격정책 드롭다운 변경 + await self.apply_price_policy_dropdown(price_policy) + + # 2. 스마트스토어정책 체크박스 변경 + await self.apply_smartstore_policy_checkbox(smartstore_policy) + + self.logger.log("✅ 웹페이지 업로드조건 적용 완료", level=logging.INFO) + + except Exception as e: + self.logger.log(f"❌ 웹페이지 업로드조건 적용 실패: {e}", level=logging.ERROR, exc_info=True) + + async def apply_price_policy_dropdown(self, price_policy: int): + """가격정책 드롭다운 변경 (1: 최대 업로드 갯수 기준, 2: 최저 옵션가 기준, 3: 상품별 각각의 대표가격 설정)""" + try: + # 가격정책 옵션 텍스트 매핑 + policy_options = { + 1: "최대 업로드 갯수 기준", + 2: "최저 옵션가 기준", + 3: "상품별 각각의 대표 가격 설정 적용" + } + + target_option = policy_options.get(price_policy, policy_options[1]) + self.logger.log(f"📋 가격정책 변경 시도: {target_option} (정책번호: {price_policy})", level=logging.DEBUG) + + # 현재 선택된 값 확인 + current_selection = self.page.locator("div.ant-modal-content .ant-select-selection-item") + try: + current_text = await current_selection.inner_text(timeout=5000) + if target_option in current_text: + self.logger.log(f"✅ 가격정책이 이미 '{target_option}'로 설정되어 있음", level=logging.DEBUG) + return + except Exception: + pass + + # 드롭다운 클릭하여 열기 + dropdown_selector = "div.ant-modal-content .ant-select" + dropdown = self.page.locator(dropdown_selector) + await dropdown.click() + self.logger.log("📋 가격정책 드롭다운 열기", level=logging.DEBUG) + + # 드롭다운 옵션 리스트 대기 + await asyncio.sleep(1) # 충분한 시간 확보 + + # 더 안전한 방식으로 옵션 선택: 드롭다운 내의 옵션들을 찾기 + option_elements = await self.page.query_selector_all(".ant-select-dropdown .ant-select-item") + self.logger.log(f"📋 발견된 옵션 수: {len(option_elements)}", level=logging.DEBUG) + + for i, option_element in enumerate(option_elements): + try: + option_text = await option_element.inner_text() + self.logger.log(f"📋 옵션 {i+1}: '{option_text}'", level=logging.DEBUG) + if target_option in option_text: + await option_element.click() + self.logger.log(f"📋 가격정책 옵션 클릭 성공: {target_option}", level=logging.DEBUG) + break + except Exception as e: + continue + else: + self.logger.log(f"❌ 가격정책 옵션을 찾지 못함: {target_option}", level=logging.ERROR) + return + + # 변경 확인 + await asyncio.sleep(0.5) + try: + current_text = await current_selection.inner_text(timeout=5000) + if target_option in current_text: + self.logger.log(f"✅ 가격정책 변경 완료: {target_option}", level=logging.INFO) + else: + self.logger.log(f"⚠️ 가격정책 변경 확인 실패. 현재: '{current_text}', 목표: '{target_option}'", level=logging.WARNING) + except Exception as e: + self.logger.log(f"⚠️ 가격정책 변경 확인 중 오류: {e}", level=logging.WARNING) + + except Exception as e: + self.logger.log(f"❌ 가격정책 드롭다운 변경 실패: {e}", level=logging.ERROR) + + async def apply_smartstore_policy_checkbox(self, smartstore_policy: bool): + """스마트스토어정책 체크박스 변경""" + try: + self.logger.log(f"📋 스마트스토어정책 변경 시도: {'ON' if smartstore_policy else 'OFF'}", level=logging.DEBUG) + + # 더 구체적인 선택자 사용: 스마트스토어 마켓 정책 체크박스만 선택 + try: + # 방법 1: get_by_role을 사용해서 구체적으로 선택 (로그에서 확인된 정확한 이름 사용) + smartstore_checkbox = self.page.get_by_role("checkbox", name="스마트스토어 마켓 정책 적용하기- 1") + + # 현재 체크 상태 확인 + is_currently_checked = await smartstore_checkbox.is_checked() + self.logger.log(f"📋 현재 스마트스토어정책 체크 상태: {'ON' if is_currently_checked else 'OFF'}", level=logging.DEBUG) + + if is_currently_checked == smartstore_policy: + self.logger.log(f"✅ 스마트스토어정책이 이미 {'ON' if smartstore_policy else 'OFF'}으로 설정되어 있음", level=logging.DEBUG) + return + + # 체크박스 상태 변경 + await smartstore_checkbox.click() + self.logger.log(f"📋 스마트스토어정책 체크박스 {'ON' if smartstore_policy else 'OFF'} 클릭 완료", level=logging.DEBUG) + + # 변경 확인 + await asyncio.sleep(0.5) + final_checked = await smartstore_checkbox.is_checked() + if final_checked == smartstore_policy: + self.logger.log(f"✅ 스마트스토어정책 변경 완료: {'ON' if smartstore_policy else 'OFF'}", level=logging.INFO) + else: + self.logger.log(f"⚠️ 스마트스토어정책 변경 확인 실패. 현재: {'ON' if final_checked else 'OFF'}, 목표: {'ON' if smartstore_policy else 'OFF'}", level=logging.WARNING) + + except Exception as e1: + self.logger.log(f"❌ 방법 1 실패, 방법 2 시도: {e1}", level=logging.DEBUG) + + # 방법 2: 더 구체적인 선택자 사용 + try: + # 모든 체크박스를 찾아서 텍스트로 구분 + all_checkboxes = await self.page.query_selector_all("div.ant-modal-content input[type='checkbox']") + self.logger.log(f"📋 발견된 체크박스 수: {len(all_checkboxes)}", level=logging.DEBUG) + + target_checkbox = None + for i, checkbox in enumerate(all_checkboxes): + try: + # 체크박스 근처의 라벨 텍스트 확인 + parent_element = await checkbox.query_selector("..") + if parent_element: + parent_text = await parent_element.inner_text() + self.logger.log(f"📋 체크박스 {i+1} 텍스트: '{parent_text}'", level=logging.DEBUG) + + if "스마트스토어 마켓 정책" in parent_text or "마켓 정책" in parent_text: + target_checkbox = checkbox + self.logger.log(f"📋 스마트스토어정책 체크박스 발견: {i+1}번째", level=logging.DEBUG) + break + except Exception: + continue + + if target_checkbox: + is_currently_checked = await target_checkbox.is_checked() + self.logger.log(f"📋 현재 스마트스토어정책 체크 상태: {'ON' if is_currently_checked else 'OFF'}", level=logging.DEBUG) + + if is_currently_checked != smartstore_policy: + await target_checkbox.click() + self.logger.log(f"📋 스마트스토어정책 체크박스 {'ON' if smartstore_policy else 'OFF'} 클릭 완료", level=logging.DEBUG) + + await asyncio.sleep(0.5) + final_checked = await target_checkbox.is_checked() + if final_checked == smartstore_policy: + self.logger.log(f"✅ 스마트스토어정책 변경 완료: {'ON' if smartstore_policy else 'OFF'}", level=logging.INFO) + else: + self.logger.log(f"⚠️ 스마트스토어정책 변경 확인 실패", level=logging.WARNING) + else: + self.logger.log("❌ 스마트스토어정책 체크박스를 찾지 못함", level=logging.ERROR) + + except Exception as e2: + self.logger.log(f"❌ 방법 2도 실패: {e2}", level=logging.ERROR) + + except Exception as e: + self.logger.log(f"❌ 스마트스토어정책 체크박스 변경 실패: {e}", level=logging.ERROR) + + #------------------------------------ # 이미지 처리 관련 코드 #------------------------------------ @@ -6373,12 +6836,41 @@ class ImageWorkerManager: self.logger.log(f"워커 재시작 실행: {cause}", level=logging.INFO) try: - # 프로세스 종료 - if self.proc.is_alive(): - self.proc.terminate() - self.proc.join(timeout=3) - if self.proc.is_alive(): - self.proc.kill() + # 재시작 원인별 대기 시간 설정 + restart_delays = { + "timeout": 5, + "memory_threshold": 3, + "memory_error": 10, # 메모리 에러는 더 긴 대기 + "error": 5, + "periodic": 2 + } + delay = restart_delays.get(cause, 3) + + # 프로세스 안전하게 종료 + if hasattr(self, 'proc') and self.proc and self.proc.is_alive(): + try: + self.logger.log(f"워커 프로세스 종료 시도 (PID: {self.proc.pid})", level=logging.DEBUG) + self.proc.terminate() + + # 정상 종료 대기 (더 긴 시간) + if not self.proc.join(timeout=8): + self.logger.log("정상 종료 실패 → 강제 종료", level=logging.WARNING) + self.proc.kill() + self.proc.join(timeout=2) + + if self.proc.is_alive(): + self.logger.log("⚠️ 프로세스가 여전히 살아있음", level=logging.WARNING) + else: + self.logger.log("✅ 워커 프로세스 종료 완료", level=logging.DEBUG) + + except Exception as e: + self.logger.log(f"프로세스 종료 중 오류: {e}", level=logging.WARNING) + + # 안전한 대기 (메모리 안정화) + if delay > 0: + self.logger.log(f"메모리 안정화 대기: {delay}초", level=logging.DEBUG) + import time + time.sleep(delay) # 강화된 메모리 정리 self.logger.log("ImageWorker으로 인한 강화된 메모리 정리", level=logging.INFO) @@ -6460,6 +6952,34 @@ class ImageWorkerManager: if old_count > 0: self.logger.log(f"🗑️ 재시작 시 {old_count}개 pending requests 정리 완료", level=logging.INFO) + # 재시작 원인별 안전 설정 적용 + updated_toggle_states = self.toggle_states.copy() + + # 메모리/에러로 인한 재시작 시 더 안전한 설정 적용 + if cause in ["memory_error", "timeout", "error"]: + self.logger.log(f"🔒 {cause}로 인한 재시작 → 안전 모드 설정 적용", level=logging.INFO) + + # GPU 기능 비활성화 + updated_toggle_states['use_cuda'] = False + updated_toggle_states['migan_use_cuda'] = False + + # 모든 이미지 처리를 CPU 모드로 전환 + updated_toggle_states['optionIMGTrans_type'] = 'CPU' + updated_toggle_states['detail_IMGTrans_type'] = 'CPU' + updated_toggle_states['thumb_trans_type'] = 'CPU' + + # 백그라운드 제거도 안전하게 + updated_toggle_states['thumb_nukki'] = False # 배경제거 비활성화 + + # 메모리 절약 설정 + updated_toggle_states['max_image_size'] = 800 # 이미지 크기 제한 + updated_toggle_states['enable_aggressive_memory_cleanup'] = True + + self.logger.log("🔒 안전 모드: GPU 비활성화, CPU 모드, 배경제거 비활성화", level=logging.INFO) + + # 업데이트된 toggle_states를 사용 + self.toggle_states = updated_toggle_states + # proc_args 업데이트 log_path = self.proc_args[3] # log_path 위치 수정 self.proc_args = ( @@ -6701,125 +7221,6 @@ class ImageWorkerManager: self.logger.log(f"❌ 응답 타임아웃: uid={uid[:8]}, 총 대기시간={elapsed:.1f}초", level=logging.WARNING) raise TimeoutError("image worker response timeout") - def start_UploadSelectedMarketsJob_task(self, upload_conditions=None): - """업로드 작업 시작 (MainUI에서 호출)""" - try: - # AsyncRunner를 통해 비동기 함수 실행 - if hasattr(self, 'async_runner') and self.async_runner: - self.async_runner.run_async(self.ed_bulk_upload_selected_markets(upload_conditions)) - else: - # 대안: QThread에서 실행 - asyncio.create_task(self.ed_bulk_upload_selected_markets(upload_conditions)) - - self.logger.log("업로드 작업 시작 요청 완료", level=logging.INFO) - except Exception as e: - self.logger.log(f"업로드 작업 시작 오류: {e}", level=logging.ERROR, exc_info=True) - - async def apply_upload_conditions_to_page(self, price_policy: int, smartstore_policy: bool): - """업로드조건을 웹페이지에 실제로 적용""" - try: - self.logger.log(f"🔧 웹페이지에 업로드조건 적용 시작 - 가격정책: {price_policy}, 스마트스토어정책: {smartstore_policy}", level=logging.INFO) - - # 1. 가격정책 드롭다운 변경 - await self.apply_price_policy_dropdown(price_policy) - - # 2. 스마트스토어정책 체크박스 변경 - await self.apply_smartstore_policy_checkbox(smartstore_policy) - - self.logger.log("✅ 웹페이지 업로드조건 적용 완료", level=logging.INFO) - - except Exception as e: - self.logger.log(f"❌ 웹페이지 업로드조건 적용 실패: {e}", level=logging.ERROR, exc_info=True) - - async def apply_price_policy_dropdown(self, price_policy: int): - """가격정책 드롭다운 변경 (1: 최대 업로드 갯수 기준, 2: 최저 옵션가 기준, 3: 상품별 각각의 대표가격 설정)""" - try: - # 가격정책 옵션 텍스트 매핑 - policy_options = { - 1: "최대 업로드 갯수 기준", - 2: "최저 옵션가 기준", - 3: "상품별 각각의 대표 가격 설정 적용" - } - - target_option = policy_options.get(price_policy, policy_options[1]) - self.logger.log(f"📋 가격정책 변경 시도: {target_option}", level=logging.DEBUG) - - # 현재 선택된 값 확인 - current_selection = self.page.locator("div.ant-modal-content .ant-select-selection-item") - try: - current_text = await current_selection.inner_text(timeout=5000) - if target_option in current_text: - self.logger.log(f"✅ 가격정책이 이미 '{target_option}'로 설정되어 있음", level=logging.DEBUG) - return - except Exception: - pass - - # 드롭다운 클릭하여 열기 - dropdown_selector = "div.ant-modal-content .ant-select" - dropdown = self.page.locator(dropdown_selector) - await dropdown.click() - self.logger.log("📋 가격정책 드롭다운 열기", level=logging.DEBUG) - - # 드롭다운 옵션 리스트 대기 - await asyncio.sleep(0.5) # 드롭다운이 완전히 열릴 시간 확보 - - # 해당 옵션 클릭 - option_locator = self.page.get_by_text(target_option, exact=True).first - await expect(option_locator).to_be_visible(timeout=5000) - await option_locator.click() - - self.logger.log(f"📋 가격정책 옵션 클릭: {target_option}", level=logging.DEBUG) - - # 변경 확인 - await expect(current_selection).to_contain_text(target_option, timeout=5000) - self.logger.log(f"✅ 가격정책 변경 완료: {target_option}", level=logging.INFO) - - except Exception as e: - self.logger.log(f"❌ 가격정책 드롭다운 변경 실패: {e}", level=logging.ERROR) - - async def apply_smartstore_policy_checkbox(self, smartstore_policy: bool): - """스마트스토어정책 체크박스 변경""" - try: - self.logger.log(f"📋 스마트스토어정책 변경 시도: {'ON' if smartstore_policy else 'OFF'}", level=logging.DEBUG) - - # 스마트스토어정책 체크박스 찾기 - checkbox_container = self.page.locator("div.ant-modal-content .ant-checkbox") - checkbox_input = checkbox_container.locator("input.ant-checkbox-input") - - # 현재 체크 상태 확인 - try: - is_currently_checked = await checkbox_input.is_checked() - if is_currently_checked == smartstore_policy: - self.logger.log(f"✅ 스마트스토어정책이 이미 {'ON' if smartstore_policy else 'OFF'}으로 설정되어 있음", level=logging.DEBUG) - return - except Exception: - pass - - # 체크박스 상태 변경 - if smartstore_policy: - # 체크하기 - if not await checkbox_input.is_checked(): - await checkbox_container.click() - self.logger.log("📋 스마트스토어정책 체크박스 ON", level=logging.DEBUG) - else: - # 체크 해제하기 - if await checkbox_input.is_checked(): - await checkbox_container.click() - self.logger.log("📋 스마트스토어정책 체크박스 OFF", level=logging.DEBUG) - - # 변경 확인 - if smartstore_policy: - await expect(checkbox_input).to_be_checked(timeout=5000) - await expect(checkbox_container).to_have_class(re.compile(r"ant-checkbox-checked")) - else: - await expect(checkbox_input).not_to_be_checked(timeout=5000) - await expect(checkbox_container).not_to_have_class(re.compile(r"ant-checkbox-checked")) - - self.logger.log(f"✅ 스마트스토어정책 변경 완료: {'ON' if smartstore_policy else 'OFF'}", level=logging.INFO) - - except Exception as e: - self.logger.log(f"❌ 스마트스토어정책 체크박스 변경 실패: {e}", level=logging.ERROR) - def restart(self, cause="periodic"): """cause: 'periodic' | 'error'""" """현재 워커 프로세스를 종료하고 새로 띄운다""" diff --git a/mainUI_SP.py b/mainUI_SP.py index 93cf2f2a..d8eca9f3 100644 --- a/mainUI_SP.py +++ b/mainUI_SP.py @@ -90,9 +90,13 @@ class MAIN_GUI(QMainWindow): # 수동 로그인 QMessageBox 참조 저장 self.manual_login_message_box = None - # 업로드조건 설정 변수들 초기화 - self.upload_price_policy = 1 # 1: 최대 업로드 갯수 기준, 2: 최저 옵션가 기준, 3: 상품별 각각의 대표가격 설정 - self.smartstore_market_policy = True # 스스 마켓정책적용 (기본값: True) + # 업로드조건 설정 변수들 초기화 (settings에서 로드) + self.upload_price_policy = self.settings_manager.get_value("upload_conditions/price_policy", 1) # 1: 최대 업로드 갯수 기준, 2: 최저 옵션가 기준, 3: 상품별 각각의 대표가격 설정 + self.smartstore_market_policy = self.settings_manager.get_value("upload_conditions/smartstore_market_policy", True) # 스스 마켓정책적용 + + # 로드된 업로드조건 설정값 로그 출력 + if hasattr(self, 'logger') and self.logger: + self.logger.log(f"업로드조건 초기값 로드 - 가격정책: {self.upload_price_policy}, 스스정책: {self.smartstore_market_policy}", level=logging.DEBUG) # 자동 스크롤 관련 변수 초기화 self.scroll_timer = QTimer(self) @@ -279,6 +283,18 @@ class MAIN_GUI(QMainWindow): self.browser_controller.image_worker_fatal_signal.connect(self.on_image_worker_fatal) self.browser_controller.premium_event_started.connect(self.on_premium_event_started) self.browser_controller.browser_login_error.connect(self.on_browser_login_error) + + # 업로드 관련 시그널 연결 (항상 연결되도록) + if hasattr(self.browser_controller, 'upload_progress_updated'): + self.browser_controller.upload_progress_updated.connect(self.on_upload_progress_updated) + if hasattr(self.browser_controller, 'upload_step_changed'): + self.browser_controller.upload_step_changed.connect(self.on_upload_step_changed) + if hasattr(self.browser_controller, 'upload_completed'): + self.browser_controller.upload_completed.connect(self.on_upload_completed) + if hasattr(self.browser_controller, 'upload_log_message'): + self.browser_controller.upload_log_message.connect(self.on_upload_log_message) + if hasattr(self.browser_controller, 'step_completed'): + self.browser_controller.step_completed.connect(self.on_step_completed) self.browser_controller.browser_ad1_close_error.connect(self.on_browser_ad1_close_error) self.browser_controller.browser_ad2_close_error.connect(self.on_browser_ad2_close_error) self.browser_controller.browser_group_list_error.connect(self.on_browser_group_list_error) @@ -3181,7 +3197,7 @@ class MAIN_GUI(QMainWindow): # Percenty 버튼 텍스트 변경 if self.is_register_product_mode: - self.PercentyJob_button.setText('등록상품 \n 상품편집 시작') + self.PercentyJob_button.setText('현재그룹\n 상품편집 시작') else: self.PercentyJob_button.setText('상품편집\n시작') @@ -3395,13 +3411,19 @@ class MAIN_GUI(QMainWindow): if reply == QMessageBox.StandardButton.Yes: # 선택마켓 정보 확인 - full_info = self.biz_dbManager.get_biz_full_info() - markets = full_info.get('markets', {}) - if not markets: + work_combinations = self.biz_dbManager.get_biz_full_info() + + # 모든 작업순서의 마켓을 합쳐서 확인 + all_markets = {} + for work_combo in work_combinations: + if isinstance(work_combo, dict) and 'markets' in work_combo: + all_markets.update(work_combo['markets']) + + if not all_markets: self.logger.log("선택된 마켓 정보가 없습니다. 먼저 사업자관리에서 마켓을 선택하세요.", level=logging.WARNING) return - self.logger.log(f"마켓정보전송 - {len(markets)}개 마켓", level=logging.INFO) + self.logger.log(f"마켓정보전송 - {len(all_markets)}개 마켓", level=logging.INFO) # 올바른 선택마켓 구조로 브라우저 컨트롤러에 전달 legacy_info = self.biz_dbManager.get_biz_full_info_legacy() @@ -3627,17 +3649,7 @@ class MAIN_GUI(QMainWindow): self.upload_progress_dialog.pause_requested.connect(self.on_upload_pause_requested) self.upload_progress_dialog.resume_requested.connect(self.on_upload_resume_requested) - # 브라우저 컨트롤러 시그널 연결 (진행상황 업데이트) - if hasattr(self.browser_controller, 'upload_progress_updated'): - self.browser_controller.upload_progress_updated.connect(self.on_upload_progress_updated) - if hasattr(self.browser_controller, 'upload_step_changed'): - self.browser_controller.upload_step_changed.connect(self.on_upload_step_changed) - if hasattr(self.browser_controller, 'upload_completed'): - self.browser_controller.upload_completed.connect(self.on_upload_completed) - if hasattr(self.browser_controller, 'upload_log_message'): - self.browser_controller.upload_log_message.connect(self.on_upload_log_message) - if hasattr(self.browser_controller, 'step_completed'): - self.browser_controller.step_completed.connect(self.on_step_completed) + # 브라우저 컨트롤러 시그널은 이미 __init__에서 연결됨 (중복 연결 제거) # 비모달로 다이얼로그 표시 self.upload_progress_dialog.show() @@ -3901,19 +3913,113 @@ class MAIN_GUI(QMainWindow): def on_upload_completed(self, success=True, message=""): """업로드 완료 처리""" try: + self.logger.log(f"📢 on_upload_completed 호출됨 - 성공: {success}, 메시지: {message}", level=logging.INFO) + + # 진행률 다이얼로그가 있으면 업데이트 if hasattr(self, 'upload_progress_dialog') and self.upload_progress_dialog: if success: self.upload_progress_dialog.complete_upload() - self.upload_progress_dialog.add_log("모든 작업이 성공적으로 완료되었습니다!") + completion_message = message if message else "모든 작업이 성공적으로 완료되었습니다!" + self.upload_progress_dialog.add_log(completion_message) else: - self.upload_progress_dialog.add_log(f"업로드 중 오류 발생: {message}") + error_message = f"업로드 중 오류 발생: {message}" + self.upload_progress_dialog.add_log(error_message) # 오류 발생 시에도 닫기 버튼 활성화 self.upload_progress_dialog.pause_button.setEnabled(False) self.upload_progress_dialog.cancel_button.setEnabled(False) self.upload_progress_dialog.close_button.setEnabled(True) + + # 항상 완료 알림 다이얼로그 표시 (UI 쓰레드에서 실행) + if success: + completion_message = message if message else "모든 작업이 성공적으로 완료되었습니다!" + QTimer.singleShot(100, lambda: self.show_upload_completion_dialog(True, completion_message)) + else: + error_message = f"업로드 중 오류 발생: {message}" if message else "업로드 중 오류가 발생했습니다." + QTimer.singleShot(100, lambda: self.show_upload_completion_dialog(False, error_message)) + except Exception as e: self.logger.log(f"업로드 완료 처리 실패: {e}", level=logging.ERROR) + def show_upload_completion_dialog(self, success=True, message=""): + """업로드 완료/실패 알림 다이얼로그 표시""" + try: + self.logger.log(f"🔔 show_upload_completion_dialog 호출됨 - 성공: {success}, 메시지: {message}", level=logging.INFO) + from PySide6.QtWidgets import QMessageBox + from PySide6.QtCore import Qt + + msg_box = QMessageBox(self) + msg_box.setWindowTitle("업로드 완료") + + if success: + msg_box.setIcon(QMessageBox.Icon.Information) + # 통계 정보 파싱 및 포맷팅 + if "총 성공:" in message and "총 실패:" in message: + # 업로드 작업 완료 - 총 성공: 0건, 총 실패: 3건 + import re + match = re.search(r'총 성공:\s*(\d+)건,\s*총 실패:\s*(\d+)건', message) + if match: + success_count = int(match.group(1)) + fail_count = int(match.group(2)) + total_count = success_count + fail_count + + title = "📤 업로드 완료!" + detailed_msg = f""" +업로드 작업이 완료되었습니다. + +📊 업로드 통계: +• 총 처리 상품: {total_count}건 +• ✅ 성공: {success_count}건 +• ❌ 실패: {fail_count}건 + +성공률: {(success_count/total_count*100):.1f}% ({success_count}/{total_count}) + """.strip() + msg_box.setText(title) + msg_box.setDetailedText(detailed_msg) + else: + msg_box.setText("📤 업로드 완료!") + msg_box.setDetailedText(message) + else: + msg_box.setText("📤 업로드 완료!") + msg_box.setDetailedText(message) + else: + msg_box.setIcon(QMessageBox.Icon.Critical) + msg_box.setText("❌ 업로드 실패") + msg_box.setDetailedText(message) + + # 버튼 설정 + msg_box.setStandardButtons(QMessageBox.StandardButton.Ok) + msg_box.setDefaultButton(QMessageBox.StandardButton.Ok) + + # 다이얼로그 스타일 설정 + msg_box.setStyleSheet(""" + QMessageBox { + background-color: white; + font-size: 12px; + } + QMessageBox QLabel { + color: #2c3e50; + font-weight: bold; + } + QPushButton { + background-color: #3498db; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { + background-color: #2980b9; + } + """) + + # 다이얼로그 표시 + msg_box.exec() + + self.logger.log(f"업로드 완료 알림 표시 - 성공: {success}, 메시지: {message}", level=logging.INFO) + + except Exception as e: + self.logger.log(f"업로드 완료 다이얼로그 표시 실패: {e}", level=logging.ERROR) def on_market_change_button_clicked(self): """마켓정보변경 버튼 클릭 핸들러""" @@ -3938,13 +4044,19 @@ class MAIN_GUI(QMainWindow): return # 선택마켓 정보 확인 - full_info = self.biz_dbManager.get_biz_full_info() - markets = full_info.get('markets', {}) - if not markets: + work_combinations = self.biz_dbManager.get_biz_full_info() + + # 모든 작업순서의 마켓을 합쳐서 확인 + all_markets = {} + for work_combo in work_combinations: + if isinstance(work_combo, dict) and 'markets' in work_combo: + all_markets.update(work_combo['markets']) + + if not all_markets: self.logger.log("선택된 마켓 정보가 없습니다. 먼저 사업자관리에서 마켓을 선택하세요.", level=logging.WARNING) return - self.logger.log(f"마켓정보변경 시작 - {len(markets)}개 마켓", level=logging.INFO) + self.logger.log(f"마켓정보변경 시작 - {len(all_markets)}개 마켓", level=logging.INFO) # 올바른 선택마켓 구조로 브라우저 컨트롤러에 전달 legacy_info = self.biz_dbManager.get_biz_full_info_legacy() @@ -10237,25 +10349,33 @@ class MAIN_GUI(QMainWindow): def save_upload_conditions_settings(self): """업로드조건 설정 저장""" try: - self.settings_manager.set_value("upload_conditions/price_policy", self.upload_price_policy) - self.settings_manager.set_value("upload_conditions/smartstore_market_policy", self.smartstore_market_policy) + self.settings_manager.save_value("upload_conditions/price_policy", self.upload_price_policy) + self.settings_manager.save_value("upload_conditions/smartstore_market_policy", self.smartstore_market_policy) self.logger.log("업로드조건 설정 저장 완료", level=logging.DEBUG) except Exception as e: self.logger.log(f"업로드조건 설정 저장 오류: {str(e)}", level=logging.ERROR) def load_upload_conditions_settings(self): - """업로드조건 설정 불러오기""" + """업로드조건 설정 불러오기 (UI 반영)""" try: - # 기본값 설정 + # settings에서 최신값 다시 로드 (다른 곳에서 변경되었을 수 있음) self.upload_price_policy = self.settings_manager.get_value("upload_conditions/price_policy", 1) self.smartstore_market_policy = self.settings_manager.get_value("upload_conditions/smartstore_market_policy", True) - # UI에 반영 - if hasattr(self, 'price_policy_combo'): + # UI에 반영 (위젯이 생성된 후에만) + if hasattr(self, 'price_policy_combo') and self.price_policy_combo is not None: + # 시그널 차단 후 설정 (무한 루프 방지) + self.price_policy_combo.blockSignals(True) self.price_policy_combo.setCurrentIndex(self.upload_price_policy - 1) # 0, 1, 2로 변환 + self.price_policy_combo.blockSignals(False) + self.logger.log(f"가격정책 UI 반영: {self.upload_price_policy} -> index {self.upload_price_policy - 1}", level=logging.DEBUG) - if hasattr(self, 'smartstore_policy_toggle'): + if hasattr(self, 'smartstore_policy_toggle') and self.smartstore_policy_toggle is not None: + # 시그널 차단 후 설정 (무한 루프 방지) + self.smartstore_policy_toggle.blockSignals(True) self.smartstore_policy_toggle.setChecked(self.smartstore_market_policy) + self.smartstore_policy_toggle.blockSignals(False) + self.logger.log(f"스마트스토어정책 UI 반영: {self.smartstore_market_policy}", level=logging.DEBUG) self.logger.log(f"업로드조건 설정 불러오기 완료 - 가격정책: {self.upload_price_policy}, 스스정책: {self.smartstore_market_policy}", level=logging.DEBUG) except Exception as e: diff --git a/src/modules/bria_background_removal_module.py b/src/modules/bria_background_removal_module.py index 97b968dd..ae665533 100644 --- a/src/modules/bria_background_removal_module.py +++ b/src/modules/bria_background_removal_module.py @@ -127,60 +127,118 @@ class BriaBackgroundRemovalModule: self.logger.log(self._init_error, level=logging.ERROR) return False - try: - import onnxruntime as ort - - if self.logger: - self.logger.log(f"BriaAI ONNX 모델 로딩 중: {self.local_model_path}", level=logging.INFO) - - # 세션 옵션 설정 (성능 최적화) - sess_options = ort.SessionOptions() - sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL - sess_options.enable_mem_pattern = True - sess_options.enable_cpu_mem_arena = True - - # DirectML 사용시 추가 설정 - if 'DmlExecutionProvider' in self.providers: - sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL + # 단계별 폴백 시도 + fallback_providers = [ + (self.providers, "원본 providers"), + ([("CPUExecutionProvider", {})], "CPU 폴백") + ] + + for attempt_providers, attempt_name in fallback_providers: + try: + import onnxruntime as ort + if self.logger: - self.logger.log("DirectML 최적화 설정 적용", level=logging.DEBUG) + self.logger.log(f"BriaAI ONNX 모델 로딩 시도 ({attempt_name}): {self.local_model_path}", level=logging.INFO) - # ONNX 세션 생성 - self._session = ort.InferenceSession( - self.local_model_path, - sess_options=sess_options, - providers=self.providers - ) + # 세션 옵션 설정 (안전성 우선) + sess_options = ort.SessionOptions() + + # CPU 모드에서는 더 보수적인 설정 + if attempt_providers == [("CPUExecutionProvider", {})]: + sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + sess_options.enable_mem_pattern = False + sess_options.enable_cpu_mem_arena = False + sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL + if self.logger: + self.logger.log("🔒 CPU 안전 모드: 모든 최적화 비활성화", level=logging.INFO) + else: + sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_BASIC + sess_options.enable_mem_pattern = True + sess_options.enable_cpu_mem_arena = True + + # DirectML 사용시 추가 설정 + if 'DmlExecutionProvider' in str(attempt_providers): + sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL + if self.logger: + self.logger.log("DirectML 최적화 설정 적용", level=logging.DEBUG) - # 입출력 정보 가져오기 - inputs = self._session.get_inputs() - outputs = self._session.get_outputs() - - if not inputs or not outputs: - raise RuntimeError("ONNX 모델의 입출력 정의를 찾을 수 없습니다") + # ONNX 세션 생성 (타임아웃 설정) + import signal + + def timeout_handler(signum, frame): + raise TimeoutError("모델 로딩 타임아웃") + + # Windows에서는 signal.alarm이 작동하지 않으므로 threading 사용 + import threading + import time + + session_created = [False] + session_result = [None] + session_error = [None] + + def create_session(): + try: + session_result[0] = ort.InferenceSession( + self.local_model_path, + sess_options=sess_options, + providers=attempt_providers + ) + session_created[0] = True + except Exception as e: + session_error[0] = e + + # 세션 생성을 별도 스레드에서 실행 (30초 타임아웃) + session_thread = threading.Thread(target=create_session) + session_thread.daemon = True + session_thread.start() + session_thread.join(timeout=30) + + if not session_created[0]: + if session_error[0]: + raise session_error[0] + else: + raise TimeoutError("모델 로딩이 30초 내에 완료되지 않음") + + self._session = session_result[0] - self._input_name = inputs[0].name - self._output_name = outputs[0].name + # 입출력 정보 가져오기 + inputs = self._session.get_inputs() + outputs = self._session.get_outputs() + + if not inputs or not outputs: + raise RuntimeError("ONNX 모델의 입출력 정의를 찾을 수 없습니다") - # 실제 사용된 프로바이더 확인 - actual_providers = self._session.get_providers() - - if self.logger: - self.logger.log( - f"✅ BriaAI ONNX 모델 로딩 완료 | " - f"Providers: {actual_providers} | " - f"Input: {self._input_name} | Output: {self._output_name}", - level=logging.INFO - ) + self._output_name = outputs[0].name - self._model_loaded = True - return True + # 실제 사용된 프로바이더 확인 + actual_providers = self._session.get_providers() + + if self.logger: + self.logger.log( + f"✅ BriaAI ONNX 모델 로딩 완료 ({attempt_name}) | " + f"Providers: {actual_providers} | " + f"Input: {self._input_name} | Output: {self._output_name}", + level=logging.INFO + ) - except Exception as e: - self._init_error = f"BriaAI ONNX 모델 로딩 실패: {e}" - if self.logger: - self.logger.log(self._init_error, level=logging.ERROR, exc_info=True) - return False + self._model_loaded = True + self._providers_used = actual_providers + return True + + except Exception as e: + error_msg = f"BriaAI ONNX 모델 로딩 실패 ({attempt_name}): {e}" + if self.logger: + self.logger.log(error_msg, level=logging.WARNING if attempt_name != "CPU 폴백" else logging.ERROR, exc_info=True) + + # 마지막 시도가 아니면 계속 진행 + if attempt_name != "CPU 폴백": + continue + + # 모든 시도 실패 + self._init_error = "모든 provider에서 BriaAI ONNX 모델 로딩 실패" + if self.logger: + self.logger.log(self._init_error, level=logging.ERROR) + return False def _preprocess(self, image_bgr: np.ndarray) -> Tuple[np.ndarray, Tuple[int, int]]: """BGR uint8 이미지를 모델 입력(NCHW float32, 정규화)로 변환 (허깅페이스 호환)""" diff --git a/src/modules/gpu_utils.py b/src/modules/gpu_utils.py index 7327c5fe..750d3ec5 100644 --- a/src/modules/gpu_utils.py +++ b/src/modules/gpu_utils.py @@ -83,20 +83,43 @@ class GPUManager: if not use_gpu_requested: self.logger.log("GPU 가속이 비활성화됨 (toggle_states['use_cuda'] = False)", level=logging.DEBUG) self.can_use_cuda = False + self._set_safe_cpu_mode(toggle_states) return # Windows 플랫폼 확인 (DirectML 필수) if platform.system() != "Windows": self.logger.log("DirectML은 Windows 전용입니다 - CPU 모드로 전환", level=logging.WARNING) self.can_use_cuda = False + self._set_safe_cpu_mode(toggle_states) return - # DirectML 지원 확인 - directml_support = self._check_directml_support() + # DirectML 지원 확인 (안전하게 시도) + try: + directml_support = self._check_directml_support() + except Exception as e: + self.logger.log(f"DirectML 확인 중 예외 발생: {e} - CPU 모드로 안전 전환", level=logging.WARNING) + self.can_use_cuda = False + self._set_safe_cpu_mode(toggle_states) + return if not directml_support: self.logger.log("DirectML 지원을 확인할 수 없음 - CPU 모드로 전환", level=logging.WARNING) self.can_use_cuda = False + self._set_safe_cpu_mode(toggle_states) + return + + # 메모리 상태 확인 (안전장치) + try: + memory_ok = self._check_system_memory() + if not memory_ok: + self.logger.log("⚠️ 시스템 메모리 부족 감지 - 안전을 위해 CPU 모드로 전환", level=logging.WARNING) + self.can_use_cuda = False + self._set_safe_cpu_mode(toggle_states) + return + except Exception as e: + self.logger.log(f"메모리 확인 실패: {e} - 안전을 위해 CPU 모드로 전환", level=logging.WARNING) + self.can_use_cuda = False + self._set_safe_cpu_mode(toggle_states) return # 모든 검사 통과 @@ -113,6 +136,45 @@ class GPUManager: self.logger.log("📊 DirectML 가속 활성화: rembg, MIGAN, OCR 모든 모듈에서 GPU 사용", level=logging.DEBUG) self.logger.log("=== 🎯 DirectML GPU 상태 초기화 완료 🎯 ===", level=logging.DEBUG) + def _set_safe_cpu_mode(self, toggle_states: Dict[str, Any]) -> None: + """안전한 CPU 모드로 설정""" + self.can_use_cuda = False + self.directml_available = False + + # 모든 GPU 관련 설정을 CPU 모드로 강제 변경 + gpu_related_keys = [ + 'migan_use_cuda', 'use_cuda', 'optionIMGTrans_type', + 'detail_IMGTrans_type', 'thumb_trans_type' + ] + + for key in gpu_related_keys: + if key in toggle_states: + if key.endswith('_type'): + toggle_states[key] = 'CPU' + else: + toggle_states[key] = False + + self.logger.log("🔒 안전한 CPU 모드로 모든 GPU 설정 강제 비활성화", level=logging.INFO) + + def _check_system_memory(self) -> bool: + """시스템 메모리 상태 확인""" + try: + import psutil + memory = psutil.virtual_memory() + available_gb = memory.available / (1024**3) + + self.logger.log(f"💾 시스템 메모리 - 사용가능: {available_gb:.1f}GB, 사용률: {memory.percent:.1f}%", level=logging.DEBUG) + + # 사용 가능한 메모리가 2GB 미만이거나 사용률이 90% 이상이면 위험 + if available_gb < 2.0 or memory.percent > 90: + self.logger.log(f"⚠️ 메모리 부족 위험: 사용가능 {available_gb:.1f}GB, 사용률 {memory.percent:.1f}%", level=logging.WARNING) + return False + + return True + except Exception as e: + self.logger.log(f"메모리 상태 확인 실패: {e}", level=logging.WARNING) + return False # 확인 실패 시 안전하게 False 반환 + def _detect_gpu_hardware(self) -> bool: """GPU 하드웨어 감지""" try: diff --git a/src/modules/image_worker.py b/src/modules/image_worker.py index 2b7f571e..98ffe1c3 100644 --- a/src/modules/image_worker.py +++ b/src/modules/image_worker.py @@ -113,6 +113,11 @@ def worker_main( # ── ImageProcessor 초기화 및 Warm‑up ────────────────────── processor = None try: + # 안전한 초기화를 위한 메모리 정리 + import gc + gc.collect() + + logger.info("🔧 ImageProcessor3 초기화 시작...") processor = ImageProcessor3( logger=logger, page=None, @@ -123,19 +128,81 @@ def worker_main( papago_translator=None, ) - # OCR 모델 로딩을 위한 더미 호출 - dummy_path = os.path.join(base_dir, "_imgproc_warmup.png") - tmp = np.zeros((1, 1, 3), dtype=np.uint8) - cv2.imwrite(dummy_path, tmp) - processor.ocr_module.detect_text(dummy_path) - try: - os.remove(dummy_path) - except Exception: - pass + # OCR 모델 안전한 Warm-up + if processor and processor.ocr_module: + try: + logger.info("🔰 OCR 모듈 Warm-up 시작...") + dummy_path = os.path.join(base_dir, "_imgproc_warmup.png") + tmp = np.zeros((100, 100, 3), dtype=np.uint8) # 더 현실적인 크기 + cv2.imwrite(dummy_path, tmp) + + # 타임아웃 설정으로 무한 대기 방지 + import threading + import time + + warmup_success = [False] + warmup_error = [None] + + def warmup_ocr(): + try: + processor.ocr_module.detect_text(dummy_path) + warmup_success[0] = True + except Exception as e: + warmup_error[0] = e + + warmup_thread = threading.Thread(target=warmup_ocr) + warmup_thread.daemon = True + warmup_thread.start() + warmup_thread.join(timeout=30) # 30초 타임아웃 + + if warmup_success[0]: + logger.info("✅ OCR 모듈 Warm-up 성공") + elif warmup_error[0]: + logger.warning(f"⚠️ OCR 모듈 Warm-up 실패: {warmup_error[0]}") + else: + logger.warning("⚠️ OCR 모듈 Warm-up 타임아웃") + + try: + os.remove(dummy_path) + except Exception: + pass + + except Exception as e: + logger.warning(f"OCR Warm-up 실패: {e}") + else: + logger.warning("OCR 모듈이 초기화되지 않아 Warm-up 건너뜀") logger.info("🔰 ImageProcessor Warm‑up 완료") - except Exception: - logger.error("ImageProcessor 초기화 실패", exc_info=True) + + except Exception as e: + logger.error(f"ImageProcessor 초기화 실패: {e}", exc_info=True) + + # 초기화 실패 시에도 기본적인 처리가 가능하도록 최소한의 processor 생성 시도 + try: + logger.info("🔄 안전 모드로 재초기화 시도...") + + # GPU 설정을 CPU로 강제 변경 + safe_toggle_states = toggle_states.copy() + safe_toggle_states['use_cuda'] = False + safe_toggle_states['optionIMGTrans_type'] = 'CPU' + safe_toggle_states['detail_IMGTrans_type'] = 'CPU' + safe_toggle_states['thumb_trans_type'] = 'CPU' + safe_toggle_states['migan_use_cuda'] = False + + processor = ImageProcessor3( + logger=logger, + page=None, + toggle_states=safe_toggle_states, + unwanted_words=unwanted_words, + authenticated_by_admin=authenticated_by_admin, + base_dir=base_dir, + papago_translator=None, + ) + logger.info("✅ 안전 모드로 ImageProcessor 초기화 성공") + + except Exception as e2: + logger.error(f"안전 모드 초기화도 실패: {e2}", exc_info=True) + processor = None # ── READY(OK) 신호 전송 ────────────────────────────────── try: diff --git a/src/modules/onnx_ocr_module/src/onnx_ocr_wrapper.py b/src/modules/onnx_ocr_module/src/onnx_ocr_wrapper.py index 11642829..8a6e44aa 100644 --- a/src/modules/onnx_ocr_module/src/onnx_ocr_wrapper.py +++ b/src/modules/onnx_ocr_module/src/onnx_ocr_wrapper.py @@ -312,31 +312,87 @@ class ONNXOCRModule: os.environ['MKL_NUM_THREADS'] = '1' os.environ['NUMEXPR_NUM_THREADS'] = '1' - # ONNX 시스템 초기화 + # ONNX 시스템 초기화 (안전한 폴백 로직) self.text_system = None - self._initialize_onnx_system() + initialization_attempts = [] - if self.text_system is None: - # DirectML 실패 시 CPU 모드로 재시도 - if self.use_gpu: + # 1차 시도: 원본 설정으로 초기화 + try: + if self.logger: + initial_mode = "DirectML" if self.use_gpu else "CPU" + self.logger.log(f"🚀 ONNX TextSystem 초기화 시작 ({initial_mode} 모드)", level=logging.INFO) + + self._initialize_onnx_system() + + if self.text_system is not None: + gpu_status = "DirectML" if self.use_gpu else "CPU" + if self.logger: + self.logger.log(f"✅ ONNX TextSystem 초기화 완료 ({gpu_status} + {self.model_type.upper()} 모델)", level=logging.INFO) + self.logger.log(f"✅ ONNX OCR 모듈 초기화 성공 ({gpu_status} 모드)", level=logging.INFO) + return + else: + initialization_attempts.append("원본 설정 실패") + + except Exception as e: + initialization_attempts.append(f"원본 설정 예외: {e}") + if self.logger: + self.logger.log(f"ONNX 초기화 1차 시도 실패: {e}", level=logging.WARNING) + + # 2차 시도: DirectML -> CPU 폴백 + if self.use_gpu: + try: if self.logger: self.logger.log("🔄 DirectML 실패로 CPU 모드로 재시도 중...", level=logging.WARNING) self.use_gpu = False self._initialize_onnx_system() # CPU 모드로 재시도 - if self.text_system is None: + if self.text_system is not None: if self.logger: - self.logger.log("❌ CPU 모드도 실패: ONNX TextSystem 초기화 실패", level=logging.ERROR) - raise Exception("ONNX TextSystem 초기화 완전 실패") - else: + self.logger.log("✅ CPU 폴백 모드로 ONNX TextSystem 초기화 성공", level=logging.INFO) + self.logger.log(f"✅ ONNX OCR 모듈 초기화 성공 (CPU 폴백 모드)", level=logging.INFO) + return + else: + initialization_attempts.append("CPU 폴백 실패") + + except Exception as e: + initialization_attempts.append(f"CPU 폴백 예외: {e}") if self.logger: - self.logger.log("❌ ONNX TextSystem 초기화 실패", level=logging.ERROR) - raise Exception("ONNX TextSystem 초기화 실패") - else: - gpu_status = "DirectML" if self.use_gpu else "CPU" - if self.logger: - self.logger.log(f"✅ ONNX OCR 모듈 초기화 성공 ({gpu_status} 모드)", level=logging.INFO) + self.logger.log(f"CPU 폴백 시도도 실패: {e}", level=logging.WARNING) + + # 3차 시도: 더 안전한 모델로 재시도 (simp 모델) + if self.model_type != 'simp': + try: + if self.logger: + self.logger.log("🔄 더 안전한 SIMP 모델로 재시도 중...", level=logging.WARNING) + + original_model_type = self.model_type + self.model_type = 'simp' # 가장 안정적인 모델로 변경 + self.use_gpu = False # CPU 모드 강제 + self._initialize_onnx_system() + + if self.text_system is not None: + if self.logger: + self.logger.log("✅ SIMP 모델 + CPU 모드로 ONNX TextSystem 초기화 성공", level=logging.INFO) + self.logger.log(f"✅ ONNX OCR 모듈 초기화 성공 (SIMP + CPU 안전 모드)", level=logging.INFO) + return + else: + self.model_type = original_model_type # 원복 + initialization_attempts.append("SIMP 모델 폴백 실패") + + except Exception as e: + initialization_attempts.append(f"SIMP 모델 폴백 예외: {e}") + if self.logger: + self.logger.log(f"SIMP 모델 폴백도 실패: {e}", level=logging.ERROR) + + # 모든 시도 실패 + error_summary = " | ".join(initialization_attempts) + final_error = f"ONNX TextSystem 모든 초기화 시도 실패: {error_summary}" + + if self.logger: + self.logger.log(f"❌ {final_error}", level=logging.ERROR) + + raise Exception(final_error) def _determine_model_type(self): """toggle_states에서 모델 타입을 결정합니다.""" diff --git a/src/user_data/user_data_909d2ef8-7053-4006-ab40-49eb49f20383.db b/src/user_data/user_data_909d2ef8-7053-4006-ab40-49eb49f20383.db index a52be24db03e689d363d1a2df474c0e35d75bc2b..a77c5bd4d0fc4d07f00d3f72ccc4ccee333ed2a9 100644 GIT binary patch delta 248 zcmXBO%S{4d07c;p3ceBX4fqz{jxaD7SlR$BSb`Ne#Km!YjFlJ@U1|y1fdyE>ER4T# z@txIO+|m12GzzR0n$?HKRp`_8O%~lgerEM@I!HQ$Za+@bZZGLQ=UK55{oIKW7H|y5 zv4|y{z)76KX`I1XoWnBCV+9wmii@~}%eaE8xQ6Rk!#Xx_12=ICw{Zt|aS!*gi3fOy wM|g}Uc>3bm_uK!wv=#ST?RJvh=fm?L4laUL&<>KI6Lf>WP46`yUY0(70nlM#jsO4v delta 248 zcmXZWNiqXr06^jDkeDN8l9*#A5}od}EL|X1a02B9Nm)GGr;EI9%W3AL>`3PuI7D=;rZjP%F24NxK)P%_NCiapxr;6f4p1ofu&OM{pF2 zSi&(J#|fOoDV)X`EaNOza1Q5j0T*!zmvIGGaSf|j!#b|x25#aOZsQK_VgvVZ9}n;l tkMI~zUOoMJ|G(=t)9f;fv-`Y%7BqwNpcS-(iy#g