Files
HM_project_Viewer_Board/scripts/wehago_ledger_api_download.py
T

288 lines
12 KiB
Python

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