from __future__ import annotations import json import time from pathlib import Path from urllib.parse import parse_qs from openpyxl import Workbook from selenium.common.exceptions import TimeoutException from selenium.webdriver.remote.webdriver import WebDriver try: import scripts.wehago_data_download_2022_work as wehago except ModuleNotFoundError: import wehago_data_download_2022_work as wehago LEDGER_API_PATH = "/smarta/sabk0107/jungi_slip/" TOTAL_LABELS = ("월 계", "누 계", "합 계") def drain_performance_log(driver: WebDriver) -> None: driver.get_log("performance") def parse_post_data(raw: str) -> dict[str, str]: try: value = json.loads(raw) return {str(key): str(item) for key, item in value.items()} if isinstance(value, dict) else {} except json.JSONDecodeError: return {key: values[-1] for key, values in parse_qs(raw, keep_blank_values=True).items()} def wait_for_account_api_response(driver: WebDriver, account: wehago.Account, timeout: float = 8.0) -> list[dict]: expected_code = f"{account.code}00" requests: dict[str, dict[str, str]] = {} completed: set[str] = set() deadline = time.time() + timeout while time.time() < deadline: for item in driver.get_log("performance"): try: message = json.loads(item["message"])["message"] except (KeyError, TypeError, json.JSONDecodeError): continue method = message.get("method") params = message.get("params") or {} request_id = str(params.get("requestId") or "") if method == "Network.requestWillBeSent": request = params.get("request") or {} if LEDGER_API_PATH not in str(request.get("url") or ""): continue post_data = parse_post_data(str(request.get("postData") or "")) requests[request_id] = post_data requested_code = post_data.get("from_cd_acctit") if requested_code and requested_code != expected_code: raise RuntimeError( f"{account.code} 선택 후 다른 계정 API가 호출되었습니다: {requested_code}. 저장을 중단합니다." ) elif method == "Network.loadingFinished" and request_id in requests: completed.add(request_id) for completed_id in list(completed): if requests[completed_id].get("from_cd_acctit") != expected_code: continue try: raw = driver.execute_cdp_cmd("Network.getResponseBody", {"requestId": completed_id}).get("body", "") rows = json.loads(raw) except Exception: continue if not isinstance(rows, list): raise RuntimeError(f"{account.code}: 원장 API 응답이 목록 형식이 아닙니다.") return rows time.sleep(0.1) raise TimeoutException(f"{account.code}: {timeout:.0f}초 안에 계정별원장 API 응답을 받지 못했습니다.") def validate_api_rows(account: wehago.Account, rows: list[dict]) -> None: expected_code = f"{account.code}00" observed_codes = { str(row.get("cd_acctit")) for row in rows if row.get("cd_acctit") not in (None, "") } if observed_codes - {expected_code}: raise RuntimeError( f"{account.code}: 응답 내부에 다른 계정코드가 있습니다: {sorted(observed_codes)}" ) def is_total_row(row: dict) -> bool: remark = str(row.get("nm_remark") or "") return any(label in remark for label in TOTAL_LABELS) def empty_if_zero(value: object) -> object: return None if value in (0, 0.0, "0", "0.0") else value def write_api_rows(path: Path, account: wehago.Account, rows: list[dict]) -> None: workbook = Workbook() sheet = workbook.active sheet.title = "계정별원장" sheet.append(("일자", "적요", "거래처", "차변", "대변", "잔액", "전표번호", "계정코드", "계정명")) for row in rows: if is_total_row(row): continue sheet.append( ( row.get("da_date"), row.get("nm_remark"), row.get("nm_trade") or row.get("nm_ctrade"), empty_if_zero(row.get("mn_bungae_cha")), empty_if_zero(row.get("mn_bungae_dae")), row.get("mn_balance"), row.get("no_acct"), account.code, account.name, ) ) path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(".tmp.xlsx") workbook.save(temporary) workbook.close() temporary.replace(path) def write_progress(download_dir: Path, payload: dict) -> None: download_dir.mkdir(parents=True, exist_ok=True) target = download_dir / "_api_progress.json" temporary = target.with_suffix(".tmp") temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") temporary.replace(target) def quick_scroll_left_account_list(driver: WebDriver, direction: int = 1) -> bool: try: return bool( driver.execute_script( """ const direction = arguments[0]; const cells = Array.from(document.querySelectorAll('.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td')) .filter((cell) => { const r = cell.getBoundingClientRect(); return r.left < 220 && r.top > 145 && r.width > 0 && r.height > 0; }); const before = cells.map((cell) => (cell.textContent || '').trim()).join('|'); let grid = null; let node = cells[Math.floor(cells.length / 2)] || null; while (node && node !== document.body) { const r = node.getBoundingClientRect(); if (r.left < 260 && node.scrollHeight > node.clientHeight + 20) { grid = node; break; } node = node.parentElement; } if (grid) { grid.scrollTop += direction * Math.max(180, grid.clientHeight * 0.55); } else { const el = document.elementFromPoint(120, Math.min(700, window.innerHeight - 80)); el && el.dispatchEvent(new WheelEvent('wheel', {bubbles: true, cancelable: true, deltaY: direction * 650})); } const afterCells = Array.from(document.querySelectorAll('.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td')) .filter((cell) => { const r = cell.getBoundingClientRect(); return r.left < 220 && r.top > 145 && r.width > 0 && r.height > 0; }); const after = afterCells.map((cell) => (cell.textContent || '').trim()).join('|'); return before !== after; """, direction, ) ) except Exception: return False def nudge_visible_account_into_clickable_area(driver: WebDriver, account: wehago.Account) -> None: try: position = driver.execute_script( """ const code = arguments[0]; const cells = Array.from(document.querySelectorAll('.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td')); const cell = cells.find((item) => { const r = item.getBoundingClientRect(); return (item.textContent || '').trim() === code && r.left < 220 && r.width > 0 && r.height > 0; }); if (!cell) return null; const r = cell.getBoundingClientRect(); return {top: r.top, bottom: r.bottom, height: window.innerHeight || document.documentElement.clientHeight}; """, account.code, ) except Exception: position = None if not position: return if float(position.get("bottom") or 0) > float(position.get("height") or 0) - 80: quick_scroll_left_account_list(driver, 1) time.sleep(0.3) def select_account_for_api(driver: WebDriver, account: wehago.Account) -> None: try: int(account.code) except ValueError as exc: raise wehago.AccountNotAvailable(f"{account.code}: 숫자 계정코드가 아닙니다.") from exc # Keep API downloads on the same account-selection path as the safer Excel # workflow. The older API-only scroll loop could miss virtualized RealGrid # rows and left many valid accounts as AccountNotAvailable. wehago.select_account_from_left_list(driver, account) time.sleep(0.2) def move_off_current_account(driver: WebDriver, account: wehago.Account, accounts: list[wehago.Account]) -> None: visible_codes = [code for code in wehago.visible_left_account_codes(driver) if code != account.code] for code in visible_codes: if wehago.click_left_account_text(driver, wehago.Account(code, "")): time.sleep(0.3) return alternate = next((candidate for candidate in accounts if candidate.code != account.code), None) if alternate is None: raise RuntimeError(f"{account.code}: API 재조회에 필요한 다른 계정을 찾지 못했습니다.") select_account_for_api(driver, alternate) time.sleep(0.5) def request_account_rows(driver: WebDriver, account: wehago.Account, accounts: list[wehago.Account]) -> list[dict]: drain_performance_log(driver) select_account_for_api(driver, account) try: return wait_for_account_api_response(driver, account) except TimeoutException: wehago.log(f"{account.code} {account.name}: API 요청이 생략되어 다른 계정 선택 후 재시도합니다.") move_off_current_account(driver, account, accounts) drain_performance_log(driver) select_account_for_api(driver, account) return wait_for_account_api_response(driver, account, timeout=10.0) def download_accounts( driver: WebDriver, accounts: list[wehago.Account], download_dir: Path, ) -> list[Path]: driver.execute_cdp_cmd("Network.enable", {}) downloaded: list[Path] = [] manifest: list[dict[str, object]] = [] failures: list[dict[str, str]] = [] for index, account in enumerate(accounts, start=1): write_progress( download_dir, {"status": "running", "current": account.code, "completed": index - 1, "total": len(accounts)}, ) wehago.log(f"[API {index}/{len(accounts)}] {account.code} {account.name}: 조회 요청") try: rows = request_account_rows(driver, account, accounts) validate_api_rows(account, rows) except Exception as exc: message = f"{type(exc).__name__}: {exc}" failures.append({"account_code": account.code, "account_name": account.name, "reason": message}) wehago.log(f"[API {index}/{len(accounts)}] 실패, 다음 계정 계속: {account.code} {account.name}: {message}") continue target = download_dir / account.safe_filename write_api_rows(target, account, rows) downloaded.append(target) manifest.append( { "account_code": account.code, "account_name": account.name, "api_request_code": f"{account.code}00", "api_response_rows": len(rows), } ) wehago.log(f"[API {index}/{len(accounts)}] 완료: {target.name}, 응답 {len(rows)}행") write_progress( download_dir, { "status": "completed_with_failures" if failures else "completed", "completed": len(downloaded), "total": len(accounts), "accounts": manifest, "failures": failures, }, ) return downloaded