Files
WEHAGO_DB/wehago_data_download_260421.py
T

1725 lines
66 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")
service = Service(ChromeDriverManager().install(), log_output=subprocess.DEVNULL)
driver = webdriver.Chrome(service=service, 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)