1732 lines
67 KiB
Python
Executable File
1732 lines
67 KiB
Python
Executable File
"""
|
|
WEHAGO 계정별원장 엑셀 자동 다운로드
|
|
|
|
사용 흐름
|
|
1. 이 파일을 실행한다.
|
|
2. 자동으로 열린 Chrome에서 WEHAGO에 직접 로그인한다.
|
|
3. 계정별원장 화면까지 이동한 뒤 콘솔에서 Enter를 누른다.
|
|
4. 이후 왼쪽 계정 목록에서 계정을 선택하고, 우클릭 메뉴의 엑셀 다운로드와 파일명 변경을 자동 처리한다.
|
|
|
|
주의
|
|
- WEHAGO 화면의 HTML 구조는 업데이트될 수 있습니다.
|
|
- 처음 실행에서 요소를 찾지 못하면 아래 CONFIG 영역의 선택자를 README 안내에 따라 조정하세요.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
from xml.etree import ElementTree as ET
|
|
|
|
from openpyxl import Workbook, load_workbook
|
|
from openpyxl.styles import Alignment, Font, PatternFill
|
|
from selenium import webdriver
|
|
from selenium.common.exceptions import (
|
|
ElementClickInterceptedException,
|
|
NoSuchElementException,
|
|
TimeoutException,
|
|
WebDriverException,
|
|
)
|
|
from selenium.webdriver import ChromeOptions
|
|
from selenium.webdriver.chrome.service import Service
|
|
from selenium.webdriver.common.action_chains import ActionChains
|
|
from selenium.webdriver.common.by import By
|
|
from selenium.webdriver.common.keys import Keys
|
|
from selenium.webdriver.remote.webdriver import WebDriver
|
|
from selenium.webdriver.remote.webelement import WebElement
|
|
from webdriver_manager.chrome import ChromeDriverManager
|
|
|
|
|
|
# =============================================================================
|
|
# CONFIG: 이 영역만 상황에 맞게 수정하면 됩니다.
|
|
# =============================================================================
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
WEHAGO_START_URL = "https://www.wehago.com/"
|
|
|
|
# 계정별원장 화면 URL입니다.
|
|
# WEHAGO 내부 URL은 회사/기수/사용자 세션 쿼리값이 붙어야 정상 동작하는 경우가 많습니다.
|
|
# 잘못된 고정 URL을 넣으면 404가 나므로, 기본값은 비워둡니다.
|
|
ACCOUNT_LEDGER_URL = ""
|
|
LEDGER_URL_FILE = BASE_DIR / "wehago_account_ledger_url.txt"
|
|
USE_SAVED_LEDGER_URL = False
|
|
|
|
# Chrome 로그인 정보를 저장할 자동화 전용 프로필입니다.
|
|
# 첫 로그인 뒤에는 같은 프로필을 계속 사용합니다.
|
|
CHROME_USER_DATA_DIR = BASE_DIR / ".chrome-wehago-profile"
|
|
CHROME_PROFILE_NAME = "Default"
|
|
|
|
# 다운로드 폴더입니다. 결과 파일은 이 폴더 안에 계정코드_계정명.xlsx 형태로 저장됩니다.
|
|
DATA_DOWNLOAD_DIR = BASE_DIR / "data_download"
|
|
DOWNLOAD_DIR = DATA_DOWNLOAD_DIR / f"wehago_account_ledger_{datetime.now():%Y%m%d}"
|
|
DIAGNOSTIC_DIR = DATA_DOWNLOAD_DIR / "diagnostics"
|
|
FALLBACK_DOWNLOAD_DIR = Path.home() / "Downloads"
|
|
|
|
# 왼쪽 계정 목록 그리드 후보입니다.
|
|
ACCOUNT_LIST_SELECTORS = [
|
|
".realgrid",
|
|
".rg-root",
|
|
".rg-grid",
|
|
".grid-container",
|
|
".grid_container",
|
|
".gridWrap",
|
|
".grid-wrap",
|
|
"[class*='realgrid']",
|
|
"[class*='grid']",
|
|
]
|
|
|
|
# 우클릭할 계정별원장 상세 그리드 후보입니다. 오른쪽의 큰 그리드를 우선 선택합니다.
|
|
GRID_SELECTORS = [
|
|
".realgrid",
|
|
".rg-root",
|
|
".rg-grid",
|
|
".grid-container",
|
|
".grid_container",
|
|
".gridWrap",
|
|
".grid-wrap",
|
|
"[class*='realgrid']",
|
|
"[class*='grid']",
|
|
]
|
|
|
|
# 우클릭 메뉴에서 엑셀 다운로드 항목 후보입니다.
|
|
EXCEL_MENU_XPATHS = [
|
|
"//*[@id='context:엑셀변환']",
|
|
"//*[contains(normalize-space(.), '엑셀')]",
|
|
"//*[contains(normalize-space(.), 'Excel')]",
|
|
"//*[contains(normalize-space(.), 'EXCEL')]",
|
|
"//*[contains(normalize-space(.), '다운로드')]",
|
|
]
|
|
EXCEL_MENU_TEXTS = ["엑셀변환", "엑셀", "Excel", "EXCEL", "다운로드"]
|
|
|
|
WAIT_SECONDS = 20
|
|
DOWNLOAD_TIMEOUT_SECONDS = 90
|
|
DELAY_AFTER_ACCOUNT_CLICK_SECONDS = 0.2
|
|
ACCOUNT_SCROLL_TRIES = 180
|
|
ACCOUNT_SCROLL_PIXELS = 160
|
|
MAX_DOWNLOAD_ATTEMPTS = 3
|
|
PAUSE_ON_FAILURE = True
|
|
OVERLAY_WAIT_SECONDS = 30
|
|
DETAIL_ROW_WAIT_SECONDS = 15
|
|
DETAIL_CHANGE_WAIT_SECONDS = 3
|
|
DOWNLOAD_POLL_INTERVAL_SECONDS = 0.15
|
|
|
|
ACCOUNT_CODE_COLUMN = 8
|
|
ACCOUNT_NAME_COLUMN = 9
|
|
|
|
# 재실행 시 이미 같은 이름의 엑셀 파일이 있으면 건너뜁니다.
|
|
SKIP_ALREADY_DOWNLOADED = True
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Account:
|
|
code: str
|
|
name: str
|
|
|
|
@property
|
|
def safe_stem(self) -> str:
|
|
return safe_filename(f"{self.code}_{self.name}")
|
|
|
|
@property
|
|
def safe_filename(self) -> str:
|
|
return f"{self.safe_stem}.xlsx"
|
|
|
|
|
|
# 이미지에 보이는 계정 목록입니다. 필요한 계정을 추가/삭제해도 됩니다.
|
|
ACCOUNTS: list[Account] = [
|
|
Account("103", "보통예금"),
|
|
Account("104", "국고보조금"),
|
|
Account("106", "기타예금"),
|
|
Account("108", "외상매출금"),
|
|
Account("109", "대손충당금"),
|
|
Account("110", "받을어음"),
|
|
Account("112", "공사미수금"),
|
|
Account("114", "단기대여금"),
|
|
Account("116", "미수수익"),
|
|
Account("120", "미수금"),
|
|
Account("123", "단기매매증권"),
|
|
Account("125", "받아볼어음"),
|
|
Account("131", "선급금"),
|
|
Account("133", "선급비용"),
|
|
Account("135", "부가세대급금"),
|
|
Account("136", "선납세금"),
|
|
Account("137", "주.종단기채권"),
|
|
Account("138", "전도금"),
|
|
Account("170", "미완성공사도급"),
|
|
Account("183", "매도가능증권"),
|
|
Account("192", "단체퇴직보험예금"),
|
|
Account("201", "토지"),
|
|
Account("202", "건물"),
|
|
Account("203", "감가상각누계액"),
|
|
Account("206", "기계장치"),
|
|
Account("207", "감가상각누계액"),
|
|
Account("208", "차량운반구"),
|
|
Account("209", "감가상각누계액"),
|
|
Account("210", "공구와기구"),
|
|
Account("211", "감가상각누계액"),
|
|
Account("212", "비품"),
|
|
Account("213", "감가상각누계액"),
|
|
Account("219", "시설장치"),
|
|
Account("220", "감가상각누계액"),
|
|
Account("221", "연구기자재"),
|
|
Account("222", "감가상각누계액"),
|
|
Account("223", "국고보조금"),
|
|
Account("231", "영업권"),
|
|
Account("234", "실용신안권"),
|
|
Account("241", "사용수익기부자산"),
|
|
Account("251", "외상매출금"),
|
|
Account("253", "미지급금"),
|
|
Account("254", "예수금"),
|
|
Account("255", "부가세예수금"),
|
|
Account("259", "선수금"),
|
|
Account("260", "단기차입금"),
|
|
Account("262", "미지급비용"),
|
|
Account("271", "공사선수금"),
|
|
Account("274", "예수국민연금"),
|
|
Account("275", "예수건강보험"),
|
|
Account("276", "예수고용보험"),
|
|
Account("290", "주.종단기차입금"),
|
|
Account("294", "임대보증금"),
|
|
Account("331", "자본금"),
|
|
Account("342", "감자차익"),
|
|
Account("351", "이익준비금"),
|
|
Account("375", "이월이익잉여금"),
|
|
Account("377", "미처분이익잉여금"),
|
|
Account("383", "자기주식"),
|
|
Account("400", "손익"),
|
|
Account("411", "설계용역수입"),
|
|
Account("412", "감리용역수입"),
|
|
Account("413", "임대료수입"),
|
|
Account("414", "주차료수입"),
|
|
Account("415", "안전점검수입"),
|
|
Account("417", "연구용역수입"),
|
|
Account("452", "도급공사매출원가"),
|
|
Account("602", "외주비"),
|
|
Account("604", "임금"),
|
|
Account("606", "잡금"),
|
|
Account("609", "퇴직급여"),
|
|
Account("611", "복리후생비"),
|
|
Account("612", "여비교통비"),
|
|
Account("614", "통신비"),
|
|
Account("615", "가스수도료"),
|
|
Account("616", "전력비"),
|
|
Account("617", "세금과공과금"),
|
|
Account("618", "감가상각비"),
|
|
Account("619", "지급임차료"),
|
|
Account("620", "수선비"),
|
|
Account("621", "보험료"),
|
|
Account("622", "차량유지비"),
|
|
Account("625", "교육훈련비"),
|
|
Account("626", "도서인쇄비"),
|
|
Account("629", "사무용품비"),
|
|
Account("630", "소모품비"),
|
|
Account("631", "지급수수료"),
|
|
Account("634", "접대비"),
|
|
Account("636", "광고선전비"),
|
|
Account("637", "관리비"),
|
|
Account("639", "보증수수료"),
|
|
Account("643", "해외출장비"),
|
|
Account("644", "행사비용"),
|
|
Account("645", "관리현장운영비"),
|
|
Account("646", "부서비"),
|
|
Account("650", "연구개발비"),
|
|
Account("802", "직원급여"),
|
|
Account("808", "퇴직급여"),
|
|
Account("811", "복리후생비"),
|
|
Account("812", "여비교통비"),
|
|
Account("813", "접대비(기업업무추진비)"),
|
|
Account("814", "통신비"),
|
|
Account("815", "수도광열비"),
|
|
Account("816", "전력비"),
|
|
Account("817", "세금과공과금"),
|
|
Account("818", "감가상각비"),
|
|
Account("819", "지급임차료"),
|
|
Account("820", "수선비"),
|
|
Account("821", "보험료"),
|
|
Account("822", "차량유지비"),
|
|
Account("823", "경상연구개발비"),
|
|
Account("825", "교육훈련비"),
|
|
Account("826", "도서인쇄비"),
|
|
Account("829", "사무용품비"),
|
|
Account("830", "소모품비"),
|
|
Account("831", "지급수수료"),
|
|
Account("835", "대손상각비"),
|
|
Account("837", "건물관리비"),
|
|
Account("846", "부서비"),
|
|
Account("901", "이자수익"),
|
|
Account("903", "배당금수익"),
|
|
Account("905", "투자주식평가이익"),
|
|
Account("906", "유가증권처분이익"),
|
|
Account("908", "대손충당금환입"),
|
|
Account("914", "유형자산처분이익"),
|
|
Account("930", "잡이익"),
|
|
Account("931", "이자비용"),
|
|
Account("933", "기부금"),
|
|
Account("935", "외화환산손실"),
|
|
Account("937", "투자주식평가손실"),
|
|
Account("960", "잡손실"),
|
|
Account("962", "민사보전금"),
|
|
Account("964", "기타보증금"),
|
|
Account("989", "매도가능증권평가이익"),
|
|
Account("998", "법인세등"),
|
|
]
|
|
|
|
|
|
def safe_filename(name: str) -> str:
|
|
cleaned = re.sub(r'[\\/:*?"<>|]', "_", name)
|
|
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
|
return cleaned
|
|
|
|
|
|
def log(message: str) -> None:
|
|
print(f"[{datetime.now():%H:%M:%S}] {message}", flush=True)
|
|
|
|
|
|
def save_diagnostics(driver: WebDriver, label: str, error: Exception | None = None) -> Path:
|
|
DIAGNOSTIC_DIR.mkdir(parents=True, exist_ok=True)
|
|
safe_label = safe_filename(label)
|
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
base_path = DIAGNOSTIC_DIR / f"{stamp}_{safe_label}"
|
|
|
|
png_path = base_path.with_suffix(".png")
|
|
txt_path = base_path.with_suffix(".txt")
|
|
html_path = base_path.with_suffix(".html")
|
|
|
|
try:
|
|
driver.save_screenshot(str(png_path))
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
title = driver.title
|
|
except Exception:
|
|
title = ""
|
|
|
|
try:
|
|
url = driver.current_url
|
|
except Exception:
|
|
url = ""
|
|
|
|
try:
|
|
html = driver.page_source
|
|
html_path.write_text(html, encoding="utf-8")
|
|
except Exception:
|
|
html_path = Path("")
|
|
|
|
message_lines = [
|
|
f"label={label}",
|
|
f"time={datetime.now():%Y-%m-%d %H:%M:%S}",
|
|
f"url={url}",
|
|
f"title={title}",
|
|
]
|
|
if error is not None:
|
|
message_lines.append(f"error={type(error).__name__}: {error}")
|
|
message_lines.extend(
|
|
[
|
|
f"screenshot={png_path.resolve() if png_path.exists() else ''}",
|
|
f"html={html_path.resolve() if html_path and html_path.exists() else ''}",
|
|
f"download_dir={DOWNLOAD_DIR.resolve()}",
|
|
f"fallback_download_dir={FALLBACK_DOWNLOAD_DIR.resolve()}",
|
|
]
|
|
)
|
|
txt_path.write_text("\n".join(message_lines), encoding="utf-8")
|
|
return txt_path
|
|
|
|
|
|
def build_driver(download_dir: Path, headless: bool = False) -> WebDriver:
|
|
download_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
options = ChromeOptions()
|
|
options.add_argument(f"--user-data-dir={CHROME_USER_DATA_DIR}")
|
|
options.add_argument(f"--profile-directory={CHROME_PROFILE_NAME}")
|
|
options.add_argument("--start-maximized")
|
|
options.add_argument("--disable-popup-blocking")
|
|
options.add_argument("--log-level=3")
|
|
options.add_argument("--disable-logging")
|
|
options.add_argument("--disable-features=WebUSB,WebBluetooth")
|
|
options.add_experimental_option("excludeSwitches", ["enable-logging"])
|
|
options.add_experimental_option("detach", True)
|
|
options.add_experimental_option(
|
|
"prefs",
|
|
{
|
|
"download.default_directory": str(download_dir.resolve()),
|
|
"download.prompt_for_download": False,
|
|
"download.directory_upgrade": True,
|
|
"safebrowsing.enabled": True,
|
|
"profile.default_content_setting_values.automatic_downloads": 1,
|
|
"profile.default_content_settings.popups": 0,
|
|
},
|
|
)
|
|
if headless:
|
|
options.add_argument("--headless=new")
|
|
options.add_argument("--window-size=1920,1080")
|
|
|
|
try:
|
|
service = Service(ChromeDriverManager().install(), log_output=subprocess.DEVNULL)
|
|
driver = webdriver.Chrome(service=service, options=options)
|
|
except WebDriverException as exc:
|
|
log("ChromeDriverManager로 ChromeDriver를 시작하지 못했습니다. Selenium Manager 방식으로 다시 시도합니다.")
|
|
log("그래도 실패하면 WSL Ubuntu에 Chrome 및 ChromeDriver 실행 라이브러리가 설치되어 있는지 확인해야 합니다.")
|
|
log("필요 패키지 예: google-chrome-stable, libnss3, libnspr4, libgtk-3-0, libgbm1")
|
|
log(f"첫 번째 오류: {exc}")
|
|
driver = webdriver.Chrome(options=options)
|
|
driver.execute_cdp_cmd(
|
|
"Page.setDownloadBehavior",
|
|
{"behavior": "allow", "downloadPath": str(download_dir.resolve())},
|
|
)
|
|
return driver
|
|
|
|
|
|
def switch_to_frame_containing(driver: WebDriver, selectors: Iterable[str]) -> None:
|
|
"""현재 문서와 iframe을 순회하며 후보 선택자가 있는 프레임으로 이동합니다."""
|
|
driver.switch_to.default_content()
|
|
if any(driver.find_elements(By.CSS_SELECTOR, selector) for selector in selectors):
|
|
return
|
|
|
|
frames = driver.find_elements(By.CSS_SELECTOR, "iframe, frame")
|
|
for frame in frames:
|
|
driver.switch_to.default_content()
|
|
driver.switch_to.frame(frame)
|
|
if any(driver.find_elements(By.CSS_SELECTOR, selector) for selector in selectors):
|
|
return
|
|
|
|
driver.switch_to.default_content()
|
|
|
|
|
|
def first_visible_by_css(driver: WebDriver, selectors: Iterable[str], timeout: int = WAIT_SECONDS) -> WebElement:
|
|
selector_list = list(selectors)
|
|
deadline = time.time() + timeout
|
|
|
|
while time.time() < deadline:
|
|
for selector in selector_list:
|
|
for element in driver.find_elements(By.CSS_SELECTOR, selector):
|
|
if element.is_displayed():
|
|
return element
|
|
time.sleep(0.25)
|
|
|
|
raise TimeoutException(f"요소를 찾지 못했습니다. 후보: {selector_list}")
|
|
|
|
|
|
def first_clickable_by_css(driver: WebDriver, selectors: Iterable[str], timeout: int = WAIT_SECONDS) -> WebElement:
|
|
selector_list = list(selectors)
|
|
deadline = time.time() + timeout
|
|
|
|
while time.time() < deadline:
|
|
for selector in selector_list:
|
|
for element in driver.find_elements(By.CSS_SELECTOR, selector):
|
|
if element.is_displayed() and element.is_enabled():
|
|
return element
|
|
time.sleep(0.25)
|
|
|
|
raise TimeoutException(f"클릭 가능한 요소를 찾지 못했습니다. 후보: {selector_list}")
|
|
|
|
|
|
def click_visible_text_button(driver: WebDriver, texts: Iterable[str], timeout: float = 5.0) -> bool:
|
|
"""Click a visible button-like element with one of the given labels."""
|
|
labels = list(texts)
|
|
deadline = time.time() + timeout
|
|
|
|
while time.time() < deadline:
|
|
for _ in contexts_with_default_first(driver):
|
|
for label in labels:
|
|
xpath = f"//*[normalize-space(.)='{label}']"
|
|
for element in driver.find_elements(By.XPATH, xpath):
|
|
if not element.is_displayed() or not element.is_enabled():
|
|
continue
|
|
rect = rect_of(driver, element)
|
|
if rect["width"] <= 0 or rect["height"] <= 0:
|
|
continue
|
|
try:
|
|
ActionChains(driver).move_to_element(element).click(element).perform()
|
|
except WebDriverException:
|
|
try:
|
|
element.click()
|
|
except WebDriverException:
|
|
js_click_element(driver, element)
|
|
time.sleep(0.1)
|
|
driver.switch_to.default_content()
|
|
return True
|
|
time.sleep(0.1)
|
|
|
|
driver.switch_to.default_content()
|
|
return False
|
|
|
|
|
|
def accept_download_complete_popup(driver: WebDriver, timeout: float = 1.5) -> bool:
|
|
"""Close WEHAGO's Excel-download complete confirmation popup if it appears."""
|
|
try:
|
|
alert = driver.switch_to.alert
|
|
alert.accept()
|
|
time.sleep(0.1)
|
|
return True
|
|
except Exception:
|
|
driver.switch_to.default_content()
|
|
|
|
return click_visible_text_button(driver, ["확인", "OK", "Ok", "예"], timeout=timeout)
|
|
|
|
|
|
def contexts_with_default_first(driver: WebDriver):
|
|
driver.switch_to.default_content()
|
|
yield
|
|
|
|
frames = driver.find_elements(By.CSS_SELECTOR, "iframe, frame")
|
|
for frame in frames:
|
|
driver.switch_to.default_content()
|
|
driver.switch_to.frame(frame)
|
|
yield
|
|
|
|
|
|
def rect_of(driver: WebDriver, element: WebElement) -> dict:
|
|
return driver.execute_script(
|
|
"""
|
|
const r = arguments[0].getBoundingClientRect();
|
|
return {left: r.left, top: r.top, width: r.width, height: r.height};
|
|
""",
|
|
element,
|
|
)
|
|
|
|
|
|
def viewport_width(driver: WebDriver) -> int:
|
|
return int(driver.execute_script("return window.innerWidth || document.documentElement.clientWidth;"))
|
|
|
|
|
|
def js_click_element(driver: WebDriver, element: WebElement) -> None:
|
|
"""Selenium click이 0 크기 자식요소에 걸릴 때를 피하기 위해 요소 중앙에 마우스 이벤트를 보냅니다."""
|
|
driver.execute_script(
|
|
"""
|
|
const source = arguments[0];
|
|
const el = source.closest('button, a, tr, li, div') || source;
|
|
const r = el.getBoundingClientRect();
|
|
const x = r.left + r.width / 2;
|
|
const y = r.top + r.height / 2;
|
|
for (const type of ['mouseover', 'mousemove', 'mousedown', 'mouseup', 'click']) {
|
|
el.dispatchEvent(new MouseEvent(type, {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
view: window,
|
|
clientX: x,
|
|
clientY: y,
|
|
button: 0
|
|
}));
|
|
}
|
|
""",
|
|
element,
|
|
)
|
|
|
|
|
|
def has_blocking_overlay(driver: WebDriver) -> bool:
|
|
"""WEHAGO 로딩/모달 투명 레이어가 클릭을 가로막는지 확인합니다."""
|
|
try:
|
|
return bool(
|
|
driver.execute_script(
|
|
"""
|
|
const viewportArea = window.innerWidth * window.innerHeight;
|
|
const nodes = Array.from(document.querySelectorAll('div'));
|
|
return nodes.some((el) => {
|
|
const style = window.getComputedStyle(el);
|
|
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
if (style.pointerEvents === 'none') return false;
|
|
const z = Number.parseInt(style.zIndex || '0', 10);
|
|
if (!Number.isFinite(z) || z < 1000) return false;
|
|
if (style.position !== 'fixed' && style.position !== 'absolute') return false;
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width <= 0 || r.height <= 0) return false;
|
|
const area = r.width * r.height;
|
|
const coversScreen = area > viewportArea * 0.5;
|
|
const coversTopLeft = r.left <= 5 && r.top <= 5 && r.right >= window.innerWidth * 0.8 && r.bottom >= window.innerHeight * 0.8;
|
|
return coversScreen || coversTopLeft;
|
|
});
|
|
"""
|
|
)
|
|
)
|
|
except WebDriverException:
|
|
return False
|
|
|
|
|
|
def wait_for_blocking_overlay_gone(driver: WebDriver, timeout: int = OVERLAY_WAIT_SECONDS) -> None:
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if not has_blocking_overlay(driver):
|
|
return
|
|
time.sleep(0.1)
|
|
|
|
diagnostic = save_diagnostics(driver, "blocking_overlay_timeout")
|
|
raise TimeoutException(f"화면을 가리는 로딩/팝업 레이어가 사라지지 않았습니다. 진단 파일: {diagnostic.resolve()}")
|
|
|
|
|
|
def close_open_menus(driver: WebDriver) -> None:
|
|
# Do not send ESC or click outside the grid here.
|
|
# In WEHAGO, global keyboard shortcuts can cancel the current ledger view
|
|
# and reset the searched account-ledger screen to an empty initial state.
|
|
return
|
|
|
|
|
|
def click_left_account_text(driver: WebDriver, account: Account) -> bool:
|
|
"""왼쪽 계정 목록에서 계정 코드 또는 계정명을 찾아 클릭합니다."""
|
|
width = viewport_width(driver)
|
|
left_limit = width * 0.35
|
|
candidates: list[tuple[float, float, WebElement]] = []
|
|
|
|
for _ in contexts_with_default_first(driver):
|
|
for text in (account.code, account.name):
|
|
xpath = f"//*[normalize-space(.)='{text}']"
|
|
for element in driver.find_elements(By.XPATH, xpath):
|
|
if not element.is_displayed():
|
|
continue
|
|
|
|
rect = rect_of(driver, element)
|
|
if rect["left"] < left_limit and rect["width"] > 0 and rect["height"] > 0:
|
|
candidates.append((rect["top"], rect["left"], element))
|
|
|
|
if candidates:
|
|
_, _, element = sorted(candidates, key=lambda item: (item[0], item[1]))[0]
|
|
driver.execute_script("arguments[0].scrollIntoView({block: 'center', inline: 'center'});", element)
|
|
time.sleep(0.2)
|
|
wait_for_blocking_overlay_gone(driver)
|
|
try:
|
|
element.click()
|
|
except ElementClickInterceptedException:
|
|
wait_for_blocking_overlay_gone(driver)
|
|
js_click_element(driver, element)
|
|
return True
|
|
|
|
driver.switch_to.default_content()
|
|
return False
|
|
|
|
|
|
def visible_text_exists_in_left_area(driver: WebDriver, text: str) -> bool:
|
|
width = viewport_width(driver)
|
|
left_limit = width * 0.35
|
|
|
|
for _ in contexts_with_default_first(driver):
|
|
xpath = f"//*[normalize-space(.)='{text}']"
|
|
for element in driver.find_elements(By.XPATH, xpath):
|
|
if not element.is_displayed():
|
|
continue
|
|
rect = rect_of(driver, element)
|
|
if rect["left"] < left_limit and rect["width"] > 0 and rect["height"] > 0:
|
|
return True
|
|
|
|
driver.switch_to.default_content()
|
|
return False
|
|
|
|
|
|
def account_list_has_any_expected_account(driver: WebDriver, accounts: list[Account]) -> bool:
|
|
for account in accounts[: min(10, len(accounts))]:
|
|
if visible_text_exists_in_left_area(driver, account.code) or visible_text_exists_in_left_area(driver, account.name):
|
|
return True
|
|
return False
|
|
|
|
|
|
def click_query_button(driver: WebDriver) -> bool:
|
|
"""상단 조건 영역의 조회 버튼을 누릅니다. 메뉴 검색 버튼과 혼동하지 않도록 위치를 제한합니다."""
|
|
viewport = viewport_width(driver)
|
|
candidates: list[tuple[float, WebElement]] = []
|
|
|
|
for _ in contexts_with_default_first(driver):
|
|
for text in ():
|
|
xpath = f"//*[normalize-space(.)='{text}']"
|
|
for element in driver.find_elements(By.XPATH, xpath):
|
|
if not element.is_displayed() or not element.is_enabled():
|
|
continue
|
|
rect = rect_of(driver, element)
|
|
if rect["width"] <= 0 or rect["height"] <= 0:
|
|
continue
|
|
# 계정별원장 상단 조회 버튼은 화면 상단 조건줄 오른쪽에 있습니다.
|
|
if 90 <= rect["top"] <= 230 and rect["left"] > viewport * 0.45:
|
|
candidates.append((rect["left"], element))
|
|
|
|
if candidates:
|
|
_, button = sorted(candidates, key=lambda item: item[0], reverse=True)[0]
|
|
js_click_element(driver, button)
|
|
time.sleep(0.8)
|
|
wait_for_blocking_overlay_gone(driver)
|
|
return True
|
|
|
|
driver.switch_to.default_content()
|
|
return False
|
|
|
|
|
|
def wait_for_account_list_data(driver: WebDriver, accounts: list[Account], timeout: int = 45) -> bool:
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if has_blocking_overlay(driver):
|
|
time.sleep(0.5)
|
|
continue
|
|
if account_list_has_any_expected_account(driver, accounts):
|
|
return True
|
|
time.sleep(0.7)
|
|
return False
|
|
|
|
|
|
def ensure_ledger_data_loaded(driver: WebDriver, accounts: list[Account]) -> None:
|
|
if account_list_has_any_expected_account(driver, accounts):
|
|
return
|
|
|
|
if False:
|
|
log("계정 목록이 비어 있어 조회 버튼을 누르고 데이터 로딩을 기다립니다.")
|
|
else:
|
|
diagnostic = save_diagnostics(driver, "account_list_empty")
|
|
raise RuntimeError(
|
|
"왼쪽 계정 목록에 계정 데이터가 없습니다. "
|
|
"WEHAGO 화면에서 직접 조회를 눌러 계정 목록이 보이는 상태로 만든 뒤 다시 Enter를 누르세요. "
|
|
"자동 조회는 화면 조건을 초기화할 수 있어 기본으로 사용하지 않습니다. "
|
|
f"진단 파일: {diagnostic.resolve()}"
|
|
)
|
|
|
|
if click_query_button(driver):
|
|
if wait_for_account_list_data(driver, accounts):
|
|
log("계정 목록 데이터가 로딩되었습니다.")
|
|
return
|
|
|
|
diagnostic = save_diagnostics(driver, "account_list_empty_after_query")
|
|
raise RuntimeError(
|
|
"조회 후에도 왼쪽 계정 목록에 계정 데이터가 없습니다. "
|
|
"기간/회사/계정과목 조건을 확인하고, 화면에 계정 목록이 보이는 상태에서 다시 실행하세요. "
|
|
f"진단 파일: {diagnostic.resolve()}"
|
|
)
|
|
|
|
|
|
def find_left_scroll_container(driver: WebDriver) -> WebElement | None:
|
|
"""왼쪽 계정 목록을 담은 스크롤 가능한 영역을 찾습니다."""
|
|
width = viewport_width(driver)
|
|
left_limit = width * 0.35
|
|
|
|
for _ in contexts_with_default_first(driver):
|
|
best: tuple[float, WebElement] | None = None
|
|
for element in driver.find_elements(By.CSS_SELECTOR, "div, section, article"):
|
|
if not element.is_displayed():
|
|
continue
|
|
|
|
rect = rect_of(driver, element)
|
|
if rect["left"] >= left_limit or rect["height"] < 100 or rect["width"] < 100:
|
|
continue
|
|
|
|
scrollable = driver.execute_script(
|
|
"return arguments[0].scrollHeight > arguments[0].clientHeight + 20;",
|
|
element,
|
|
)
|
|
if not scrollable:
|
|
continue
|
|
|
|
score = rect["width"] * rect["height"]
|
|
if best is None or score > best[0]:
|
|
best = (score, element)
|
|
|
|
if best is not None:
|
|
return best[1]
|
|
|
|
driver.switch_to.default_content()
|
|
return None
|
|
|
|
|
|
def find_left_account_grid(driver: WebDriver) -> WebElement | None:
|
|
"""스크롤 컨테이너를 못 찾을 때 휠을 보낼 왼쪽 계정 그리드를 찾습니다."""
|
|
width = viewport_width(driver)
|
|
left_limit = width * 0.35
|
|
|
|
for _ in contexts_with_default_first(driver):
|
|
best: tuple[float, WebElement] | None = None
|
|
for selector in ACCOUNT_LIST_SELECTORS:
|
|
for element in driver.find_elements(By.CSS_SELECTOR, selector):
|
|
if not element.is_displayed():
|
|
continue
|
|
|
|
rect = rect_of(driver, element)
|
|
if rect["left"] >= left_limit or rect["height"] < 100 or rect["width"] < 100:
|
|
continue
|
|
|
|
score = rect["width"] * rect["height"]
|
|
if best is None or score > best[0]:
|
|
best = (score, element)
|
|
|
|
if best is not None:
|
|
return best[1]
|
|
|
|
driver.switch_to.default_content()
|
|
return None
|
|
|
|
|
|
def scroll_left_account_list(driver: WebDriver) -> bool:
|
|
container = find_left_scroll_container(driver)
|
|
if container is not None:
|
|
before = driver.execute_script("return arguments[0].scrollTop;", container)
|
|
driver.execute_script("arguments[0].scrollTop = arguments[0].scrollTop + arguments[1];", container, ACCOUNT_SCROLL_PIXELS)
|
|
time.sleep(0.35)
|
|
after = driver.execute_script("return arguments[0].scrollTop;", container)
|
|
if after != before:
|
|
return True
|
|
|
|
grid = find_left_account_grid(driver)
|
|
if grid is None:
|
|
return False
|
|
|
|
try:
|
|
ActionChains(driver).move_to_element(grid).scroll_by_amount(0, ACCOUNT_SCROLL_PIXELS).perform()
|
|
time.sleep(0.35)
|
|
return True
|
|
except WebDriverException:
|
|
return False
|
|
|
|
|
|
def reset_left_account_list_scroll(driver: WebDriver) -> None:
|
|
container = find_left_scroll_container(driver)
|
|
if container is not None:
|
|
driver.execute_script("arguments[0].scrollTop = 0;", container)
|
|
time.sleep(0.5)
|
|
return
|
|
|
|
grid = find_left_account_grid(driver)
|
|
if grid is None:
|
|
return
|
|
|
|
try:
|
|
ActionChains(driver).move_to_element(grid).scroll_by_amount(0, -10000).perform()
|
|
time.sleep(0.5)
|
|
except WebDriverException:
|
|
pass
|
|
|
|
|
|
def select_account_from_left_list(driver: WebDriver, account: Account) -> None:
|
|
"""계정 코드 입력칸 대신 화면 왼쪽 계정 목록의 행을 직접 선택합니다."""
|
|
if click_left_account_text(driver, account):
|
|
return
|
|
|
|
for reset_before_search in (False, True):
|
|
if reset_before_search:
|
|
reset_left_account_list_scroll(driver)
|
|
if click_left_account_text(driver, account):
|
|
return
|
|
|
|
for _ in range(ACCOUNT_SCROLL_TRIES):
|
|
if not scroll_left_account_list(driver):
|
|
break
|
|
if click_left_account_text(driver, account):
|
|
return
|
|
|
|
raise TimeoutException(f"왼쪽 계정 목록에서 {account.code} {account.name} 행을 찾지 못했습니다.")
|
|
|
|
|
|
def wait_until_download_finished(download_dir: Path, before_files: dict[Path, set[Path]]) -> Path:
|
|
deadline = time.time() + DOWNLOAD_TIMEOUT_SECONDS
|
|
|
|
while time.time() < deadline:
|
|
completed: list[Path] = []
|
|
temp_files: list[Path] = []
|
|
for folder, previous_files in before_files.items():
|
|
current_files = set(folder.glob("*"))
|
|
new_files = current_files - previous_files
|
|
temp_files.extend(p for p in current_files if p.suffix.lower() in {".crdownload", ".tmp"})
|
|
completed.extend(
|
|
p
|
|
for p in new_files
|
|
if p.is_file()
|
|
and p.suffix.lower() in {".xlsx", ".xls", ".csv"}
|
|
and not p.name.startswith("~$")
|
|
)
|
|
|
|
if completed and not temp_files:
|
|
return max(completed, key=lambda p: p.stat().st_mtime)
|
|
|
|
time.sleep(DOWNLOAD_POLL_INTERVAL_SECONDS)
|
|
|
|
scan_targets = ", ".join(str(folder.resolve()) for folder in before_files)
|
|
raise TimeoutException(f"{DOWNLOAD_TIMEOUT_SECONDS}초 동안 다운로드 완료 파일을 찾지 못했습니다. 확인 위치: {scan_targets}")
|
|
|
|
|
|
def click_excel_menu_in_current_context(driver: WebDriver, timeout: int = 3) -> bool:
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
menu_candidates: list[tuple[float, WebElement]] = []
|
|
|
|
for xpath in EXCEL_MENU_XPATHS:
|
|
for menu in driver.find_elements(By.XPATH, xpath):
|
|
if not menu.is_displayed():
|
|
continue
|
|
|
|
rect = rect_of(driver, menu)
|
|
if rect["width"] <= 0 or rect["height"] <= 0:
|
|
continue
|
|
|
|
text = (menu.text or "").strip()
|
|
element_id = menu.get_attribute("id") or ""
|
|
if not any(keyword in text or keyword in element_id for keyword in EXCEL_MENU_TEXTS):
|
|
continue
|
|
|
|
score = 0 if element_id == "context:엑셀변환" else rect["width"] * rect["height"]
|
|
menu_candidates.append((score, menu))
|
|
|
|
if menu_candidates:
|
|
_, menu = sorted(menu_candidates, key=lambda item: item[0])[0]
|
|
try:
|
|
ActionChains(driver).move_to_element(menu).click(menu).perform()
|
|
except WebDriverException:
|
|
try:
|
|
menu.click()
|
|
except WebDriverException:
|
|
js_click_element(driver, menu)
|
|
time.sleep(0.5)
|
|
return True
|
|
|
|
time.sleep(0.2)
|
|
|
|
return False
|
|
|
|
|
|
def find_detail_grid(driver: WebDriver) -> WebElement:
|
|
"""오른쪽 상세 내역 그리드를 찾습니다."""
|
|
width = viewport_width(driver)
|
|
right_start = width * 0.25
|
|
best: tuple[float, WebElement] | None = None
|
|
|
|
for _ in contexts_with_default_first(driver):
|
|
for selector in GRID_SELECTORS:
|
|
for element in driver.find_elements(By.CSS_SELECTOR, selector):
|
|
if not element.is_displayed():
|
|
continue
|
|
|
|
rect = rect_of(driver, element)
|
|
if rect["left"] < right_start or rect["width"] < 250 or rect["height"] < 120:
|
|
continue
|
|
|
|
score = rect["width"] * rect["height"]
|
|
if best is None or score > best[0]:
|
|
best = (score, element)
|
|
|
|
if best is not None:
|
|
return best[1]
|
|
|
|
driver.switch_to.default_content()
|
|
raise TimeoutException("오른쪽 상세 내역 그리드를 찾지 못했습니다.")
|
|
|
|
|
|
def find_detail_data_cell(driver: WebDriver) -> WebElement | None:
|
|
"""오른쪽 상세 그리드 안의 실제 데이터 셀을 찾습니다."""
|
|
width = viewport_width(driver)
|
|
right_start = width * 0.25
|
|
|
|
for _ in contexts_with_default_first(driver):
|
|
candidates: list[tuple[float, WebElement]] = []
|
|
for selector in [".rg-data-cell", "td[class*='rg-data-cell']", "[class*='rg-data-cell']"]:
|
|
for element in driver.find_elements(By.CSS_SELECTOR, selector):
|
|
if not element.is_displayed():
|
|
continue
|
|
rect = rect_of(driver, element)
|
|
if rect["left"] < right_start or rect["width"] <= 0 or rect["height"] <= 0:
|
|
continue
|
|
text = (element.text or "").strip()
|
|
# 금액 셀처럼 text가 비어 보이는 경우도 있어, 오른쪽 상세 영역의 보이는 데이터 셀 자체를 후보로 둡니다.
|
|
score = rect["top"] * 10000 + rect["left"]
|
|
if text or rect["top"] > 180:
|
|
candidates.append((score, element))
|
|
if candidates:
|
|
return sorted(candidates, key=lambda item: item[0])[0][1]
|
|
|
|
driver.switch_to.default_content()
|
|
return None
|
|
|
|
|
|
def detail_grid_signature(driver: WebDriver) -> str:
|
|
width = viewport_width(driver)
|
|
right_start = width * 0.25
|
|
parts: list[str] = []
|
|
|
|
for _ in contexts_with_default_first(driver):
|
|
elements = driver.find_elements(By.CSS_SELECTOR, ".rg-data-cell, td[class*='rg-data-cell'], [class*='rg-data-cell']")
|
|
for element in elements[:80]:
|
|
if not element.is_displayed():
|
|
continue
|
|
rect = rect_of(driver, element)
|
|
if rect["left"] < right_start or rect["width"] <= 0 or rect["height"] <= 0:
|
|
continue
|
|
text = (element.text or "").strip()
|
|
if text:
|
|
parts.append(text)
|
|
if len(parts) >= 20:
|
|
break
|
|
if parts:
|
|
break
|
|
|
|
driver.switch_to.default_content()
|
|
return "|".join(parts)
|
|
|
|
|
|
def wait_for_detail_change(driver: WebDriver, before_signature: str, timeout: int = DETAIL_CHANGE_WAIT_SECONDS) -> None:
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
wait_for_blocking_overlay_gone(driver)
|
|
current_signature = detail_grid_signature(driver)
|
|
if current_signature and current_signature != before_signature:
|
|
return
|
|
if find_detail_data_cell(driver) is not None and not before_signature:
|
|
return
|
|
time.sleep(0.15)
|
|
|
|
|
|
def wait_for_detail_data_cell(driver: WebDriver, timeout: int = DETAIL_ROW_WAIT_SECONDS) -> WebElement | None:
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
wait_for_blocking_overlay_gone(driver)
|
|
cell = find_detail_data_cell(driver)
|
|
if cell is not None:
|
|
return cell
|
|
time.sleep(0.15)
|
|
return None
|
|
|
|
|
|
def context_click_excel_download(driver: WebDriver) -> None:
|
|
close_open_menus(driver)
|
|
wait_for_blocking_overlay_gone(driver)
|
|
target = wait_for_detail_data_cell(driver) or find_detail_grid(driver)
|
|
ActionChains(driver).move_to_element(target).context_click(target).perform()
|
|
time.sleep(0.15)
|
|
|
|
if click_excel_menu_in_current_context(driver):
|
|
return
|
|
|
|
driver.switch_to.default_content()
|
|
if click_excel_menu_in_current_context(driver):
|
|
return
|
|
|
|
frames = driver.find_elements(By.CSS_SELECTOR, "iframe, frame")
|
|
for frame in frames:
|
|
driver.switch_to.default_content()
|
|
driver.switch_to.frame(frame)
|
|
if click_excel_menu_in_current_context(driver, timeout=2):
|
|
return
|
|
|
|
driver.switch_to.default_content()
|
|
raise NoSuchElementException("우클릭 메뉴에서 엑셀/다운로드 항목을 찾지 못했습니다.")
|
|
|
|
|
|
def rename_downloaded_file(downloaded_file: Path, account: Account, download_dir: Path) -> Path:
|
|
suffix = downloaded_file.suffix if downloaded_file.suffix.lower() in {".xlsx", ".xls", ".csv"} else ".xlsx"
|
|
target = download_dir / f"{account.safe_stem}{suffix.lower()}"
|
|
if target.exists():
|
|
target.unlink()
|
|
if downloaded_file.parent.resolve() == download_dir.resolve():
|
|
downloaded_file.rename(target)
|
|
else:
|
|
target.write_bytes(downloaded_file.read_bytes())
|
|
downloaded_file.unlink()
|
|
return target
|
|
|
|
|
|
def write_clean_xlsx_with_account_columns(path: Path, account: Account) -> None:
|
|
raw_rows = read_rows_from_xlsx_raw(path)
|
|
workbook = Workbook()
|
|
sheet = workbook.active
|
|
sheet.title = "Sheet1"
|
|
|
|
header_found = False
|
|
for _, _, values in raw_rows:
|
|
normalized = ["" if value is None else str(value).strip() for value in values]
|
|
if is_ledger_header(values):
|
|
header_found = True
|
|
break
|
|
|
|
in_table = False
|
|
for _, _, values in raw_rows:
|
|
row_values = list(values)
|
|
if header_found and is_ledger_header(row_values):
|
|
in_table = True
|
|
row_values = row_values[: ACCOUNT_CODE_COLUMN - 1]
|
|
while len(row_values) < ACCOUNT_CODE_COLUMN - 1:
|
|
row_values.append(None)
|
|
row_values.extend(["계정코드", "계정명"])
|
|
elif in_table and non_empty_row(row_values):
|
|
row_values = row_values[: ACCOUNT_CODE_COLUMN - 1]
|
|
while len(row_values) < ACCOUNT_CODE_COLUMN - 1:
|
|
row_values.append(None)
|
|
row_values.extend([account.code, account.name])
|
|
sheet.append(row_values)
|
|
|
|
workbook.save(path)
|
|
workbook.close()
|
|
|
|
|
|
def account_columns_already_in_h_i(path: Path, account: Account) -> bool:
|
|
rows = read_downloaded_rows(path)
|
|
found_header = False
|
|
checked_data_rows = 0
|
|
|
|
for _, _, values in rows:
|
|
normalized = normalize_row(values)
|
|
if is_ledger_header(values):
|
|
found_header = True
|
|
if len(normalized) < ACCOUNT_NAME_COLUMN:
|
|
return False
|
|
if normalized[ACCOUNT_CODE_COLUMN - 1 : ACCOUNT_NAME_COLUMN] != ["계정코드", "계정명"]:
|
|
return False
|
|
continue
|
|
|
|
if not found_header or not non_empty_row(values):
|
|
continue
|
|
|
|
if len(normalized) < ACCOUNT_NAME_COLUMN:
|
|
return False
|
|
if normalized[ACCOUNT_CODE_COLUMN - 1 : ACCOUNT_NAME_COLUMN] != [account.code, account.name]:
|
|
return False
|
|
checked_data_rows += 1
|
|
if checked_data_rows >= 20:
|
|
return True
|
|
|
|
return found_header
|
|
|
|
|
|
def add_account_columns_to_excel(path: Path, account: Account) -> None:
|
|
if path.suffix.lower() != ".xlsx":
|
|
return
|
|
|
|
try:
|
|
if account_columns_already_in_h_i(path, account):
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
write_clean_xlsx_with_account_columns(path, account)
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
workbook = load_workbook(path)
|
|
except Exception:
|
|
write_clean_xlsx_with_account_columns(path, account)
|
|
return
|
|
|
|
try:
|
|
for sheet in workbook.worksheets:
|
|
header_cell = None
|
|
for row in sheet.iter_rows():
|
|
for cell in row:
|
|
value = "" if cell.value is None else str(cell.value).strip()
|
|
if value == "전표번호":
|
|
header_cell = cell
|
|
break
|
|
if header_cell is not None:
|
|
break
|
|
|
|
if header_cell is None:
|
|
continue
|
|
|
|
row_index = header_cell.row
|
|
code_col = ACCOUNT_CODE_COLUMN
|
|
name_col = ACCOUNT_NAME_COLUMN
|
|
code_header = sheet.cell(row=row_index, column=code_col).value
|
|
name_header = sheet.cell(row=row_index, column=name_col).value
|
|
|
|
if str(code_header).strip() != "계정코드" or str(name_header).strip() != "계정명":
|
|
sheet.cell(row=row_index, column=code_col, value="계정코드")
|
|
sheet.cell(row=row_index, column=name_col, value="계정명")
|
|
|
|
for data_row in range(row_index + 1, sheet.max_row + 1):
|
|
values = [sheet.cell(row=data_row, column=col).value for col in range(1, sheet.max_column + 1)]
|
|
if non_empty_row(values):
|
|
sheet.cell(row=data_row, column=code_col, value=account.code)
|
|
sheet.cell(row=data_row, column=name_col, value=account.name)
|
|
|
|
workbook.save(path)
|
|
finally:
|
|
workbook.close()
|
|
|
|
|
|
def account_file_candidates(account: Account, download_dir: Path) -> list[Path]:
|
|
return [
|
|
download_dir / f"{account.safe_stem}.xlsx",
|
|
download_dir / f"{account.safe_stem}.xls",
|
|
download_dir / f"{account.safe_stem}.csv",
|
|
]
|
|
|
|
|
|
def find_account_file(account: Account, download_dir: Path) -> Path | None:
|
|
for candidate in account_file_candidates(account, download_dir):
|
|
if candidate.exists():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def download_scan_dirs(download_dir: Path) -> list[Path]:
|
|
dirs = [download_dir]
|
|
if FALLBACK_DOWNLOAD_DIR.resolve() != download_dir.resolve():
|
|
dirs.append(FALLBACK_DOWNLOAD_DIR)
|
|
return dirs
|
|
|
|
|
|
def snapshot_download_files(download_dir: Path) -> dict[Path, set[Path]]:
|
|
snapshots: dict[Path, set[Path]] = {}
|
|
for folder in download_scan_dirs(download_dir):
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
snapshots[folder] = set(folder.glob("*"))
|
|
return snapshots
|
|
|
|
|
|
def non_empty_row(values: Iterable[object]) -> bool:
|
|
return any(value is not None and str(value).strip() != "" for value in values)
|
|
|
|
|
|
def excel_column_number(cell_reference: str) -> int:
|
|
letters = "".join(ch for ch in cell_reference if ch.isalpha()).upper()
|
|
number = 0
|
|
for letter in letters:
|
|
number = number * 26 + (ord(letter) - ord("A") + 1)
|
|
return number
|
|
|
|
|
|
def read_shared_strings_from_xlsx(path: Path) -> list[str]:
|
|
namespace = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
|
|
with zipfile.ZipFile(path) as archive:
|
|
if "xl/sharedStrings.xml" not in archive.namelist():
|
|
return []
|
|
root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
|
|
|
|
strings: list[str] = []
|
|
for item in root.findall("x:si", namespace):
|
|
parts = [node.text or "" for node in item.findall(".//x:t", namespace)]
|
|
strings.append("".join(parts))
|
|
return strings
|
|
|
|
|
|
def read_rows_from_xlsx_raw(path: Path) -> list[tuple[str, int, tuple[object, ...]]]:
|
|
namespace = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
|
|
shared_strings = read_shared_strings_from_xlsx(path)
|
|
rows: list[tuple[str, int, tuple[object, ...]]] = []
|
|
|
|
with zipfile.ZipFile(path) as archive:
|
|
sheet_names = [name for name in archive.namelist() if name.startswith("xl/worksheets/sheet") and name.endswith(".xml")]
|
|
for sheet_index, sheet_name in enumerate(sorted(sheet_names), start=1):
|
|
root = ET.fromstring(archive.read(sheet_name))
|
|
for row_node in root.findall(".//x:sheetData/x:row", namespace):
|
|
row_number = int(row_node.attrib.get("r", len(rows) + 1))
|
|
values_by_column: dict[int, object] = {}
|
|
|
|
for cell in row_node.findall("x:c", namespace):
|
|
reference = cell.attrib.get("r", "")
|
|
column_number = excel_column_number(reference)
|
|
cell_type = cell.attrib.get("t", "")
|
|
value_node = cell.find("x:v", namespace)
|
|
inline_node = cell.find("x:is", namespace)
|
|
|
|
value: object = None
|
|
if cell_type == "s" and value_node is not None and value_node.text is not None:
|
|
index = int(value_node.text)
|
|
value = shared_strings[index] if index < len(shared_strings) else value_node.text
|
|
elif cell_type == "inlineStr" and inline_node is not None:
|
|
value = "".join(node.text or "" for node in inline_node.findall(".//x:t", namespace))
|
|
elif value_node is not None:
|
|
value = value_node.text
|
|
|
|
if value is not None:
|
|
values_by_column[column_number] = value
|
|
|
|
if values_by_column:
|
|
max_column = max(values_by_column)
|
|
values = tuple(values_by_column.get(column) for column in range(1, max_column + 1))
|
|
if non_empty_row(values):
|
|
rows.append((f"Sheet{sheet_index}", row_number, values))
|
|
|
|
return rows
|
|
|
|
|
|
def read_rows_from_xlsx(path: Path) -> list[tuple[str, int, tuple[object, ...]]]:
|
|
rows: list[tuple[str, int, tuple[object, ...]]] = []
|
|
try:
|
|
workbook = load_workbook(path, read_only=True, data_only=True)
|
|
try:
|
|
for sheet in workbook.worksheets:
|
|
for row_number, row in enumerate(sheet.iter_rows(values_only=True), start=1):
|
|
values = tuple(row)
|
|
if non_empty_row(values):
|
|
rows.append((sheet.title, row_number, values))
|
|
finally:
|
|
workbook.close()
|
|
return rows
|
|
except Exception:
|
|
return read_rows_from_xlsx_raw(path)
|
|
|
|
|
|
def read_rows_from_csv(path: Path) -> list[tuple[str, int, tuple[object, ...]]]:
|
|
encodings = ["utf-8-sig", "cp949", "euc-kr"]
|
|
last_error: Exception | None = None
|
|
|
|
for encoding in encodings:
|
|
try:
|
|
rows: list[tuple[str, int, tuple[object, ...]]] = []
|
|
with path.open("r", encoding=encoding, newline="") as file:
|
|
reader = csv.reader(file)
|
|
for row_number, row in enumerate(reader, start=1):
|
|
values = tuple(row)
|
|
if non_empty_row(values):
|
|
rows.append(("CSV", row_number, values))
|
|
return rows
|
|
except UnicodeDecodeError as exc:
|
|
last_error = exc
|
|
|
|
raise UnicodeDecodeError("csv", b"", 0, 1, f"CSV 인코딩을 읽지 못했습니다: {last_error}")
|
|
|
|
|
|
def read_downloaded_rows(path: Path) -> list[tuple[str, int, tuple[object, ...]]]:
|
|
suffix = path.suffix.lower()
|
|
if suffix == ".xlsx":
|
|
return read_rows_from_xlsx(path)
|
|
if suffix == ".csv":
|
|
return read_rows_from_csv(path)
|
|
raise ValueError(".xls 파일은 현재 자동 취합에서 읽을 수 없습니다. WEHAGO 다운로드 형식을 .xlsx로 바꿔주세요.")
|
|
|
|
|
|
def normalize_row(values: Iterable[object]) -> list[str]:
|
|
return ["" if value is None else str(value).strip() for value in values]
|
|
|
|
|
|
def is_ledger_header(values: Iterable[object]) -> bool:
|
|
normalized = normalize_row(values)
|
|
return "전표번호" in normalized and ("일자" in normalized or "적요" in normalized)
|
|
|
|
|
|
def ensure_account_header_columns(values: tuple[object, ...]) -> tuple[object, ...]:
|
|
row = list(values)
|
|
while len(row) < ACCOUNT_NAME_COLUMN:
|
|
row.append(None)
|
|
row[ACCOUNT_CODE_COLUMN - 1] = "계정코드"
|
|
row[ACCOUNT_NAME_COLUMN - 1] = "계정명"
|
|
return tuple(row)
|
|
|
|
|
|
def ensure_account_data_columns(values: tuple[object, ...], account: Account) -> tuple[object, ...]:
|
|
row = list(values)
|
|
while len(row) < ACCOUNT_NAME_COLUMN:
|
|
row.append(None)
|
|
row[ACCOUNT_CODE_COLUMN - 1] = account.code
|
|
row[ACCOUNT_NAME_COLUMN - 1] = account.name
|
|
return tuple(row)
|
|
|
|
|
|
def extract_ledger_data_rows(
|
|
rows: list[tuple[str, int, tuple[object, ...]]],
|
|
account: Account,
|
|
) -> tuple[list[object], list[tuple[str, int, tuple[object, ...]]]]:
|
|
headers: list[object] = []
|
|
data_rows: list[tuple[str, int, tuple[object, ...]]] = []
|
|
found_header = False
|
|
|
|
for sheet_name, row_number, values in rows:
|
|
if is_ledger_header(values):
|
|
header = ensure_account_header_columns(values)
|
|
found_header = True
|
|
if len(header) > len(headers):
|
|
headers = list(header)
|
|
continue
|
|
|
|
if not found_header:
|
|
continue
|
|
|
|
if not non_empty_row(values):
|
|
continue
|
|
|
|
if is_ledger_header(values):
|
|
continue
|
|
|
|
row = ensure_account_data_columns(values, account)
|
|
data_rows.append((sheet_name, row_number, row))
|
|
|
|
return headers, data_rows
|
|
|
|
|
|
def autosize_columns(ws, max_width: int = 42) -> None:
|
|
for column_cells in ws.columns:
|
|
column_letter = column_cells[0].column_letter
|
|
values = [cell.value for cell in column_cells if cell.value is not None]
|
|
if not values:
|
|
continue
|
|
width = min(max(len(str(value)) for value in values) + 2, max_width)
|
|
ws.column_dimensions[column_letter].width = width
|
|
|
|
|
|
def style_header(ws, row: int = 1) -> None:
|
|
fill = PatternFill("solid", fgColor="D9EAF7")
|
|
font = Font(bold=True)
|
|
for cell in ws[row]:
|
|
cell.fill = fill
|
|
cell.font = font
|
|
cell.alignment = Alignment(horizontal="center")
|
|
|
|
|
|
def build_summary_rows(
|
|
accounts: list[Account],
|
|
download_dir: Path,
|
|
failures: list[tuple[Account, str]],
|
|
) -> list[dict[str, object]]:
|
|
failure_map = {account.code: message for account, message in failures}
|
|
rows: list[dict[str, object]] = []
|
|
|
|
for account in accounts:
|
|
path = find_account_file(account, download_dir)
|
|
if account.code in failure_map:
|
|
status = "오류"
|
|
message = failure_map[account.code]
|
|
elif path is None:
|
|
status = "누락"
|
|
message = "다운로드 파일을 찾지 못했습니다."
|
|
else:
|
|
status = "정상"
|
|
message = ""
|
|
|
|
rows.append(
|
|
{
|
|
"계정코드": account.code,
|
|
"계정명": account.name,
|
|
"상태": status,
|
|
"파일명": path.name if path else "",
|
|
"데이터행수": 0,
|
|
"메시지": message,
|
|
}
|
|
)
|
|
|
|
return rows
|
|
|
|
|
|
def consolidate_downloads(
|
|
accounts: list[Account],
|
|
download_dir: Path,
|
|
failures: list[tuple[Account, str]] | None = None,
|
|
) -> Path:
|
|
failures = failures or []
|
|
output_path = download_dir / f"통합_계정별원장_{datetime.now():%Y%m%d_%H%M%S}.xlsx"
|
|
summary_rows = build_summary_rows(accounts, download_dir, failures)
|
|
summary_by_code = {str(row["계정코드"]): row for row in summary_rows}
|
|
|
|
workbook = Workbook()
|
|
data_ws = workbook.active
|
|
data_ws.title = "취합데이터"
|
|
status_ws = workbook.create_sheet("계정별상태")
|
|
error_ws = workbook.create_sheet("오류_누락")
|
|
|
|
data_header = ["계정코드", "계정명", "원본파일", "원본시트", "원본행번호"]
|
|
data_ws.append(data_header)
|
|
|
|
max_value_columns = 0
|
|
source_headers: list[object] = []
|
|
for account in accounts:
|
|
path = find_account_file(account, download_dir)
|
|
if path is None:
|
|
continue
|
|
|
|
try:
|
|
add_account_columns_to_excel(path, account)
|
|
rows = read_downloaded_rows(path)
|
|
summary_by_code[account.code]["데이터행수"] = len(rows)
|
|
if not rows:
|
|
summary_by_code[account.code]["상태"] = "확인필요"
|
|
summary_by_code[account.code]["메시지"] = "파일은 있으나 읽을 수 있는 데이터 행이 없습니다."
|
|
continue
|
|
|
|
file_headers, ledger_rows = extract_ledger_data_rows(rows, account)
|
|
if file_headers and len(file_headers) > len(source_headers):
|
|
source_headers = file_headers
|
|
|
|
data_row_count = 0
|
|
for sheet_name, row_number, values in ledger_rows:
|
|
max_value_columns = max(max_value_columns, len(values))
|
|
data_ws.append([account.code, account.name, path.name, sheet_name, row_number, *values])
|
|
data_row_count += 1
|
|
summary_by_code[account.code]["데이터행수"] = data_row_count
|
|
|
|
if not ledger_rows:
|
|
summary_by_code[account.code]["상태"] = "확인필요"
|
|
summary_by_code[account.code]["메시지"] = "표 헤더는 찾았지만 취합할 거래 데이터 행이 없습니다."
|
|
|
|
except Exception as exc:
|
|
summary_by_code[account.code]["상태"] = "취합오류"
|
|
summary_by_code[account.code]["메시지"] = f"{type(exc).__name__}: {exc}"
|
|
|
|
for column_index in range(1, max_value_columns + 1):
|
|
header_value = source_headers[column_index - 1] if column_index <= len(source_headers) else f"원본열{column_index}"
|
|
data_ws.cell(row=1, column=len(data_header) + column_index, value=header_value)
|
|
|
|
status_ws.append(["계정코드", "계정명", "상태", "파일명", "데이터행수", "메시지"])
|
|
for row in summary_rows:
|
|
status_ws.append([row["계정코드"], row["계정명"], row["상태"], row["파일명"], row["데이터행수"], row["메시지"]])
|
|
|
|
error_ws.append(["계정코드", "계정명", "상태", "파일명", "메시지"])
|
|
for row in summary_rows:
|
|
if row["상태"] != "정상":
|
|
error_ws.append([row["계정코드"], row["계정명"], row["상태"], row["파일명"], row["메시지"]])
|
|
|
|
for ws in (data_ws, status_ws, error_ws):
|
|
style_header(ws)
|
|
ws.freeze_panes = "A2"
|
|
ws.auto_filter.ref = ws.dimensions
|
|
autosize_columns(ws)
|
|
|
|
workbook.save(output_path)
|
|
return output_path
|
|
|
|
|
|
def load_saved_ledger_url() -> str:
|
|
if not USE_SAVED_LEDGER_URL or not LEDGER_URL_FILE.exists():
|
|
return ""
|
|
return LEDGER_URL_FILE.read_text(encoding="utf-8").strip()
|
|
|
|
|
|
def save_ledger_url(url: str) -> None:
|
|
if USE_SAVED_LEDGER_URL and "smarta.wehago.com" in url and "SABK0107" in url:
|
|
LEDGER_URL_FILE.write_text(url, encoding="utf-8")
|
|
log(f"계정별원장 주소 저장: {LEDGER_URL_FILE.resolve()}")
|
|
|
|
|
|
def is_wehago_not_found_page(driver: WebDriver) -> bool:
|
|
try:
|
|
title = driver.title or ""
|
|
body_text = driver.find_element(By.TAG_NAME, "body").text
|
|
except Exception:
|
|
return False
|
|
|
|
checks = [title, body_text]
|
|
return any("페이지를 찾을 수 없습니다" in text or "404 Error" in text for text in checks)
|
|
|
|
|
|
def ledger_screen_ready(driver: WebDriver) -> bool:
|
|
if is_wehago_not_found_page(driver):
|
|
return False
|
|
|
|
try:
|
|
left_grid = find_left_account_grid(driver)
|
|
detail_grid = find_detail_grid(driver)
|
|
return left_grid is not None and detail_grid is not None
|
|
except Exception:
|
|
driver.switch_to.default_content()
|
|
return False
|
|
|
|
|
|
def open_wehago(driver: WebDriver) -> None:
|
|
saved_url = ""
|
|
target_url = ACCOUNT_LEDGER_URL.strip() or WEHAGO_START_URL
|
|
driver.get(target_url)
|
|
log("Chrome이 열렸습니다.")
|
|
log(f"계정별 다운로드 저장 위치: {DOWNLOAD_DIR.resolve()}")
|
|
log(f"보조 확인 위치: {FALLBACK_DOWNLOAD_DIR.resolve()}")
|
|
|
|
if saved_url:
|
|
log(f"저장된 계정별원장 주소를 열었습니다: {saved_url}")
|
|
elif ACCOUNT_LEDGER_URL.strip():
|
|
log(f"설정된 계정별원장 주소를 열었습니다: {ACCOUNT_LEDGER_URL.strip()}")
|
|
|
|
log("WEHAGO에 로그인하세요. 계정별원장 화면이 정상으로 열려 있으면 Enter를 누르세요.")
|
|
log("만약 404 화면이면 WEHAGO 메뉴에서 계정별원장 화면을 직접 연 뒤 Enter를 누르세요.")
|
|
input("계정별원장 화면 준비 후 Enter: ")
|
|
|
|
if is_wehago_not_found_page(driver):
|
|
log("현재 화면이 WEHAGO 404 페이지입니다. 고정 URL 또는 저장 URL이 현재 세션에서 유효하지 않습니다.")
|
|
log("브라우저에서 WEHAGO 메인으로 이동한 뒤, 메뉴로 계정별원장 화면을 직접 열어주세요.")
|
|
log("정상 계정별원장 화면이 보이면 다시 Enter를 누르세요.")
|
|
input("계정별원장 화면을 직접 연 뒤 Enter: ")
|
|
|
|
if not ledger_screen_ready(driver):
|
|
diagnostic = save_diagnostics(driver, "ledger_screen_not_ready")
|
|
raise RuntimeError(
|
|
"계정별원장 화면의 왼쪽 계정 목록 또는 오른쪽 상세 그리드를 찾지 못했습니다. "
|
|
f"진단 파일: {diagnostic.resolve()}"
|
|
)
|
|
|
|
log("계정별원장 화면 확인 완료. 사용자가 조회해 둔 현재 화면을 그대로 사용합니다.")
|
|
log("자동 조회, 저장 URL 재열기, 화면 초기화 동작은 수행하지 않습니다.")
|
|
|
|
|
|
def download_account_ledger(driver: WebDriver, account: Account, index: int, total: int) -> Path:
|
|
started_at = time.perf_counter()
|
|
close_open_menus(driver)
|
|
wait_for_blocking_overlay_gone(driver)
|
|
log(f"[{index}/{total}] {account.code} {account.name}: 왼쪽 계정 목록에서 선택")
|
|
before_files = snapshot_download_files(DOWNLOAD_DIR)
|
|
before_detail = detail_grid_signature(driver)
|
|
|
|
select_account_from_left_list(driver, account)
|
|
time.sleep(DELAY_AFTER_ACCOUNT_CLICK_SECONDS)
|
|
wait_for_blocking_overlay_gone(driver)
|
|
wait_for_detail_change(driver, before_detail, timeout=DETAIL_CHANGE_WAIT_SECONDS)
|
|
wait_for_detail_data_cell(driver, timeout=DETAIL_ROW_WAIT_SECONDS)
|
|
|
|
log(f"[{index}/{total}] {account.code} {account.name}: 우클릭 엑셀 다운로드")
|
|
context_click_excel_download(driver)
|
|
|
|
downloaded = wait_until_download_finished(DOWNLOAD_DIR, before_files)
|
|
if accept_download_complete_popup(driver):
|
|
log(f"[{index}/{total}] {account.code} {account.name}: 다운로드 완료 확인창 닫음")
|
|
renamed = rename_downloaded_file(downloaded, account, DOWNLOAD_DIR)
|
|
add_account_columns_to_excel(renamed, account)
|
|
elapsed = time.perf_counter() - started_at
|
|
log(f"[{index}/{total}] 완료: {renamed.name} ({elapsed:.1f}초)")
|
|
return renamed
|
|
|
|
|
|
def run(
|
|
accounts: list[Account],
|
|
start_index: int = 0,
|
|
end_index: int | None = None,
|
|
headless: bool = False,
|
|
merge: bool = True,
|
|
pause_on_failure: bool = PAUSE_ON_FAILURE,
|
|
) -> None:
|
|
selected_accounts = accounts[start_index:end_index]
|
|
if not selected_accounts:
|
|
raise ValueError("처리할 계정이 없습니다. start/end 옵션을 확인하세요.")
|
|
|
|
driver = build_driver(DOWNLOAD_DIR, headless=headless)
|
|
failures: list[tuple[Account, str]] = []
|
|
should_keep_browser_open = False
|
|
|
|
try:
|
|
open_wehago(driver)
|
|
|
|
total = len(selected_accounts)
|
|
pending_accounts = list(selected_accounts)
|
|
last_errors: dict[str, str] = {}
|
|
|
|
for attempt in range(1, MAX_DOWNLOAD_ATTEMPTS + 1):
|
|
if not pending_accounts:
|
|
break
|
|
|
|
if attempt > 1:
|
|
log(f"{attempt}회차 재시도를 시작합니다. 대상: {len(pending_accounts)}개 계정")
|
|
|
|
next_pending: list[Account] = []
|
|
for account in pending_accounts:
|
|
original_index = selected_accounts.index(account) + 1
|
|
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"[{original_index}/{total}] {account.code} {account.name}: 이미 있음, 건너뜀")
|
|
last_errors.pop(account.code, None)
|
|
continue
|
|
|
|
try:
|
|
download_account_ledger(driver, account, original_index, total)
|
|
last_errors.pop(account.code, None)
|
|
except Exception as exc:
|
|
message = f"{type(exc).__name__}: {exc}"
|
|
last_errors[account.code] = message
|
|
diagnostic = save_diagnostics(driver, f"attempt{attempt}_failed_{account.code}_{account.name}", exc)
|
|
log(f"[실패 {attempt}/{MAX_DOWNLOAD_ATTEMPTS}] {account.code} {account.name}: {message}")
|
|
log(f"[진단 저장] {diagnostic.resolve()}")
|
|
if attempt < MAX_DOWNLOAD_ATTEMPTS:
|
|
next_pending.append(account)
|
|
|
|
pending_accounts = next_pending
|
|
|
|
failures = [
|
|
(account, last_errors.get(account.code, "3회 재시도 후에도 다운로드 파일을 만들지 못했습니다."))
|
|
for account in selected_accounts
|
|
if find_account_file(account, DOWNLOAD_DIR) is None
|
|
]
|
|
if failures:
|
|
should_keep_browser_open = True
|
|
|
|
for index, account in enumerate([], start=1):
|
|
existing_file = find_account_file(account, DOWNLOAD_DIR)
|
|
if SKIP_ALREADY_DOWNLOADED and existing_file is not None:
|
|
log(f"[{index}/{total}] {account.code} {account.name}: 이미 있음, 건너뜀")
|
|
continue
|
|
|
|
try:
|
|
close_open_menus(driver)
|
|
wait_for_blocking_overlay_gone(driver)
|
|
log(f"[{index}/{total}] {account.code} {account.name}: 왼쪽 계정 목록에서 선택")
|
|
before_files = snapshot_download_files(DOWNLOAD_DIR)
|
|
|
|
select_account_from_left_list(driver, account)
|
|
time.sleep(DELAY_AFTER_ACCOUNT_CLICK_SECONDS)
|
|
wait_for_blocking_overlay_gone(driver)
|
|
|
|
log(f"[{index}/{total}] {account.code} {account.name}: 우클릭 엑셀 다운로드")
|
|
context_click_excel_download(driver)
|
|
|
|
downloaded = wait_until_download_finished(DOWNLOAD_DIR, before_files)
|
|
if accept_download_complete_popup(driver):
|
|
log(f"[{index}/{total}] {account.code} {account.name}: 다운로드 완료 확인창 닫음")
|
|
renamed = rename_downloaded_file(downloaded, account, DOWNLOAD_DIR)
|
|
add_account_columns_to_excel(renamed, account)
|
|
log(f"[{index}/{total}] 완료: {renamed.name}")
|
|
|
|
except Exception as exc: # 계정 하나 실패해도 다음 계정으로 진행합니다.
|
|
message = f"{type(exc).__name__}: {exc}"
|
|
failures.append((account, message))
|
|
should_keep_browser_open = True
|
|
diagnostic = save_diagnostics(driver, f"failed_{account.code}_{account.name}", exc)
|
|
log(f"[실패] {account.code} {account.name}: {message}")
|
|
log(f"[진단 저장] {diagnostic.resolve()}")
|
|
|
|
log(f"작업 완료. 저장 위치: {DOWNLOAD_DIR.resolve()}")
|
|
if merge:
|
|
log("다운로드 파일 취합을 시작합니다.")
|
|
merged_file = consolidate_downloads(selected_accounts, DOWNLOAD_DIR, failures)
|
|
log(f"통합 엑셀 생성 완료: {merged_file.resolve()}")
|
|
|
|
if failures:
|
|
log("실패한 계정 목록")
|
|
for account, message in failures:
|
|
log(f"- {account.code} {account.name}: {message}")
|
|
should_keep_browser_open = True
|
|
if pause_on_failure and not headless:
|
|
log("실패가 있어 Chrome 창을 바로 닫지 않습니다.")
|
|
log("화면을 확인한 뒤 Enter를 누르면 스크립트만 종료되고 Chrome 창은 유지됩니다.")
|
|
input("확인 후 Enter: ")
|
|
raise RuntimeError("일부 계정 다운로드에 실패했습니다. 통합 파일의 오류_누락 시트를 확인하세요.")
|
|
|
|
finally:
|
|
if should_keep_browser_open and pause_on_failure and not headless:
|
|
log("진단을 위해 Chrome 창을 유지합니다. 필요하면 직접 닫으세요.")
|
|
else:
|
|
driver.quit()
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="WEHAGO 계정별원장 엑셀 자동 다운로드")
|
|
parser.add_argument("--start", type=int, default=0, help="0부터 시작하는 시작 순번")
|
|
parser.add_argument("--end", type=int, default=None, help="0부터 시작하는 끝 순번. 이 순번은 포함하지 않음")
|
|
parser.add_argument("--headless", action="store_true", help="브라우저 창 없이 실행. 로그인/화면 확인 뒤에는 권장")
|
|
parser.add_argument("--no-merge", action="store_true", help="다운로드 후 통합 엑셀을 만들지 않음")
|
|
parser.add_argument("--merge-only", action="store_true", help="브라우저 자동화 없이 기존 다운로드 파일만 취합")
|
|
parser.add_argument("--no-pause-on-failure", action="store_true", help="실패해도 화면 확인 대기 없이 종료")
|
|
parser.add_argument("--reset-ledger-url", action="store_true", help="저장된 계정별원장 주소를 삭제하고 WEHAGO 메인부터 시작")
|
|
return parser.parse_args()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
args = parse_args()
|
|
try:
|
|
if args.reset_ledger_url and LEDGER_URL_FILE.exists():
|
|
LEDGER_URL_FILE.unlink()
|
|
log(f"저장된 계정별원장 주소를 삭제했습니다: {LEDGER_URL_FILE.resolve()}")
|
|
|
|
selected = ACCOUNTS[args.start:args.end]
|
|
if args.merge_only:
|
|
merged = consolidate_downloads(selected, DOWNLOAD_DIR)
|
|
log(f"통합 엑셀 생성 완료: {merged.resolve()}")
|
|
else:
|
|
run(
|
|
ACCOUNTS,
|
|
start_index=args.start,
|
|
end_index=args.end,
|
|
headless=args.headless,
|
|
merge=not args.no_merge,
|
|
pause_on_failure=not args.no_pause_on_failure,
|
|
)
|
|
except KeyboardInterrupt:
|
|
log("사용자가 중지했습니다.")
|
|
except RuntimeError as exc:
|
|
log(str(exc))
|
|
sys.exit(1)
|
|
except WebDriverException as exc:
|
|
log(f"Chrome/Selenium 오류: {exc}")
|
|
log("Chrome이 이미 실행 중이면 모두 닫고 다시 시도하세요.")
|
|
sys.exit(1)
|