288 lines
12 KiB
Python
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
|