diff --git a/data.db b/data.db index 512571a..1adcb31 100644 Binary files a/data.db and b/data.db differ diff --git a/scripts/run_wehago_download_2022_9225.cmd b/scripts/run_wehago_download_2022_9225.cmd index d5f99eb..5ab317f 100644 --- a/scripts/run_wehago_download_2022_9225.cmd +++ b/scripts/run_wehago_download_2022_9225.cmd @@ -6,7 +6,8 @@ set "WEHAGO_ALLOW_UNCHANGED_DETAIL=" set "WEHAGO_SCAN_EXISTING_ACCOUNT_MODE=" set "WEHAGO_EXPECTED_PERIOD_START=2022.01.01" set "WEHAGO_EXPECTED_PERIOD_END=2022.12.31" -set "WEHAGO_DOWNLOAD_DIR=\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\2022" +set "WEHAGO_DOWNLOAD_DIR=\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\hanmac\2022" +set "WEHAGO_MERGED_FILENAME=2022_계정별원장_취합.xlsx" set "WEHAGO_ACCOUNT_LEDGER_URL=https://smarta.wehago.com/#/smarta/account/SABK0107?sao&cno=1173867&cd_com=biz202103030006368&gisu=27&yminsa=2026&searchData=2022010120221231&color=#1C90FB&companyName=(%%EC%%A3%%BC)%%ED%%95%%9C%%EB%%A7%%A5%%EA%%B8%%B0%%EC%%88%%A0&companyID=b21344" cd /d C:\Windows -python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\wehago_data_download_2022_work.py" --no-pause-on-failure --no-merge %* +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\wehago_data_download_2022_work.py" --no-pause-on-failure %* diff --git a/scripts/run_wehago_download_2023_allow_unchanged_9225.cmd b/scripts/run_wehago_download_2023_allow_unchanged_9225.cmd index 04a4fd6..8c0cbc1 100644 --- a/scripts/run_wehago_download_2023_allow_unchanged_9225.cmd +++ b/scripts/run_wehago_download_2023_allow_unchanged_9225.cmd @@ -6,7 +6,8 @@ set "WEHAGO_ALLOW_UNCHANGED_DETAIL=1" set "WEHAGO_SCAN_EXISTING_ACCOUNT_MODE=" set "WEHAGO_EXPECTED_PERIOD_START=2023.01.01" set "WEHAGO_EXPECTED_PERIOD_END=2023.12.31" -set "WEHAGO_DOWNLOAD_DIR=\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\2023" +set "WEHAGO_DOWNLOAD_DIR=\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\hanmac\2023" +set "WEHAGO_MERGED_FILENAME=2023_계정별원장_취합.xlsx" set "WEHAGO_ACCOUNT_LEDGER_URL=https://smarta.wehago.com/#/smarta/account/SABK0107?sao&cno=1173867&cd_com=biz202103030006368&gisu=28&yminsa=2026&searchData=2023010120231231&color=#1C90FB&companyName=(%%EC%%A3%%BC)%%ED%%95%%9C%%EB%%A7%%A5%%EA%%B8%%B0%%EC%%88%%A0&companyID=b21344" cd /d C:\Windows -python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\wehago_data_download_2022_work.py" --no-pause-on-failure --no-merge %* +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\wehago_data_download_2022_work.py" --no-pause-on-failure %* diff --git a/scripts/run_wehago_download_2024_allow_unchanged_9225.cmd b/scripts/run_wehago_download_2024_allow_unchanged_9225.cmd index 2cc838d..58ec603 100644 --- a/scripts/run_wehago_download_2024_allow_unchanged_9225.cmd +++ b/scripts/run_wehago_download_2024_allow_unchanged_9225.cmd @@ -6,7 +6,8 @@ set "WEHAGO_ALLOW_UNCHANGED_DETAIL=1" set "WEHAGO_SCAN_EXISTING_ACCOUNT_MODE=" set "WEHAGO_EXPECTED_PERIOD_START=2024.01.01" set "WEHAGO_EXPECTED_PERIOD_END=2024.12.31" -set "WEHAGO_DOWNLOAD_DIR=\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\2024" +set "WEHAGO_DOWNLOAD_DIR=\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\hanmac\2024" +set "WEHAGO_MERGED_FILENAME=2024_계정별원장_취합.xlsx" set "WEHAGO_ACCOUNT_LEDGER_URL=https://smarta.wehago.com/#/smarta/account/SABK0107?sao&cno=1173867&cd_com=biz202103030006368&gisu=29&yminsa=2026&searchData=2024010120241231&color=#1C90FB&companyName=(%%EC%%A3%%BC)%%ED%%95%%9C%%EB%%A7%%A5%%EA%%B8%%B0%%EC%%88%%A0&companyID=b21344" cd /d C:\Windows -python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\wehago_data_download_2022_work.py" --no-pause-on-failure --no-merge %* +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\wehago_data_download_2022_work.py" --no-pause-on-failure %* diff --git a/scripts/run_wehago_jangheon_2023_2025_9226.cmd b/scripts/run_wehago_jangheon_2023_2025_9226.cmd new file mode 100644 index 0000000..2a44d19 --- /dev/null +++ b/scripts/run_wehago_jangheon_2023_2025_9226.cmd @@ -0,0 +1,3 @@ +@echo off +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9226" +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\run_wehago_jangheon_2023_2025_visible.py" %* diff --git a/scripts/run_wehago_jangheon_2023_2025_visible.py b/scripts/run_wehago_jangheon_2023_2025_visible.py new file mode 100644 index 0000000..ef9fc1c --- /dev/null +++ b/scripts/run_wehago_jangheon_2023_2025_visible.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import os +import re +import sys +import time +from pathlib import Path + +import wehago_data_download_2022_work as wehago + + +def detect_debugger_address() -> str: + explicit = os.environ.get("WEHAGO_CHROME_DEBUGGER_ADDRESS", "").strip() + if explicit: + return explicit + + try: + for line in Path("/etc/resolv.conf").read_text(encoding="utf-8").splitlines(): + if line.startswith("nameserver "): + host_ip = line.split()[1].strip() + if host_ip: + return f"{host_ip}:9226" + except Exception: + pass + + return "127.0.0.1:9226" + + +DEBUGGER_ADDRESS = detect_debugger_address() +WEHAGO_HOME_URL = "https://www.wehago.com/" +if os.name == "nt": + DOWNLOAD_ROOT = Path(os.environ.get("WEHAGO_WINDOWS_DOWNLOAD_ROOT", r"C:\Users\User\Downloads\wehago\jangheon_construction")) +else: + DOWNLOAD_ROOT = Path("/home/b17301/WEHAGO_DB/data_download/jangheon_construction") +TARGET_PERIODS = [ + (15, 2023), + (16, 2024), + (17, 2025), +] + + +def log(message: str) -> None: + print(message, flush=True) + + +def year_bounds(year: int) -> tuple[str, str]: + return f"{year}.01.01", f"{year}.12.31" + + +def build_period_url(base_url: str, gisu: int, year: int) -> str: + search_data = f"{year}0101{year}1231" + updated = re.sub(r"gisu=\d+", f"gisu={gisu}", base_url) + updated = re.sub(r"searchData=\d{16}", f"searchData={search_data}", updated) + return updated + + +def wait_until_ledger_ready(driver) -> str: + log("WEHAGO 로그인 및 장헌산업 15기(2023) 계정별원장 화면 준비를 기다립니다.") + while True: + try: + current_url = driver.current_url + except Exception: + current_url = "" + + try: + ready = "SABK0107" in current_url and wehago.ledger_screen_ready(driver) + except Exception: + ready = False + + if ready: + start_value, end_value = wehago.read_top_period_values(driver) + log(f"화면 확인 완료: {current_url}") + log(f"상단 기간: {start_value or '?'} ~ {end_value or '?'}") + return current_url + + time.sleep(2) + + +def run_period(base_url: str, gisu: int, year: int, first_run: bool) -> None: + download_dir = DOWNLOAD_ROOT / str(year) + download_dir.mkdir(parents=True, exist_ok=True) + + wehago.CHROME_DEBUGGER_ADDRESS = DEBUGGER_ADDRESS + wehago.DOWNLOAD_DIR = download_dir + wehago.SCAN_EXISTING_ACCOUNT_MODE = True + wehago.SCAN_ALL_VISIBLE_ACCOUNT_MODE = True + wehago.REFRESH_BEFORE_RUN = False + wehago.OPEN_ACCOUNT_LEDGER_URL = True + wehago.ACCOUNT_LEDGER_URL = build_period_url(base_url, gisu, year) + wehago.EXPECTED_PERIOD_START, wehago.EXPECTED_PERIOD_END = year_bounds(year) + wehago.AVAILABLE_ACCOUNT_CODES = None + wehago.MERGED_FILENAME = f"{year}_계정별원장_취합.xlsx" + + log(f"{gisu}기({year}) 다운로드 시작") + log(f"저장 위치: {download_dir}") + wehago.run(wehago.ACCOUNTS, merge=False, pause_on_failure=False) + merged_path = wehago.consolidate_download_dir(download_dir, output_name=f"{year}_계정별원장_취합.xlsx") + log(f"{gisu}기({year}) 취합 완료: {merged_path}") + log(f"{gisu}기({year}) 다운로드 완료") + + +def main() -> int: + DOWNLOAD_ROOT.mkdir(parents=True, exist_ok=True) + + wehago.CHROME_DEBUGGER_ADDRESS = DEBUGGER_ADDRESS + wehago.DOWNLOAD_DIR = DOWNLOAD_ROOT / "bootstrap" + log(f"디버그 Chrome 연결 주소: {DEBUGGER_ADDRESS}") + driver = wehago.build_driver(wehago.DOWNLOAD_DIR) + + try: + try: + current_url = driver.current_url + except Exception: + current_url = "" + if "wehago" not in current_url.lower(): + driver.get(WEHAGO_HOME_URL) + + base_url = wait_until_ledger_ready(driver) + finally: + try: + driver.quit() + except Exception: + pass + + for index, (gisu, year) in enumerate(TARGET_PERIODS): + run_period(base_url, gisu, year, first_run=index == 0) + + log("장헌산업 15기~17기 계정별원장 다운로드가 모두 끝났습니다.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/setup_windows_all_portproxies.cmd b/scripts/setup_windows_all_portproxies.cmd index fccc1b8..42ece8e 100644 --- a/scripts/setup_windows_all_portproxies.cmd +++ b/scripts/setup_windows_all_portproxies.cmd @@ -3,16 +3,9 @@ setlocal net session >nul 2>&1 if not "%errorlevel%"=="0" ( - echo This script must be run as Administrator. - echo. - echo 1. Open Windows Start menu. - echo 2. Type cmd. - echo 3. Right-click Command Prompt and choose "Run as administrator". - echo 4. Run this command: - echo %~f0 %* - echo. - pause - exit /b 1 + echo Requesting Administrator privileges... + powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%~f0' -ArgumentList '%*' -Verb RunAs" + exit /b %errorlevel% ) set SCRIPT_DIR=%~dp0 diff --git a/scripts/setup_windows_all_portproxies.ps1 b/scripts/setup_windows_all_portproxies.ps1 index 6c39de1..5433673 100644 --- a/scripts/setup_windows_all_portproxies.ps1 +++ b/scripts/setup_windows_all_portproxies.ps1 @@ -5,11 +5,81 @@ param( $ErrorActionPreference = "Stop" -$apps = @( - @{ Name = "hm-biz-process"; Port = 8000; RuleName = "HM-BIZ-PROCESS-8000" }, - @{ Name = "my-intranet-app"; Port = 8010; RuleName = "MyIntranetApp-8010" }, - @{ Name = "b17301"; Port = 8020; RuleName = "B17301-8020" } -) +$knownPorts = @{ + 8000 = @{ Name = "hm-biz-process"; RuleName = "HM-BIZ-PROCESS-8000" } + 8010 = @{ Name = "my-intranet-app"; RuleName = "MyIntranetApp-8010" } + 8020 = @{ Name = "b17301"; RuleName = "B17301-8020" } +} + +function Get-WslListeningPorts { + $raw = wsl.exe bash -lc "ss -ltnH 2>/dev/null" + if (-not $raw) { + throw "WSL listening TCP 포트를 확인하지 못했습니다." + } + + $portMap = @{} + + foreach ($line in ($raw -split "`r?`n")) { + if ([string]::IsNullOrWhiteSpace($line)) { + continue + } + + $parts = ($line -split "\s+") | Where-Object { $_ -ne "" } + if ($parts.Count -lt 4) { + continue + } + + $localAddress = $parts[3].Trim() + if ([string]::IsNullOrWhiteSpace($localAddress)) { + continue + } + + $hostPart = $localAddress + $portPart = "" + $lastColon = $localAddress.LastIndexOf(":") + if ($lastColon -lt 0) { + continue + } + + $hostPart = $localAddress.Substring(0, $lastColon) + $portPart = $localAddress.Substring($lastColon + 1) + $parsedPort = 0 + if (-not [int]::TryParse($portPart, [ref]$parsedPort)) { + continue + } + + $port = [int]$parsedPort + if ($port -le 0) { + continue + } + + $normalizedHost = $hostPart.Trim("[", "]") + $isLoopback = ( + $normalizedHost -eq "127.0.0.1" -or + $normalizedHost -eq "::1" -or + $normalizedHost -eq "localhost" + ) + + if (-not $portMap.ContainsKey($port)) { + $portMap[$port] = [ordered]@{ + Port = $port + Hosts = New-Object System.Collections.Generic.List[string] + ExternallyReachable = $false + } + } + + $entry = $portMap[$port] + if (-not $entry.Hosts.Contains($normalizedHost)) { + $entry.Hosts.Add($normalizedHost) + } + + if (-not $isLoopback) { + $entry.ExternallyReachable = $true + } + } + + return $portMap.Values | Sort-Object Port +} if ([string]::IsNullOrWhiteSpace($WslIp)) { $detected = wsl.exe hostname -I 2>$null @@ -37,15 +107,32 @@ if ($ListenAddress -eq "0.0.0.0") { } $listenAddresses = $listenAddresses | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique -Write-Host "Setting Windows portproxy for all WSL apps..." -ForegroundColor Cyan +Write-Host "Setting Windows portproxy for all active WSL TCP ports..." -ForegroundColor Cyan Write-Host "WSL IP: $WslIp" +$portEntries = @(Get-WslListeningPorts) +if (-not $portEntries -or $portEntries.Count -eq 0) { + throw "외부에서 접근 가능한 WSL TCP 포트를 찾지 못했습니다." +} -foreach ($app in $apps) { - $port = [int]$app.Port - $ruleName = [string]$app.RuleName +Write-Host "Detected ports: $((@($portEntries | ForEach-Object { $_.Port })) -join ', ')" + +$reachableEntries = @($portEntries | Where-Object { $_.ExternallyReachable }) +$loopbackOnlyEntries = @($portEntries | Where-Object { -not $_.ExternallyReachable }) + +if (-not $reachableEntries -or $reachableEntries.Count -eq 0) { + throw "현재 WSL에서 내부망에 공개 가능한 포트를 찾지 못했습니다. 앱이 0.0.0.0 또는 WSL IP에 바인딩되어 있는지 확인해 주세요." +} + +$configuredPorts = New-Object System.Collections.Generic.List[int] + +foreach ($entry in $reachableEntries) { + $port = [int]$entry.Port + $appInfo = $knownPorts[$port] + $displayName = if ($appInfo) { [string]$appInfo.Name } else { "wsl-port-$port" } + $ruleName = if ($appInfo) { [string]$appInfo.RuleName } else { "WSL-PORT-$port" } Write-Host "" - Write-Host "$($app.Name): $port" -ForegroundColor Cyan + Write-Host "$displayName : $port ($($entry.Hosts -join ', '))" -ForegroundColor Cyan foreach ($address in $listenAddresses) { netsh interface portproxy delete v4tov4 listenport=$port listenaddress=$address | Out-Null @@ -63,6 +150,10 @@ foreach ($app in $apps) { -Action Allow ` -Protocol TCP ` -LocalPort $port | Out-Null + + if (-not $configuredPorts.Contains($port)) { + $configuredPorts.Add($port) + } } Write-Host "" @@ -70,14 +161,27 @@ Write-Host "Completed." -ForegroundColor Green Write-Host "Use these URLs from another PC on the intranet:" if ($windowsIps) { foreach ($ip in $windowsIps) { - Write-Host "hm-biz-process : http://$ip`:8000" - Write-Host "my-intranet-app: http://$ip`:8010" - Write-Host "b17301 : http://$ip`:8020" + foreach ($port in $configuredPorts) { + $appInfo = $knownPorts[$port] + $label = if ($appInfo) { [string]$appInfo.Name } else { "wsl-port-$port" } + Write-Host ("{0,-15}: http://{1}:{2}" -f $label, $ip, $port) + } } } else { - Write-Host "hm-biz-process : http://:8000" - Write-Host "my-intranet-app: http://:8010" - Write-Host "b17301 : http://:8020" + foreach ($port in $configuredPorts) { + $appInfo = $knownPorts[$port] + $label = if ($appInfo) { [string]$appInfo.Name } else { "wsl-port-$port" } + Write-Host ("{0,-15}: http://:{1}" -f $label, $port) + } +} + +if ($loopbackOnlyEntries.Count -gt 0) { + Write-Host "" + Write-Host "Loopback-only ports (not published to intranet):" -ForegroundColor Yellow + foreach ($entry in $loopbackOnlyEntries) { + Write-Host ("- {0}: {1}" -f $entry.Port, ($entry.Hosts -join ', ')) + } + Write-Host "These ports are bound only to 127.0.0.1/::1 inside WSL. Run those apps on 0.0.0.0 (or the WSL IP) to expose them to the intranet." -ForegroundColor Yellow } Write-Host "" Write-Host "Tip: if WSL restarts and its IP changes, run this script again." diff --git a/scripts/setup_windows_portproxy.cmd b/scripts/setup_windows_portproxy.cmd index 8ecb802..01d6856 100644 --- a/scripts/setup_windows_portproxy.cmd +++ b/scripts/setup_windows_portproxy.cmd @@ -3,16 +3,9 @@ setlocal net session >nul 2>&1 if not "%errorlevel%"=="0" ( - echo This script must be run as Administrator. - echo. - echo 1. Open Windows Start menu. - echo 2. Type cmd. - echo 3. Right-click Command Prompt and choose "Run as administrator". - echo 4. Run this command: - echo %~f0 %* - echo. - pause - exit /b 1 + echo Requesting Administrator privileges... + powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%~f0' -ArgumentList '%*' -Verb RunAs" + exit /b %errorlevel% ) set SCRIPT_DIR=%~dp0 diff --git a/scripts/show_access_info.sh b/scripts/show_access_info.sh index 0e0cb04..eb6e81b 100755 --- a/scripts/show_access_info.sh +++ b/scripts/show_access_info.sh @@ -8,23 +8,61 @@ WINDOWS_IPS="$( 2>/dev/null | tr -d '\r' )" +PORT_REPORT="$(ss -ltnH 2>/dev/null | awk ' + { + local_addr=$4 + n=split(local_addr, parts, ":") + if (n < 2) next + port=parts[n] + host=substr(local_addr, 1, length(local_addr) - length(port) - 1) + gsub(/^\[/, "", host) + gsub(/\]$/, "", host) + key=port SUBSEP host + if (!seen[key]++) { + hosts[port] = hosts[port] ? hosts[port] "," host : host + } + } + END { + for (port in hosts) { + print port "|" hosts[port] + } + } +' | sort -n)" + +ACTIVE_PORTS="$(printf '%s\n' "$PORT_REPORT" | awk -F'|' ' + NF >= 2 { + if ($2 !~ /(^|,)(127\.0\.0\.1|::1|localhost)(,|$)/ || $2 ~ /(^|,)(0\.0\.0\.0|\*|::)(,|$)/) { + ports = ports ? ports "," $1 : $1 + } + } + END { print ports } +')" + +LOOPBACK_ONLY_PORTS="$(printf '%s\n' "$PORT_REPORT" | awk -F'|' ' + NF >= 2 { + if ($2 ~ /(^|,)(127\.0\.0\.1|::1|localhost)(,|$)/ && $2 !~ /(^|,)(0\.0\.0\.0|\*|::)(,|$)/) { + ports = ports ? ports "," $1 : $1 + } + } + END { print ports } +')" + cat < WebElement | None: def scroll_left_account_list(driver: WebDriver) -> bool: grid = find_left_account_grid(driver) - if grid is None: - return False try: before = visible_left_account_codes(driver) - cdp_wheel_element(driver, grid, ACCOUNT_SCROLL_PIXELS) + if grid is not None: + cdp_wheel_element(driver, grid, ACCOUNT_SCROLL_PIXELS) + else: + driver.execute_cdp_cmd( + "Input.dispatchMouseEvent", + {"type": "mouseWheel", "x": 250, "y": 500, "deltaX": 0, "deltaY": ACCOUNT_SCROLL_PIXELS}, + ) time.sleep(ACCOUNT_SCROLL_SETTLE_SECONDS) after = visible_left_account_codes(driver) + if after == before: + driver.execute_cdp_cmd( + "Input.dispatchMouseEvent", + {"type": "mouseWheel", "x": 250, "y": 500, "deltaX": 0, "deltaY": ACCOUNT_SCROLL_PIXELS}, + ) + time.sleep(ACCOUNT_SCROLL_SETTLE_SECONDS) + after = visible_left_account_codes(driver) return after != before except WebDriverException: return False def reset_left_account_list_scroll(driver: WebDriver) -> None: + drag_left_realgrid_scrollbar_to_fraction(driver, 0.0) + grid = find_left_account_grid(driver) if grid is None: return try: - cdp_wheel_element(driver, grid, -10000) - time.sleep(ACCOUNT_SCROLL_SETTLE_SECONDS) + cdp_click_element(driver, grid) except WebDriverException: pass + for _ in range(3): + try: + driver.execute_cdp_cmd("Input.dispatchKeyEvent", {"type": "keyDown", "key": "Home", "code": "Home"}) + driver.execute_cdp_cmd("Input.dispatchKeyEvent", {"type": "keyUp", "key": "Home", "code": "Home"}) + time.sleep(ACCOUNT_SCROLL_SETTLE_SECONDS) + except WebDriverException: + break + + previous_first_code = "" + for _ in range(8): + try: + visible_codes = visible_left_account_codes(driver) + current_first_code = visible_codes[0] if visible_codes else "" + cdp_wheel_element(driver, grid, -6000) + time.sleep(ACCOUNT_SCROLL_SETTLE_SECONDS) + visible_codes = visible_left_account_codes(driver) + next_first_code = visible_codes[0] if visible_codes else "" + except WebDriverException: + break + + if next_first_code and next_first_code == previous_first_code: + break + previous_first_code = next_first_code + + try: + if next_first_code and int(next_first_code) <= 103: + break + if current_first_code and next_first_code and int(next_first_code) >= int(current_first_code): + break + except ValueError: + continue + def jump_left_account_list_near(driver: WebDriver, account: Account) -> None: """계정 코드 순번으로 가상 스크롤 위치를 대략 맞춰 긴 순차 스크롤을 줄입니다.""" @@ -1672,20 +1718,24 @@ def advance_to_next_existing_left_account(driver: WebDriver, previous_code: str) def click_and_confirm(candidate_code: str) -> Account | None: if candidate_code == previous_code: return None + before_detail = detail_grid_signature(driver) if not click_left_account_text(driver, Account(candidate_code, "")): return None time.sleep(DELAY_AFTER_ACCOUNT_CLICK_SECONDS) wait_for_blocking_overlay_gone(driver) try: - wait_for_detail_data_cell(driver, timeout=DETAIL_ROW_WAIT_SECONDS) + wait_for_detail_change( + driver, + before_detail, + expected_account=Account(candidate_code, ""), + timeout=max(DETAIL_CHANGE_WAIT_SECONDS, DETAIL_ROW_WAIT_SECONDS), + ) except TimeoutException: - pass - selected = selected_left_account(driver) - if selected is not None and selected.code != previous_code: - return selected - if selected is not None and selected.code == candidate_code: - return selected - return None + return None + try: + return assert_selected_account(driver, Account(candidate_code, ""), timeout=0.7) + except TimeoutException: + return None previous_num: int | None try: @@ -1945,6 +1995,11 @@ def wait_for_detail_change( return if expected_account is not None and current_signature and find_detail_data_cell(driver) is not None: selected = selected_left_account(driver) + if selected is None: + try: + selected = assert_selected_account(driver, expected_account, timeout=0.2) + except TimeoutException: + selected = recently_clicked_left_account(driver, expected_account) if selected is not None and selected.code == expected_account.code: log( f"{expected_account.code} {expected_account.name}: 선택 계정 확인 완료. " @@ -2184,6 +2239,19 @@ def account_with_inferred_name(account: Account, path: Path | None = None) -> Ac return account +def is_generated_output_file(path: Path) -> bool: + stem = path.stem.strip() + if not stem: + return True + generated_markers = [ + "계정별원장_취합", + "ledger_계정별원장", + "ledger_merge", + "resume2025", + ] + return any(marker in stem for marker in generated_markers) + + def download_scan_dirs(download_dir: Path) -> list[Path]: dirs = [download_dir] if FALLBACK_DOWNLOAD_DIR.resolve() != download_dir.resolve(): @@ -2439,15 +2507,62 @@ def build_summary_rows( return rows +def discover_downloaded_accounts(download_dir: Path) -> list[Account]: + discovered: dict[str, Account] = {} + for path in sorted(download_dir.glob("*")): + if not path.is_file(): + continue + if path.suffix.lower() not in {".xlsx", ".xls", ".csv"}: + continue + stem = path.stem.strip() + if not stem or stem.startswith("ledger_"): + continue + if "계정별원장_취합" in stem or "ledger_merge" in stem or stem.startswith("resume2025"): + continue + if "_" not in stem: + continue + code, name = stem.split("_", 1) + code = code.strip() + name = name.strip() + if not code.isdigit(): + continue + discovered[code] = Account(code, name) + return list(discovered.values()) + + +def verify_download_coverage( + expected_accounts: list[Account], + download_dir: Path, + skipped: list[tuple[Account, str]] | None = None, +) -> tuple[list[Account], list[Account]]: + skipped_codes = {account.code for account, _ in (skipped or [])} + expected_by_code = {account.code: account for account in expected_accounts if account.code not in skipped_codes} + downloaded_by_code = {account.code: account for account in discover_downloaded_accounts(download_dir)} + + missing = [ + expected_by_code[code] + for code in sorted(expected_by_code.keys(), key=lambda value: int(value)) + if code not in downloaded_by_code + ] + extra = [ + downloaded_by_code[code] + for code in sorted(downloaded_by_code.keys(), key=lambda value: int(value)) + if code not in expected_by_code + ] + return missing, extra + + def consolidate_downloads( accounts: list[Account], download_dir: Path, failures: list[tuple[Account, str]] | None = None, skipped: list[tuple[Account, str]] | None = None, + output_path: Path | None = None, ) -> Path: failures = failures or [] skipped = skipped or [] - output_path = download_dir / f"ledger_계정별원장_{datetime.now():%Y%m%d_%H%M%S}.xlsx" + if output_path is None: + output_path = download_dir / (MERGED_FILENAME or f"ledger_계정별원장_{datetime.now():%Y%m%d_%H%M%S}.xlsx") summary_rows = build_summary_rows(accounts, download_dir, failures, skipped) summary_by_code = {str(row["계정코드"]): row for row in summary_rows} @@ -2519,6 +2634,19 @@ def consolidate_downloads( return output_path +def consolidate_download_dir( + download_dir: Path, + failures: list[tuple[Account, str]] | None = None, + skipped: list[tuple[Account, str]] | None = None, + output_name: str | None = None, +) -> Path: + accounts = discover_downloaded_accounts(download_dir) + if not accounts: + raise ValueError(f"취합할 계정별원장 파일이 없습니다: {download_dir}") + output_path = download_dir / output_name if output_name else None + return consolidate_downloads(accounts, download_dir, failures, skipped, output_path=output_path) + + def merge_scope_accounts(all_accounts: list[Account]) -> list[Account]: """취합 대상은 계정코드 100 이상 전체로 고정합니다.""" scoped: list[Account] = [] @@ -2757,7 +2885,7 @@ def download_account_ledger(driver: WebDriver, account: Account, index: int, tot return renamed -def download_selected_account_ledger(driver: WebDriver, account: Account, index: int, total: int) -> Path: +def download_selected_account_ledger(driver: WebDriver, account: Account, index: int, total: int | str) -> Path: started_at = time.perf_counter() close_open_menus(driver) wait_for_blocking_overlay_gone(driver) @@ -2786,9 +2914,18 @@ def run_scan_existing_accounts( selected_accounts: list[Account], failures: list[tuple[Account, str]], skipped_accounts: list[tuple[Account, str]], -) -> None: +) -> list[Account]: + if SCAN_ALL_VISIBLE_ACCOUNT_MODE: + available_codes = sorted(ensure_available_account_codes(driver), key=lambda value: int(value) if value.isdigit() else value) + if available_codes: + log( + f"메모리에서 읽은 계정코드 {len(available_codes)}개는 참고만 하고, " + "누락 방지를 위해 맨 위부터 실제 화면 스캔 방식으로 전체 계정을 순차 다운로드합니다." + ) + target_by_code = {account.code: account for account in selected_accounts} seen_codes: set[str] = set() + seen_accounts: dict[str, Account] = {} expected_existing_count = len(target_by_code) reset_left_account_list_scroll(driver) selected = select_first_existing_left_account(driver) @@ -2805,17 +2942,27 @@ def run_scan_existing_accounts( else: no_move_count = 0 seen_codes.add(code) + seen_accounts[code] = Account(selected.code, selected.name) target = target_by_code.get(code) - if target is not None: + if SCAN_ALL_VISIBLE_ACCOUNT_MODE: + account = Account(selected.code, selected.name) + display_total: int | str = "?" + elif target is not None: account = Account(target.code, target.name or selected.name) + display_total = expected_existing_count + else: + account = None + display_total = expected_existing_count + + if account is not None: existing_file = find_account_file(account, DOWNLOAD_DIR) if SKIP_ALREADY_DOWNLOADED and existing_file is not None: add_account_columns_to_excel(existing_file, account) - log(f"[{row_number}/{expected_existing_count}] {account.code} {account.name}: 이미 있음, 건너뜀") + log(f"[{row_number}/{display_total}] {account.code} {account.name}: 이미 있음, 건너뜀") else: try: - download_selected_account_ledger(driver, account, row_number, expected_existing_count) + download_selected_account_ledger(driver, account, row_number, display_total) except Exception as exc: message = f"{type(exc).__name__}: {exc}" failures.append((account, message)) @@ -2831,9 +2978,14 @@ def run_scan_existing_accounts( break selected = next_selected + if SCAN_ALL_VISIBLE_ACCOUNT_MODE: + return list(seen_accounts.values()) + for account in selected_accounts: if account.code not in seen_codes: - skipped_accounts.append((account, "현재 2022년 계정별원장 좌측 실제 계정 목록에 표시되지 않았습니다.")) + skipped_accounts.append((account, "현재 계정별원장 좌측 실제 계정 목록에 표시되지 않았습니다.")) + + return list(seen_accounts.values()) def run( @@ -2862,11 +3014,17 @@ def run( total = len(selected_accounts) if SCAN_EXISTING_ACCOUNT_MODE: log("좌측 실제 계정 목록을 순차 스캔하는 방식으로 다운로드합니다.") - run_scan_existing_accounts(driver, accounts, selected_accounts, failures, skipped_accounts) + scanned_accounts = run_scan_existing_accounts(driver, accounts, selected_accounts, failures, skipped_accounts) + expected_accounts = scanned_accounts if SCAN_ALL_VISIBLE_ACCOUNT_MODE and scanned_accounts else merge_scope_accounts(accounts) + missing_after_run, extra_after_run = verify_download_coverage(expected_accounts, DOWNLOAD_DIR, skipped_accounts) + for account in missing_after_run: + failures.append((account, "화면에서 확인된 계정인데 다운로드 파일이 생성되지 않았습니다.")) + if extra_after_run: + log("검증 참고: 기대 목록 밖의 파일이 있어도 보존합니다: " + ", ".join(f"{account.code} {account.name}" for account in extra_after_run)) log(f"작업 완료. 저장 위치: {DOWNLOAD_DIR.resolve()}") if merge: log("다운로드 파일 취합을 시작합니다.") - merged_file = consolidate_downloads(merge_scope_accounts(accounts), DOWNLOAD_DIR, failures, skipped_accounts) + merged_file = consolidate_downloads(expected_accounts, DOWNLOAD_DIR, failures, skipped_accounts) log(f"통합 엑셀 생성 완료: {merged_file.resolve()}") if failures: should_keep_browser_open = True