DMF_Crawler/docs/design/04-onboarding-wizard.md
Yun Chan 56a6e2da93 chore: 저장소 구조 정리 및 문서화, 첫 커밋
- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes)
- 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에
  안내용 README.md 추가
- CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가
- docs/README.md 문서 지도 갱신
2026-09-04 09:25:44 +09:00

276 KiB
Raw Blame History

첫 실행 온보딩 마법사 설계 — 더블클릭으로 끝내는 설정

이 문서의 역할: 이 시스템을 쓰는 사람은 개발자가 아니다. API 키가 뭔지, 환경변수가 뭔지, 파이썬이 뭔지 모른다. 이 문서는 그 사람이 아이콘 하나를 더블클릭하는 것만으로 06:00 자동 실행 등록과 복구를 끝내게 만드는 GUI 마법사의 화면·전이·문구·구현 코드 정본이다. 공공데이터포털 인증키는 선택이며, 키가 없어도 기존 자료 리포트 생성은 막지 않는다. 인증키 등록은 공식 API 최신 수집을 켜고 싶을 때 진행한다. AGY는 두 용도다. source.mode=auto에서 인증키가 없거나 source.mode=browser이면 수집용 AGY가 headful/visible Chrome 으로 CCBAC03 xlsx 를 받고, AI 요약은 사용자가 명시적으로 켠 뒤에만 요약용 AGY를 호출한다. Google 로그인은 선택이지만 headful 브라우저 수집 또는 AI 요약을 쓰려면 AGY 준비/로그인이 필요할 수 있다. 01-architecture.md 가 예고한 M4 산출물이며, 그 문서의 §2 디렉터리 구조 · §3.15 checks.py · §3.17 gui/app.py · §5 진입점 규약을 그대로 따른다.

작성일: 2026-09-02 상태: 확정 (M4 구현 대기) 상위 문서: 01-architecture.md (구조 정본) · 00-DATA-SOURCE-DECISION.md (데이터 소스) · 00b-baseline-data-analysis.md (실측 기준선) · research/05a-agy-cli-ssot.md (agy 정본) 검증 환경: Windows 11 Pro 10.0.26220 (ko-KR), Windows PowerShell 5.1.26100.9223, agy 1.1.24(실측), Python 3.14/3.13/3.12/3.11 (py launcher)


0. 한눈에 보기

  • 진입점은 2단이다. 최초 1회는 bootstrap.cmd(파이썬·venv 준비), 그 뒤로는 DMF 설정.lnkpythonw.exe -m dmf_crawler onboard --mode setup. 01-architecture.md §5 규약 그대로다.
  • 콘솔 창이 보이는 구간은 기본 1곳뿐이다: ① bootstrap.cmd 최초 실행. ② agy 로그인 유도는 사용자가 headful 브라우저 수집 또는 AI 요약을 쓰는 경우에만 선택적으로 보인다(대화형 TUI 라 불가피). 나머지 전 과정은 pythonw.exe(PE 서브시스템 = WINDOWS_GUI)라 콘솔이 아예 생성되지 않는다 (1.2절).
  • GUI 는 stdlib tkinter 로 확정한다 (ADR-12). 외부 GUI 의존성 0. 온보딩·복구·진단이 checks.py 단일 진단 엔진을 공유하는 같은 창이다 (ADR-24).
  • 체크 항목은 checks.py 의 12종이 정본이다. 이 문서가 항목을 새로 만들지 않는다. 각 항목의 검사 코드 · 통과 기준 · 사용자 문구 · fix_action 을 4절에서 확정한다.
  • 모든 실패 항목은 반드시 액션 버튼을 갖는다. fix_action 이 없는 체크는 checks.py 등록 단계에서 계약으로 거부한다 (ADR §3.17 R7.7). 막다른 골목이 구조적으로 불가능하다.
  • 공공데이터포털 인증키는 선택이다. 등록하면 공식 API 최신 수집을 사용하고, 없어도 기존 자료 리포트·스케줄 설치·복구 화면은 계속 동작한다. 저장할 때는 DPAPI(사용자 범위)로 %LOCALAPPDATA%\DMF_Crawler\service_key.bin 에 저장한다 (ADR-13, secrets_dpapi.py 계약). GUI 는 원문을 화면·로그·예외 어디에도 남기지 않고 key_fingerprint()(sha256 앞 8자)만 표시한다.
  • 입력된 키는 저장 전에 실제 API 를 1회 호출해 검증한다. Encoding/Decoding 키는 %[0-9A-Fa-f]{2} 매치로 자동 판별해 Decoding 형태로 정규화 저장한다(ADR-05 가 전제한 params= 자동 인코딩과 일치). 쿼터 초과(코드 22/23)는 "키 유효"로 판정한다 — 실패로 처리하면 사용자가 멀쩡한 키를 하루 종일 다시 입력하게 된다.
  • agy login 서브커맨드는 존재하지 않는다(v1.1.24 실측). 로그인 유도는 인자 없는 agy 를 새 콘솔 창에서 띄우고, 토큰 파일을 폴링해 완료를 감지한다 (9절).
  • 2026-09-03 정정: agy 는 AI 요약뿐 아니라 수집에도 쓰인다. source.mode=auto 에서 공공데이터포털 인증키가 없거나 source.mode=browser 인 경우, AGY가 headful/visible Chrome 으로 의약품안전나라 xlsx 를 다운로드한다. AI 요약을 끈 상태(agy.enabled=false)여도 수집용 headful AGY 경로는 별도 요구다.
  • ⚠️ ADR-10 의 "배치 = S4U" 는 이 PC 에서 등록이 실패한다(실측). 비관리자 계정은 Logon as Batch 권한이 없어 S4U/Password 등록이 "액세스가 거부되었습니다"로 거부된다. 따라서 install_tasks.ps1S4U 시도 → 실패 시 Interactive 폴백을 반드시 구현해야 한다 (10절, 부록 B).
  • UAC 승격을 한 번도 띄우지 않는다. venv·DPAPI·agy(%LOCALAPPDATA%)·바로가기·Interactive 태스크 전부 medium 무결성으로 완결된다.

1. 기술 스택 확정

1.1 확정 결과 (전부 01-architecture.md 와 일치)

확정 근거 기각한 대안
최초 진입점 bootstrap.cmd ADR-20 · §5. venv 생성 → pip install -e . → 바로가기 생성 → GUI 기동 .exe 인스톨러(SmartScreen), .vbs+PowerShell(파이썬 부트스트랩 중복)
일상 진입점 DMF 설정.lnk.venv\Scripts\pythonw.exe -m dmf_crawler onboard --mode setup §5 바로가기 표 .bat 노출(콘솔 창), .ps1 직접(파일 연결이 편집)
GUI 툴킷 stdlib tkinter (+ttk) ADR-12. 요구 N5(의존성 최소) + R6.2(콘솔 금지) 동시 충족 PySide6/PyQt, WinForms(pythonnet), 웹 UI, PySimpleGUI, BurntToast
진단 엔진 checks.py 12종, run_all() / run_one() ADR-24. CLI doctor 와 GUI onboard같은 엔진의 렌더러 온보딩·복구 별도 구현
비밀 저장 DPAPI 사용자 범위 → %LOCALAPPDATA%\DMF_Crawler\service_key.bin ADR-13, secrets_dpapi.py 환경변수, .env, 평문 파일, 레지스트리
키 검증 저장 전 numOfRows=1 실호출 checks ⑤ 정규식만, 검증 없음
agy 설치 scripts/bootstrap_agy.ps1install.ps1 1순위 / winget 2순위 §2 트리 · agy SSOT §3.3 winget 단독
agy 로그인 새 콘솔에서 인자 없는 agy + 토큰 파일 폴링 agy login 은 존재하지 않음(실측) agy login, 헤드리스 로그인
스케줄러 scripts/install_tasks.ps1 — 작업 3종 ADR-10 단일 작업, Windows Service, WSL cron

1.2 콘솔 창을 없애는 원리 — PE 서브시스템

각 실행 호스트의 PE Optional Header + 68 오프셋(Subsystem 필드)을 직접 읽어 확인했다.

# 2 = WINDOWS_GUI (콘솔 없음) / 3 = WINDOWS_CUI (콘솔 강제 생성)
function Get-PeSubsystem {
    param([Parameter(Mandatory)][string]$Path)
    $b  = [IO.File]::ReadAllBytes($Path)
    $pe = [BitConverter]::ToInt32($b, 0x3C)
    [BitConverter]::ToUInt16($b, $pe + 4 + 20 + 68)
}
호스트 Subsystem 콘솔 창 이 프로젝트에서
cmd.exe 3 (CUI) 뜬다 bootstrap.cmd 최초 1회만
powershell.exe 3 (CUI) 뜬다 항상 -WindowStyle Hidden + 부모가 GUI
python.exe 3 (CUI) 뜬다 배치 실행에만 (S4U 세션이라 데스크톱 없음)
pythonw.exe 2 (GUI) 없음 모든 GUI 진입점
wscript.exe 2 (GUI) 없음 미사용

CUI 바이너리는 프로세스 생성 시점에 OS 가 콘솔(conhost)을 할당한다. -WindowStyle Hidden 은 프로세스가 시작된 뒤에 창을 숨기므로 흰 창이 한 번 번쩍인다. 따라서 .lnk 의 대상은 반드시 pythonw.exe 여야 한다. 01-architecture.md §5 의 결론과 동일하다:

pythonw.exe는 콘솔을 만들지 않는다. .bat/.cmd는 반드시 콘솔 창을 띄우므로 최초 설치용 bootstrap.cmd 외에는 배치 파일을 사용자에게 노출하지 않는다.

bootstrap.cmd 만 예외인 이유 — 닭이 먼저냐 달걀이 먼저냐 문제다. GUI 가 파이썬으로 쓰여 있으므로 파이썬이 없으면 GUI 를 띄울 수 없다. 그러니 파이썬 설치를 안내하는 단계만은 파이썬 밖에 있어야 한다. 이 구간에서 콘솔이 보이는 것은 회피 불가능이며, 대신 친절한 한국어 진행 메시지로 덮는다 (11.1). 한 번 끝나면 다시는 보이지 않는다.

1.3 python.exe 가 PATH 에 있어도 파이썬이 설치된 것이 아니다

bootstrap.cmd 가 반드시 피해야 할 함정이다. 실측:

C:\Users\encep\AppData\Local\Microsoft\WindowsApps\python.exe
  → C:\Program Files\WindowsApps\PythonSoftwareFoundation.PythonManager_...\python.exe

이것은 Microsoft Store 앱 실행 별칭 스텁이다. Microsoft 공식 FAQ: "Running the shortcut executable with any command-line arguments will return an error code to indicate that Python was not installed."where python 성공은 설치의 증거가 아니다.

올바른 감지 순서 (bootstrap.cmd 구현):

  1. py.exe -3 -V — Store 판에는 py 런처가 없다(공식 FAQ). 가장 신뢰할 만한 신호다. 실측 출력:
    -V:3.14[-64] *   C:\Users\encep\AppData\Local\Python\pythoncore-3.14-64\python.exe
    -V:3.13          C:\Users\encep\AppData\Local\Programs\Python\Python313\python.exe
    
  2. python.exe -c "import sys;print(sys.version_info[:2])"종료 코드와 출력을 확인 (스텁은 비정상 종료).
  3. 알려진 설치 경로(%LOCALAPPDATA%\Programs\Python\Python3xx\python.exe) 직접 검사.

⚠️ ADR-20 은 uv 를 명시적으로 기각했다("비개발자 PC에 추가 도구를 설치시키지 않는다"). 이 머신에 uv 0.8.3 이 있지만 부트스트랩이 그것에 의존해서는 안 된다. python -m venv + pip install -e . 만 쓴다.

1.4 SmartScreen 을 "우회"하지 않고 "회피"한다

Microsoft Defender SmartScreen 공식 문서는 경고 트리거를 "Checking downloaded files against a list of files that are well known and downloaded frequently" 로 규정한다. 판정 대상은 다운로드된 파일이다.

  • 로컬에서 생성한 .lnk / .cmd / .ps1 에는 Mark-of-the-Web(Zone.Identifier) 가 붙지 않는다. SmartScreen 프롬프트가 발생하지 않는다.
  • 서명 없는 PyInstaller .exe 는 파일 평판이 0 이라 빌드할 때마다 "Windows에서 PC를 보호했습니다 → 추가 정보 → 실행" 2클릭을 강요한다. 애초에 ADR-20 이 배포 형태를 소스+venv 로 확정했으므로 이 문제 자체가 없다.
  • ZIP 배포는 압축 해제 시 전 파일에 MOTW 를 전파한다. 대응은 우회가 아니라 정석이다 — bootstrap.cmdUnblock-File사용자에게 무엇인지 설명한 뒤 한 번 돌린다. 파일 속성 창의 [차단 해제] 체크박스와 정확히 동일한 동작이다.

PowerShell 스크립트(scripts\*.ps1)는 powershell.exe -ExecutionPolicy Bypass -File 로 호출한다. 공식 문서: "…This parameter does not change the PowerShell execution policy that's set in the registry." 레지스트리를 건드리지 않는다.

금지: Set-ExecutionPolicy -Scope LocalMachine Unrestricted. ⚠️ 한계: GPO 로 강제된 회사 PC 에서는 Bypass 도 무력하다("The Group Policy setting overrides the execution policies set in PowerShell in all scopes."). GPO 우회를 시도하지 않고 "IT 담당자에게 문의" 안내로 우아하게 실패한다. 이때도 GUI 자체는 정상 동작한다 — PowerShell 이 필요한 것은 install_tasks.ps1 / make_shortcuts.ps1 / bootstrap_agy.ps1 세 가지 보조 작업뿐이고, 그 실패는 각각 수동 대체 경로를 갖는다 (13절).

1.5 왜 DPAPI 파일인가 (ADR-13 보강)

방식 배치 읽기 다른 사용자로부터 보호 GUI 관리 채택
평문 .env 읽으면 끝. git 커밋·클라우드 동기화 사고 쉬움
사용자 환경변수 HKCU\Environment REG_SZ 평문. 자식 프로세스 상속·크래시 덤프 노출. setx 는 1024자에서 조용히 절단 보통
Credential Manager 내보내기·진단 불투명, keyring 의존성
DPAPI 파일 다른 계정·다른 PC 는 복호화 불가 파일 하나 — 존재/부재/손상을 GUI 가 즉시 표시, 초기화는 삭제 한 번

결정적 이점 3가지

  1. 의존성 0. ctypescrypt32.dll 직접 호출. ADR-20 의 "의존성 3개" 상한을 건드리지 않는다.
  2. 실패가 조용하다. entropy 불일치 → Win32 13, blob 손상 → Win32 87, 다른 PC/계정 → 복호화 불가. secrets_dpapi.load_service_key() 계약대로 전부 None 으로 강등되고, checks.py ④ 가 "키 미설정"과 동일하게 취급해 GUI 복구 경로로 보낸다.
  3. 요구 N6(이식성)과 일치한다. ADR-13 원문: "다른 PC로 폴더를 복사하면 키가 복호화되지 않는다. 요구 N6은 '폴더 복사 + 온보딩 재실행'으로 정의돼 있으므로 오히려 요구와 일치한다."

DataProtectionScope.LocalMachine 금지. 공식 경고: "Any process running on the computer can unprotect data." DPAPI 백서는 더 노골적이다 — "by using this flag no "real" protection is provided by DPAPI."

전역 상수 (전 모듈이 동일해야 함)

상수 정의 위치
비밀 파일 %LOCALAPPDATA%\DMF_Crawler\service_key.bin secrets_dpapi.py (§3.2)
DPAPI entropy DMF_Crawler/serviceKey/v1 (UTF-8) secrets_dpapi.py
agy 바이너리 %LOCALAPPDATA%\agy\bin\agy.exe agy/client.py (§3.11)
agy 토큰 %USERPROFILE%\.gemini\antigravity-cli\antigravity-oauth-token agy SSOT §4.2
작업 이름 DMF Crawler\Daily · \Agent · \AgyUpdate install_tasks.ps1

1.6 ⚠️ 스케줄러 — ADR-10 과 실측의 충돌

ADR-10 은 배치 작업을 S4U(비대화형) 로, UI 에이전트를 Interactive 로 분리한다. 그런데 실측 머신(ALPACA-HOME\encep, Microsoft 계정, Administrators 미소속)에서 3가지 LogonType 등록을 시도한 결과:

LogonType 결과
Password "사용자 이름 또는 암호가 올바르지 않습니다"
S4U "액세스가 거부되었습니다"
Interactive 등록 성공 (NextRunTime 확인)

원인은 MS 공식 문서에 명시돼 있다: "Tasks registered with the TASK_LOGON_PASSWORD or TASK_LOGON_S4U flag will only launch if the specified user has the Logon as Batch privilege enabled. Administrators and Backup Operators group users have this privilege enabled by default."

이 문서의 대응 — ADR-10 을 뒤집지 않고, 등록 코드에 폴백을 넣는다.

S4U 시도 → 성공하면 ADR-10 그대로 (로그오프 상태에서도 06:00 실행)
        → 실패하면 Interactive 로 폴백 + GUI 가 사용자에게 정직하게 고지:
          "이 컴퓨터에 로그인되어 있을 때만 자동 실행됩니다."

폴백해도 시스템은 온전하다. Interactive 세션은 사용자 프로필이 로드돼 있어 DPAPI 마스터키가 이미 해제돼 있고, agy OAuth 토큰(사용자 프로필 평문 파일)에도 접근할 수 있다. 오히려 두 비밀의 접근 조건이 동시에 만족된다.

이 충돌은 부록 B 에 등록한다. 01-architecture.md ADR-10 에 "S4U 등록 실패 시 Interactive 폴백" 한 줄을 추가해야 한다.


2. 이 문서가 따르는 구조 (01-architecture.md 발췌)

이 절은 참조 편의를 위한 발췌다. 정본은 01-architecture.md §2·§5 이며, 충돌이 생기면 그쪽이 이긴다.

2.1 이 문서가 명세하는 파일

파일 아키텍처상 책임 이 문서의 절
bootstrap.cmd 최초 설치 진입점. venv 생성 → pip install → 바로가기 생성 → 온보딩 GUI 기동 11.1
src\dmf_crawler\gui\app.py 온보딩=복구 단일 창. checks.py 결과를 체크리스트로 렌더 11.3
src\dmf_crawler\gui\steps.py 단계별 액션: 키 입력·발급페이지 열기·agy 설치·재로그인·작업 등록 11.4
src\dmf_crawler\gui\widgets.py 상태 뱃지·진행률 바·스크롤 프레임·복사 가능 로그 영역 11.5
src\dmf_crawler\checks.py 진단 엔진 12종 4절 · 11.2
src\dmf_crawler\secrets_dpapi.py DPAPI 암·복호화 11.6
scripts\make_shortcuts.ps1 DMF 설정.lnk / 지금 실행.lnk 생성 11.7
scripts\bootstrap_agy.ps1 agy 존재 확인 → 미설치 시 무인 설치 11.8
scripts\install_tasks.ps1 작업 3종 idempotent 등록 11.9

2.2 GUI 가 호출하는 CLI 명령 (§5 진입점 표)

명령 호출 주체 종료 코드
onboard --mode {setup,recover,inspect} [--focus <key>] [--run-now] 바로가기, notify/pump.py GUI 종료값
doctor [--json] [--fix-tasks] GUI 내부, 사람 0 전부 통과 / 1 WARN / 2 CRITICAL
run --trigger manual GUI "지금 실행" 0 SUCCESS·PARTIAL·SKIPPED / 1 FAILED / 2 BLOCKED / 130 중단
install-task --time 06:00 GUI 버튼, bootstrap.cmd 0 / 2
uninstall-task GUI "자동 실행 끄기" 0
version GUI 정보 화면 0

종료 코드 0 의 의미가 중요하다 — §5 규약: "SUCCESS / PARTIAL / SKIPPED — 리포트 파일이 존재한다". 즉 agy 가 죽어 AI 브리핑이 빠졌어도 리포트만 나왔으면 성공이다. GUI 는 이것을 "완료(일부 기능 생략)"로 표시하지 실패로 표시하지 않는다.

2.3 사용자가 보게 되는 경로

항목 경로
리포트 reports\DMF_리포트_YYYY-MM-DD.xlsx · reports\DMF_리포트_최신.xlsx
설정 config\config.toml
로그 logs\run_YYYYMMDD_HHMMSS\pipeline.log
상태 state\heartbeat.json · state\alerts.json
바로가기 바탕화면 + 프로젝트 루트의 DMF 설정.lnk · 지금 실행.lnk

3. 사용자 여정 (User Journey)

사용자는 개발 지식이 전혀 없다고 가정한다. 실측 기준선(00b-baseline-data-analysis.md)에 따라 전체 등록 건수는 9,084건이다.

3.1 최악 시나리오 — 아무것도 준비되지 않은 PC

# 사용자가 하는 행동 보이는 화면 소요 자동/수동
0 받은 DMF_Crawler.zipD:\workspace 나 문서 폴더에 압축 해제 탐색기 30초 수동
1 폴더 안 bootstrap.cmd 더블클릭 검은 콘솔 창 + 한국어 진행 메시지
2 [1/5] 파이썬을 찾는 중… 없음[1/5] 파이썬을 설치합니다 2~5분 자동 (winget, 관리자 권한 불필요)
3 [2/5] 실행 환경 준비 중… (venv + pip install) 30초~2분 자동
4 [3/5] 차단 해제[4/5] 바로가기 생성[5/5] 설정 창을 엽니다 5초 자동
5 콘솔 창이 스스로 닫히고 [S1] 준비 상태 체크리스트가 뜬다 3초
6 인증키 행의 [키 입력] 클릭 [S2] 인증키 등록
7 [발급 페이지 열기] → 브라우저 로그인 → [활용신청] → 자동승인 → 마이페이지에서 키 복사 브라우저 (마법사는 뒤로 물러남) 3~10분 수동 — 유일하게 사람만 할 수 있는 단계
8 마법사로 돌아와 [붙여넣기][저장] "인증키를 확인하는 중…" → ✔ "정상 확인됨 (전체 9,084건)" 3~6초 자동 검증
9 AGY 수집/AI 요약 행의 [설치하기] [S3] 진행률 + 접힌 로그 1~4분 (약 187 MB) 자동
10 AI 요약 사용 을 켠 사용자가 Google 로그인 행의 [로그인] 선택 [S4] 로그인 안내 → 검은 콘솔 1개 + 브라우저 1~3분 선택/반자동
11 브라우저에서 계정 선택 → 허용 마법사가 자동으로 ✔ 로 바뀜 (2초 폴링) 자동 감지
12 자동 실행 행의 [등록하기] [S5] 자동 실행 등록 → ✔ 2~5초 자동, UAC 없음
13 [S6] 준비 완료 — 큰 [지금 실행] 버튼
14 [지금 실행] [S7] 실행 중 진행률 (7 스테이지) 1~5분 자동
15 [S8] 완료 + [리포트 열기]

총 소요: 10~30분. 그중 사람이 실제로 판단해야 하는 시간은 7단계(키 발급)뿐이며 나머지는 버튼 한 번씩이다.

3.2 현실적 시나리오 — 파이썬과 agy 는 이미 있는 PC

# 행동 화면 소요
1 bootstrap.cmd 더블클릭 콘솔이 [1/5]~[5/5] 를 빠르게 지나감 40초
2 [S1] — 인증키와 자동 실행만 ✖ 3초
3 [키 입력] → 발급 → 붙여넣기 → 저장 [S2] → ✔ 3~10분
4 [등록하기] → [지금 실행] [S5] → [S7] → [S8] 2~6분

총 6~17분.

3.3 두 번째 이후 — 전부 준비된 상태

# 행동 화면 소요
1 DMF 설정.lnk 더블클릭 [S6] 준비 완료 (체크리스트를 건너뛴다) 2초
2 [지금 실행] 또는 [닫기]

일상적으로는 마법사를 열 일 자체가 없다. 06:00 배치가 알아서 돌고 사용자는 reports\DMF_리포트_최신.xlsx 만 본다. 마법사는 문제가 생겼을 때 notify/pump.py--mode recover --focus <check_key> 로 강제 기동한다 (12절).

3.4 사용자 인지 부하를 줄이는 5가지 규칙

  1. 기술 용어를 화면에 쓰지 않는다. "DPAPI", "OAuth", "venv", "ExecutionPolicy", "serviceKey", "returnReasonCode", "S4U" 전부 금지. → "인증키", "Google 로그인", "실행 환경", "이 컴퓨터에만 암호화해 저장".
  2. 한 화면에 결정 하나. 체크리스트에서 실패 행의 버튼은 행마다 하나뿐이다.
  3. 3초 넘는 작업에는 예외 없이 진행 표시. tkinter 는 단일 스레드라 작업을 워커 스레드로 빼지 않으면 창이 얼어붙는다 (11.5의 run_in_thread).
  4. 실패에 항상 다음 행동을 붙인다. fix_action 이 없는 체크는 등록 자체가 거부된다 — 계약으로 강제된다.
  5. 되돌릴 수 있게 한다. 키는 [키 다시 입력]으로 교체, 작업은 [자동 실행 끄기]로 해제, 리포트는 report-only 로 재생성.

4. 전제 조건 체크 항목 전체

01-architecture.md §3.15 가 확정한 12종이 정본이다. 이 문서는 항목을 새로 만들지 않고, 각 항목의 검사 코드 · 통과 기준 · 사용자 문구 · fix_action 을 확정한다.

4.1 요약표

checks.run_all() 은 이 순서로 실행한다. 앞 항목이 실패하면 뒤 항목은 ok=False, detail="앞 단계 확인 후 판정합니다" 로 회색 처리한다.

# key title (화면 표시) severity fix_action 자동 해결
python_venv 실행 환경 CRITICAL open_bootstrap_help 🟡 안내
dependencies 필요한 부품 CRITICAL install_deps
config_valid 설정 파일 CRITICAL open_config 🟡 안내
api_key_present 공식 API 인증키(선택) WARN enter_api_key
api_key_valid 공식 API 사용 가능 여부(선택) WARN enter_api_key
agy_installed AGY 수집/AI 요약 WARN install_agy
agy_auth AGY 로그인 WARN login_agy 🟡 반자동
database 자료 보관소 CRITICAL repair_db
tasks_registered 자동 실행 등록 WARN install_tasks
disk_space 저장 공간 CRITICAL open_cleanmgr
report_writable 리포트 저장 폴더 CRITICAL open_reports_dir 🟡
recent_runs 최근 실행 상태 WARN open_last_log 🟡

severity 의 의미

  • CRITICAL: 하나라도 실패면 [지금 실행] 이 비활성화된다. doctor 종료 코드 2.
  • WARN: 실패해도 실행은 가능하다. doctor 종료 코드 1. 공공데이터포털 인증키는 선택이며, 키가 없어도 기존 자료 리포트 생성은 계속된다. 수집용 agy 는 API 키가 없거나 browser 모드일 때 최신 수집에 필요하고, 요약용 agy 는 리포트의 부가 가치다 (ADR-15, 요구 R4.4). Google 로그인은 선택이지만 headful 브라우저 수집 또는 AI 요약을 쓸 때만 설치/로그인을 안내한다.

계약 (§3.17 R7.7): fix_actionNone 인 체크는 checks.py 등록 단계에서 ValueError 로 거부한다. 막다른 골목이 구조적으로 생길 수 없다.


4.2 항목별 상세

python_venv — 실행 환경

  • 무엇을: .venv 가 존재하고 그 인터프리터가 3.11 이상인가.
  • 검사:
    def _check_python_venv() -> CheckResult:
        import sys
        from .paths import VENV_PYTHONW
        ver = sys.version_info
        in_venv = sys.prefix != sys.base_prefix
        ok = ver >= (3, 11) and in_venv and VENV_PYTHONW.exists()
        return CheckResult(
            key="python_venv", title="실행 환경", ok=ok,
            detail=(f"Python {ver.major}.{ver.minor}.{ver.micro} · 전용 환경 사용 중"
                    if ok else
                    f"Python {ver.major}.{ver.minor} · 전용 실행 환경이 준비되지 않았습니다"),
            fix_hint="프로그램 폴더의 bootstrap.cmd 를 한 번 더 실행하면 자동으로 준비됩니다.",
            fix_action="open_bootstrap_help", severity=Severity.CRITICAL)
    
  • 통과 기준: 3.11+ 그리고 venv 안에서 실행 중 그리고 .venv\Scripts\pythonw.exe 존재.
  • 실패 문구: 프로그램 전용 실행 환경이 준비되지 않았습니다.\n\n프로그램 폴더의 bootstrap.cmd 를 한 번 더 실행해 주세요.
  • 자동 해결: 🟡 이 체크가 실패하면 GUI 자체가 못 떠 있을 가능성이 높다. 실질적으로는 bootstrap.cmd 가 이 상태를 먼저 처리한다 (11.1). GUI 안의 이 체크는 "venv 밖에서 손으로 실행한 개발자"용 안전망이다.
  • 버튼: [준비 방법 보기] → 탐색기로 폴더를 열고 bootstrap.cmd 를 선택 상태로 강조한다.

dependencies — 필요한 부품

  • 무엇을: 런타임 의존성 3개(httpx, XlsxWriter, jsonschema — §8.1)가 import 되는가.
  • 검사:
    _REQUIRED = [("httpx", "httpx"), ("xlsxwriter", "XlsxWriter"), ("jsonschema", "jsonschema")]
    
    def _check_dependencies() -> CheckResult:
        import importlib
        missing = []
        for mod, dist in _REQUIRED:
            try:
                importlib.import_module(mod)
            except Exception:
                missing.append(dist)
        ok = not missing
        return CheckResult(
            key="dependencies", title="필요한 부품", ok=ok,
            detail="모두 설치되어 있습니다" if ok else f"빠진 것: {', '.join(missing)}",
            fix_hint="[설치하기] 를 누르면 자동으로 내려받아 설치합니다. (약 30초~2분)",
            fix_action="install_deps", severity=Severity.CRITICAL)
    
  • 실패 문구: 프로그램 실행에 필요한 부품이 설치되지 않았습니다.\n빠진 것: httpx, XlsxWriter\n\n[설치하기] 를 누르면 자동으로 준비합니다.
  • 자동 해결: .venv\Scripts\python.exe -m pip install -e <PROJECT_ROOT> 를 워커 스레드에서 실행하고 출력을 [S3] 진행률 창의 로그 영역에 흘린다.

config_valid — 설정 파일

  • 무엇을: config\config.toml 이 존재하고 tomllib 로 파싱되며 Config 검증을 통과하는가.
  • 검사:
    def _check_config() -> CheckResult:
        from .config import load_config
        from .errors import ConfigError
        try:
            cfg = load_config()
        except FileNotFoundError:
            return CheckResult("config_valid", "설정 파일", False,
                "설정 파일이 없습니다.", "[기본값으로 만들기] 를 누르면 지금 만듭니다.",
                "open_config", Severity.CRITICAL)
        except ConfigError as e:
            return CheckResult("config_valid", "설정 파일", False,
                f"설정 파일에 문제가 있습니다: {e}",
                "[설정 파일 열기] 를 눌러 해당 줄을 고치거나, [기본값으로 되돌리기] 를 누르세요.",
                "open_config", Severity.CRITICAL)
        return CheckResult("config_valid", "설정 파일", True,
            f"정상 · 실행 시각 {cfg.schedule.daily_time}", "",
            "open_config", Severity.CRITICAL)
    
  • 실패 문구: 설정 파일을 읽을 수 없습니다.\n\n<파싱 오류 원문>\n\n[설정 파일 열기] 를 누르면 메모장으로 열립니다.\n[기본값으로 되돌리기] 를 누르면 처음 상태로 복구합니다.
  • 자동 해결: 🟡 [기본값으로 되돌리기] 는 기존 파일을 config.toml.bak.YYYYMMDD_HHMMSS 로 옮긴 뒤 배포본을 복사한다. 덮어쓰기 전에 반드시 백업한다.

api_key_present — 공식 API 인증키(선택)

  • 무엇을: 공식 API 최신 수집을 켤 인증키가 DPAPI 파일에 있고 복호화까지 되는가. 파일이 있어도 다른 PC 에서 복사됐으면 못 푼다.
  • 정책: 공공데이터포털 인증키는 선택이다. 실패해도 [지금 실행] 을 막지 않으며, 키가 없어도 기존 자료 리포트 생성은 계속된다.
  • 검사:
    def _check_api_key_present() -> CheckResult:
        from .secrets_dpapi import load_service_key, key_fingerprint, SECRET_PATH
        key = load_service_key()          # 계약: 없거나 복호화 실패 시 None
        if key:
            return CheckResult("api_key_present", "공식 API 인증키(선택)", True,
                f"등록됨 (식별번호 {key_fingerprint()})", "",
                "enter_api_key", Severity.WARN)
        detail = ("저장된 인증키를 읽을 수 없습니다. 공식 API 자동 수집만 비활성화됩니다."
                  if SECRET_PATH.exists() else "선택 항목입니다. 아직 등록되지 않았습니다.")
        return CheckResult("api_key_present", "공식 API 인증키(선택)", False, detail,
            "공식 API 경로가 필요할 때만 [키 입력] 으로 등록하세요. 키가 없어도 기존 자료 리포트 생성은 막지 않습니다.",
            "enter_api_key", Severity.WARN)
    
  • 표시 규칙: 원문을 절대 화면에 쓰지 않는다. key_fingerprint()(sha256 앞 8자)만 보여준다 — 요구 N7, §3.2 계약.
  • 자동 해결: [S2] 입력 다이얼로그 (7절).

api_key_valid — 공식 API 사용 가능 여부(선택)

  • 무엇을: 저장된 키가 지금 실제로 동작하는가. 서비스키는 회원당 1개뿐이고 재발급하면 기존 키가 자동 폐기된다. 이 실패는 공식 API 최신 수집만 비활성화하며, 기존 자료 리포트 생성은 막지 않는다.
  • 검사: numOfRows=1&type=json 으로 1건 조회 (7.4의 validate_service_key).
  • 통과 기준: resultCode == "00", 또는 게이트웨이 코드 22/23(쿼터 초과 — 키는 유효).
  • 호출 비용: 하루 10,000 호출 한도 중 1회.
  • ⚠️ 캐싱 규칙: 마법사를 열 때마다 호출하면 낭비다. state\heartbeat.jsonapi_key_verified_at12시간 이내면 재호출하지 않고 ✔ 로 표시한다. [다시 검사] 는 캐시를 무시하고 강제 재검증한다.
    def _check_api_key_valid(cfg, force: bool = False) -> CheckResult:
        from .state import read_heartbeat
        from .secrets_dpapi import load_service_key
        hb = read_heartbeat()
        last = hb.get("api_key_verified_at")
        if not force and last and _age_hours(last) < 12:
            return CheckResult("api_key_valid", "공식 API 사용 가능 여부(선택)", True,
                f"{_fmt_ago(last)} 확인됨", "", "enter_api_key", Severity.WARN)
        key = load_service_key()
        if not key:
            return CheckResult("api_key_valid", "공식 API 사용 가능 여부(선택)", False,
                "선택 항목입니다. 키가 없어 공식 API 실호출 확인을 건너뜁니다.", "공식 API 자동 수집이 필요할 때만 인증키를 등록해 주세요.",
                "enter_api_key", Severity.WARN)
        res = validate_service_key(key, cfg)          # 7.4
        ...
    
  • 실패 문구: 코드별로 다르다 (7.6 표).
  • 자동 해결: 재입력 다이얼로그.

agy_installed — AGY 수집/AI 요약

  • 무엇을: %LOCALAPPDATA%\agy\bin\agy.exe 실물이 있고 실행되는가.
  • 검사:
    AGY_EXE = Path(os.environ["LOCALAPPDATA"]) / "agy" / "bin" / "agy.exe"
    
    def _check_agy_installed() -> CheckResult:
        ok, ver = False, None
        if AGY_EXE.exists() and AGY_EXE.stat().st_size > 1_000_000:   # 스텁·중단 다운로드 방지
            env = {**os.environ, "AGY_CLI_DISABLE_AUTO_UPDATE": "true"}
            try:
                p = subprocess.run([str(AGY_EXE), "--version"], capture_output=True,
                                   text=True, timeout=30, env=env,
                                   creationflags=subprocess.CREATE_NO_WINDOW)
                ok = (p.returncode == 0 and bool(p.stdout.strip()))
                ver = p.stdout.strip().splitlines()[0] if ok else None
            except Exception:
                ok = False
        return CheckResult("agy_installed", "AGY 수집/AI 요약", ok,
            f"설치됨 (버전 {ver})" if ok else "설치되어 있지 않습니다.",
            "[설치하기] 를 누르면 자동 설치합니다. (약 190 MB, 1~4분)\n"
            "설치하지 않아도 리포트는 정상적으로 만들어집니다. AI 요약만 빠집니다.",
            "install_agy", severity=Severity.WARN)
    
  • 통과 기준: --version 종료 코드 0 + 버전 문자열. 실측 1.1.24, 파일 크기 187,601,560 bytes.
  • ⚠️ where.exe agy 를 쓰지 않는다. winget Links 심볼릭까지 잡혀 두 경로가 나온다(agy SSOT §3.2 실측 함정). 절대 경로 고정이 정답이며 agy/client.py 의 호출 규약과도 일치한다.
  • 차단성: WARN. 실패 문구의 마지막 문장이 핵심이다 — 사용자가 여기서 포기하지 않게 한다.

agy_auth — Google 로그인

  • 무엇을: OAuth 인증이 살아 있는가. §3.15 계약: "최근 agy_calls 기반 판정, 헬스체크 호출 안 함".
  • 검사 — 2단 판정:
    AGY_TOKEN = Path.home() / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
    
    def _check_agy_auth(cfg) -> CheckResult:
        # 1) 최근 agy 호출 결과가 AUTH 실패였는가 (가장 신뢰할 수 있는 신호)
        from .storage.repo import last_agy_error_kind
        if last_agy_error_kind(within_days=3) == "AUTH":
            return CheckResult("agy_auth", "Google 로그인", False,
                "지난 실행에서 로그인이 만료된 것으로 확인되었습니다.",
                "[로그인] 을 눌러 다시 로그인해 주세요. (최초 1회 방식과 동일)",
                "login_agy", Severity.WARN)
        # 2) 토큰 파일 존재 여부 (호출 이력이 없을 때의 대체 신호)
        ok = AGY_TOKEN.exists() and AGY_TOKEN.stat().st_size > 50
        return CheckResult("agy_auth", "Google 로그인", ok,
            "로그인되어 있습니다" if ok else "최초 1회 로그인이 필요합니다.",
            "[로그인] 을 누르면 검은 창과 브라우저가 열립니다.\n"
            "로그인하지 않아도 리포트는 정상적으로 만들어집니다.",
            "login_agy", Severity.WARN)
    
  • 통과 기준: 최근 3일 내 AUTH 오류가 없고, 토큰 파일이 50바이트 초과. (실측 정상 크기 504 bytes)
  • ⚠️agy -p 로 실제 확인하지 않는가: 첫 호출 오버헤드가 input 28,317 토큰 / 33.7초다(agy SSOT §4.3 실측). ADR-16 이 못박은 원칙 — "인증 상태는 별도 헬스체크가 아니라 실제 작업 호출의 결과로 판정한다." 이 검사가 agy_calls 테이블을 먼저 보는 이유다.

database — 자료 보관소

  • 무엇을: data\dmf.sqlite3 존재 · PRAGMA integrity_check · schema_version 이 코드가 기대하는 버전과 일치하는가.
  • 검사:
    def _check_database(cfg) -> CheckResult:
        from .storage.db import connect, current_schema_version, EXPECTED_SCHEMA_VERSION
        from .paths import DB_PATH
        if not DB_PATH.exists():
            return CheckResult("database", "자료 보관소", False,
                "아직 만들어지지 않았습니다. (처음 실행 시 자동 생성)",
                "[준비하기] 를 누르면 지금 만듭니다.", "repair_db", Severity.CRITICAL)
        try:
            with connect(DB_PATH) as con:
                if con.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
                    return CheckResult("database", "자료 보관소", False,
                        "자료 파일이 손상되었습니다.",
                        "[복구하기] 를 누르면 가장 최근 백업본으로 되돌립니다.\n"
                        "되돌린 뒤 [지금 실행] 을 누르면 오늘 자료를 다시 받습니다.",
                        "repair_db", Severity.CRITICAL)
                ver = current_schema_version(con)
        except Exception as e:
            return CheckResult("database", "자료 보관소", False,
                f"자료 파일을 열 수 없습니다: {type(e).__name__}",
                "[복구하기] 를 눌러 주세요.", "repair_db", Severity.CRITICAL)
        if ver < EXPECTED_SCHEMA_VERSION:
            return CheckResult("database", "자료 보관소", False,
                f"자료 형식 갱신이 필요합니다. (현재 {ver} → 필요 {EXPECTED_SCHEMA_VERSION})",
                "[갱신하기] 를 누르면 백업을 먼저 뜬 뒤 갱신합니다.",
                "repair_db", Severity.CRITICAL)
        return CheckResult("database", "자료 보관소", True, f"정상 (형식 {ver})", "",
            "repair_db", Severity.CRITICAL)
    
  • 자동 해결: repair_db 액션이 상황에 따라 db migrate(ADR-04: 적용 직전 VACUUM INTO 백업) 또는 backup\ 최신본 복원을 수행한다.

tasks_registered — 자동 실행 등록

  • 무엇을: 작업 3종(Daily / Agent / AgyUpdate)이 등록돼 있고, 가리키는 실행 경로가 현재 폴더와 일치하는가.
  • 검사:
    TASKS = [r"\DMF Crawler\Daily", r"\DMF Crawler\Agent", r"\DMF Crawler\AgyUpdate"]
    
    def _check_tasks(cfg) -> CheckResult:
        from .paths import PROJECT_ROOT
        missing, stale = [], []
        for t in TASKS:
            p = subprocess.run(["schtasks.exe", "/Query", "/TN", t, "/XML"],
                               capture_output=True, text=True, encoding="utf-16-le",
                               errors="replace", creationflags=subprocess.CREATE_NO_WINDOW)
            name = t.rsplit("\\", 1)[-1]
            if p.returncode != 0:
                missing.append(name); continue
            # 폴더를 옮기면 태스크가 유령이 된다 → 경로 일치까지 확인
            if str(PROJECT_ROOT).lower() not in (p.stdout or "").lower():
                stale.append(name)
        if missing:
            return CheckResult("tasks_registered", "자동 실행 등록", False,
                f"등록되지 않았습니다. (빠진 것: {', '.join(missing)})",
                "[등록하기] 를 누르면 바로 설정됩니다. 관리자 권한은 필요 없습니다.",
                "install_tasks", Severity.WARN)
        if stale:
            return CheckResult("tasks_registered", "자동 실행 등록", False,
                "예전 폴더를 가리키고 있습니다. 프로그램 폴더를 옮기신 것 같습니다.",
                "[다시 등록] 을 눌러 지금 폴더로 갱신해 주세요.",
                "install_tasks", Severity.WARN)
        return CheckResult("tasks_registered", "자동 실행 등록", True,
            _next_run_text(), "", "install_tasks", Severity.WARN)
    
  • STALE_PATH 는 실무에서 반드시 생긴다. 사용자가 폴더를 바탕화면에서 문서로 옮기는 순간 태스크는 존재하지만 유령이 된다. 매일 06:00 에 조용히 실패하고 아무도 모른다.

disk_space — 저장 공간

  • 무엇을: agy(187 MB) + 파이썬(150 MB) + 스냅샷·백업 누적 여유.
  • 검사:
    def _check_disk(cfg) -> CheckResult:
        import shutil
        from .paths import PROJECT_ROOT
        free_gb = shutil.disk_usage(PROJECT_ROOT).free / (1024 ** 3)
        need = cfg.general.min_free_gb if cfg else 2.0
        ok = free_gb >= need
        return CheckResult("disk_space", "저장 공간", ok,
            f"여유 {free_gb:.1f} GB" + ("" if ok else f" (최소 {need:.0f} GB 필요)"),
            "[디스크 정리] 를 눌러 공간을 확보한 뒤 [다시 검사] 를 눌러 주세요.",
            "open_cleanmgr", Severity.CRITICAL)
    
  • 통과 기준: 2.0 GB 이상 (config.tomlgeneral.min_free_gb).
  • 버튼: [디스크 정리] → cleanmgr.exe.

report_writable — 리포트 저장 폴더

  • 무엇을: reports\ 에 쓸 수 있는가. 그리고 오늘 자 리포트가 Excel 에 잠겨 있지 않은가.
  • 검사:
    def _check_report_writable(cfg) -> CheckResult:
        from .paths import REPORTS_DIR
        from datetime import date
        try:
            REPORTS_DIR.mkdir(parents=True, exist_ok=True)
            probe = REPORTS_DIR / f".w_{os.getpid()}.tmp"
            probe.write_text("ok", encoding="utf-8"); probe.unlink()
        except Exception as e:
            return CheckResult("report_writable", "리포트 저장 폴더", False,
                f"폴더에 파일을 만들 수 없습니다: {type(e).__name__}",
                "[폴더 열기] 로 위치를 확인하고, 쓰기가 가능한 곳으로 프로그램을 옮겨 주세요.",
                "open_reports_dir", Severity.CRITICAL)
        today = REPORTS_DIR / f"DMF_리포트_{date.today():%Y-%m-%d}.xlsx"
        if today.exists() and _is_locked(today):
            return CheckResult("report_writable", "리포트 저장 폴더", False,
                f"오늘 자 리포트가 Excel 에서 열려 있습니다.\n{today.name}",
                "Excel 을 닫은 뒤 [다시 검사] 를 눌러 주세요.\n"
                "닫지 않아도 실행은 되지만 다른 이름으로 저장됩니다.",
                "open_reports_dir", Severity.CRITICAL)
        return CheckResult("report_writable", "리포트 저장 폴더", True,
            str(REPORTS_DIR), "", "open_reports_dir", Severity.CRITICAL)
    
    def _is_locked(p: Path) -> bool:
        try:
            with open(p, "r+b"):
                return False
        except OSError:
            return True
    
  • 파일명을 정확히 보여주는 것이 핵심이다. "어떤 파일인지" 모르면 사용자는 못 닫는다.
  • 참고: report/atomic.py 가 잠김 시 폴백 파일명으로 저장하므로(§3.13) 실행 자체는 실패하지 않는다. 문구에 이 사실을 명시해 사용자를 안심시킨다.

recent_runs — 최근 실행 상태

  • 무엇을: state\heartbeat.json 이 신선한가. 연속 실패가 쌓이고 있지 않은가.
  • 검사:
    def _check_recent_runs(cfg) -> CheckResult:
        from .state import read_heartbeat
        from .storage.repo import consecutive_failures
        hb = read_heartbeat()
        if not hb.get("last_success_at"):
            return CheckResult("recent_runs", "최근 실행 상태", True,
                "아직 한 번도 실행하지 않았습니다.", "",
                "open_last_log", Severity.WARN)
        age_h = _age_hours(hb["last_success_at"])
        fails = consecutive_failures()
        stale_h = cfg.watchdog.stale_hours if cfg else 30
        if age_h > stale_h:
            return CheckResult("recent_runs", "최근 실행 상태", False,
                f"마지막 성공이 {age_h/24:.1f}일 전입니다. 연속 실패 {fails}회.",
                "[로그 보기] 로 원인을 확인하거나 [지금 실행] 으로 직접 돌려 보세요.",
                "open_last_log", Severity.WARN)
        return CheckResult("recent_runs", "최근 실행 상태", True,
            f"마지막 성공 {_fmt_ago(hb['last_success_at'])}", "",
            "open_last_log", Severity.WARN)
    
  • 이 체크의 존재 이유: 06:00 배치가 조용히 실패하는 것이 이 시스템의 가장 위험한 실패 모드다. 사용자가 마법사를 열었을 때 즉시 보이게 한다.

4.3 인터넷 연결은 왜 독립 체크가 아닌가

12종에 "인터넷 연결"이 없다. 의도적이다. 연결 여부는 그 자체로 의미가 없고, "무엇에 연결이 안 되는가"만이 의미가 있다.api_key_valid 가 실패하면 그 안에서 원인을 나눠 표시한다.

# validate_service_key() 의 반환 kind 에 따라 문구가 갈린다
if res.kind == "network":
    detail = ("인터넷에 연결되어 있지 않거나 회사 방화벽이 접속을 막고 있습니다.\n"
              "· Wi-Fi / 유선 연결을 확인해 주세요.\n"
              "· 회사 PC 라면 IT 담당자에게 apis.data.go.kr (443) 허용을 요청해 주세요.")

agy_installed 의 설치 실패도 같은 방식으로 antigravity.google (443) 을 지목한다. [주소 복사] 버튼으로 IT 담당자에게 보낼 문구를 클립보드에 담아 준다.


5. 화면 설계 (ASCII 와이어프레임)

모든 창은 화면 중앙 상단 1/3 지점, 크기 고정(resizable(False, False)), 폰트 맑은 고딕(제목 16pt Bold / 본문 10pt). ttk 테마는 Windows 에서 vista 를 쓴다. DPI 는 Tk 루트 생성 전에 SetProcessDpiAwarenessContext(-4) 로 PerMonitorV2 를 켠다 (11.5).

상태 아이콘: 통과(#167A3C) / 실패·CRITICAL(#BE2828) / 실패·WARN(#C77700) / 검사 중(회색) / 판정 보류(연회색)


[S0] bootstrap.cmd 최초 실행 — 콘솔 (80 × 25)

이 프로젝트에서 콘솔이 보이는 첫 번째 설치 화면이다. 영어 로그가 쏟아지지 않게 >nul 로 덮고 한국어 진행 표시만 남긴다.

┌─ DMF 크롤러 설치 ────────────────────────────────────────────────┐
│                                                                  │
│  ================================================                │
│    DMF 크롤러 최초 설치                                          │
│  ================================================                │
│                                                                  │
│  이 창은 설치가 끝나면 자동으로 닫힙니다.                        │
│  처음 한 번만 나타납니다.                                        │
│                                                                  │
│  [1/5] 파이썬을 찾는 중...                                       │
│        찾음: Python 3.12 (C:\Users\...\Python312\python.exe)     │
│  [2/5] 실행 환경을 준비하는 중... (30초~2분)                     │
│        완료                                                      │
│  [3/5] 파일 차단을 해제하는 중...                                │
│        완료                                                      │
│  [4/5] 바탕화면 바로가기를 만드는 중...                          │
│        완료                                                      │
│  [5/5] 설정 창을 엽니다...                                       │
│                                                                  │
│  설치가 끝났습니다. 잠시 후 설정 창이 열립니다.                  │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

파이썬이 없을 때 [1/5] 가 이렇게 바뀐다.

│  [1/5] 파이썬을 찾는 중...                                       │
│        설치되어 있지 않습니다.                                   │
│                                                                  │
│        파이썬을 지금 설치할까요? (약 2~5분)                      │
│        관리자 권한은 필요하지 않습니다.                          │
│                                                                  │
│        [Y] 예, 설치합니다    [N] 아니오, 직접 설치하겠습니다      │
│        선택 > _                                                  │

[S1] 메인 상태 체크리스트 — --mode setup (780 × 600)

┌────────────────────────────────────────────────────────────────────────────┐
│ 🧪  DMF 크롤러 설정                                            [─] [×]     │
├────────────────────────────────────────────────────────────────────────────┤
│                                                                            │
│   DMF 크롤러 준비 상태                                                      │
│   ✖ 표시된 항목의 오른쪽 버튼을 눌러 하나씩 해결해 주세요.  (7 / 12 완료)   │
│                                                                            │
│  ┌──────────────────────────────────────────────────────────────────────┐  │
│  │ ✔  실행 환경                                                         │  │
│  │    Python 3.12.7 · 전용 환경 사용 중                                 │  │
│  ├──────────────────────────────────────────────────────────────────────┤  │
│  │ ✔  필요한 부품                                                       │  │
│  │    모두 설치되어 있습니다                                            │  │
│  ├──────────────────────────────────────────────────────────────────────┤  │
│  │ ✔  설정 파일                                                         │  │
│  │    정상 · 실행 시각 06:00                                            │  │
│  ├──────────────────────────────────────────────────────────────────────┤  │
│  │ ✖  공공데이터포털 인증키                     ┌────────────────────┐  │  │
│  │    아직 등록되지 않았습니다.                 │     키 입력        │  │  │
│  │                                             └────────────────────┘  │  │
│  ├──────────────────────────────────────────────────────────────────────┤  │
│  │   인증키 사용 가능 여부                                             │  │
│  │    인증키 등록 후 확인합니다.                                        │  │
│  ├──────────────────────────────────────────────────────────────────────┤  │
│  │ ▲  AGY 수집/AI 요약  (상황에 따라 필요)      ┌────────────────────┐  │  │
│  │    설치되어 있지 않습니다.                   │     설치하기       │  │  │
│  │                                             └────────────────────┘  │  │
│  ├──────────────────────────────────────────────────────────────────────┤  │
│  │ ▲  AGY 로그인     (상황에 따라 필요)         ┌────────────────────┐  │  │
│  │    최초 1회 로그인이 필요합니다.             │      로그인        │  │  │
│  │                                             └────────────────────┘  │  │
│  ├──────────────────────────────────────────────────────────────────────┤  │
│  │ ✔  자료 보관소            정상 (형식 3)                              │  │
│  ├──────────────────────────────────────────────────────────────────────┤  │
│  │ ▲  자동 실행 등록                            ┌────────────────────┐  │  │
│  │    등록되지 않았습니다.                      │     등록하기       │  │  │
│  │                                             └────────────────────┘  │  │
│  ├──────────────────────────────────────────────────────────────────────┤  │
│  │ ✔  저장 공간              여유 184.2 GB                              │  │
│  ├──────────────────────────────────────────────────────────────────────┤  │
│  │ ✔  리포트 저장 폴더       D:\workspace\DMF_Crawler\reports           │  │
│  ├──────────────────────────────────────────────────────────────────────┤  │
│  │ ✔  최근 실행 상태         아직 한 번도 실행하지 않았습니다.           │  │ ▼
│  └──────────────────────────────────────────────────────────────────────┘  │
│                                                                            │
│  ┌───────────────┐ ┌───────────┐ ┌──────────────┐          ┌───────────┐  │
│  │  지금 실행    │ │ 다시 검사 │ │  진단 결과   │          │   닫기    │  │
│  │  (비활성)     │ │           │ │  복사하기    │          │           │  │
│  └───────────────┘ └───────────┘ └──────────────┘          └───────────┘  │
└────────────────────────────────────────────────────────────────────────────┘

설계 포인트

  • (7 / 12 완료) 를 항상 보여준다. "얼마나 남았는지"를 모르는 것이 가장 큰 불안이다.
  • 버튼은 실패한 행에만 나타난다. 통과한 행은 버튼이 없어 시선이 자연스럽게 실패 항목으로 간다.
  • WARN(▲) 항목 제목에 (없어도 됨) 을 붙인다. 사용자가 여기서 막혀 포기하는 것을 막는 유일한 방법이다.
  • [진단 결과 복사하기]doctor --json 출력을 클립보드에 담는다. 사용자가 도움을 요청할 때 붙여넣을 것을 만들어 준다 — 막다른 골목 방지의 마지막 장치다.
  • [지금 실행]CRITICAL 이 모두 통과일 때만 활성화된다. WARN 은 막지 않는다.

[S1-R] 복구 모드 — --mode recover --focus <key> (780 × 480)

notify/pump.py 가 CRITICAL 알림을 만나 강제 기동한 창. 전체 목록 대신 문제 항목만 크게 보여준다.

┌────────────────────────────────────────────────────────────────────────────┐
│ ⚠  DMF 크롤러 — 확인이 필요합니다                              [─] [×]     │
├────────────────────────────────────────────────────────────────────────────┤
│                                                                            │
│   오늘 아침 자동 실행이 완료되지 못했습니다.                                │
│                                                                            │
│  ┌──────────────────────────────────────────────────────────────────────┐  │
│  │  ✖   공공데이터포털 인증키                                           │  │
│  │                                                                      │  │
│  │  무엇이       저장된 인증키가 더 이상 사용되지 않습니다.              │  │
│  │  왜           공공데이터포털에서 인증키를 새로 발급하면              │  │
│  │               예전 키는 자동으로 사라집니다.                         │  │
│  │  어떻게       마이페이지에서 현재 인증키를 복사해 다시 등록하세요.    │  │
│  │  다음 행동    아래 [키 다시 입력] 을 눌러 주세요. 1분이면 됩니다.     │  │
│  │                                                                      │  │
│  │        ┌──────────────────┐  ┌────────────────────────┐             │  │
│  │        │   키 다시 입력   │  │   마이페이지 열기      │             │  │
│  │        └──────────────────┘  └────────────────────────┘             │  │
│  └──────────────────────────────────────────────────────────────────────┘  │
│                                                                            │
│   ※ 어제까지의 리포트는 그대로 남아 있습니다.                               │
│      해결하면 [지금 실행] 으로 오늘 자료를 바로 받을 수 있습니다.           │
│                                                                            │
│  ┌────────────────┐ ┌──────────────────┐              ┌───────────────┐   │
│  │  전체 상태 보기│ │  나중에 하기     │              │ 리포트 폴더   │   │
│  └────────────────┘ └──────────────────┘              └───────────────┘   │
└────────────────────────────────────────────────────────────────────────────┘

문구 4요소(무엇/왜/어떻게/다음 행동)notify/messages.py 의 템플릿 규격 그대로다 (§3.16 · docs/ops/02-failure-alerting.md). 이 창은 그 템플릿의 렌더러일 뿐 별도 문구 체계를 만들지 않는다.


[S2] 인증키 등록 (720 × 460)

┌──────────────────────────────────────────────────────────────────────┐
│ 🔑  공공데이터포털 인증키 등록                                 [×]   │
├──────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  공공데이터포털에서 발급받은 인증키를 붙여넣어 주세요.                 │
│                                                                      │
│  아직 인증키가 없다면                                                 │
│   1. 아래 [발급 페이지 열기] 를 누릅니다.                             │
│   2. 로그인한 뒤 [활용신청] 버튼을 누릅니다. (무료, 즉시 승인)         │
│   3. 마이페이지 > 데이터 활용 > Open API > 활용신청 현황 에서          │
│      "일반 인증키" 를 통째로 복사합니다.                              │
│   4. 여기로 돌아와 아래 칸에 붙여넣습니다.                            │
│                                                                      │
│  인증키                                                               │
│  ┌────────────────────────────────────────────────┐  ┌────────────┐ │
│  │ ●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●● │  │ ☐ 표시     │ │
│  └────────────────────────────────────────────────┘  └────────────┘ │
│                                                                      │
│  ┌──────────────┐  ┌────────────────────┐  ┌───────────────────┐    │
│  │  붙여넣기    │  │  발급 페이지 열기  │  │  마이페이지 열기  │    │
│  └──────────────┘  └────────────────────┘  └───────────────────┘    │
│                                                                      │
│  ┌────────────────────────────────────────────────────────────────┐ │
│  │  ⋯ 인증키를 확인하는 중입니다…                                  │ │
│  └────────────────────────────────────────────────────────────────┘ │
│                                                                      │
│  인증키는 이 컴퓨터의 사용자 계정으로 암호화되어 저장됩니다.           │
│  다른 컴퓨터로 파일을 복사해도 열리지 않습니다.                       │
│                                                                      │
│                              ┌────────────┐  ┌────────────┐         │
│                              │   저장     │  │   취소     │         │
│                              └────────────┘  └────────────┘         │
└──────────────────────────────────────────────────────────────────────┘

상태 표시줄의 4가지 상태

  ⋯ 인증키를 확인하는 중입니다…                          ← 검증 진행 (회색)
  ✔ 정상 확인되었습니다. (전체 9,084건)                   ← 성공 (녹색)
  ▲ 오늘 조회 횟수를 다 썼습니다. 인증키 자체는 정상입니다. ← 저장함 (주황)
  ✖ 등록되지 않은 인증키입니다.                           ← 실패 (빨강)
     마이페이지에서 현재 인증키를 다시 복사해 주세요.

[S3] 진행률 창 — AGY 설치 / 부품 설치 공용 (660 × 420)

┌────────────────────────────────────────────────────────────────────┐
│ ⬇  AGY 수집/AI 요약 설치                                           │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│  Antigravity CLI 를 내려받아 설치하고 있습니다.                     │
│  약 190 MB 이며 인터넷 속도에 따라 1~4분 정도 걸립니다.              │
│                                                                    │
│  ████████████████████████████░░░░░░░░░░░░░░░░░░░░  58 %             │
│                                                                    │
│  진행 내용                                    ┌──────────────────┐ │
│  ┌──────────────────────────────────────────┐ │  ☐ 자세히 보기   │ │
│  │ [1/4] 설치 스크립트를 받는 중…      ✔    │ └──────────────────┘ │
│  │ [2/4] 프로그램 내려받는 중…        58 %  │                      │
│  │ [3/4] 파일 검사                         │                      │
│  │ [4/4] 설치 확인                         │                      │
│  └──────────────────────────────────────────┘                      │
│                                                                    │
│  ┌────────────────────────────────────────────────────────────────┐│
│  │ Downloading manifest windows_amd64.json ...                    ││ ← [자세히
│  │ Verifying SHA512 ...                                           ││   보기] 체크
│  │ Copying to C:\Users\...\AppData\Local\agy\bin\agy.exe          ││   시에만 표시
│  └────────────────────────────────────────────────────────────────┘│
│                                                                    │
│                                              ┌────────────┐        │
│                                              │   취소     │        │
│                                              └────────────┘        │
└────────────────────────────────────────────────────────────────────┘

설계 포인트: 원시 로그는 기본으로 숨긴다. 영어 로그가 쏟아지면 비개발자는 "뭔가 잘못됐다"고 느낀다. 대신 4단계 한국어 요약을 보여주고 원문은 [자세히 보기]에 접어 둔다. 실패하면 로그가 자동으로 펼쳐진다.


[S4] Google 로그인 유도 (700 × 460)

┌──────────────────────────────────────────────────────────────────────┐
│ 🔓  Google 계정 로그인                                               │
├──────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  AI 요약 기능을 쓰려면 Google 계정 로그인이 필요합니다.                │
│  이 작업은 최초 1회만 하면 됩니다.                                    │
│                                                                      │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │  1  아래 [로그인 창 열기] 를 누릅니다.                          │  │
│  │  2  검은 창이 하나 열리고, 잠시 뒤 브라우저가 뜹니다.            │  │
│  │  3  브라우저에서 Google 계정을 선택하고 [허용] 을 누릅니다.      │  │
│  │  4  이 화면이 자동으로 "완료" 로 바뀝니다.                      │  │
│  │  5  검은 창은 그때 닫으셔도 됩니다.                             │  │
│  └────────────────────────────────────────────────────────────────┘  │
│                                                                      │
│  ┌───────────────────────┐                                           │
│  │   로그인 창 열기      │                                           │
│  └───────────────────────┘                                           │
│                                                                      │
│  상태                                                                 │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │  ⋯ 로그인이 끝나기를 기다리는 중입니다…      (남은 시간 4:12)   │  │
│  └────────────────────────────────────────────────────────────────┘  │
│                                                                      │
│  브라우저가 안 열리나요?                                              │
│  검은 창에 https:// 로 시작하는 주소가 보이면 그 줄을 복사해          │
│  브라우저 주소창에 붙여넣어 주세요.                                   │
│                                                                      │
│                       ┌──────────────┐  ┌────────────────────┐       │
│                       │  나중에 하기 │  │  다 했어요 (확인)  │       │
│                       └──────────────┘  └────────────────────┘       │
└──────────────────────────────────────────────────────────────────────┘

설계 포인트

  • "검은 창이 열립니다"를 미리 예고한다. 예고 없이 콘솔이 뜨면 사용자는 바이러스로 의심한다. 이 프로젝트에서 콘솔이 정당하게 등장하는 두 번째이자 마지막 지점이다.
  • 자동 폴링(2초)으로 감지하되 [다 했어요] 수동 버튼도 둔다. 폴링이 실패해도 사용자가 진행할 수 있어야 한다.
  • [나중에 하기] 를 반드시 제공한다. agy 는 WARN 항목이다. 다만 source.mode=auto 에서 API 키가 없으면 최신 수집은 headful AGY 경로를 쓰므로, 사용자가 나중에 하기를 누르면 기존 성공 자료 리포트로 폴백할 수 있음을 자연스럽게 알려준다.
  • 브라우저 자동 실행 실패 대비 안내를 미리 적어 둔다 (SSH·원격 세션에서는 manual URL loop 로 빠진다 — agy SSOT §4.1).

[S5] 자동 실행 등록 (680 × 480)

┌────────────────────────────────────────────────────────────────────┐
│ ⏰  매일 아침 자동 실행 설정                                       │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│  매일 아침 정해진 시각에 DMF 자료를 받아 리포트를 만듭니다.          │
│                                                                    │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  실행 시각      ┌──────────┐                                 │  │
│  │                 │  06:00 ▾ │   (05:00 ~ 09:00)               │  │
│  │                 └──────────┘                                 │  │
│  │                                                              │  │
│  │  ☑ 컴퓨터가 꺼져 있어 놓친 경우, 켜진 뒤 바로 실행            │  │
│  │  ☑ 노트북 배터리로 동작 중일 때도 실행                        │  │
│  │  ☐ 실행 시각에 컴퓨터를 절전에서 깨우기                       │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                    │
│  함께 등록되는 것                                                   │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  · 자료 수집          매일 06:00                              │  │
│  │  · 알림 확인          로그인 중 15분마다                      │  │
│  │  · AI 프로그램 갱신   매주 일요일 04:00                       │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                    │
│  관리자 권한은 필요하지 않습니다.                                   │
│                                                                    │
│                         ┌────────────┐  ┌────────────┐             │
│                         │   등록     │  │   취소     │             │
│                         └────────────┘  └────────────┘             │
└────────────────────────────────────────────────────────────────────┘

등록 직후 — S4U 폴백이 일어났을 때 반드시 뜨는 고지

│  ✔ 등록되었습니다.                                                 │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  ⚠ 알아두실 점                                                │  │
│  │  이 컴퓨터에 로그인되어 있을 때만 자동 실행됩니다.             │  │
│  │  아침에 PC 를 켜고 로그인해 두시면 됩니다.                     │  │
│  │                                                              │  │
│  │  (로그인 없이도 실행하려면 관리자 권한이 필요한데,             │  │
│  │   그 방식은 보안상 사용하지 않습니다.)                        │  │
│  └──────────────────────────────────────────────────────────────┘  │

이미 등록되어 있을 때

│  ✔ 이미 등록되어 있습니다.                                         │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  다음 실행     2026-09-03 (수) 06:00                          │  │
│  │  마지막 실행   2026-09-02 06:00  ·  성공                      │  │
│  │  실행 방식     로그인 상태에서 실행                            │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                    │
│    ┌──────────────┐ ┌──────────────┐ ┌──────────────┐             │
│    │  설정 변경   │ │ 자동실행 끄기│ │    닫기      │             │
│    └──────────────┘ └──────────────┘ └──────────────┘             │

[S6] 준비 완료 / 즉시 실행 — --mode inspect 기본 화면 (720 × 480)

┌──────────────────────────────────────────────────────────────────────┐
│ ✅  DMF 크롤러                                             [─] [×]   │
├──────────────────────────────────────────────────────────────────────┤
│                                                                      │
│                                                                      │
│                        모든 준비가 끝났습니다                         │
│                                                                      │
│                 매일 아침 06:00 에 자동으로 실행됩니다.                │
│                                                                      │
│                                                                      │
│                ┌────────────────────────────────┐                    │
│                │                                │                    │
│                │          지금 실행             │                    │
│                │                                │                    │
│                └────────────────────────────────┘                    │
│                                                                      │
│                                                                      │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │  마지막 실행    2026-09-02 06:00  ·  성공                       │  │
│  │  신규 12건 · 변경 3건 · 취하 1건   (전체 9,084건)               │  │
│  │  다음 실행      2026-09-03 (수) 06:00                           │  │
│  └────────────────────────────────────────────────────────────────┘  │
│                                                                      │
│  ┌───────────────┐ ┌────────────────┐ ┌───────────┐ ┌────────────┐  │
│  │ 리포트 열기   │ │ 설정 다시 열기 │ │ 다시 검사 │ │   닫기     │  │
│  └───────────────┘ └────────────────┘ └───────────┘ └────────────┘  │
└──────────────────────────────────────────────────────────────────────┘

설계 포인트: 준비가 끝난 뒤에는 체크리스트를 보여주지 않는다. 12개 항목이 전부 ✔ 인 목록은 정보 가치가 0 이고 화면만 무겁게 한다. 필요하면 [설정 다시 열기]로 [S1] 로 간다.


[S7] 실행 중 (660 × 400)

pipeline.pySTAGES 를 그대로 비춘다. 진행률은 완료 스테이지 수 / 전체 스테이지 수다.

┌────────────────────────────────────────────────────────────────────┐
│ ▶  DMF 자료 수집 중                                                │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│  오늘 자 DMF 자료를 받아 리포트를 만들고 있습니다.                   │
│                                                                    │
│  ██████████████████████████████████░░░░░░░░░░░░░░  71 %             │
│                                                                    │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  ✔  준비 확인                                                 │  │
│  │  ✔  자료 받기            (9,084 / 9,084 건)                   │  │
│  │  ✔  정리하기                                                  │  │
│  │  ✔  안전 점검                                                 │  │
│  │  ⋯  어제와 비교하는 중                                        │  │
│  │    AI 요약 만들기                                            │  │
│  │    엑셀 리포트 만들기                                        │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                    │
│  경과 1분 12초                              ┌────────────┐         │
│                                             │   중단     │         │
│                                             └────────────┘         │
└────────────────────────────────────────────────────────────────────┘

[S8] 실행 완료 (660 × 400)

┌────────────────────────────────────────────────────────────────────┐
│ ✅  완료                                                           │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│             오늘 자 리포트를 만들었습니다.                          │
│                                                                    │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  전체 등록      9,084 건                                      │  │
│  │  신규              12 건                                      │  │
│  │  변경               3 건                                      │  │
│  │  취하               1 건                                      │  │
│  │                                                              │  │
│  │  파일   reports\DMF_리포트_2026-09-02.xlsx                    │  │
│  │  소요   1분 47초                                              │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                    │
│    ┌────────────────┐ ┌────────────────┐ ┌────────────┐           │
│    │  리포트 열기   │ │  폴더 열기     │ │   닫기     │           │
│    └────────────────┘ └────────────────┘ └────────────┘           │
└────────────────────────────────────────────────────────────────────┘

AI 요약이 빠진 채 성공했을 때(PARTIAL, 종료 코드 0) — 실패로 표시하지 않는다.

│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  ▲  AI 요약은 이번에 만들지 못했습니다.                       │  │
│  │      사유: Google 로그인 만료                                 │  │
│  │      표와 숫자는 모두 정상입니다.                             │  │
│  │      ┌──────────────────────┐                                │  │
│  │      │  지금 로그인하기     │                                │  │
│  │      └──────────────────────┘                                │  │
│  └──────────────────────────────────────────────────────────────┘  │

[S9] 막힘 안내 — 자동 해결 불가 (680 × 420)

막다른 골목 금지 원칙을 화면으로 구현한 것. 어떤 실패에서도 최소 3개의 다음 행동이 있다.

┌────────────────────────────────────────────────────────────────────┐
│ ⚠  설치를 마치지 못했습니다                                        │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│  AGY를 자동으로 설치하지 못했습니다.                                │
│                                                                    │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  원인으로 보이는 것                                           │  │
│  │  회사 네트워크가 다운로드 주소 접속을 막고 있습니다.           │  │
│  │  (antigravity.google 연결 실패)                               │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                    │
│  다음 중 하나를 해보세요                                            │
│   · [다시 시도]  — 일시적인 문제일 수 있습니다.                     │
│   · [직접 설치]  — 설치 명령을 복사해 직접 실행하는 방법입니다.      │
│   · [건너뛰기]  — AI 요약 없이 리포트만 만듭니다. 문제없습니다.      │
│   · [진단 복사]  — 도움을 요청할 때 붙여넣을 내용입니다.            │
│                                                                    │
│  ┌───────────┐ ┌────────────┐ ┌───────────┐ ┌──────────┐ ┌───────┐│
│  │ 다시 시도 │ │ 직접 설치  │ │ 로그 열기 │ │진단 복사 │ │건너뛰기││
│  └───────────┘ └────────────┘ └───────────┘ └──────────┘ └───────┘│
└────────────────────────────────────────────────────────────────────┘

6. 상태 머신

6.1 전이 다이어그램

   bootstrap.cmd 더블클릭                DMF 설정.lnk 더블클릭
            │                                    │
            ▼                                    │
   ┌──────────────────┐                          │
   │   BOOTSTRAPPING  │  [S0] 콘솔                │
   │  파이썬→venv→pip │                          │
   └────────┬─────────┘                          │
            │ 실패 → 콘솔에 한국어 안내 + pause    │
            │ 성공                                │
            └────────────────┬───────────────────┘
                             ▼
                   ┌───────────────────┐
                   │     PROBING       │  checks.run_all()  (2~8초)
                   └─────────┬─────────┘
                             │
        ┌────────────────────┼────────────────────┐
        │                    │                    │
  CRITICAL 실패 ≥ 1     WARN 만 실패          전부 통과
  (또는 --mode recover)      │                    │
        │                    │                    │
        ▼                    ▼                    ▼
 ┌──────────────┐    ┌──────────────┐     ┌──────────────┐
 │ [S1] / [S1-R]│    │    [S1]      │     │    [S6]      │
 │ 실행 비활성  │    │  실행 활성   │     │ 준비 완료    │
 └──┬──┬──┬──┬──┘    └──────┬───────┘     └──────┬───────┘
    │  │  │  │              │                    │
    │  │  │  │  [지금 실행] └──────────┬─────────┘ [지금 실행]
    │  │  │  │                         ▼
    │  │  │  │                 ┌──────────────┐
    │  │  │  │                 │ [S7] RUNNING │  run --trigger manual
    │  │  │  │                 └──────┬───────┘
    │  │  │  │                        │
    │  │  │  │        ┌───────────────┼───────────────┐
    │  │  │  │      코드 0          코드 1          코드 2
    │  │  │  │        │               │               │
    │  │  │  │        ▼               ▼               ▼
    │  │  │  │  ┌──────────┐   ┌──────────┐   ┌──────────────┐
    │  │  │  │  │[S8] DONE │   │[S9]BLOCK │   │[S1-R] RECOVER│
    │  │  │  │  └────┬─────┘   └────┬─────┘   └──────┬───────┘
    │  │  │  │       │              │                │
    ▼  ▼  ▼  ▼      │              │                │
 ┌────┐┌────┐┌────┐┌────┐         │                │
 │[S2]││[S3]││[S4]││[S5]│         │                │
 │ 키 ││설치││로그││작업│         │                │
 │입력││진행││ 인 ││등록│         │                │
 └─┬──┘└─┬──┘└─┬──┘└─┬──┘         │                │
   │  실패│     │     │            │ 다시시도/건너뛰기│
   │     ▼     │     │            │                │
   │  ┌──────┐ │     │            │                │
   │  │ [S9] │ │     │            │                │
   │  └──┬───┘ │     │            │                │
   │     │     │     │            │                │
   └─────┴─────┴─────┴────────────┴────────────────┘
                     │
                     ▼
            ┌──────────────────┐
            │   RE-PROBING     │  checks.run_one(변경된 key) 만 재실행
            └────────┬─────────┘
                     │
                     └──────────► PROBING 결과 분기로 복귀

6.2 상태 정의표

상태 진입 조건 화면 나가는 전이
BOOTSTRAPPING bootstrap.cmd 실행 [S0] 콘솔 성공 → PROBING / 실패 → 콘솔 안내 후 pause
PROBING GUI 시작, [다시 검사] [S1] 의 3분기
NEEDS_SETUP CRITICAL ≥ 1 실패 [S1] (실행 비활성) fix_action
RECOVER --mode recover [S1-R] fix_action / [전체 상태 보기] → NEEDS_SETUP
READY_WITH_WARN CRITICAL 0, WARN ≥ 1 [S1] (실행 활성) [지금 실행] / fix_action
ALL_READY 전부 통과 [S6] [지금 실행] / [설정 다시 열기]
KEY_INPUT enter_api_key [S2] 저장 성공 → RE_PROBING(api_key_present, api_key_valid)
INSTALLING install_agy / install_deps [S3] 완료 → RE_PROBING / 실패 → BLOCKED
AGY_LOGIN login_agy [S4] 감지·타임아웃·보류 → RE_PROBING(agy_auth)
TASK_REG install_tasks [S5] 등록·취소 → RE_PROBING(tasks_registered)
RUNNING [지금 실행] [S7] 0 → DONE / 1 → BLOCKED / 2 → RECOVER / 130 → PROBING
DONE run 종료 코드 0 [S8] [닫기] → ALL_READY
BLOCKED 자동 해결 실패 [S9] [다시 시도] / [직접 하기] / [건너뛰기] → RE_PROBING
RE_PROBING 하위 작업 종료 [S1] 부분 갱신 PROBING 분기로 복귀

6.3 불변 규칙 4가지

  1. BLOCKED 에서 나가는 간선은 항상 3개 이상이다. 막다른 골목 금지의 형식적 표현이며, checks.pyfix_action 필수 계약이 이를 뒷받침한다.

  2. RE_PROBING 은 전체 재검사가 아니다. 방금 건드린 key 와 그 의존 key 만 run_one() 으로 다시 본다.

    완료된 액션 재검사할 key
    enter_api_key api_key_present, api_key_valid
    install_deps dependencies
    install_agy agy_installed, agy_auth
    login_agy agy_auth
    install_tasks tasks_registered
    repair_db database, recent_runs
    open_config config_valid (그리고 파생으로 tasks_registered)

    api_key_valid 의 API 호출을 매번 태우지 않기 위한 규칙이다.

  3. 어떤 상태에서도 [×] 로 창을 닫을 수 있다. 진행 중인 워커 스레드에는 취소 플래그를 세우고, 이미 저장된 것은 유지한다.

  4. RUNNING 중에는 다른 창을 열지 않는다. 실행 락(state\run.lock, ADR-23)과 충돌하는 조작을 UI 레벨에서 미리 막는다.


7. 인증키 입력 화면 상세

7.1 문구 원칙

비개발자가 읽고 그대로 따라할 수 있어야 한다.

나쁜 예 좋은 예
"serviceKey 를 입력하세요" "공공데이터포털에서 발급받은 인증키를 붙여넣어 주세요"
"Decoding 키를 사용해야 합니다" (아무 말도 하지 않는다 — 코드가 자동 판별한다)
"returnReasonCode 30" "등록되지 않은 인증키입니다. 마이페이지에서 다시 복사해 주세요"
"DPAPI CurrentUser 스코프로 암호화" "이 컴퓨터의 사용자 계정으로 암호화되어 저장됩니다"
"API 호출 한도 초과 (22)" "오늘 조회 횟수를 다 썼습니다. 인증키 자체는 정상입니다"

발급 절차는 화면에 4단계로 박아 둔다. 00-DATA-SOURCE-DECISION.md §9 의 7단계 중 사용자가 실제로 클릭할 것만 남겨 압축한 것이다.

 1. 아래 [발급 페이지 열기] 를 누릅니다.
 2. 로그인한 뒤 [활용신청] 버튼을 누릅니다. (무료, 즉시 승인)
 3. 마이페이지 > 데이터 활용 > Open API > 활용신청 현황 에서
    "일반 인증키" 를 통째로 복사합니다.
 4. 여기로 돌아와 아래 칸에 붙여넣습니다.

3번의 경로 문자열은 공공데이터포털 공식 FAQ 가 지정한 경로 그대로다. 이 한 문장만 있으면 사용자는 헤매지 않는다.

7.2 버튼과 입력 동작

요소 동작 비고
[발급 페이지 열기] webbrowser.open("https://www.data.go.kr/data/15057075/openapi.do") 데이터셋 상세 페이지 직행
[마이페이지 열기] webbrowser.open("https://www.data.go.kr/") 이미 신청한 사용자용
[붙여넣기] self.clipboard_get().strip() tk.TclError 를 반드시 잡는다 (클립보드가 비었거나 텍스트가 아닐 때)
입력 칸 ttk.Entry(show="●") 어깨너머 노출 방지. Ctrl+V 는 tkinter 기본 동작으로 이미 된다
[☐ 표시] 체크 시 entry.configure(show="") 붙여넣기가 제대로 됐는지 눈으로 봐야 한다
[저장] 길이·문자셋 검사 → API 실시간 검증 → DPAPI 저장 7.3~7.6
Enter / Esc bind("<Return>", save) / bind("<Escape>", close) 마우스 없이도 진행 가능

창을 띄운 직후 attributes("-topmost", False) 로 최상위를 해제한다. 그렇게 하지 않으면 사용자가 브라우저에서 키를 복사할 때 이 창이 브라우저를 계속 가려 아무것도 못 한다.

self.attributes("-topmost", True)
self.after(400, lambda: self.attributes("-topmost", False))

7.3 Encoding / Decoding 키 자동 판별

공공데이터포털 API 실패의 1위 원인이다. 사용자는 마이페이지에서 보이는 두 형태 중 아무거나 복사한다.

Decoding : AbCd+Ef/GhIj0123456789KLmnOP==
Encoding : AbCd%2BEf%2FGhIj0123456789KLmnOP%3D%3D

판별 규칙 — % 하나로 100% 갈린다. Decoding 형태의 문자셋은 Base64(AZ az 09 + / =)뿐이고 %결코 포함하지 않는다. 반면 Encoding 형태는 %2B/%2F/%3D 를 반드시 포함한다. 따라서 %[0-9A-Fa-f]{2} 매치 = Encoding 키다.

저장 규약: 항상 Decoding 형태로 정규화해 저장한다. ADR-05 가 확정한 httpxparams= 자동 인코딩과 정확히 맞는다 — params= 는 값을 한 번 인코딩하므로 넣는 값은 반드시 Decoding 형태여야 한다.

import re
import urllib.parse

_PCT = re.compile(r"%[0-9A-Fa-f]{2}")


def normalize_service_key(raw: str) -> str:
    """Encoding 키든 Decoding 키든 받아서 '디코딩된 원본' 으로 되돌린다.

    data.go.kr 인증키의 Decoding 형태 문자셋은 Base64(A-Za-z0-9+/=)뿐이고
    '%' 를 결코 포함하지 않는다. 따라서 '%XX' 가 보이면 Encoding 형태다.

    사용자가 이중 인코딩된 값(%252B)을 붙여넣는 사고까지 흡수하되,
    값이 더 이상 변하지 않으면 즉시 중단해 무한 unquote 를 피한다(멱등).
    """
    k = raw.strip().strip('"').strip("'")
    for _ in range(3):
        if not _PCT.search(k):
            break
        nxt = urllib.parse.unquote(k)
        if nxt == k:
            break
        k = nxt
    return k

이중 인코딩이 왜 코드 30 을 만드는가httpxparams=urlencode() 는 값을 한 번 더 인코딩한다. 여기에 Encoding 키를 그대로 넣으면 %2B%252B 로 변질돼 서버가 전혀 다른 키로 인식한다. 위 함수로 항상 원본으로 되돌린 뒤 params= 에 넘기면 어떤 입력이 와도 동일한 요청이 나간다.

7.4 입력 즉시 실시간 API 검증

저장 버튼을 누른 순간 실제로 API 를 1회 호출한다. 잘못된 키가 저장되면 06:00 배치가 조용히 죽고, 사용자는 며칠 뒤에야 리포트가 안 온다는 걸 알아챈다.

# src/dmf_crawler/keycheck.py — checks.py 와 gui/steps.py 가 공유한다
from __future__ import annotations

import json
import re
import urllib.parse
from dataclasses import dataclass

import httpx

API_URL = "https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01"
PORTAL_DATASET = "https://www.data.go.kr/data/15057075/openapi.do"
PORTAL_MYPAGE = "https://www.data.go.kr/"
MYPAGE_HINT = "마이페이지 > 데이터 활용 > Open API > 활용신청 현황"


@dataclass(frozen=True, slots=True)
class KeyCheck:
    ok: bool
    kind: str            # "service" | "gateway" | "network" | "unparsable" | "empty"
    status: int | None
    code: str | None     # "00" | "20" | "22" | "30" | "31" ...
    msg: str | None      # errMsg 또는 resultMsg 원문
    total_count: int | None


def _probe(key: str, timeout: float = 20.0) -> KeyCheck:
    """키 하나로 1건만 조회한다. 예외를 던지지 않고 구조화된 값을 돌려준다."""
    params = {"serviceKey": key, "pageNo": 1, "numOfRows": 1, "type": "json"}
    try:
        # params= 가 값을 정확히 1회 인코딩한다 (ADR-05). 키는 Decoding 형태여야 한다.
        r = httpx.get(API_URL, params=params, timeout=timeout,
                      headers={"User-Agent": "DMF-Crawler/onboarding"})
        status, body = r.status_code, r.text
    except Exception as e:
        return KeyCheck(False, "network", None, None, f"{type(e).__name__}: {e}", None)

    # --- (A) 게이트웨이 오류 봉투 : OpenAPI_ServiceResponse / cmmMsgHeader ---
    #     실측: 키 누락 -> HTTP 401, 잘못된 키 -> HTTP 403, 오퍼레이션 오타 -> HTTP 400.
    #     정상 봉투와 경로가 완전히 다르므로 반드시 먼저 판정한다.
    if "OpenAPI_ServiceResponse" in body:
        code = msg = None
        try:
            h = json.loads(body)["OpenAPI_ServiceResponse"]["cmmMsgHeader"]
            code, msg = str(h.get("returnReasonCode")), h.get("errMsg")
        except Exception:
            # type=json 을 줘도 GW 가 XML 로 답하는 경우가 있다 -> XML 폴백
            m = re.search(r"<returnReasonCode>([^<]*)</returnReasonCode>", body)
            e = re.search(r"<errMsg>([^<]*)</errMsg>", body)
            code = m.group(1) if m else None
            msg = e.group(1) if e else None
        return KeyCheck(False, "gateway", status, code, msg, None)

    # --- (B) 정상/서비스 봉투 : response.header.resultCode 또는 header.resultCode ---
    try:
        j = json.loads(body)
    except ValueError:
        return KeyCheck(False, "unparsable", status, None, body[:200], None)

    hdr = j.get("header") or (j.get("response") or {}).get("header") or {}
    bdy = j.get("body") or (j.get("response") or {}).get("body") or {}
    raw_code = hdr.get("resultCode")
    code = str(raw_code).zfill(2) if raw_code is not None else None
    total = bdy.get("totalCount")
    try:
        total = int(total) if total is not None else None
    except (TypeError, ValueError):
        total = None
    return KeyCheck(code == "00", "service", status, code, hdr.get("resultMsg"), total)


def validate_service_key(raw: str) -> tuple[str | None, KeyCheck]:
    """붙여넣은 값에서 '실제로 되는' 키를 골라 돌려준다.

    반환 (저장할 Decoding 형태 키 또는 None, 마지막 진단)

    · 정규화본을 먼저, 원문을 그 다음으로 시도한다.
    · 네트워크 실패면 후보를 더 시도하지 않는다 (쿼터 낭비 방지).
    · 쿼터 초과(22/23)는 '키가 유효하다' 는 증거이므로 반드시 저장한다.
      이걸 실패로 처리하면 사용자가 멀쩡한 키를 하루 종일 다시 입력하게 된다.
    """
    if not raw or not raw.strip():
        return None, KeyCheck(False, "empty", None, None, "인증키가 비어 있습니다", None)

    candidates: list[str] = []
    for c in (normalize_service_key(raw), raw.strip()):
        if c and c not in candidates:
            candidates.append(c)

    last: KeyCheck | None = None
    for c in candidates:
        last = _probe(c)
        if last.ok:
            return c, last
        if last.kind == "network":
            return None, last
        if last.code in ("22", "23"):
            return c, last
    return None, last

⚠️ 오류 봉투가 두 종류라는 사실이 이 코드의 핵심이다. j["response"]["header"]["resultCode"] 만 보는 파서는 잘못된 키에서 곧바로 KeyError 로 죽고, 그 예외 메시지에 URL(= 인증키 포함)이 딸려 나오는 2차 사고까지 난다.

GW 레벨 오류 정상 / 서비스 레벨
HTTP 401 / 403 / 400 200
루트 OpenAPI_ServiceResponse.cmmMsgHeader response.header 또는 header
코드 필드 returnReasonCode resultCode
메시지 errMsg, returnAuthMsg resultMsg

⚠️ 정상 응답의 최상위 래퍼가 response 인지 아닌지는 유효 키가 없어 확정하지 못했다. 위 코드는 양쪽을 모두 흡수하도록 방어적으로 작성했다. 최초 검증 성공 시 실제 구조를 00-DATA-SOURCE-DECISION.md 부록 B 에 기록한다 (부록 B).

7.5 GUI 를 얼리지 않고 검증하기

tkinter 는 단일 스레드다. _probe() 가 최대 20초 걸리므로 메인 스레드에서 부르면 창이 흰색으로 굳고 제목에 "(응답 없음)"이 뜬다. 비개발자는 그 순간 프로그램이 죽었다고 판단하고 강제 종료한다.

def _on_save(self) -> None:
    raw = self.var_key.get().strip()
    if len(raw) < 20:
        self._set_status("bad", "인증키가 너무 짧습니다. 전체를 붙여넣었는지 확인해 주세요.")
        return
    if not re.fullmatch(r"[A-Za-z0-9%+/=_.\-]+", raw):
        self._set_status("bad", "인증키에 들어갈 수 없는 문자가 있습니다. 앞뒤 공백과 줄바꿈을 지워 주세요.")
        return

    self._set_busy(True)
    self._set_status("busy", "인증키를 확인하는 중입니다…")

    # 워커 스레드에서 네트워크를 태우고, 결과는 after() 로 메인 스레드에 돌려준다.
    def work() -> tuple[str | None, KeyCheck]:
        return validate_service_key(raw)

    def done(result: tuple[str | None, KeyCheck]) -> None:
        key, res = result
        self._set_busy(False)
        if key is None:
            self._set_status("bad", explain_key_check(res))
            return
        save_service_key(key)                       # DPAPI 저장
        touch_heartbeat(api_key_verified_at=utcnow_iso())
        if res.ok:
            n = f"{res.total_count:,}" if res.total_count else "?"
            self._set_status("ok", f"정상 확인되었습니다. (전체 {n}건)")
        else:
            self._set_status("warn", explain_key_check(res))
        self.after(1200, self._close_ok)

    run_in_thread(self, work, done)                 # 11.5 공통 유틸

7.6 오류 코드 → 사용자 문구 매핑

returnReasonCode 가 아니라 errMsg 문자열을 1차 키로 써야 정확하다. 코드 20 하나에 SERVICE_KEY_IS_NULL / PERMISSION_DENIED / SERVICE_ACCESS_DENIED_ERROR 세 원인이 겹쳐 있기 때문이다.

코드 errMsg 화면 문구 저장? 다음 행동
00 NORMAL_CODE 정상 확인되었습니다. (전체 N건) 1.2초 뒤 자동으로 닫힘
20 SERVICE_KEY_IS_NULL 인증키가 비어 있습니다. 재입력
20 SERVICE_ACCESS_DENIED_ERROR 이 자료에 대한 사용 신청이 아직 완료되지 않았습니다.
마이페이지 > 데이터 활용 > Open API > 활용신청 현황 에서 상태를 확인해 주세요.
[마이페이지 열기]
21 TEMPORARILY_DISABLE_THE_SERVICEKEY_ERROR 인증키가 일시적으로 중지된 상태입니다. [마이페이지 열기]
22 LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS ▲ 오늘 조회 횟수를 다 썼습니다. 인증키 자체는 정상입니다.
자정이 지나면 자동으로 초기화됩니다.
저장 저장하고 닫음
23 ..._PER_SECOND_EXCEEDS_ERROR ▲ 잠시 뒤 다시 시도해 주세요. 인증키 자체는 정상입니다. 저장 저장하고 닫음
29 BLACKLIST_IP_ACCESS_ERROR 이 컴퓨터의 인터넷 주소가 차단되어 있습니다.
공공데이터포털 활용지원센터(1566-0025)로 문의해 주세요.
[전화번호 복사]
30 SERVICE_KEY_IS_NOT_REGISTERED_ERROR 등록되지 않은 인증키입니다.
포털에서 인증키를 새로 발급하면 예전 키는 자동으로 사라집니다.
마이페이지에서 현재 인증키를 다시 복사해 주세요.
[마이페이지 열기]
31 DEADLINE_HAS_EXPIRED_ERROR 인증키 사용 기간이 끝났습니다.
마이페이지 > 활용신청 현황 에서 '활용연장신청' 을 해주세요.
[마이페이지 열기]
32 UNREGISTERED_IP_ERROR 신청할 때 등록한 인터넷 주소와 지금 주소가 다릅니다. [마이페이지 열기]
10 11 12 INVALID_REQUEST / NO_OPENAPI_SERVICE 자료 제공 방식이 바뀐 것 같습니다.
프로그램 업데이트가 필요할 수 있습니다.
[자료 안내 페이지 열기]
01 04 05 99 일시 장애 잠시 문제가 있었습니다. 다시 시도해 주세요. [다시 시도]
network 인터넷 연결을 확인해 주세요.
회사 PC 라면 IT 담당자에게 apis.data.go.kr (443) 허용을 요청해 주세요.
[다시 시도] / [주소 복사]
_HUMAN: dict[str, str] = {
    "20": "인증키가 비어 있거나 이 자료에 대한 사용 신청이 완료되지 않았습니다.\n"
          f"{MYPAGE_HINT} 에서 상태를 확인해 주세요.",
    "21": f"인증키가 일시적으로 중지된 상태입니다.\n{MYPAGE_HINT} 에서 확인해 주세요.",
    "22": "오늘 조회 횟수를 다 썼습니다. 인증키 자체는 정상입니다.\n"
          "자정이 지나면 자동으로 초기화됩니다.",
    "23": "잠시 뒤 다시 시도해 주세요. 인증키 자체는 정상입니다.",
    "29": "이 컴퓨터의 인터넷 주소가 차단되어 있습니다.\n"
          "공공데이터포털 활용지원센터(1566-0025)로 문의해 주세요.",
    "30": "등록되지 않은 인증키입니다.\n"
          "포털에서 인증키를 새로 발급하면 예전 키는 자동으로 사라집니다.\n"
          f"{MYPAGE_HINT} 에서 현재 인증키를 다시 복사해 주세요.",
    "31": f"인증키 사용 기간이 끝났습니다.\n{MYPAGE_HINT} 에서 '활용연장신청' 을 해주세요.",
    "32": "신청할 때 등록한 인터넷 주소와 지금 주소가 다릅니다.\n"
          f"{MYPAGE_HINT} 에서 변경신청을 해주세요.",
    "10": "자료 제공 방식이 바뀐 것 같습니다. 프로그램 업데이트가 필요할 수 있습니다.",
    "11": "자료 제공 방식이 바뀐 것 같습니다. 프로그램 업데이트가 필요할 수 있습니다.",
    "12": "자료 제공 방식이 바뀐 것 같습니다. 프로그램 업데이트가 필요할 수 있습니다.",
}


def explain_key_check(res: KeyCheck) -> str:
    """GUI 에 그대로 띄울 한국어 문장. 인증키 원문은 절대 포함하지 않는다."""
    if res.ok:
        n = f"{res.total_count:,}" if res.total_count else "?"
        return f"정상 확인되었습니다. (전체 {n}건)"
    if res.kind == "empty":
        return "인증키를 입력해 주세요."
    if res.kind == "network":
        return ("인터넷에 연결되어 있지 않거나 회사 방화벽이 접속을 막고 있습니다.\n"
                "회사 PC 라면 IT 담당자에게 apis.data.go.kr (443) 허용을 요청해 주세요.")
    if res.kind == "unparsable":
        return "서버가 예상과 다른 응답을 보냈습니다. 잠시 뒤 다시 시도해 주세요."
    return _HUMAN.get(res.code or "", "잠시 문제가 있었습니다. 다시 시도해 주세요.")

코드 30 이 가장 중요한 항목이다. 공공데이터포털은 회원당 서비스키가 1개뿐이고, 재발급하면 기존 키가 자동 폐기된다. 사용자가 몇 달 뒤 전혀 다른 목적(날씨 API 등)으로 키를 재발급하는 순간 이 배치는 다음날 06:00 에 코드 30 으로 죽는다. 사용자는 자기가 무엇을 깨뜨렸는지 짐작조차 못 한다. 그래서 문구에 "새로 발급하면 예전 키는 자동으로 사라집니다"를 반드시 넣는다.

7.7 인증키 사용 기한 보조 경보

인증키에는 사용 기한이 있고 만료되면 코드 31 이 뜬다. 흔히 "24개월"이라 하지만 2차 출처뿐이고 신청 폼이 로그인 벽 뒤에 있어 확정하지 못했다(⚠️ 미검증).

따라서 24개월을 하드코딩해 D-day 를 계산하지 않는다. 2겹으로 간다.

경보 방식 신뢰도
주 경보 매 실행마다 응답에서 코드 31 을 관측alerts 기록 → pump 가 복구 GUI 기동 확실
보조 경보 heartbeat.jsonapi_key_first_success_at 으로부터 22개월 경과 시 [S1] 상단 배너 참고용
def key_age_banner() -> str | None:
    """보조 경보일 뿐이다. 진짜 판정은 언제나 API 응답 코드 31 이다."""
    hb = read_heartbeat()
    first = hb.get("api_key_first_success_at")
    if not first:
        return None
    months = (datetime.now(timezone.utc) - _parse_iso(first)).days / 30.44
    if months >= 22:
        return (f"인증키를 사용한 지 약 {months:.0f}개월이 지났습니다. "
                "사용 기간이 끝나기 전에 공공데이터포털 마이페이지에서 "
                "'활용연장신청' 을 해두세요.")
    return None

7.8 저장 위치와 방식

secrets_dpapi.py 계약(§3.2)을 그대로 따른다.

  • 경로: %LOCALAPPDATA%\DMF_Crawler\service_key.bin
  • 방식: DPAPI CryptProtectData, 사용자 범위, optionalEntropy = b"DMF_Crawler/serviceKey/v1"
  • 원자적 쓰기: .tmp 에 쓰고 os.replace() — 쓰는 도중 전원이 나가도 반쪽 파일이 남지 않는다
  • 화면 표시: 원문 대신 key_fingerprint()(sha256 앞 8자)
  • 로그 금지: 예외 메시지·재시도 로그에 URL 을 그대로 찍으면 인증키가 파일에 영구히 남는다. http.py 가 예외에 URL 을 담되 serviceKey마스킹하는 것이 §3.3 계약이다.

8. agy 설치 화면 상세

8.1 설치 여부 판정

CHK ⑥ _check_agy_installed() 를 그대로 쓴다. 세 겹으로 확인한다.

  1. 절대 경로 존재%LOCALAPPDATA%\agy\bin\agy.exe. where.exe agy 는 winget Links 심볼릭까지 잡아 두 경로가 나오므로(agy SSOT §3.2 실측) 신뢰하지 않는다.
  2. 파일 크기 > 1 MB — 실측 정상 크기 187,601,560 bytes. 0바이트 스텁이나 중단된 다운로드를 걸러낸다.
  3. --version 종료 코드 0 — 실제로 실행되는가. 실측 출력 1.1.24.

8.2 설치 방식: install.ps1 1순위, winget 2순위

방식 장점 단점 순위
install.ps1 공식 1순위 경로 · SHA512 무결성 검증 내장 · 이미 설치돼 있으면 아무것도 안 하고 종료 코드 0(멱등) · Unblock-File 로 MOTW 자동 제거 · 관리자 권한 불필요 진행률을 구조화해 내보내지 않음 1순위
winget 표준 패키지 관리 ⚠️ SYSTEM/서비스 세션에서 동작하지 않음 · 소스 동기화 지연 · 버전이 뒤처짐(실측: winget 설치본 1.1.10 vs 실제 1.1.24) 2순위 폴백

1순위가 실패하면 자동으로 2순위를 시도하고, 둘 다 실패하면 [S9] 막힘 안내로 간다. 이 순서를 scripts\bootstrap_agy.ps1 에 구현한다 (11.8).

install.ps1 의 동작(agy SSOT §3.3 전문 확인): TLS 1.2 강제 → 기존 설치 감지(있으면 즉시 종료 0) → 아키텍처 감지 → 매니페스트 다운로드 → 스테이징 다운로드 → SHA512 검증 → 배치 + Unblock-Fileagy install 로 PATH 구성 → 스테이징 정리. 체크섬 불일치 시 Security Halt: Checksum verification failed. 로 중단된다. 별도 무결성 검증이 불필요하다. 업데이트 목적으로는 쓸 수 없다 — 기존 바이너리가 있으면 아무것도 안 하고 빠진다. 업데이트는 AgyUpdate 주간 작업(agy update)이 담당한다.

8.3 진행률 표시 — 4단계 한국어 요약

install.ps1 은 퍼센트를 내보내지 않는다. 따라서 출력 문자열을 패턴 매칭해 4단계로 환산한다.

_AGY_PHASES = [
    (re.compile(r"", re.I),                       1, "설치 스크립트를 받는 중…",  5),
    (re.compile(r"manifest|download", re.I),      2, "프로그램 내려받는 중…",     40),
    (re.compile(r"verify|sha512|hash|checksum", re.I), 3, "파일 검사 중…",        80),
    (re.compile(r"install|path|configur", re.I),  4, "설치 확인 중…",            92),
]


def _agy_phase(line: str) -> tuple[int, str, int] | None:
    """설치 스크립트 출력 한 줄을 단계·문구·퍼센트로 환산한다."""
    for pat, step, label, pct in reversed(_AGY_PHASES[1:]):
        if pat.search(line):
            return step, label, pct
    return None

패턴이 하나도 안 잡히면 ttk.Progressbar(mode="indeterminate")(무한 스크롤)로 폴백한다. 진행률을 못 보여줄 바에는 "돌고 있다"는 것만이라도 보여주는 게 낫다.

8.4 실패 시 수동 설치 안내

[직접 설치] 버튼을 누르면 나오는 화면.

┌──────────────────────────────────────────────────────────────┐
│ 📋  직접 설치하는 방법                                       │
├──────────────────────────────────────────────────────────────┤
│  아래 명령을 복사해서 실행하면 설치됩니다.                    │
│                                                              │
│  1. 시작 메뉴에서 "PowerShell" 을 검색해 실행합니다.          │
│  2. 아래 [명령 복사] 를 누른 뒤 그 창에 붙여넣고 Enter.       │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ irm https://antigravity.google/cli/install.ps1 | iex    │  │
│  └────────────────────────────────────────────────────────┘  │
│                    ┌──────────────┐                          │
│                    │  명령 복사   │                          │
│                    └──────────────┘                          │
│                                                              │
│  3. 설치가 끝나면 이 창으로 돌아와 [다시 확인] 을 누릅니다.   │
│                                                              │
│  ※ 관리자 권한은 필요하지 않습니다.                           │
│  ※ 이 프로그램은 AI 요약에만 쓰입니다.                        │
│     설치하지 않아도 리포트는 정상적으로 만들어집니다.          │
│                                                              │
│    ┌────────────────┐ ┌──────────────┐ ┌──────────────┐     │
│    │ PowerShell 열기│ │  다시 확인   │ │  건너뛰기    │     │
│    └────────────────┘ └──────────────┘ └──────────────┘     │
└──────────────────────────────────────────────────────────────┘

[PowerShell 열기] 는 실제 창을 띄워 준다 — 사용자가 시작 메뉴에서 찾지 못할 가능성을 없앤다.

subprocess.Popen(["powershell.exe", "-NoExit", "-NoProfile"],
                 creationflags=subprocess.CREATE_NEW_CONSOLE)

8.5 설치 후 검증

설치 직후 곧바로 checks.run_one("agy_installed") 을 부른다. PATH 갱신을 기다릴 필요가 없다 — 원래부터 절대 경로 판정이기 때문이다.

after = run_one("agy_installed", cfg)
if after.ok:
    self._show_ok(f"AGY가 설치되었습니다. ({after.detail})")
else:
    self._show_blocked(
        title="AGY 수집/AI 요약 설치",
        cause=diagnose_agy_failure(log_text),      # 네트워크 차단 / 디스크 부족 / 체크섬 실패
        actions=["다시 시도", "직접 설치", "로그 열기", "진단 복사", "건너뛰기"])

8.6 설치 시 반드시 지킬 것

  • AGY_CLI_DISABLE_AUTO_UPDATE=true설치 시점부터 자식 프로세스 환경에 주입한다. 백그라운드 self-update 가 06:00 배치 도중 바이너리를 교체하면 실행이 실패한다 (agy SSOT §3.6, §16).
  • 업데이트는 주간 AgyUpdate 작업으로 분리한다. 온보딩에서는 하지 않는다.
  • 설치 로그는 logs\onboarding_YYYYMMDD.log 에 남긴다. [로그 열기] 가 이것을 연다.

9. agy 로그인 유도 상세

9.1 ⚠️ agy login 은 존재하지 않는다 (실측 정정)

agy --help 의 서브커맨드 목록을 v1.1.24 에서 직접 확인했다.

Available subcommands:
  agent           List available agents
  agents          List available agents
  changelog       Show changelog and release notes
  help            Show help for subcommands
  install         Configure environment paths and shell settings
  mcp             Manage MCP servers (add, remove, list, enable, disable)
  mic-serve       Serve this machine's microphone to a CLI on another host
  models          List available models
  plugin          Manage plugins (install, uninstall, list, enable, disable)
  plugins         Alias for plugin
  update          Update CLI

login 이 없다. agy SSOT §5.2 의 목록과도 일치한다. 따라서 agy login 을 호출하는 코드는 "unknown subcommand" 로 실패한다.

올바른 방법: 인자 없이 agy 를 실행하면 TUI 가 뜨고, 미인증 상태면 로그인 화면이 먼저 나온다. 공식 문서: "If valid credentials exist it authenticates silently; otherwise it opens the default browser for OAuth login." 그리고 "Headless mode uses your cached credentials. Authenticate once with an interactive agy session first."

9.2 로그인 상태 판정 — 비대화형으로 어떻게 아는가

토큰 파일의 존재와 갱신 시각을 본다.

%USERPROFILE%\.gemini\antigravity-cli\antigravity-oauth-token

실측: 504 bytes, 평문 JSON {"token":{"access_token":"ya29.<REDACTED>","token_type":"...", ...}}. Windows 자격 증명 관리자에는 관련 항목이 없다(cmdkey /list 실측). 즉 파일 하나만 보면 된다.

agy -p 로 실제 호출해 확인하지 않는가: 첫 호출 오버헤드가 input 28,317 토큰 / 33.7초다 (agy SSOT §4.3 실측). 온보딩 화면에서 매번 태울 비용이 아니다. ADR-16 의 원칙과도 충돌한다.

판정 3단계 (신뢰도 오름차순)

단계 판정 누가 수행
1 토큰 파일 존재 + 50바이트 초과 마법사 (CHK ⑦ 2단계)
2 agy_calls 테이블의 최근 ErrorKind == "AUTH" 마법사 (CHK ⑦ 1단계, 우선)
3 agy -p 실제 호출 배치만 수행. agy/client.classify_error()AUTH 를 판정

9.3 로그인 창 실행 — 완전한 코드

def start_agy_login_console() -> subprocess.Popen | None:
    """대화형 agy 를 새 콘솔 창에서 띄운다.

    · `agy login` 서브커맨드는 존재하지 않는다(v1.1.24 실측).
      인자 없이 실행하면 TUI 가 뜨고, 미인증이면 로그인 화면이 먼저 나온다.
    · agy 는 TUI 이므로 반드시 '진짜' 콘솔이 필요하다.
      CREATE_NO_WINDOW 나 파이프 리다이렉트로 띄우면 화면이 깨지고
      사용자가 아무것도 볼 수 없다. 그래서 여기서만 콘솔을 정당하게 노출한다.
    · cmd /k 로 감싸는 이유: agy 가 즉시 종료해도 창이 남아
      메시지(수동 인증 URL 등)를 사용자가 읽을 수 있게 하기 위해서다.

    반환: 시작된 프로세스. 실패 시 None.
    """
    if not AGY_EXE.exists():
        raise FileNotFoundError("agy 가 설치되어 있지 않습니다. 먼저 설치를 완료해 주세요.")

    env = {**os.environ, "AGY_CLI_DISABLE_AUTO_UPDATE": "true"}

    banner = (
        'title DMF 크롤러 - Google 로그인'
        ' && echo.'
        ' && echo   [ Google 계정 로그인 ]'
        ' && echo   브라우저가 열리면 계정을 선택하고 [허용] 을 눌러 주세요.'
        ' && echo   끝나면 설정 창으로 돌아가세요. 이 창은 닫으셔도 됩니다.'
        ' && echo.'
        f' && "{AGY_EXE}"'
    )
    try:
        return subprocess.Popen(
            ["cmd.exe", "/k", banner],
            cwd=str(Path.home()),
            env=env,
            creationflags=subprocess.CREATE_NEW_CONSOLE,   # 진짜 콘솔이 필요하다
        )
    except Exception:
        return None

9.4 완료 대기 폴링 루프

토큰 파일의 "존재 + 최종 수정 시각 + 크기"를 2초마다 확인한다. 로그인 전에 이미 (만료된) 토큰 파일이 있을 수 있으므로, 시작 시점의 스냅샷과 비교변화를 감지해야 한다.

tkinter 에서는 while 루프를 돌리면 안 된다. after() 로 자기 자신을 다시 예약해야 이벤트 루프가 계속 돈다.

class AgyLoginDialog(tk.Toplevel):

    POLL_MS = 2000
    TIMEOUT_SEC = 300          # 5분. 계정 선택·2단계 인증까지 넉넉히 준다.

    def _snapshot(self) -> tuple[int, int] | None:
        """시작 시점 기준선. '변화' 를 감지하기 위한 것이다."""
        if not AGY_TOKEN.exists():
            return None
        st = AGY_TOKEN.stat()
        return (st.st_mtime_ns, st.st_size)

    def _start_wait(self) -> None:
        self._before = self._snapshot()
        self._deadline = time.monotonic() + self.TIMEOUT_SEC
        self._poll()

    def _poll(self) -> None:
        if self._cancelled:
            return
        remain = int(self._deadline - time.monotonic())

        if AGY_TOKEN.exists():
            st = AGY_TOKEN.stat()
            valid = st.st_size > 50
            changed = (self._before is None
                       or (st.st_mtime_ns, st.st_size) != self._before)
            if valid and changed:
                # 쓰기 완료를 기다린다 (부분 기록 방지)
                self.after(700, self._succeed)
                return

        if remain <= 0:
            self._timeout()
            return

        self._set_status("busy",
                         f"로그인이 끝나기를 기다리는 중입니다…   "
                         f"(남은 시간 {remain // 60}:{remain % 60:02d})")
        self._after_id = self.after(self.POLL_MS, self._poll)   # 자기 자신을 재예약

after() 재귀가 이 루프의 핵심이다. while + time.sleep 을 쓰면 tkinter 창이 흰색으로 굳고 제목 표시줄에 "(응답 없음)"이 뜬다. 비개발자는 그 순간 프로그램이 죽었다고 판단하고 강제 종료한다. 창을 닫을 때 self.after_cancel(self._after_id) 를 반드시 호출한다. 안 하면 파괴된 위젯에 콜백이 걸려 TclError 가 난다.

9.5 타임아웃과 폴백

결과 화면 다음 행동
SUCCESS ✔ "Google 로그인이 완료되었습니다." 1.2초 뒤 자동으로 [S1] 복귀, 항목 ✔
TIMEOUT (5분) ▲ "아직 로그인이 확인되지 않았습니다." [다시 기다리기] / [다 했어요] / [나중에 하기]
CANCELLED [S1] 복귀, 항목은 ▲ 유지

[다 했어요] 버튼이 중요하다. 폴링이 어떤 이유로든 변화를 놓쳤을 때(예: 로그인 전에도 이미 유효한 토큰 파일이 있었고 agy 가 그것을 그대로 재사용한 경우) 사용자가 직접 진행할 수 있어야 한다. 이 버튼은 checks.run_one("agy_auth") 를 한 번 더 부르고 결과를 그대로 반영한다.

9.6 Session 0 제약 — 왜 배치에서 로그인을 시도하면 안 되는가

Windows 는 Session 0 격리를 강제한다. 서비스와 SYSTEM 계정 프로세스는 Session 0 에서 돌고, 사용자 데스크톱(Session 1+)과 완전히 분리되어 있다.

상황 콘솔 창 브라우저 실행 OAuth 로그인
마법사 (대화형 세션) 보임 가능
Agent 태스크 (Interactive, 로그온 중) 보임 🟡 가능 — pump 가 복구 GUI 를 띄우는 경로
Daily 배치 (S4U 또는 데스크톱 없는 세션) 안 보임 안 뜸 불가
SYSTEM 계정 / 서비스 Session 0 에 갇힘 영원히 불가

설계 규칙 3가지 — ADR-10 · ADR-11 의 직접 귀결이다.

  1. Daily 배치는 로그인을 시도하지 않는다. agy/client.classify_error()AUTH 를 판정하면 alerts 테이블에 의도만 기록하고, AI 단계를 건너뛴 채 리포트를 생성한 뒤 종료 코드 0(PARTIAL) 으로 끝낸다.
  2. 알림 표시는 Agent 태스크(notify-pump)가 한다. 그것이 데스크톱을 가진 유일한 프로세스다. 15분 주기로 돌다가 CRITICAL 을 만나면 onboard --mode recover --focus agy_auth 를 기동한다.
  3. SYSTEM 계정으로는 절대 등록하지 않는다. 토큰 파일이 사용자 프로필 아래 있어 접근 자체가 불가능하고, DPAPI 사용자 범위 복호화도 함께 깨진다 (agy SSOT §16, ADR-13).

10. 작업 스케줄러 등록

10.1 등록되는 작업 3종 (ADR-10)

이름 트리거 LogonType 실행 목적
\DMF Crawler\Daily 매일 schedule.daily_time(기본 06:00) S4U → 실패 시 Interactive .venv\Scripts\python.exe -m dmf_crawler run --trigger scheduled 수집·diff·리포트
\DMF Crawler\Agent 로그온 시 + 15분 반복 Interactive (필수) .venv\Scripts\pythonw.exe -m dmf_crawler notify-pump --once 워치독·알림 표시·복구 GUI
\DMF Crawler\AgyUpdate 매주 일요일 04:00 Interactive %LOCALAPPDATA%\agy\bin\agy.exe update agy 계획 업데이트
  • Dailypython.exe(콘솔)를 쓴다. S4U/비대화형 세션에는 데스크톱이 없으므로 콘솔 창이 사용자에게 보이지 않는다.
  • Agent 는 반드시 pythonw.exe 다. Interactive 세션이라 python.exe 를 쓰면 15분마다 검은 창이 번쩍인다.
  • AgyUpdate 는 배치 도중 바이너리 교체를 막기 위해 06:00 과 시간대를 분리했다 (agy SSOT §3.6).

10.2 ⚠️ S4U → Interactive 폴백 (실측 기반 필수 사항)

1.6절에서 확인했듯 비관리자 계정은 S4U 등록 자체가 거부된다. install_tasks.ps1 은 반드시 이렇게 동작해야 한다.

# scripts\install_tasks.ps1 의 핵심 로직 (전문은 11.9)
function Register-DmfTask {
    param(
        [Parameter(Mandatory)][string]$TaskName,
        [Parameter(Mandatory)]$Action,
        [Parameter(Mandatory)]$Trigger,
        [Parameter(Mandatory)]$Settings,
        [ValidateSet('S4U','Interactive')][string]$PreferredLogon = 'Interactive',
        [string]$Description = ''
    )
    $user = "$env:USERDOMAIN\$env:USERNAME"

    # S4U 를 먼저 시도하고, 'Logon as Batch' 권한이 없어 거부되면 Interactive 로 내려온다.
    # 실측: 비관리자 계정에서 S4U 등록은 '액세스가 거부되었습니다' 로 실패한다.
    $order = if ($PreferredLogon -eq 'S4U') { @('S4U','Interactive') } else { @('Interactive') }

    foreach ($logon in $order) {
        try {
            $principal = New-ScheduledTaskPrincipal -UserId $user -LogonType $logon -RunLevel Limited
            Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger `
                                   -Principal $principal -Settings $Settings `
                                   -Description $Description -Force -ErrorAction Stop | Out-Null
            return [pscustomobject]@{ TaskName = $TaskName; LogonType = $logon; Ok = $true; Error = $null }
        } catch {
            $lastErr = $_.Exception.Message
            Write-Verbose "[$TaskName] LogonType=$logon 실패: $lastErr"
        }
    }
    return [pscustomobject]@{ TaskName = $TaskName; LogonType = $null; Ok = $false; Error = $lastErr }
}

폴백이 일어나면 GUI 가 반드시 고지한다 ([S5] 하단 박스). 사용자가 "로그오프해도 돌겠지"라고 잘못 믿는 것이 가장 나쁘다.

if result["Daily"]["LogonType"] == "Interactive":
    notice = ("이 컴퓨터에 로그인되어 있을 때만 자동 실행됩니다.\n"
              "아침에 PC 를 켜고 로그인해 두시면 됩니다.\n\n"
              "(로그인 없이도 실행하려면 관리자 권한이 필요한데, "
              "그 방식은 보안상 사용하지 않습니다.)")

10.3 설정값 고정

$settings = New-ScheduledTaskSettingsSet `
    -StartWhenAvailable `                              # 놓친 실행을 켜진 뒤 수행
    -AllowStartIfOnBatteries `
    -DontStopIfGoingOnBatteries `
    -ExecutionTimeLimit (New-TimeSpan -Hours 2) `
    -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 10) `
    -MultipleInstances IgnoreNew                       # ADR-23 3중 방어의 세 번째
$trigger.RandomDelay = 'PT3M'                          # 여러 PC 가 동시에 API 를 때리는 것 방지

⚠️ cmdlet 이름은 New-ScheduledTaskSettingsSet 이다. New-ScheduledTaskSettings 라는 cmdlet 은 존재하지 않는다 (실제로 오타로 실패한 적이 있다).

RestartCount 3 은 §5 종료 코드 규약과 맞물린다 — 코드 1(FAILED)은 재시도 가치가 있고, 코드 2(BLOCKED)는 재시도해도 같은 결과지만 ADR-23 의 idempotency 가드와 알림 dedup 이 흡수하므로 무해하다.

10.4 UAC 승격 처리

이 마법사는 UAC 를 띄우지 않는다. 위 설정 조합(현재 사용자 · Interactive/S4U · RunLevel Limited)은 비관리자 세션에서 등록에 성공하는 것이 실측으로 확인됐다.

GUI 는 install-task CLI 를 통해 PowerShell 스크립트를 호출하며, 스크립트 실행은 -ExecutionPolicy Bypass 로 한다.

def install_tasks(daily_time: str = "06:00", wake: bool = False) -> dict:
    """scripts\\install_tasks.ps1 를 호출한다. 관리자 권한 불필요."""
    ps1 = SCRIPTS_DIR / "install_tasks.ps1"
    args = ["powershell.exe", "-NoProfile", "-NonInteractive",
            "-ExecutionPolicy", "Bypass", "-File", str(ps1),
            "-ProjectRoot", str(PROJECT_ROOT),
            "-DailyTime", daily_time,
            "-Json"]
    if wake:
        args.append("-WakeToRun")
    p = subprocess.run(args, capture_output=True, text=True, encoding="utf-8",
                       errors="replace", timeout=120,
                       creationflags=subprocess.CREATE_NO_WINDOW)
    try:
        return json.loads(p.stdout)
    except ValueError:
        raise RuntimeError(p.stderr.strip() or p.stdout.strip() or "작업 등록에 실패했습니다.")

승격이 필요한 작업은 설계에서 전부 배제했다: -RunLevel Highest, 다른 사용자 계정 대상 태스크, HKLM 쓰기, C:\Program Files 설치. 비개발자에게 방패 아이콘을 보여주지 않는 것 자체가 신뢰 요소다.

GPO 로 PowerShell 이 막힌 환경에서는 이 호출이 실패한다. 그때는 [S9] 막힘 안내로 가서 schtasks.exe 수동 명령을 복사해 주는 폴백을 제공한다 (13절).

10.5 이미 등록된 경우

CHK ⑨ 가 3가지 상태를 구분한다.

상태 화면 버튼
NONE (미등록) "등록되지 않았습니다" [등록하기]
STALE_PATH (폴더 이동됨) "예전 폴더를 가리키고 있습니다" [다시 등록]
OK 다음 실행 시각 + 마지막 결과 + 실행 방식 [설정 변경] / [자동실행 끄기]

등록은 Register-ScheduledTask -Force멱등하다. [다시 등록]은 같은 코드 경로를 그대로 쓴다.

10.6 마지막 실행 결과 읽기

_TASK_RESULT_HUMAN = {
    0:      "성공",
    1:      "실패 (일시적 원인일 수 있음)",
    2:      "확인 필요 (인증키·설정 문제)",
    130:    "사용자가 중단함",
    267009: "실행 중",              # SCHED_S_TASK_RUNNING
    267011: "아직 실행된 적 없음",   # SCHED_S_TASK_HAS_NOT_RUN
    267014: "사용자가 작업을 종료함",
}


def last_task_result() -> tuple[str, str | None]:
    """(사람이 읽는 결과, 다음 실행 시각) 을 돌려준다."""
    p = subprocess.run(["schtasks.exe", "/Query", "/TN", r"\DMF Crawler\Daily", "/FO", "LIST", "/V"],
                       capture_output=True, text=True, encoding="utf-8",
                       errors="replace", creationflags=subprocess.CREATE_NO_WINDOW)
    ...

이 값을 [S6] 준비 완료 화면의 "마지막 실행" 줄에 표시한다. 06:00 배치가 조용히 실패해도 사용자가 다음에 마법사를 열면 즉시 알 수 있다. CHK ⑫ recent_runs 와 함께 이 시스템의 조용한 실패를 막는 2중 장치다.

10.7 해제

# scripts\uninstall_tasks.ps1
foreach ($t in @('\DMF Crawler\Daily', '\DMF Crawler\Agent', '\DMF Crawler\AgyUpdate')) {
    Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction SilentlyContinue
}

GUI 의 [자동실행 끄기]는 이것을 호출하고, 끄기 전에 확인 다이얼로그를 띄운다: 자동 실행을 끄면 매일 아침 리포트가 만들어지지 않습니다. 필요할 때 [지금 실행] 으로 직접 돌릴 수는 있습니다. 끄시겠습니까?


11. 완전한 구현 코드

생략 없이 완결적으로 쓴다. ... 을 쓰지 않는다. 각 파일은 01-architecture.md §2 트리의 경로에 그대로 배치한다.

인코딩 규칙

확장자 인코딩 이유
.py UTF-8 (BOM 없음) PEP 263 기본값
.ps1 UTF-8 with BOM BOM 이 없으면 Windows PowerShell 5.1 이 한글을 CP949 로 오독한다 (실측: "가나다 한글 ABC".Length 가 10 이 아니라 13 이 되고 "媛?섎떎 ?쒓? ABC" 로 깨진다). chcp 65001 로도 해결되지 않는다
.cmd CP949(ANSI) 또는 순수 ASCII cmd.exe 는 기본 코드페이지로 파일을 읽는다. chcp 65001 을 먼저 실행하고 UTF-8 로 저장하는 방법도 있으나 BOM 이 명령으로 해석되는 사고가 있어 CP949 가 안전하다

11.1 bootstrap.cmd — 최초 설치 진입점

@echo off
setlocal EnableExtensions EnableDelayedExpansion
title DMF 크롤러 설치
cd /d "%~dp0"

set "ROOT=%~dp0"
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
set "VENV=%ROOT%\.venv"
set "VPY=%VENV%\Scripts\python.exe"
set "VPYW=%VENV%\Scripts\pythonw.exe"
set "LOGDIR=%ROOT%\logs"
if not exist "%LOGDIR%" mkdir "%LOGDIR%" >nul 2>&1
set "LOG=%LOGDIR%\bootstrap.log"

echo ================================================
echo   DMF 크롤러 최초 설치
echo ================================================
echo.
echo 이 창은 설치가 끝나면 자동으로 닫힙니다.
echo 처음 한 번만 나타납니다.
echo.

rem ===================================================================
rem  [1/5] 파이썬 찾기
rem  주의: python.exe 가 PATH 에 있어도 파이썬이 설치된 것이 아니다.
rem        Microsoft Store 앱 실행 별칭 스텁이 항상 PATH 에 존재하며,
rem        인자를 주고 실행하면 오류 코드를 돌려준다(MS 공식 FAQ).
rem        그래서 py 런처를 1순위로 쓴다. Store 판에는 py 가 없다.
rem ===================================================================
echo [1/5] 파이썬을 찾는 중...
set "PYEXE="

rem -- 이미 venv 가 있으면 그대로 쓴다 (재실행 시 빠른 경로)
if exist "%VPY%" (
    "%VPY%" -c "import sys;raise SystemExit(0 if sys.version_info>=(3,11) else 1)" >nul 2>&1
    if !errorlevel! equ 0 (
        set "PYEXE=%VPY%"
        echo         기존 실행 환경을 사용합니다.
        goto :have_python
    )
)

rem -- 1순위: py 런처
where py.exe >nul 2>&1
if !errorlevel! equ 0 (
    for %%V in (3.13 3.12 3.11) do (
        if not defined PYEXE (
            py -%%V -c "import sys" >nul 2>&1
            if !errorlevel! equ 0 (
                for /f "delims=" %%P in ('py -%%V -c "import sys;print(sys.executable)" 2^>nul') do set "PYEXE=%%P"
            )
        )
    )
    if not defined PYEXE (
        py -3 -c "import sys;raise SystemExit(0 if sys.version_info>=(3,11) else 1)" >nul 2>&1
        if !errorlevel! equ 0 (
            for /f "delims=" %%P in ('py -3 -c "import sys;print(sys.executable)" 2^>nul') do set "PYEXE=%%P"
        )
    )
)

rem -- 2순위: PATH 의 python.exe (스텁이 아닌지 실제 실행으로 확인)
if not defined PYEXE (
    python -c "import sys;raise SystemExit(0 if sys.version_info>=(3,11) else 1)" >nul 2>&1
    if !errorlevel! equ 0 (
        for /f "delims=" %%P in ('python -c "import sys;print(sys.executable)" 2^>nul') do set "PYEXE=%%P"
    )
)

rem -- 3순위: 알려진 설치 경로 직접 탐색
if not defined PYEXE (
    for %%D in (
        "%LOCALAPPDATA%\Programs\Python\Python313"
        "%LOCALAPPDATA%\Programs\Python\Python312"
        "%LOCALAPPDATA%\Programs\Python\Python311"
        "C:\Python313" "C:\Python312" "C:\Python311"
    ) do (
        if not defined PYEXE if exist "%%~D\python.exe" set "PYEXE=%%~D\python.exe"
    )
)

if defined PYEXE goto :have_python

rem ===================================================================
rem  파이썬이 없다 -> 설치 여부를 묻는다
rem ===================================================================
echo         설치되어 있지 않습니다.
echo.
echo         파이썬을 지금 설치할까요? ^(약 2~5분^)
echo         관리자 권한은 필요하지 않습니다.
echo.
choice /c YN /n /m "        [Y] 예, 설치합니다   [N] 아니오, 직접 설치하겠습니다   선택: "
if errorlevel 2 goto :manual_python

echo.
echo         파이썬을 설치하는 중입니다. 창을 닫지 마세요...
where winget.exe >nul 2>&1
if !errorlevel! neq 0 goto :manual_python

winget install --id Python.Python.3.12 --scope user --silent ^
    --accept-package-agreements --accept-source-agreements >>"%LOG%" 2>&1

rem -- 설치 직후 이 창의 PATH 는 갱신되지 않는다. 설치 경로를 직접 찾는다.
for %%D in (
    "%LOCALAPPDATA%\Programs\Python\Python313"
    "%LOCALAPPDATA%\Programs\Python\Python312"
    "%LOCALAPPDATA%\Programs\Python\Python311"
) do (
    if not defined PYEXE if exist "%%~D\python.exe" set "PYEXE=%%~D\python.exe"
)
if not defined PYEXE (
    where py.exe >nul 2>&1
    if !errorlevel! equ 0 (
        for /f "delims=" %%P in ('py -3 -c "import sys;print(sys.executable)" 2^>nul') do set "PYEXE=%%P"
    )
)
if not defined PYEXE goto :manual_python
echo         설치되었습니다.

:have_python
echo         찾음: %PYEXE%
echo.

rem ===================================================================
rem  [2/5] 가상환경 + 의존성
rem ===================================================================
echo [2/5] 실행 환경을 준비하는 중... ^(30초~2분^)
if not exist "%VPY%" (
    "%PYEXE%" -m venv "%VENV%" >>"%LOG%" 2>&1
    if !errorlevel! neq 0 goto :fail_venv
)
"%VPY%" -m pip install --upgrade pip --disable-pip-version-check -q >>"%LOG%" 2>&1
"%VPY%" -m pip install -e "%ROOT%" --disable-pip-version-check -q >>"%LOG%" 2>&1
if !errorlevel! neq 0 goto :fail_deps
echo         완료
echo.

rem ===================================================================
rem  [3/5] Mark-of-the-Web 해제
rem  ZIP 으로 받아 압축을 풀면 전 파일에 Zone.Identifier 가 붙어
rem  .ps1 실행이 차단될 수 있다. 파일 속성 창의 [차단 해제] 와 같은 동작이다.
rem ===================================================================
echo [3/5] 파일 차단을 해제하는 중...
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ^
    -Command "Get-ChildItem -LiteralPath '%ROOT%' -Recurse -File -ErrorAction SilentlyContinue | Unblock-File -ErrorAction SilentlyContinue" >>"%LOG%" 2>&1
echo         완료
echo.

rem ===================================================================
rem  [4/5] 바로가기
rem ===================================================================
echo [4/5] 바탕화면 바로가기를 만드는 중...
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ^
    -File "%ROOT%\scripts\make_shortcuts.ps1" -ProjectRoot "%ROOT%" >>"%LOG%" 2>&1
if !errorlevel! neq 0 (
    echo         건너뜀 ^(바로가기 없이도 사용할 수 있습니다^)
) else (
    echo         완료
)
echo.

rem ===================================================================
rem  [5/5] 온보딩 GUI 기동
rem  pythonw.exe 는 콘솔을 만들지 않는다. start "" 로 띄우고 이 창은 닫는다.
rem ===================================================================
echo [5/5] 설정 창을 엽니다...
echo.
echo 설치가 끝났습니다. 잠시 후 설정 창이 열립니다.
start "" "%VPYW%" -m dmf_crawler onboard --mode setup
timeout /t 3 /nobreak >nul
exit /b 0


rem ===================================================================
rem  실패 경로 - 항상 '다음에 할 수 있는 행동' 을 알려준다
rem ===================================================================
:manual_python
echo.
echo ------------------------------------------------
echo   파이썬을 직접 설치해 주세요
echo ------------------------------------------------
echo.
echo   1. 브라우저에서 아래 주소를 엽니다.
echo        https://www.python.org/downloads/
echo   2. [Download Python 3.12.x] 를 눌러 설치 파일을 받습니다.
echo   3. 설치 화면 아래쪽의
echo        [Add python.exe to PATH]  <-- 이 체크박스를 반드시 켭니다.
echo   4. 설치가 끝나면 이 창을 닫고 bootstrap.cmd 를 다시 실행합니다.
echo.
choice /c YN /n /m "   지금 다운로드 페이지를 열까요? [Y/N]: "
if not errorlevel 2 start "" "https://www.python.org/downloads/"
echo.
pause
exit /b 1

:fail_venv
echo.
echo   [오류] 실행 환경을 만들지 못했습니다.
echo.
echo   다음을 확인해 주세요.
echo     - 이 폴더에 파일을 만들 수 있는 권한이 있는지
echo       ^(C:\Program Files 아래라면 [문서] 폴더로 옮겨 주세요^)
echo     - 백신 프로그램이 차단하고 있지 않은지
echo.
echo   자세한 내용: %LOG%
echo.
pause
exit /b 1

:fail_deps
echo.
echo   [오류] 필요한 부품을 내려받지 못했습니다.
echo.
echo   다음을 확인해 주세요.
echo     - 인터넷에 연결되어 있는지
echo     - 회사 방화벽이 pypi.org 접속을 막고 있지 않은지
echo       ^(IT 담당자에게 pypi.org, files.pythonhosted.org 허용을 요청하세요^)
echo.
echo   자세한 내용: %LOG%
echo.
pause
exit /b 1

11.2 src/dmf_crawler/checks.py — 진단 엔진 (핵심 골격)

4절의 12개 체크 함수를 등록·실행한다. fix_action 필수 계약이 여기서 강제된다.

"""전제조건 진단 엔진.

온보딩(R6)·복구(R7)·CLI doctor 가 공유하는 단일 진실(ADR-24).
이 모듈은 어떤 상황에서도 예외로 죽지 않는다 — 진단기가 죽으면 진단이 불가능해진다.
"""
from __future__ import annotations

import enum
import os
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable


class Severity(enum.StrEnum):
    CRITICAL = "CRITICAL"
    WARN = "WARN"


@dataclass(frozen=True, slots=True)
class CheckResult:
    key: str
    title: str
    ok: bool
    detail: str
    fix_hint: str
    fix_action: str | None
    severity: Severity


# --- 등록부 -------------------------------------------------------------
# 실행 순서가 곧 화면 표시 순서다.
_REGISTRY: list[tuple[str, Callable[..., CheckResult], str]] = []


def _register(key: str, fn: Callable[..., CheckResult], fix_action: str) -> None:
    """체크를 등록한다.

    fix_action 이 비어 있으면 등록을 거부한다.
    이것이 '막다른 골목 금지'(요구 R7.7)를 계약으로 강제하는 지점이다.
    화면에 실패로 뜨는데 누를 버튼이 없는 항목은 존재할 수 없다.
    """
    if not fix_action:
        raise ValueError(f"check '{key}' 에 fix_action 이 없습니다. "
                         "모든 체크는 사용자가 취할 행동을 반드시 제공해야 합니다.")
    _REGISTRY.append((key, fn, fix_action))


def _safe(key: str, title: str, fn: Callable[..., CheckResult],
          fix_action: str, *args, **kwargs) -> CheckResult:
    """체크 자체가 던지는 예외를 흡수해 결과값으로 바꾼다."""
    try:
        return fn(*args, **kwargs)
    except Exception as e:                       # noqa: BLE001 - 진단기는 죽으면 안 된다
        return CheckResult(
            key=key, title=title, ok=False,
            detail=f"확인하는 중 문제가 생겼습니다: {type(e).__name__}: {e}",
            fix_hint="[다시 검사] 를 눌러 보고, 계속되면 [진단 결과 복사하기] 로 "
                     "내용을 복사해 도움을 요청해 주세요.",
            fix_action=fix_action, severity=Severity.CRITICAL)


def run_all(cfg=None) -> list[CheckResult]:
    """12종을 순서대로 실행한다. 앞 항목이 실패하면 뒤 항목을 보류로 표시한다."""
    results: list[CheckResult] = []
    blocked_by: str | None = None

    for key, fn, fix_action in _REGISTRY:
        title = _TITLES[key]
        if blocked_by and key in _DEPENDS_ON.get(blocked_by, ()):
            results.append(CheckResult(
                key=key, title=title, ok=False,
                detail="앞 단계 확인 후 판정합니다.",
                fix_hint="", fix_action=fix_action, severity=_SEVERITY[key]))
            continue
        r = _safe(key, title, fn, fix_action, cfg)
        results.append(r)
        if not r.ok and r.severity is Severity.CRITICAL and key in _GATES:
            blocked_by = key
    return results


def run_one(key: str, cfg=None, **kwargs) -> CheckResult:
    """단일 체크만 다시 실행한다. GUI 의 부분 재검사가 쓴다."""
    for k, fn, fix_action in _REGISTRY:
        if k == key:
            return _safe(k, _TITLES[k], fn, fix_action, cfg, **kwargs)
    raise KeyError(f"알 수 없는 체크: {key}")


def verdict(results: list[CheckResult]) -> int:
    """doctor 종료 코드. 0=전부 통과, 1=WARN 존재, 2=CRITICAL 존재."""
    if any(not r.ok and r.severity is Severity.CRITICAL for r in results):
        return 2
    if any(not r.ok for r in results):
        return 1
    return 0


def can_run(results: list[CheckResult]) -> bool:
    """[지금 실행] 버튼 활성화 여부. WARN 은 실행을 막지 않는다."""
    return not any(not r.ok and r.severity is Severity.CRITICAL for r in results)


# --- 메타데이터 ---------------------------------------------------------
_TITLES = {
    "python_venv":      "실행 환경",
    "dependencies":     "필요한 부품",
    "config_valid":     "설정 파일",
    "api_key_present":  "공식 API 인증키(선택)",
    "api_key_valid":    "공식 API 사용 가능 여부(선택)",
    "agy_installed":    "AGY 수집/AI 요약",
    "agy_auth":         "AGY 로그인",
    "database":         "자료 보관소",
    "tasks_registered": "자동 실행 등록",
    "disk_space":       "저장 공간",
    "report_writable":  "리포트 저장 폴더",
    "recent_runs":      "최근 실행 상태",
}

_SEVERITY = {
    "python_venv":      Severity.CRITICAL,
    "dependencies":     Severity.CRITICAL,
    "config_valid":     Severity.CRITICAL,
    "api_key_present":  Severity.WARN,
    "api_key_valid":    Severity.WARN,
    "agy_installed":    Severity.WARN,
    "agy_auth":         Severity.WARN,
    "database":         Severity.CRITICAL,
    "tasks_registered": Severity.WARN,
    "disk_space":       Severity.CRITICAL,
    "report_writable":  Severity.CRITICAL,
    "recent_runs":      Severity.WARN,
}

# 이 체크가 실패하면 뒤의 어떤 체크를 '판정 보류' 로 만드는가
_GATES = {"python_venv", "dependencies"}
_DEPENDS_ON = {
    "python_venv":     ("dependencies", "config_valid", "database",
                        "api_key_present", "api_key_valid"),
    "dependencies":    ("api_key_valid", "database"),
    "api_key_present": ("api_key_valid",),
}

# GUI 가 액션을 마친 뒤 어떤 key 만 다시 검사할지 (상태 머신 6.3-2)
REPROBE_AFTER = {
    "enter_api_key": ("api_key_present", "api_key_valid"),
    "install_deps":  ("dependencies",),
    "install_agy":   ("agy_installed", "agy_auth"),
    "login_agy":     ("agy_auth",),
    "install_tasks": ("tasks_registered",),
    "repair_db":     ("database", "recent_runs"),
    "open_config":   ("config_valid", "tasks_registered"),
    "open_cleanmgr": ("disk_space",),
    "open_reports_dir": ("report_writable",),
    "open_last_log": ("recent_runs",),
    "open_bootstrap_help": ("python_venv", "dependencies"),
}


# --- 등록 (4절의 함수들. 본문은 4.2 참조) --------------------------------
_register("python_venv",      _check_python_venv,      "open_bootstrap_help")
_register("dependencies",     _check_dependencies,     "install_deps")
_register("config_valid",     _check_config,           "open_config")
_register("api_key_present",  _check_api_key_present,  "enter_api_key")
_register("api_key_valid",    _check_api_key_valid,    "enter_api_key")
_register("agy_installed",    _check_agy_installed,    "install_agy")
_register("agy_auth",         _check_agy_auth,         "login_agy")
_register("database",         _check_database,         "repair_db")
_register("tasks_registered", _check_tasks,            "install_tasks")
_register("disk_space",       _check_disk,             "open_cleanmgr")
_register("report_writable",  _check_report_writable,  "open_reports_dir")
_register("recent_runs",      _check_recent_runs,      "open_last_log")

11.3 src/dmf_crawler/gui/app.py — 온보딩=복구 단일 창

"""온보딩·복구·진단 단일 창 (요구 R6·R7).

checks.run_all() 결과를 체크리스트로 그리고, 각 항목의 fix_action 을 버튼으로 노출한다.
GUI 는 판단하지 않는다 — 판단은 전부 checks.py 에 있고 여기는 렌더러다(ADR-24).
"""
from __future__ import annotations

import ctypes
import json
import subprocess
import sys
import tkinter as tk
from tkinter import messagebox, ttk
from typing import Literal

from .. import checks
from ..checks import CheckResult, Severity
from ..config import load_config
from ..paths import PROJECT_ROOT, REPORTS_DIR, VENV_PYTHON
from . import steps
from .widgets import (CENTER, COL, FONT, ScrollFrame, apply_dpi_awareness,
                      center_window, init_style, run_in_thread)


class OnboardApp(tk.Tk):

    def __init__(self, mode: str = "setup", focus_key: str | None = None,
                 run_now: bool = False) -> None:
        super().__init__()
        self.mode = mode
        self.focus_key = focus_key
        self._results: list[CheckResult] = []
        self._cfg = None
        self._busy = False

        self.title("DMF 크롤러 설정")
        self.resizable(False, False)
        init_style(self)
        self._build_chrome()

        # 창을 띄운 뒤 최상위를 해제한다.
        # 그렇게 하지 않으면 사용자가 브라우저에서 인증키를 복사할 때
        # 이 창이 브라우저를 계속 가려 아무것도 못 한다.
        self.attributes("-topmost", True)
        self.after(400, lambda: self.attributes("-topmost", False))

        self.protocol("WM_DELETE_WINDOW", self._on_close)
        self.after(120, lambda: self.probe(initial=True, run_now=run_now))

    # ---------------------------------------------------------------- 뼈대
    def _build_chrome(self) -> None:
        self.geometry("780x600")
        center_window(self, 780, 600)

        head = ttk.Frame(self, padding=(24, 18, 24, 6))
        head.pack(fill="x")
        self.lbl_title = ttk.Label(head, text="DMF 크롤러 준비 상태", style="H1.TLabel")
        self.lbl_title.pack(anchor="w")
        self.lbl_sub = ttk.Label(head, text="상태를 확인하는 중입니다…", style="Sub.TLabel")
        self.lbl_sub.pack(anchor="w", pady=(4, 0))

        # 인증키 사용 기한 보조 경보 배너 (7.7). 평소에는 숨어 있다.
        self.banner = ttk.Label(self, text="", style="Banner.TLabel",
                                wraplength=720, padding=(12, 8))

        self.body = ScrollFrame(self, height=380)
        self.body.pack(fill="both", expand=True, padx=22, pady=(8, 6))

        bar = ttk.Frame(self, padding=(22, 6, 22, 18))
        bar.pack(fill="x")
        self.btn_run = ttk.Button(bar, text="지금 실행", style="Primary.TButton",
                                  command=self._on_run_now, width=14)
        self.btn_run.pack(side="left")
        ttk.Button(bar, text="다시 검사", width=11,
                   command=lambda: self.probe(force=True)).pack(side="left", padx=8)
        ttk.Button(bar, text="진단 결과 복사하기", width=18,
                   command=self._copy_diagnosis).pack(side="left")
        ttk.Button(bar, text="닫기", width=10,
                   command=self._on_close).pack(side="right")

    # ---------------------------------------------------------------- 진단
    def probe(self, initial: bool = False, force: bool = False,
              only: tuple[str, ...] | None = None, run_now: bool = False) -> None:
        """checks 를 워커 스레드에서 돌린다. GUI 는 절대 얼지 않는다."""
        if self._busy:
            return
        self._busy = True
        self.lbl_sub.configure(text="상태를 확인하는 중입니다…")
        self.btn_run.state(["disabled"])

        def work() -> list[CheckResult]:
            try:
                self._cfg = load_config()
            except Exception:
                self._cfg = None
            if only:
                merged = {r.key: r for r in self._results}
                for k in only:
                    kwargs = {"force": True} if k == "api_key_valid" else {}
                    merged[k] = checks.run_one(k, self._cfg, **kwargs)
                # 등록 순서를 유지한다
                return [merged[k] for k, _, _ in checks._REGISTRY if k in merged]
            return checks.run_all(self._cfg)

        def done(results: list[CheckResult]) -> None:
            self._busy = False
            self._results = results
            self._render()
            if run_now and checks.can_run(results):
                self._on_run_now()

        run_in_thread(self, work, done)

    # ---------------------------------------------------------------- 렌더
    def _render(self) -> None:
        ok_n = sum(1 for r in self._results if r.ok)
        total = len(self._results)
        runnable = checks.can_run(self._results)

        # 전부 통과 + setup/inspect 모드면 [S6] 준비 완료 화면으로 간다.
        if ok_n == total and self.mode != "recover":
            self._render_ready()
            return
        if self.mode == "recover" and self.focus_key:
            self._render_recover()
            return

        self.lbl_title.configure(text="DMF 크롤러 준비 상태")
        self.lbl_sub.configure(
            text=("✖ 표시된 항목의 오른쪽 버튼을 눌러 하나씩 해결해 주세요."
                  if not runnable else
                  "▲ 항목은 없어도 실행할 수 있습니다.")
                 + f"   ({ok_n} / {total} 완료)")

        msg = steps.key_age_banner()
        if msg:
            self.banner.configure(text="⚠ " + msg)
            self.banner.pack(fill="x", padx=22, pady=(0, 4), before=self.body)
        else:
            self.banner.pack_forget()

        self.body.clear()
        for r in self._results:
            self._render_row(self.body.inner, r)

        self.btn_run.state(["!disabled"] if runnable else ["disabled"])

    def _render_row(self, parent: tk.Widget, r: CheckResult) -> None:
        row = ttk.Frame(parent, padding=(10, 9))
        row.pack(fill="x")
        ttk.Separator(parent, orient="horizontal").pack(fill="x")

        if r.ok:
            mark, color = "✔", COL["ok"]
        elif r.detail.startswith("앞 단계"):
            mark, color = "", COL["muted"]
        elif r.severity is Severity.WARN:
            mark, color = "▲", COL["warn"]
        else:
            mark, color = "✖", COL["bad"]

        tk.Label(row, text=mark, fg=color, bg=COL["bg"],
                 font=("Segoe UI Symbol", 14, "bold")).pack(side="left", padx=(0, 12))

        text = ttk.Frame(row)
        text.pack(side="left", fill="x", expand=True)
        title = r.title + ("  (없어도 됨)" if r.severity is Severity.WARN else "")
        ttk.Label(text, text=title, style="Item.TLabel").pack(anchor="w")
        ttk.Label(text, text=r.detail, style="Detail.TLabel",
                  wraplength=520, justify="left").pack(anchor="w")

        if not r.ok and r.fix_action and not r.detail.startswith("앞 단계"):
            ttk.Button(row, text=steps.BUTTON_LABEL[r.fix_action], width=16,
                       command=lambda a=r.fix_action: self._do_action(a)
                       ).pack(side="right", padx=(8, 0))

    def _render_ready(self) -> None:
        """[S6] 준비 완료. 전부 ✔ 인 목록은 정보 가치가 0 이므로 보여주지 않는다."""
        self.banner.pack_forget()
        self.body.clear()
        f = ttk.Frame(self.body.inner, padding=(20, 40))
        f.pack(fill="both", expand=True)

        ttk.Label(f, text="모든 준비가 끝났습니다", style="H0.TLabel").pack(pady=(10, 6))
        daily = self._cfg.schedule.daily_time if self._cfg else "06:00"
        ttk.Label(f, text=f"매일 아침 {daily} 에 자동으로 실행됩니다.",
                  style="Sub.TLabel").pack()

        ttk.Button(f, text="지금 실행", style="Big.TButton",
                   command=self._on_run_now).pack(pady=26, ipady=12, ipadx=40)

        box = ttk.Frame(f, style="Card.TFrame", padding=14)
        box.pack(fill="x", padx=30)
        for line in steps.summary_lines(self._results):
            ttk.Label(box, text=line, style="Detail.TLabel").pack(anchor="w")

        self.lbl_title.configure(text="DMF 크롤러")
        self.lbl_sub.configure(text="")
        self.btn_run.state(["!disabled"])

    def _render_recover(self) -> None:
        """[S1-R] 복구 모드. 문제 항목 하나만 크게 보여준다."""
        target = next((r for r in self._results if r.key == self.focus_key), None)
        if target is None or target.ok:
            self.mode = "setup"
            self._render()
            return

        self.banner.pack_forget()
        self.lbl_title.configure(text="확인이 필요합니다")
        self.lbl_sub.configure(text="오늘 아침 자동 실행이 완료되지 못했습니다.")

        self.body.clear()
        card = ttk.Frame(self.body.inner, style="Card.TFrame", padding=20)
        card.pack(fill="x", padx=16, pady=16)

        ttk.Label(card, text=f"✖   {target.title}", style="H1.TLabel",
                  foreground=COL["bad"]).pack(anchor="w", pady=(0, 12))
        for label, value in steps.four_part_message(target):
            line = ttk.Frame(card); line.pack(fill="x", pady=2)
            ttk.Label(line, text=label, width=10, style="Key.TLabel").pack(side="left", anchor="n")
            ttk.Label(line, text=value, wraplength=560, justify="left",
                      style="Detail.TLabel").pack(side="left", anchor="w")

        btns = ttk.Frame(card); btns.pack(anchor="w", pady=(16, 0))
        ttk.Button(btns, text=steps.BUTTON_LABEL[target.fix_action], width=16,
                   command=lambda: self._do_action(target.fix_action)).pack(side="left")
        ttk.Button(btns, text="마이페이지 열기", width=16,
                   command=lambda: steps.open_portal_mypage()).pack(side="left", padx=8)

        ttk.Label(self.body.inner,
                  text="※ 어제까지의 리포트는 그대로 남아 있습니다.\n"
                       "   해결하면 [지금 실행] 으로 오늘 자료를 바로 받을 수 있습니다.",
                  style="Detail.TLabel", justify="left").pack(anchor="w", padx=20)

        ttk.Button(self.body.inner, text="전체 상태 보기", width=16,
                   command=self._switch_to_setup).pack(anchor="w", padx=20, pady=12)

    def _switch_to_setup(self) -> None:
        self.mode = "setup"
        self.focus_key = None
        self._render()

    # ---------------------------------------------------------------- 액션
    def _do_action(self, action: str) -> None:
        """fix_action 을 실행하고, 끝나면 관련 체크만 다시 돌린다."""
        if self._busy:
            return
        handler = steps.ACTIONS.get(action)
        if handler is None:
            messagebox.showerror("오류", f"알 수 없는 동작입니다: {action}", parent=self)
            return
        try:
            handler(self, self._cfg)
        except Exception as e:                   # noqa: BLE001 - GUI 는 죽지 않는다
            steps.show_blocked(
                self, title=steps.BUTTON_LABEL.get(action, action),
                cause=f"{type(e).__name__}: {e}",
                actions=("다시 시도", "로그 열기", "진단 복사", "건너뛰기"),
                retry=lambda: self._do_action(action))
        finally:
            self.probe(only=checks.REPROBE_AFTER.get(action))

    def _on_run_now(self) -> None:
        if self._busy:
            return
        code = steps.run_pipeline(self, self._cfg)      # [S7] -> [S8]/[S9]
        if code == 2:
            failed = next((r for r in self._results
                           if not r.ok and r.severity is Severity.CRITICAL), None)
            self.mode = "recover"
            self.focus_key = failed.key if failed else None
        self.probe(force=(code == 0))

    def _copy_diagnosis(self) -> None:
        """도움을 요청할 때 붙여넣을 내용을 클립보드에 담는다.

        막다른 골목 방지의 마지막 장치다. 인증키 원문은 절대 포함하지 않는다.
        """
        payload = {
            "generated_at": steps.utcnow_iso(),
            "project_root": str(PROJECT_ROOT),
            "python": sys.version,
            "mode": self.mode,
            "checks": [
                {"key": r.key, "title": r.title, "ok": r.ok,
                 "severity": str(r.severity), "detail": r.detail}
                for r in self._results
            ],
        }
        text = json.dumps(payload, ensure_ascii=False, indent=2)
        self.clipboard_clear()
        self.clipboard_append(text)
        messagebox.showinfo(
            "복사했습니다",
            "진단 결과를 복사했습니다.\n"
            "도움을 요청하실 때 메일이나 메신저에 붙여넣어 주세요.\n\n"
            "※ 인증키 같은 비밀 정보는 포함되지 않습니다.", parent=self)

    def _on_close(self) -> None:
        if self._busy and not messagebox.askyesno(
                "확인", "진행 중인 작업이 있습니다. 정말 닫으시겠습니까?", parent=self):
            return
        self.destroy()


def launch(mode: Literal["setup", "recover", "inspect"] = "setup",
           focus_key: str | None = None, run_now: bool = False) -> int:
    """GUI 진입점. cli.py 의 onboard 커맨드가 호출한다."""
    apply_dpi_awareness()          # Tk 루트 생성 '전' 이어야 한다
    try:
        app = OnboardApp(mode=mode, focus_key=focus_key, run_now=run_now)
    except tk.TclError as e:
        # GUI 가 뜨지 못하면 doctor 텍스트 출력으로 폴백한다 (§3.17 계약)
        print(f"[오류] 창을 띄우지 못했습니다: {e}", file=sys.stderr)
        from ..cli import doctor_text
        return doctor_text()
    app.mainloop()
    return 0

11.4 src/dmf_crawler/gui/steps.py — 단계별 액션

"""체크리스트의 fix_action 구현.

각 함수는 (app, cfg) 를 받고, 필요한 다이얼로그를 띄운 뒤 돌아온다.
재검사는 호출자(app._do_action)가 REPROBE_AFTER 에 따라 수행한다.
"""
from __future__ import annotations

import json
import os
import re
import subprocess
import time
import tkinter as tk
import webbrowser
from datetime import datetime, timezone
from pathlib import Path
from tkinter import messagebox, ttk

from ..keycheck import (MYPAGE_HINT, PORTAL_DATASET, PORTAL_MYPAGE,
                        explain_key_check, validate_service_key)
from ..paths import (AGY_EXE, AGY_TOKEN, LOGS_DIR, PROJECT_ROOT, REPORTS_DIR,
                     SCRIPTS_DIR, VENV_PYTHON, CONFIG_PATH)
from ..secrets_dpapi import save_service_key
from ..state import read_heartbeat, touch_heartbeat
from .widgets import COL, ScrollFrame, center_window, run_in_thread

BUTTON_LABEL = {
    "open_bootstrap_help": "준비 방법 보기",
    "install_deps":        "설치하기",
    "open_config":         "설정 열기",
    "enter_api_key":       "키 입력",
    "install_agy":         "설치하기",
    "login_agy":           "로그인",
    "repair_db":           "복구하기",
    "install_tasks":       "등록하기",
    "open_cleanmgr":       "디스크 정리",
    "open_reports_dir":    "폴더 열기",
    "open_last_log":       "로그 보기",
}


def utcnow_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


# ====================================================================== 인증키
class KeyDialog(tk.Toplevel):
    """[S2] 인증키 등록."""

    def __init__(self, master: tk.Misc) -> None:
        super().__init__(master)
        self.saved = False
        self.title("공공데이터포털 인증키 등록")
        self.resizable(False, False)
        self.transient(master)
        center_window(self, 720, 460)

        self.var_key = tk.StringVar()
        self.var_show = tk.BooleanVar(value=False)

        pad = ttk.Frame(self, padding=(24, 20))
        pad.pack(fill="both", expand=True)

        ttk.Label(pad, text="공공데이터포털에서 발급받은 인증키를 붙여넣어 주세요.",
                  style="H1.TLabel").pack(anchor="w")
        ttk.Label(pad, justify="left", style="Detail.TLabel", text=(
            "\n아직 인증키가 없다면\n"
            " 1. 아래 [발급 페이지 열기] 를 누릅니다.\n"
            " 2. 로그인한 뒤 [활용신청] 버튼을 누릅니다. (무료, 즉시 승인)\n"
            f" 3. {MYPAGE_HINT} 에서\n"
            '    "일반 인증키" 를 통째로 복사합니다.\n'
            " 4. 여기로 돌아와 아래 칸에 붙여넣습니다.")).pack(anchor="w")

        row = ttk.Frame(pad); row.pack(fill="x", pady=(16, 6))
        self.entry = ttk.Entry(row, textvariable=self.var_key, show="●", width=56)
        self.entry.pack(side="left")
        ttk.Checkbutton(row, text="표시", variable=self.var_show,
                        command=self._toggle_show).pack(side="left", padx=12)

        btns = ttk.Frame(pad); btns.pack(fill="x", pady=(4, 10))
        ttk.Button(btns, text="붙여넣기", width=12, command=self._paste).pack(side="left")
        ttk.Button(btns, text="발급 페이지 열기", width=18,
                   command=lambda: webbrowser.open(PORTAL_DATASET)).pack(side="left", padx=8)
        ttk.Button(btns, text="마이페이지 열기", width=16,
                   command=lambda: webbrowser.open(PORTAL_MYPAGE)).pack(side="left")

        self.status = tk.Label(pad, text="", anchor="w", justify="left",
                               bg=COL["card"], fg=COL["muted"], wraplength=650,
                               padx=10, pady=8)
        self.status.pack(fill="x", pady=(6, 8))

        ttk.Label(pad, style="Detail.TLabel", justify="left", text=(
            "인증키는 이 컴퓨터의 사용자 계정으로 암호화되어 저장됩니다.\n"
            "다른 컴퓨터로 파일을 복사해도 열리지 않습니다.")).pack(anchor="w")

        act = ttk.Frame(pad); act.pack(anchor="e", pady=(12, 0))
        self.btn_save = ttk.Button(act, text="저장", width=12, command=self._on_save)
        self.btn_save.pack(side="left", padx=6)
        ttk.Button(act, text="취소", width=12, command=self.destroy).pack(side="left")

        self.bind("<Return>", lambda _e: self._on_save())
        self.bind("<Escape>", lambda _e: self.destroy())
        self.entry.focus_set()
        self.grab_set()
        self.attributes("-topmost", True)
        self.after(400, lambda: self.attributes("-topmost", False))

    def _toggle_show(self) -> None:
        self.entry.configure(show="" if self.var_show.get() else "●")

    def _paste(self) -> None:
        try:
            self.var_key.set(self.clipboard_get().strip())
        except tk.TclError:
            self._set_status("bad", "복사된 내용이 없습니다. 인증키를 먼저 복사해 주세요.")

    def _set_status(self, kind: str, text: str) -> None:
        self.status.configure(
            text={"busy": "⋯ ", "ok": "✔ ", "warn": "▲ ", "bad": "✖ "}.get(kind, "") + text,
            fg={"busy": COL["muted"], "ok": COL["ok"],
                "warn": COL["warn"], "bad": COL["bad"]}.get(kind, COL["muted"]))

    def _on_save(self) -> None:
        raw = self.var_key.get().strip()
        if len(raw) < 20:
            self._set_status("bad", "인증키가 너무 짧습니다. 전체를 붙여넣었는지 확인해 주세요.")
            return
        if not re.fullmatch(r"[A-Za-z0-9%+/=_.\-]+", raw):
            self._set_status("bad", "인증키에 들어갈 수 없는 문자가 있습니다. "
                                    "앞뒤 공백과 줄바꿈을 지워 주세요.")
            return

        self.btn_save.state(["disabled"])
        self._set_status("busy", "인증키를 확인하는 중입니다…")

        def work():
            return validate_service_key(raw)

        def done(result):
            key, res = result
            self.btn_save.state(["!disabled"])
            if key is None:
                self._set_status("bad", explain_key_check(res))
                return
            save_service_key(key)
            hb = read_heartbeat()
            patch = {"api_key_verified_at": utcnow_iso()}
            if not hb.get("api_key_first_success_at"):
                patch["api_key_first_success_at"] = utcnow_iso()
            touch_heartbeat(**patch)
            self.saved = True
            self._set_status("ok" if res.ok else "warn", explain_key_check(res))
            self.after(1200, self.destroy)

        run_in_thread(self, work, done)


def enter_api_key(app, cfg) -> None:
    dlg = KeyDialog(app)
    app.wait_window(dlg)


# ====================================================================== 진행률
class ProgressDialog(tk.Toplevel):
    """[S3] 외부 명령을 돌리며 진행률과 로그를 보여준다."""

    def __init__(self, master: tk.Misc, title: str, headline: str,
                 phases: list[str], argv: list[str],
                 phase_of, env: dict | None = None) -> None:
        super().__init__(master)
        self.ok = False
        self.log_text = ""
        self._cancelled = False
        self._proc: subprocess.Popen | None = None
        self._phase_of = phase_of

        self.title(title)
        self.resizable(False, False)
        self.transient(master)
        self.protocol("WM_DELETE_WINDOW", self._cancel)
        center_window(self, 660, 420)

        pad = ttk.Frame(self, padding=(24, 20)); pad.pack(fill="both", expand=True)
        ttk.Label(pad, text=headline, wraplength=600, justify="left",
                  style="Detail.TLabel").pack(anchor="w")

        self.bar = ttk.Progressbar(pad, length=600, mode="determinate", maximum=100)
        self.bar.pack(pady=(14, 10))

        head = ttk.Frame(pad); head.pack(fill="x")
        ttk.Label(head, text="진행 내용", style="Key.TLabel").pack(side="left")
        self.var_verbose = tk.BooleanVar(value=False)
        ttk.Checkbutton(head, text="자세히 보기", variable=self.var_verbose,
                        command=self._toggle_log).pack(side="right")

        self.phase_labels: list[tk.Label] = []
        box = ttk.Frame(pad, style="Card.TFrame", padding=10)
        box.pack(fill="x", pady=(4, 8))
        for i, name in enumerate(phases, start=1):
            lb = tk.Label(box, text=f"[{i}/{len(phases)}] {name}    ",
                          anchor="w", bg=COL["card"], fg=COL["muted"])
            lb.pack(fill="x")
            self.phase_labels.append(lb)

        # 원시 로그는 기본으로 숨긴다. 영어 로그가 쏟아지면 사용자는 겁먹는다.
        self.log = tk.Text(pad, height=6, width=76, bg="#1E1E1E", fg="#D4D4D4",
                           font=("Consolas", 9), wrap="none")

        ttk.Button(pad, text="취소", width=12, command=self._cancel).pack(anchor="e", pady=(6, 0))

        self.grab_set()
        self.after(80, lambda: self._start(argv, env))

    def _toggle_log(self) -> None:
        if self.var_verbose.get():
            self.log.pack(fill="x", pady=(4, 6))
        else:
            self.log.pack_forget()

    def _set_phase(self, step: int, label: str, pct: int) -> None:
        for i, lb in enumerate(self.phase_labels, start=1):
            base = lb.cget("text").split("]", 1)[1].rsplit("  ", 1)[0].strip()
            if i < step:
                lb.configure(text=f"[{i}/{len(self.phase_labels)}] {base}    ✔", fg=COL["ok"])
            elif i == step:
                lb.configure(text=f"[{i}/{len(self.phase_labels)}] {label}    {pct}%",
                             fg=COL["fg"])
            else:
                lb.configure(text=f"[{i}/{len(self.phase_labels)}] {base}    ", fg=COL["muted"])
        self.bar["value"] = pct

    def _start(self, argv: list[str], env: dict | None) -> None:
        def work() -> int:
            self._proc = subprocess.Popen(
                argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                text=True, encoding="utf-8", errors="replace",
                env={**os.environ, **(env or {})},
                creationflags=subprocess.CREATE_NO_WINDOW)
            for line in self._proc.stdout:            # type: ignore[union-attr]
                if self._cancelled:
                    self._proc.kill()
                    break
                self.log_text += line
                self.after(0, self._on_line, line.rstrip("\n"))
            self._proc.wait()
            return self._proc.returncode

        def done(code: int) -> None:
            self.ok = (code == 0) and not self._cancelled
            if not self.ok and not self.var_verbose.get():
                self.var_verbose.set(True)            # 실패하면 로그를 자동으로 펼친다
                self._toggle_log()
                self.after(1500, self.destroy)
            else:
                self._set_phase(len(self.phase_labels), "완료", 100)
                self.after(600, self.destroy)

        run_in_thread(self, work, done)

    def _on_line(self, line: str) -> None:
        self.log.insert("end", line + "\n")
        self.log.see("end")
        ph = self._phase_of(line)
        if ph:
            self._set_phase(*ph)

    def _cancel(self) -> None:
        self._cancelled = True
        if self._proc and self._proc.poll() is None:
            try:
                self._proc.kill()
            except Exception:
                pass
        self.destroy()


# ====================================================================== agy
_AGY_PHASES = [
    (re.compile(r"manifest|download", re.I),            2, "프로그램 내려받는 중…", 40),
    (re.compile(r"verify|sha512|hash|checksum", re.I),  3, "파일 검사 중…",         80),
    (re.compile(r"install|path|configur", re.I),        4, "설치 확인 중…",         92),
]


def _agy_phase(line: str):
    for pat, step, label, pct in reversed(_AGY_PHASES):
        if pat.search(line):
            return step, label, pct
    return None


def install_agy(app, cfg) -> None:
    dlg = ProgressDialog(
        app, title="AGY 수집/AI 요약 설치",
        headline=("Antigravity CLI 를 내려받아 설치하고 있습니다.\n"
                  "약 190 MB 이며 인터넷 속도에 따라 1~4분 정도 걸립니다."),
        phases=["설치 스크립트를 받는 중…", "프로그램 내려받는 중…",
                "파일 검사", "설치 확인"],
        argv=["powershell.exe", "-NoProfile", "-NonInteractive",
              "-ExecutionPolicy", "Bypass",
              "-File", str(SCRIPTS_DIR / "bootstrap_agy.ps1")],
        phase_of=_agy_phase,
        env={"AGY_CLI_DISABLE_AUTO_UPDATE": "true"})
    app.wait_window(dlg)

    if not dlg.ok:
        show_blocked(
            app, title="AGY 수집/AI 요약 설치",
            cause=_diagnose_agy_failure(dlg.log_text),
            actions=("다시 시도", "직접 설치", "로그 열기", "진단 복사", "건너뛰기"),
            retry=lambda: install_agy(app, cfg),
            manual=_agy_manual_help)


def _diagnose_agy_failure(log: str) -> str:
    low = log.lower()
    if "checksum" in low or "security halt" in low:
        return ("내려받은 파일 검사에 실패했습니다.\n"
                "네트워크가 불안정하거나 중간에서 파일이 바뀌었을 수 있습니다.")
    if any(k in low for k in ("could not resolve", "unable to connect",
                              "ssl", "timed out", "제한 시간")):
        return ("회사 네트워크가 다운로드 주소 접속을 막고 있습니다.\n"
                "(antigravity.google 연결 실패)")
    if "space" in low or "디스크" in log:
        return "디스크 여유 공간이 부족합니다."
    if "denied" in low or "액세스가 거부" in log:
        return "설치 폴더에 쓸 권한이 없습니다."
    return "알 수 없는 이유로 설치가 끝나지 않았습니다."


def _agy_manual_help(app) -> None:
    win = tk.Toplevel(app)
    win.title("직접 설치하는 방법")
    win.resizable(False, False)
    win.transient(app)
    center_window(win, 620, 380)
    pad = ttk.Frame(win, padding=(24, 20)); pad.pack(fill="both", expand=True)

    ttk.Label(pad, justify="left", style="Detail.TLabel", text=(
        "아래 명령을 복사해서 실행하면 설치됩니다.\n\n"
        ' 1. 시작 메뉴에서 "PowerShell" 을 검색해 실행합니다.\n'
        " 2. 아래 [명령 복사] 를 누른 뒤 그 창에 붙여넣고 Enter.")).pack(anchor="w")

    cmd = "irm https://antigravity.google/cli/install.ps1 | iex"
    box = tk.Text(pad, height=2, width=64, bg=COL["card"], relief="solid", bd=1)
    box.insert("1.0", cmd)
    box.configure(state="disabled")
    box.pack(fill="x", pady=10)

    def copy_cmd() -> None:
        app.clipboard_clear(); app.clipboard_append(cmd)
        messagebox.showinfo("복사했습니다", "PowerShell 창에 붙여넣어 주세요.", parent=win)

    ttk.Button(pad, text="명령 복사", width=14, command=copy_cmd).pack(anchor="w")
    ttk.Label(pad, justify="left", style="Detail.TLabel", text=(
        "\n 3. 설치가 끝나면 이 창을 닫고 [다시 검사] 를 누릅니다.\n\n"
        "※ 관리자 권한은 필요하지 않습니다.\n"
        "※ 이 프로그램은 AI 요약에만 쓰입니다.\n"
        "   설치하지 않아도 리포트는 정상적으로 만들어집니다.")).pack(anchor="w")

    act = ttk.Frame(pad); act.pack(anchor="w", pady=(10, 0))
    ttk.Button(act, text="PowerShell 열기", width=16, command=lambda: subprocess.Popen(
        ["powershell.exe", "-NoExit", "-NoProfile"],
        creationflags=subprocess.CREATE_NEW_CONSOLE)).pack(side="left")
    ttk.Button(act, text="닫기", width=12, command=win.destroy).pack(side="left", padx=8)
    win.grab_set()


# ====================================================================== 로그인
class AgyLoginDialog(tk.Toplevel):
    """[S4] Google 로그인 유도 + 토큰 파일 폴링."""

    POLL_MS = 2000
    TIMEOUT_SEC = 300

    def __init__(self, master: tk.Misc) -> None:
        super().__init__(master)
        self.result = "CANCELLED"
        self._after_id: str | None = None
        self._before = self._snapshot()
        self._deadline = 0.0

        self.title("Google 계정 로그인")
        self.resizable(False, False)
        self.transient(master)
        self.protocol("WM_DELETE_WINDOW", self._later)
        center_window(self, 700, 460)

        pad = ttk.Frame(self, padding=(24, 20)); pad.pack(fill="both", expand=True)
        ttk.Label(pad, justify="left", style="Detail.TLabel", text=(
            "AI 요약 기능을 쓰려면 Google 계정 로그인이 필요합니다.\n"
            "이 작업은 최초 1회만 하면 됩니다.")).pack(anchor="w")

        card = ttk.Frame(pad, style="Card.TFrame", padding=14)
        card.pack(fill="x", pady=12)
        ttk.Label(card, justify="left", style="Detail.TLabel", text=(
            " 1  아래 [로그인 창 열기] 를 누릅니다.\n"
            " 2  검은 창이 하나 열리고, 잠시 뒤 브라우저가 뜹니다.\n"
            " 3  브라우저에서 Google 계정을 선택하고 [허용] 을 누릅니다.\n"
            " 4  이 화면이 자동으로 \"완료\" 로 바뀝니다.\n"
            " 5  검은 창은 그때 닫으셔도 됩니다.")).pack(anchor="w")

        ttk.Button(pad, text="로그인 창 열기", width=20,
                   command=self._open_console).pack(anchor="w")

        ttk.Label(pad, text="상태", style="Key.TLabel").pack(anchor="w", pady=(12, 2))
        self.status = tk.Label(pad, text="", anchor="w", bg=COL["card"], fg=COL["muted"],
                               padx=10, pady=8, wraplength=630, justify="left")
        self.status.pack(fill="x")

        ttk.Label(pad, justify="left", style="Detail.TLabel", text=(
            "\n브라우저가 안 열리나요?\n"
            "검은 창에 https:// 로 시작하는 주소가 보이면 그 줄을 복사해\n"
            "브라우저 주소창에 붙여넣어 주세요.")).pack(anchor="w")

        act = ttk.Frame(pad); act.pack(anchor="e", pady=(12, 0))
        ttk.Button(act, text="나중에 하기", width=14, command=self._later).pack(side="left", padx=6)
        ttk.Button(act, text="다 했어요 (확인)", width=18,
                   command=self._manual_confirm).pack(side="left")

        self._set_status("busy", "[로그인 창 열기] 를 눌러 시작해 주세요.")
        self.grab_set()

    @staticmethod
    def _snapshot() -> tuple[int, int] | None:
        """시작 시점 기준선. 만료된 토큰이 이미 있을 수 있으므로 '변화' 를 봐야 한다."""
        if not AGY_TOKEN.exists():
            return None
        st = AGY_TOKEN.stat()
        return (st.st_mtime_ns, st.st_size)

    def _set_status(self, kind: str, text: str) -> None:
        self.status.configure(
            text={"busy": "⋯ ", "ok": "✔ ", "warn": "▲ ", "bad": "✖ "}.get(kind, "") + text,
            fg={"busy": COL["muted"], "ok": COL["ok"],
                "warn": COL["warn"], "bad": COL["bad"]}.get(kind, COL["muted"]))

    def _open_console(self) -> None:
        try:
            start_agy_login_console()
        except FileNotFoundError as e:
            self._set_status("bad", str(e))
            return
        self._before = self._snapshot()
        self._deadline = time.monotonic() + self.TIMEOUT_SEC
        self._poll()

    def _poll(self) -> None:
        """after() 로 자기 자신을 재예약한다.

        while + sleep 을 쓰면 창이 얼어붙고 '(응답 없음)' 이 뜬다.
        비개발자는 그 순간 프로그램이 죽었다고 판단하고 강제 종료한다.
        """
        remain = int(self._deadline - time.monotonic())
        if AGY_TOKEN.exists():
            st = AGY_TOKEN.stat()
            if st.st_size > 50 and (self._before is None
                                    or (st.st_mtime_ns, st.st_size) != self._before):
                self.after(700, self._succeed)      # 쓰기 완료 대기 (부분 기록 방지)
                return
        if remain <= 0:
            self._set_status("warn",
                             "아직 로그인이 확인되지 않았습니다.\n"
                             "브라우저에서 로그인을 마치셨다면 [다 했어요] 를 눌러 주세요.")
            return
        self._set_status("busy", f"로그인이 끝나기를 기다리는 중입니다…   "
                                 f"(남은 시간 {remain // 60}:{remain % 60:02d})")
        self._after_id = self.after(self.POLL_MS, self._poll)

    def _succeed(self) -> None:
        self.result = "SUCCESS"
        self._set_status("ok", "Google 로그인이 완료되었습니다.")
        self.after(1200, self._close)

    def _manual_confirm(self) -> None:
        """폴링이 변화를 놓쳤을 때의 안전망."""
        if AGY_TOKEN.exists() and AGY_TOKEN.stat().st_size > 50:
            self._succeed()
        else:
            self._set_status("bad", "아직 로그인 정보가 확인되지 않습니다.\n"
                                    "검은 창에서 로그인을 완료했는지 확인해 주세요.")

    def _later(self) -> None:
        self.result = "CANCELLED"
        self._close()

    def _close(self) -> None:
        if self._after_id:
            try:
                self.after_cancel(self._after_id)   # 파괴된 위젯에 콜백이 걸리는 것을 막는다
            except Exception:
                pass
        self.destroy()


def start_agy_login_console() -> subprocess.Popen | None:
    """대화형 agy 를 새 콘솔 창에서 띄운다. (9.3 참조)"""
    if not AGY_EXE.exists():
        raise FileNotFoundError("AGY가 아직 설치되지 않았습니다. "
                                "먼저 설치를 완료해 주세요.")
    env = {**os.environ, "AGY_CLI_DISABLE_AUTO_UPDATE": "true"}
    banner = (
        'title DMF 크롤러 - Google 로그인'
        ' && echo.'
        ' && echo   [ Google 계정 로그인 ]'
        ' && echo   브라우저가 열리면 계정을 선택하고 [허용] 을 눌러 주세요.'
        ' && echo   끝나면 설정 창으로 돌아가세요. 이 창은 닫으셔도 됩니다.'
        ' && echo.'
        f' && "{AGY_EXE}"'
    )
    return subprocess.Popen(["cmd.exe", "/k", banner], cwd=str(Path.home()), env=env,
                            creationflags=subprocess.CREATE_NEW_CONSOLE)


def login_agy(app, cfg) -> None:
    dlg = AgyLoginDialog(app)
    app.wait_window(dlg)


# ====================================================================== 작업 등록
class TaskDialog(tk.Toplevel):
    """[S5] 자동 실행 등록."""

    def __init__(self, master: tk.Misc, cfg) -> None:
        super().__init__(master)
        self.registered = False
        self.title("매일 아침 자동 실행 설정")
        self.resizable(False, False)
        self.transient(master)
        center_window(self, 680, 480)

        default_time = cfg.schedule.daily_time if cfg else "06:00"
        self.var_time = tk.StringVar(value=default_time)
        self.var_missed = tk.BooleanVar(value=True)
        self.var_battery = tk.BooleanVar(value=True)
        self.var_wake = tk.BooleanVar(value=False)

        pad = ttk.Frame(self, padding=(24, 20)); pad.pack(fill="both", expand=True)
        ttk.Label(pad, text="매일 아침 정해진 시각에 DMF 자료를 받아 리포트를 만듭니다.",
                  style="Detail.TLabel").pack(anchor="w")

        card = ttk.Frame(pad, style="Card.TFrame", padding=14); card.pack(fill="x", pady=12)
        row = ttk.Frame(card); row.pack(anchor="w", pady=(0, 10))
        ttk.Label(row, text="실행 시각", width=12, style="Key.TLabel").pack(side="left")
        ttk.Combobox(row, textvariable=self.var_time, width=8, state="readonly",
                     values=[f"{h:02d}:{m:02d}" for h in range(5, 10) for m in (0, 30)]
                     ).pack(side="left")
        ttk.Checkbutton(card, text="컴퓨터가 꺼져 있어 놓친 경우, 켜진 뒤 바로 실행",
                        variable=self.var_missed).pack(anchor="w")
        ttk.Checkbutton(card, text="노트북 배터리로 동작 중일 때도 실행",
                        variable=self.var_battery).pack(anchor="w")
        ttk.Checkbutton(card, text="실행 시각에 컴퓨터를 절전에서 깨우기",
                        variable=self.var_wake).pack(anchor="w")

        ttk.Label(pad, text="함께 등록되는 것", style="Key.TLabel").pack(anchor="w")
        info = ttk.Frame(pad, style="Card.TFrame", padding=12); info.pack(fill="x", pady=(4, 12))
        for line in ("· 자료 수집          매일 " + default_time,
                     "· 알림 확인          로그인 중 15분마다",
                     "· AI 프로그램 갱신   매주 일요일 04:00"):
            ttk.Label(info, text=line, style="Detail.TLabel").pack(anchor="w")

        self.status = tk.Label(pad, text="관리자 권한은 필요하지 않습니다.",
                               anchor="w", justify="left", bg=COL["bg"], fg=COL["muted"],
                               wraplength=620)
        self.status.pack(fill="x")

        act = ttk.Frame(pad); act.pack(anchor="e", pady=(14, 0))
        self.btn_ok = ttk.Button(act, text="등록", width=12, command=self._on_register)
        self.btn_ok.pack(side="left", padx=6)
        ttk.Button(act, text="취소", width=12, command=self.destroy).pack(side="left")
        self.grab_set()

    def _on_register(self) -> None:
        self.btn_ok.state(["disabled"])
        self.status.configure(text="⋯ 등록하는 중입니다…", fg=COL["muted"])

        def work() -> dict:
            return install_tasks(daily_time=self.var_time.get(), wake=self.var_wake.get())

        def done(res: dict) -> None:
            self.btn_ok.state(["!disabled"])
            failed = [t for t, v in res.items() if not v.get("Ok")]
            if failed:
                self.status.configure(
                    text="✖ 일부 작업을 등록하지 못했습니다: " + ", ".join(failed),
                    fg=COL["bad"])
                return
            self.registered = True
            # S4U 폴백이 일어났으면 반드시 고지한다.
            # 사용자가 '로그오프해도 돌겠지' 라고 잘못 믿는 것이 가장 나쁘다.
            if res.get("Daily", {}).get("LogonType") == "Interactive":
                self.status.configure(fg=COL["warn"], text=(
                    "✔ 등록되었습니다.\n\n"
                    "⚠ 이 컴퓨터에 로그인되어 있을 때만 자동 실행됩니다.\n"
                    "   아침에 PC 를 켜고 로그인해 두시면 됩니다.\n"
                    "   (로그인 없이도 실행하려면 관리자 권한이 필요한데,\n"
                    "    그 방식은 보안상 사용하지 않습니다.)"))
                self.after(6000, self.destroy)
            else:
                self.status.configure(text="✔ 등록되었습니다.", fg=COL["ok"])
                self.after(1500, self.destroy)

        run_in_thread(self, work, done)


def install_tasks(daily_time: str = "06:00", wake: bool = False) -> dict:
    """scripts\\install_tasks.ps1 를 호출한다. 관리자 권한 불필요."""
    argv = ["powershell.exe", "-NoProfile", "-NonInteractive",
            "-ExecutionPolicy", "Bypass",
            "-File", str(SCRIPTS_DIR / "install_tasks.ps1"),
            "-ProjectRoot", str(PROJECT_ROOT),
            "-DailyTime", daily_time, "-Json"]
    if wake:
        argv.append("-WakeToRun")
    p = subprocess.run(argv, capture_output=True, text=True, encoding="utf-8",
                       errors="replace", timeout=180,
                       creationflags=subprocess.CREATE_NO_WINDOW)
    try:
        return json.loads(p.stdout)
    except ValueError:
        raise RuntimeError(p.stderr.strip() or p.stdout.strip()
                           or "작업 등록에 실패했습니다.")


def install_tasks_action(app, cfg) -> None:
    dlg = TaskDialog(app, cfg)
    app.wait_window(dlg)


# ====================================================================== 기타 액션
def install_deps(app, cfg) -> None:
    dlg = ProgressDialog(
        app, title="필요한 부품 설치",
        headline="프로그램 실행에 필요한 부품을 내려받아 설치하고 있습니다.\n"
                 "보통 30초에서 2분 정도 걸립니다.",
        phases=["목록 확인", "내려받는 중", "설치 중", "확인"],
        argv=[str(VENV_PYTHON), "-m", "pip", "install", "-e", str(PROJECT_ROOT),
              "--disable-pip-version-check"],
        phase_of=lambda ln: (2, "내려받는 중…", 50) if "Downloading" in ln
                            else (3, "설치 중…", 80) if "Installing" in ln
                            else (4, "확인…", 95) if "Successfully" in ln else None)
    app.wait_window(dlg)
    if not dlg.ok:
        show_blocked(app, title="필요한 부품 설치",
                     cause="인터넷 연결 또는 회사 방화벽 문제로 보입니다.\n"
                           "(pypi.org 접속 실패)",
                     actions=("다시 시도", "로그 열기", "진단 복사"),
                     retry=lambda: install_deps(app, cfg))


def open_config(app, cfg) -> None:
    if not CONFIG_PATH.exists():
        _restore_default_config()
    subprocess.Popen(["notepad.exe", str(CONFIG_PATH)])
    messagebox.showinfo("설정 파일",
                        "메모장으로 열었습니다.\n"
                        "고친 뒤 저장하고 [다시 검사] 를 눌러 주세요.", parent=app)


def _restore_default_config() -> None:
    """기존 파일을 백업한 뒤 배포본을 복사한다. 덮어쓰기 전 백업은 필수다."""
    from shutil import copyfile
    if CONFIG_PATH.exists():
        stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        CONFIG_PATH.rename(CONFIG_PATH.with_suffix(f".toml.bak.{stamp}"))
    CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
    copyfile(PROJECT_ROOT / "config" / "config.default.toml", CONFIG_PATH)


def repair_db(app, cfg) -> None:
    dlg = ProgressDialog(
        app, title="자료 보관소 준비",
        headline="자료 보관소를 확인하고 필요한 경우 복구합니다.\n"
                 "복구 전에 현재 파일을 자동으로 백업합니다.",
        phases=["백업", "검사", "갱신", "확인"],
        argv=[str(VENV_PYTHON), "-m", "dmf_crawler", "db", "migrate"],
        phase_of=lambda ln: (2, "검사 중…", 40) if "integrity" in ln.lower()
                            else (3, "갱신 중…", 70) if "migrat" in ln.lower() else None)
    app.wait_window(dlg)
    if not dlg.ok:
        show_blocked(app, title="자료 보관소",
                     cause="자료 파일을 복구하지 못했습니다.",
                     actions=("다시 시도", "백업 폴더 열기", "로그 열기", "진단 복사"),
                     retry=lambda: repair_db(app, cfg))


def open_cleanmgr(app, cfg) -> None:
    subprocess.Popen(["cleanmgr.exe"])
    messagebox.showinfo("디스크 정리",
                        "디스크 정리 창이 열립니다.\n"
                        "공간을 확보한 뒤 [다시 검사] 를 눌러 주세요.", parent=app)


def open_reports_dir(app, cfg) -> None:
    REPORTS_DIR.mkdir(parents=True, exist_ok=True)
    subprocess.Popen(["explorer.exe", str(REPORTS_DIR)])


def open_last_log(app, cfg) -> None:
    dirs = sorted((d for d in LOGS_DIR.glob("run_*") if d.is_dir()),
                  key=lambda d: d.name, reverse=True)
    if not dirs:
        messagebox.showinfo("로그", "아직 실행 기록이 없습니다.", parent=app)
        return
    log = dirs[0] / "pipeline.log"
    if log.exists():
        subprocess.Popen(["notepad.exe", str(log)])
    else:
        subprocess.Popen(["explorer.exe", str(dirs[0])])


def open_bootstrap_help(app, cfg) -> None:
    """bootstrap.cmd 를 탐색기에서 선택 상태로 강조한다."""
    target = PROJECT_ROOT / "bootstrap.cmd"
    subprocess.Popen(["explorer.exe", "/select,", str(target)])
    messagebox.showinfo(
        "준비 방법",
        "폴더가 열리고 bootstrap.cmd 가 선택됩니다.\n\n"
        "이 파일을 더블클릭하면 실행 환경이 자동으로 준비됩니다.\n"
        "끝나면 이 창으로 돌아와 [다시 검사] 를 눌러 주세요.", parent=app)


def open_portal_mypage() -> None:
    webbrowser.open(PORTAL_MYPAGE)


ACTIONS = {
    "enter_api_key":       enter_api_key,
    "install_agy":         install_agy,
    "login_agy":           login_agy,
    "install_tasks":       install_tasks_action,
    "install_deps":        install_deps,
    "open_config":         open_config,
    "repair_db":           repair_db,
    "open_cleanmgr":       open_cleanmgr,
    "open_reports_dir":    open_reports_dir,
    "open_last_log":       open_last_log,
    "open_bootstrap_help": open_bootstrap_help,
}


# ====================================================================== 막힘 안내
def show_blocked(app, title: str, cause: str, actions: tuple[str, ...],
                 retry=None, manual=None) -> None:
    """[S9] 자동 해결 불가.

    나가는 길이 항상 3개 이상이어야 한다. 막다른 골목 금지의 구현이다.
    """
    win = tk.Toplevel(app)
    win.title(f"{title} — 완료하지 못했습니다")
    win.resizable(False, False)
    win.transient(app)
    center_window(win, 680, 420)

    pad = ttk.Frame(win, padding=(24, 20)); pad.pack(fill="both", expand=True)
    ttk.Label(pad, text=f"{title}을(를) 자동으로 완료하지 못했습니다.",
              style="H1.TLabel").pack(anchor="w")

    card = ttk.Frame(pad, style="Card.TFrame", padding=14); card.pack(fill="x", pady=12)
    ttk.Label(card, text="원인으로 보이는 것", style="Key.TLabel").pack(anchor="w")
    ttk.Label(card, text=cause, wraplength=600, justify="left",
              style="Detail.TLabel").pack(anchor="w", pady=(4, 0))

    ttk.Label(pad, text="다음 중 하나를 해보세요", style="Key.TLabel").pack(anchor="w")
    hints = {
        "다시 시도": "일시적인 문제일 수 있습니다.",
        "직접 설치": "설치 명령을 복사해 직접 실행하는 방법입니다.",
        "건너뛰기": "이 기능 없이 진행합니다. 리포트는 정상적으로 만들어집니다.",
        "로그 열기": "무슨 일이 있었는지 자세히 볼 수 있습니다.",
        "진단 복사": "도움을 요청할 때 붙여넣을 내용입니다.",
        "백업 폴더 열기": "예전 자료 파일을 직접 확인할 수 있습니다.",
    }
    for a in actions:
        ttk.Label(pad, text=f" · [{a}] — {hints.get(a, '')}",
                  style="Detail.TLabel").pack(anchor="w")

    bar = ttk.Frame(pad); bar.pack(anchor="w", pady=(16, 0))
    handlers = {
        "다시 시도":      lambda: (win.destroy(), retry() if retry else None),
        "직접 설치":      lambda: manual(app) if manual else None,
        "로그 열기":      lambda: open_last_log(app, None),
        "진단 복사":      lambda: app._copy_diagnosis(),
        "백업 폴더 열기": lambda: subprocess.Popen(
                              ["explorer.exe", str(PROJECT_ROOT / "backup")]),
        "건너뛰기":      win.destroy,
    }
    for a in actions:
        ttk.Button(bar, text=a, width=13,
                   command=handlers.get(a, win.destroy)).pack(side="left", padx=4)
    win.grab_set()


# ====================================================================== 실행
def run_pipeline(app, cfg) -> int:
    """[S7] -> [S8]/[S9]. run --trigger manual 을 돌리고 종료 코드를 돌려준다."""
    stages = ["준비 확인", "자료 받기", "정리하기", "안전 점검",
              "어제와 비교", "AI 요약 만들기", "엑셀 리포트 만들기"]
    dlg = ProgressDialog(
        app, title="DMF 자료 수집 중",
        headline="오늘 자 DMF 자료를 받아 리포트를 만들고 있습니다.",
        phases=stages,
        argv=[str(VENV_PYTHON), "-m", "dmf_crawler", "run", "--trigger", "manual"],
        phase_of=_pipeline_phase)
    app.wait_window(dlg)

    code = 0 if dlg.ok else 1
    if dlg.ok:
        _show_done(app, dlg.log_text)
    else:
        show_blocked(app, title="자료 수집",
                     cause="수집 도중 문제가 발생했습니다.\n"
                           "인터넷 연결이 끊겼거나 서버가 일시적으로 응답하지 않을 수 있습니다.",
                     actions=("다시 시도", "로그 열기", "진단 복사", "건너뛰기"),
                     retry=lambda: run_pipeline(app, cfg))
    return code


_STAGE_PAT = re.compile(r"STAGE\s+(\d+)/(\d+)\s+(\S+)")


def _pipeline_phase(line: str):
    """pipeline.py 가 stdout 에 찍는 'STAGE 3/7 normalize' 를 파싱한다."""
    m = _STAGE_PAT.search(line)
    if not m:
        return None
    cur, total = int(m.group(1)), int(m.group(2))
    return cur, m.group(3), int(cur * 100 / total)


def _show_done(app, log_text: str) -> None:
    """[S8] 완료. PARTIAL(AI 빠짐)도 성공으로 표시한다 — 종료 코드 0 의 의미."""
    hb = read_heartbeat()
    win = tk.Toplevel(app)
    win.title("완료")
    win.resizable(False, False)
    win.transient(app)
    center_window(win, 660, 420)

    pad = ttk.Frame(win, padding=(24, 20)); pad.pack(fill="both", expand=True)
    ttk.Label(pad, text="오늘 자 리포트를 만들었습니다.", style="H1.TLabel").pack(anchor="w")

    card = ttk.Frame(pad, style="Card.TFrame", padding=14); card.pack(fill="x", pady=12)
    for label, key in (("전체 등록", "total_count"), ("신규", "new_count"),
                       ("변경", "changed_count"), ("취하", "withdrawn_count")):
        row = ttk.Frame(card); row.pack(fill="x")
        ttk.Label(row, text=label, width=12, style="Key.TLabel").pack(side="left")
        ttk.Label(row, text=f"{hb.get(key, 0):,} 건", style="Detail.TLabel").pack(side="left")
    report = hb.get("report_path", "")
    ttk.Label(card, text=f"\n파일   {report}", style="Detail.TLabel").pack(anchor="w")

    if hb.get("ai_skipped_reason"):
        warn = ttk.Frame(pad, style="Card.TFrame", padding=12); warn.pack(fill="x")
        ttk.Label(warn, foreground=COL["warn"], justify="left", style="Detail.TLabel",
                  text=("▲  AI 요약은 이번에 만들지 못했습니다.\n"
                        f"    사유: {hb['ai_skipped_reason']}\n"
                        "    표와 숫자는 모두 정상입니다.")).pack(anchor="w")
        if hb.get("ai_skipped_kind") == "AUTH":
            ttk.Button(warn, text="지금 로그인하기", width=18,
                       command=lambda: (win.destroy(), login_agy(app, None))
                       ).pack(anchor="w", pady=(6, 0))

    bar = ttk.Frame(pad); bar.pack(anchor="w", pady=(14, 0))
    ttk.Button(bar, text="리포트 열기", width=14, command=lambda: os.startfile(report)
               ).pack(side="left", padx=4)
    ttk.Button(bar, text="폴더 열기", width=14,
               command=lambda: subprocess.Popen(["explorer.exe", str(REPORTS_DIR)])
               ).pack(side="left", padx=4)
    ttk.Button(bar, text="닫기", width=12, command=win.destroy).pack(side="left", padx=4)
    win.grab_set()


# ====================================================================== 보조
def key_age_banner() -> str | None:
    """인증키 사용 기한 보조 경보 (7.7). 진짜 판정은 언제나 API 응답 코드 31 이다."""
    hb = read_heartbeat()
    first = hb.get("api_key_first_success_at")
    if not first:
        return None
    try:
        since = datetime.fromisoformat(first)
    except ValueError:
        return None
    months = (datetime.now(timezone.utc) - since).days / 30.44
    if months >= 22:
        return (f"인증키를 사용한 지 약 {months:.0f}개월이 지났습니다. "
                "사용 기간이 끝나기 전에 공공데이터포털 마이페이지에서 "
                "'활용연장신청' 을 해두세요.")
    return None


def summary_lines(results) -> list[str]:
    """[S6] 준비 완료 화면의 요약 박스."""
    hb = read_heartbeat()
    lines = []
    if hb.get("last_success_at"):
        lines.append(f"마지막 실행    {hb['last_success_at'][:16].replace('T', ' ')}  ·  성공")
        lines.append(f"신규 {hb.get('new_count', 0)}건 · 변경 {hb.get('changed_count', 0)}건 · "
                     f"취하 {hb.get('withdrawn_count', 0)}건   "
                     f"(전체 {hb.get('total_count', 0):,}건)")
    else:
        lines.append("아직 한 번도 실행하지 않았습니다.")
    task = next((r for r in results if r.key == "tasks_registered"), None)
    if task and task.ok:
        lines.append(f"다음 실행      {task.detail}")
    return lines


def four_part_message(r) -> list[tuple[str, str]]:
    """복구 화면의 무엇/왜/어떻게/다음 행동 4요소."""
    return [
        ("무엇이", r.detail),
        ("왜", _WHY.get(r.key, "확인이 필요한 상태입니다.")),
        ("어떻게", r.fix_hint or "아래 버튼을 눌러 주세요."),
        ("다음 행동", f"[{BUTTON_LABEL.get(r.fix_action, '확인')}] 을 눌러 주세요."),
    ]


_WHY = {
    "api_key_present": "인증키가 저장되어 있지 않거나 읽을 수 없는 상태입니다.",
    "api_key_valid":   "공공데이터포털에서 인증키를 새로 발급하면 예전 키는 자동으로 사라집니다.",
    "agy_auth":        "Google 로그인 정보는 일정 기간이 지나면 만료됩니다.",
    "database":        "자료 파일이 손상되었거나 형식 갱신이 필요합니다.",
    "report_writable": "리포트를 저장할 폴더에 문제가 있습니다.",
    "disk_space":      "저장 공간이 부족하면 리포트를 만들 수 없습니다.",
    "tasks_registered":"자동 실행이 등록되지 않으면 아침에 리포트가 만들어지지 않습니다.",
}

11.5 src/dmf_crawler/gui/widgets.py — 공통 위젯

"""GUI 공통 위젯과 유틸.

핵심은 run_in_thread 다. tkinter 는 단일 스레드라
네트워크·subprocess 를 메인 스레드에서 돌리면 창이 흰색으로 굳고
제목에 '(응답 없음)' 이 뜬다. 비개발자는 그 순간 프로그램이 죽었다고 판단한다.
"""
from __future__ import annotations

import ctypes
import queue
import sys
import threading
import tkinter as tk
from tkinter import ttk
from typing import Callable, TypeVar

T = TypeVar("T")

COL = {
    "bg":     "#FFFFFF",
    "card":   "#F6F7F8",
    "fg":     "#1F2328",
    "muted":  "#6E7781",
    "ok":     "#167A3C",
    "warn":   "#C77700",
    "bad":    "#BE2828",
    "accent": "#0072B2",
}
FONT = "맑은 고딕"
CENTER = 3          # 화면 세로의 1/3 지점에 창을 놓는다


def apply_dpi_awareness() -> None:
    """PerMonitorV2 DPI 인식을 켠다.

    반드시 Tk 루트를 만들기 '전' 에 호출해야 한다.
    공식 문서: "you must call the corresponding API before any HWNDs have been created."
    """
    if sys.platform != "win32":
        return
    try:
        # DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4 (Windows 10 1703+)
        ctypes.windll.user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
    except (AttributeError, OSError):
        try:
            ctypes.windll.shcore.SetProcessDpiAwareness(2)
        except (AttributeError, OSError):
            try:
                ctypes.windll.user32.SetProcessDPIAware()
            except (AttributeError, OSError):
                pass


def init_style(root: tk.Misc) -> None:
    root.configure(bg=COL["bg"])
    # Tk 의 논리 폰트 크기를 실제 DPI 에 맞춘다
    try:
        root.call("tk", "scaling", root.winfo_fpixels("1i") / 72.0)
    except tk.TclError:
        pass
    st = ttk.Style(root)
    if "vista" in st.theme_names():
        st.theme_use("vista")
    st.configure(".", background=COL["bg"], foreground=COL["fg"], font=(FONT, 10))
    st.configure("H0.TLabel", font=(FONT, 18, "bold"))
    st.configure("H1.TLabel", font=(FONT, 13, "bold"))
    st.configure("Item.TLabel", font=(FONT, 11, "bold"))
    st.configure("Key.TLabel", font=(FONT, 10, "bold"), foreground=COL["muted"])
    st.configure("Sub.TLabel", foreground=COL["muted"])
    st.configure("Detail.TLabel", foreground=COL["muted"])
    st.configure("Banner.TLabel", background="#FFF4CE", foreground="#6B5200")
    st.configure("Card.TFrame", background=COL["card"], relief="solid", borderwidth=1)
    st.configure("Primary.TButton", font=(FONT, 10, "bold"))
    st.configure("Big.TButton", font=(FONT, 14, "bold"))


def center_window(win: tk.Misc, w: int, h: int) -> None:
    win.update_idletasks()
    x = (win.winfo_screenwidth() - w) // 2
    y = (win.winfo_screenheight() - h) // CENTER
    win.geometry(f"{w}x{h}+{max(x, 0)}+{max(y, 0)}")


def run_in_thread(widget: tk.Misc, work: Callable[[], T],
                  done: Callable[[T], None]) -> None:
    """work() 를 워커 스레드에서 돌리고 결과를 메인 스레드의 done() 으로 넘긴다.

    tkinter 위젯은 반드시 메인 스레드에서만 건드려야 하므로,
    결과를 큐에 넣고 after() 로 폴링해 꺼낸다.
    """
    q: queue.Queue = queue.Queue(maxsize=1)

    def runner() -> None:
        try:
            q.put(("ok", work()))
        except Exception as e:                  # noqa: BLE001 - 그대로 UI 로 넘긴다
            q.put(("err", e))

    threading.Thread(target=runner, daemon=True).start()

    def poll() -> None:
        try:
            kind, payload = q.get_nowait()
        except queue.Empty:
            widget.after(80, poll)
            return
        if kind == "err":
            raise payload
        done(payload)

    widget.after(80, poll)


class ScrollFrame(ttk.Frame):
    """세로 스크롤이 되는 프레임. 항목을 inner 에 붙인다."""

    def __init__(self, master: tk.Misc, height: int = 360) -> None:
        super().__init__(master)
        self.canvas = tk.Canvas(self, height=height, bg=COL["bg"],
                                highlightthickness=1, highlightbackground="#D0D7DE")
        vsb = ttk.Scrollbar(self, orient="vertical", command=self.canvas.yview)
        self.inner = ttk.Frame(self.canvas)

        self._win = self.canvas.create_window((0, 0), window=self.inner, anchor="nw")
        self.canvas.configure(yscrollcommand=vsb.set)
        self.canvas.pack(side="left", fill="both", expand=True)
        vsb.pack(side="right", fill="y")

        self.inner.bind("<Configure>", lambda _e: self.canvas.configure(
            scrollregion=self.canvas.bbox("all")))
        self.canvas.bind("<Configure>", lambda e: self.canvas.itemconfigure(
            self._win, width=e.width))
        self.canvas.bind_all("<MouseWheel>", self._on_wheel)

    def _on_wheel(self, event) -> None:
        try:
            self.canvas.yview_scroll(int(-event.delta / 120), "units")
        except tk.TclError:
            pass

    def clear(self) -> None:
        for child in self.inner.winfo_children():
            child.destroy()

11.6 src/dmf_crawler/secrets_dpapi.py — DPAPI 암·복호화

"""공공데이터포털 serviceKey 를 사용자 범위 DPAPI 로 암·복호화한다 (ADR-13).

평문은 메모리에만 존재하고 로그·예외 메시지에 절대 넣지 않는다 (요구 N7).
표준 라이브러리 ctypes 만 쓴다 — 의존성 0.
PowerShell 의 [Security.Cryptography.ProtectedData] 와 blob 호환된다
(같은 entropy 를 쓰면 서로 읽는다).
"""
from __future__ import annotations

import ctypes
import ctypes.wintypes as wt
import hashlib
import os
from pathlib import Path

from .paths import APP_DATA_DIR

SECRET_PATH: Path = APP_DATA_DIR / "service_key.bin"
_ENTROPY = b"DMF_Crawler/serviceKey/v1"
_CRYPTPROTECT_UI_FORBIDDEN = 0x1

# use_last_error=True 가 없으면 ctypes.get_last_error() 가 항상 0 을 돌려준다.
# 그러면 entropy 불일치(13)와 blob 손상(87)을 구분할 수 없다.
_crypt32 = ctypes.WinDLL("crypt32.dll", use_last_error=True)
_kernel32 = ctypes.WinDLL("kernel32.dll", use_last_error=True)
_crypt32.CryptProtectData.restype = wt.BOOL
_crypt32.CryptUnprotectData.restype = wt.BOOL


class _BLOB(ctypes.Structure):
    _fields_ = [("cbData", wt.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]


def _blob(data: bytes) -> _BLOB:
    buf = ctypes.create_string_buffer(data, len(data))
    return _BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))


def _out_bytes(b: _BLOB) -> bytes:
    return ctypes.string_at(b.pbData, b.cbData)


def _protect(plaintext: str) -> bytes:
    src, ent, out = _blob(plaintext.encode("utf-8")), _blob(_ENTROPY), _BLOB()
    ok = _crypt32.CryptProtectData(ctypes.byref(src), None, ctypes.byref(ent),
                                   None, None, _CRYPTPROTECT_UI_FORBIDDEN,
                                   ctypes.byref(out))
    if not ok:
        raise OSError(ctypes.get_last_error(), "CryptProtectData 실패")
    try:
        return _out_bytes(out)
    finally:
        _kernel32.LocalFree(out.pbData)


def _unprotect(blob: bytes) -> str:
    """실측 오류: entropy 불일치 -> WinError 13, blob 손상 -> WinError 87."""
    src, ent, out = _blob(blob), _blob(_ENTROPY), _BLOB()
    ok = _crypt32.CryptUnprotectData(ctypes.byref(src), None, ctypes.byref(ent),
                                     None, None, _CRYPTPROTECT_UI_FORBIDDEN,
                                     ctypes.byref(out))
    if not ok:
        raise OSError(ctypes.get_last_error(), "CryptUnprotectData 실패")
    try:
        return _out_bytes(out).decode("utf-8")
    finally:
        _kernel32.LocalFree(out.pbData)


def save_service_key(plaintext: str) -> Path:
    """원자적으로 저장한다. 쓰는 도중 전원이 나가도 반쪽 파일이 남지 않는다."""
    plaintext = plaintext.strip()
    if not plaintext:
        raise ValueError("빈 인증키는 저장할 수 없습니다.")
    SECRET_PATH.parent.mkdir(parents=True, exist_ok=True)
    tmp = SECRET_PATH.with_suffix(".tmp")
    tmp.write_bytes(_protect(plaintext))
    os.replace(tmp, SECRET_PATH)
    return SECRET_PATH


def load_service_key() -> str | None:
    """없거나 복호화 실패 시 None (§3.2 계약).

    복호화 실패는 예외가 아니라 '정상적으로 예상되는 상태' 다 —
    다른 PC 로 폴더를 복사했거나 Windows 계정이 바뀌면 반드시 일어난다.
    호출부(checks.py)가 '키 미설정' 과 동일하게 취급해 GUI 복구 경로로 보낸다.
    """
    if not SECRET_PATH.exists():
        return None
    try:
        return _unprotect(SECRET_PATH.read_bytes())
    except OSError:
        return None
    except Exception:                            # noqa: BLE001
        return None


def delete_service_key() -> None:
    if SECRET_PATH.exists():
        SECRET_PATH.unlink()


def key_fingerprint() -> str | None:
    """sha256 앞 8자. 로그·GUI 표시용 (원문이 아니다)."""
    key = load_service_key()
    if not key:
        return None
    return hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]

11.7 scripts/make_shortcuts.ps1 — 바로가기 생성

UTF-8 with BOM 으로 저장할 것. BOM 이 없으면 한글 바로가기 이름이 깨진다.

#requires -Version 5.1
<#
.SYNOPSIS
  "DMF 설정.lnk" / "지금 실행.lnk" 를 프로젝트 루트와 바탕화면에 만든다.
.DESCRIPTION
  대상은 반드시 pythonw.exe 다. python.exe 나 .cmd 를 대상으로 하면
  더블클릭할 때마다 검은 콘솔 창이 번쩍인다 (PE 서브시스템 = WINDOWS_CUI).
  관리자 권한은 필요하지 않다.
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory)][string]$ProjectRoot
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$pythonw = Join-Path $ProjectRoot '.venv\Scripts\pythonw.exe'
if (-not (Test-Path -LiteralPath $pythonw)) {
    throw "실행 환경을 찾을 수 없습니다: $pythonw"
}

$iconFile = Join-Path $ProjectRoot 'assets\dmf.ico'
if (-not (Test-Path -LiteralPath $iconFile)) {
    $iconFile = "$env:SystemRoot\System32\imageres.dll,109"   # 폴백: 시스템 아이콘
}

$targets = @(
    $ProjectRoot,
    [System.Environment]::GetFolderPath('Desktop')
) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -Unique

$specs = @(
    @{ Name = 'DMF 설정.lnk'
       Args = '-m dmf_crawler onboard --mode setup'
       Desc = 'DMF 크롤러 설정 및 상태 확인' },
    @{ Name = '지금 실행.lnk'
       Args = '-m dmf_crawler onboard --mode inspect --run-now'
       Desc = 'DMF 자료를 지금 받아 리포트를 만듭니다' }
)

$shell = New-Object -ComObject WScript.Shell
try {
    foreach ($dir in $targets) {
        foreach ($s in $specs) {
            $path = Join-Path $dir $s.Name
            $lnk = $shell.CreateShortcut($path)
            $lnk.TargetPath       = $pythonw
            $lnk.Arguments        = $s.Args
            $lnk.WorkingDirectory = $ProjectRoot
            $lnk.IconLocation     = $iconFile
            $lnk.Description      = $s.Desc
            $lnk.WindowStyle      = 1          # SW_SHOWNORMAL (호스트가 GUI 라 콘솔 없음)
            $lnk.Save()
            Write-Host "바로가기 생성: $path"
        }
    }
} finally {
    [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($shell)
}
exit 0

11.8 scripts/bootstrap_agy.ps1 — agy 설치

UTF-8 with BOM 으로 저장할 것.

#requires -Version 5.1
<#
.SYNOPSIS
  agy.exe 존재를 확인하고, 없으면 공식 install.ps1 로 설치한다.
.DESCRIPTION
  1순위: 공식 install.ps1 (SHA512 무결성 검증 내장, 멱등, 관리자 권한 불필요)
  2순위: winget (1순위 실패 시에만)
  둘 다 실패하면 종료 코드 1 을 돌려주고, GUI 가 [S9] 막힘 안내로 넘어간다.
#>
[CmdletBinding()]
param(
    [switch]$Force   # 이미 설치돼 있어도 설치 시도
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Continue'

# 설치 중 백그라운드 self-update 가 끼어들면 바이너리가 교체돼 실패한다.
$env:AGY_CLI_DISABLE_AUTO_UPDATE = 'true'

$agyExe = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe'

function Test-AgyOk {
    if (-not (Test-Path -LiteralPath $agyExe)) { return $null }
    # 0바이트 스텁·중단된 다운로드를 걸러낸다. 실측 정상 크기 187,601,560 bytes.
    if ((Get-Item -LiteralPath $agyExe).Length -lt 1MB) { return $null }
    $v = & $agyExe --version 2>$null
    if ($LASTEXITCODE -ne 0 -or -not $v) { return $null }
    return ($v | Select-Object -First 1).ToString().Trim()
}

$existing = Test-AgyOk
if ($existing -and -not $Force) {
    Write-Host "이미 설치되어 있습니다. (버전 $existing)"
    exit 0
}

# ---------------------------------------------------------------- 1순위
Write-Host "설치 스크립트를 받는 중..."
$ok = $false
try {
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    $script = (New-Object System.Net.WebClient).DownloadString(
        'https://antigravity.google/cli/install.ps1')
    Write-Host "Downloading manifest and binary ..."
    Invoke-Expression $script
    $ok = $true
} catch {
    Write-Warning "공식 설치 스크립트 실패: $($_.Exception.Message)"
}

$ver = Test-AgyOk
if ($ver) {
    Write-Host "설치 확인: 버전 $ver"
    exit 0
}

# ---------------------------------------------------------------- 2순위
Write-Host "winget 으로 다시 시도합니다..."
try {
    $winget = Get-Command winget.exe -ErrorAction Stop
    & $winget.Source install --id Google.AntigravityCLI --silent `
        --accept-package-agreements --accept-source-agreements
} catch {
    Write-Warning "winget 을 사용할 수 없습니다: $($_.Exception.Message)"
}

$ver = Test-AgyOk
if ($ver) {
    Write-Host "설치 확인: 버전 $ver"
    exit 0
}

Write-Error "설치에 실패했습니다. 네트워크 연결 또는 방화벽 설정을 확인해 주세요."
exit 1

11.9 scripts/install_tasks.ps1 — 작업 3종 등록

UTF-8 with BOM 으로 저장할 것.

#requires -Version 5.1
<#
.SYNOPSIS
  Task Scheduler 작업 3종을 idempotent 하게 등록한다 (ADR-10).
.DESCRIPTION
  · Daily      매일 지정 시각. S4U 시도 -> 실패 시 Interactive 폴백.
  · Agent      로그온 시 + 15분 반복. Interactive 필수 (UI 를 띄우는 유일한 프로세스).
  · AgyUpdate  주간 agy 업데이트. 06:00 배치와 시간대를 분리한다.

  관리자 권한은 필요하지 않다. -RunLevel Limited 로 등록한다.

  ⚠ 실측: 비관리자 계정은 'Logon as Batch' 권한이 없어
     S4U/Password 등록이 '액세스가 거부되었습니다' 로 실패한다.
     그래서 폴백이 필수다.
.OUTPUTS
  -Json 을 주면 { "Daily": {...}, "Agent": {...}, "AgyUpdate": {...} } 를 stdout 으로.
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory)][string]$ProjectRoot,
    [string]$DailyTime = '06:00',
    [switch]$WakeToRun,
    [switch]$Json
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Continue'

$folder  = '\DMF Crawler'
$python  = Join-Path $ProjectRoot '.venv\Scripts\python.exe'
$pythonw = Join-Path $ProjectRoot '.venv\Scripts\pythonw.exe'
$agyExe  = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe'
$user    = "$env:USERDOMAIN\$env:USERNAME"

if (-not (Test-Path -LiteralPath $python)) {
    throw "실행 환경을 찾을 수 없습니다: $python"
}

function New-DmfSettings {
    param([switch]$Wake)
    $s = New-ScheduledTaskSettingsSet `
            -StartWhenAvailable `
            -AllowStartIfOnBatteries `
            -DontStopIfGoingOnBatteries `
            -ExecutionTimeLimit (New-TimeSpan -Hours 2) `
            -RestartCount 3 `
            -RestartInterval (New-TimeSpan -Minutes 10) `
            -MultipleInstances IgnoreNew
    if ($Wake) { $s.WakeToRun = $true }
    return $s
}

function Register-DmfTask {
    <#
      LogonType 을 순서대로 시도하고 첫 성공에서 멈춘다.
      전부 실패하면 Ok=$false 와 마지막 오류를 돌려준다 (예외를 던지지 않는다).
    #>
    param(
        [Parameter(Mandatory)][string]$TaskName,
        [Parameter(Mandatory)]$Action,
        [Parameter(Mandatory)]$Trigger,
        [Parameter(Mandatory)]$Settings,
        [string[]]$LogonOrder = @('Interactive'),
        [string]$Description = ''
    )
    $lastErr = $null
    foreach ($logon in $LogonOrder) {
        try {
            $principal = New-ScheduledTaskPrincipal -UserId $user `
                            -LogonType $logon -RunLevel Limited
            Register-ScheduledTask -TaskName $TaskName -TaskPath $folder `
                -Action $Action -Trigger $Trigger -Principal $principal `
                -Settings $Settings -Description $Description -Force `
                -ErrorAction Stop | Out-Null
            return [ordered]@{ Ok = $true; LogonType = $logon; Error = $null }
        } catch {
            $lastErr = $_.Exception.Message
            Write-Verbose "[$TaskName] LogonType=$logon 실패: $lastErr"
        }
    }
    return [ordered]@{ Ok = $false; LogonType = $null; Error = $lastErr }
}

$result = [ordered]@{}

# ---------------------------------------------------------------- Daily
$dailyAction = New-ScheduledTaskAction -Execute $python `
    -Argument '-m dmf_crawler run --trigger scheduled' -WorkingDirectory $ProjectRoot
$dailyTrigger = New-ScheduledTaskTrigger -Daily -At $DailyTime
# 여러 PC 가 동시에 API 를 때리는 것을 피한다 (초당 호출 제한 코드 23 예방)
$dailyTrigger.RandomDelay = 'PT3M'
$result['Daily'] = Register-DmfTask -TaskName 'Daily' `
    -Action $dailyAction -Trigger $dailyTrigger `
    -Settings (New-DmfSettings -Wake:$WakeToRun) `
    -LogonOrder @('S4U', 'Interactive') `
    -Description 'DMF 등록정보 일일 수집 및 리포트 생성'

# ---------------------------------------------------------------- Agent
# UI 를 띄울 수 있어야 하므로 Interactive 만 쓴다. pythonw 라 콘솔이 뜨지 않는다.
$agentAction = New-ScheduledTaskAction -Execute $pythonw `
    -Argument '-m dmf_crawler notify-pump --once' -WorkingDirectory $ProjectRoot
$agentTrigger = New-ScheduledTaskTrigger -AtLogOn -User $user
$agentTrigger.Repetition = (New-ScheduledTaskTrigger -Once -At (Get-Date) `
    -RepetitionInterval (New-TimeSpan -Minutes 15) `
    -RepetitionDuration ([TimeSpan]::MaxValue)).Repetition
$agentSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
    -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
    -ExecutionTimeLimit (New-TimeSpan -Minutes 10) -MultipleInstances IgnoreNew
$result['Agent'] = Register-DmfTask -TaskName 'Agent' `
    -Action $agentAction -Trigger $agentTrigger -Settings $agentSettings `
    -LogonOrder @('Interactive') `
    -Description 'DMF 크롤러 알림 확인 및 복구 안내'

# ---------------------------------------------------------------- AgyUpdate
# 06:00 배치 도중 바이너리가 교체되는 것을 막기 위해 시간대를 분리한다.
if (Test-Path -LiteralPath $agyExe) {
    $updAction  = New-ScheduledTaskAction -Execute $agyExe -Argument 'update'
    $updTrigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday -At '04:00'
    $updSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
        -ExecutionTimeLimit (New-TimeSpan -Minutes 30) -MultipleInstances IgnoreNew
    $result['AgyUpdate'] = Register-DmfTask -TaskName 'AgyUpdate' `
        -Action $updAction -Trigger $updTrigger -Settings $updSettings `
        -LogonOrder @('Interactive') `
        -Description 'Antigravity CLI 주간 업데이트'
} else {
    $result['AgyUpdate'] = [ordered]@{
        Ok = $true; LogonType = 'skipped'
        Error = 'agy 가 설치되어 있지 않아 건너뜀'
    }
}

if ($Json) {
    $result | ConvertTo-Json -Depth 4
} else {
    foreach ($k in $result.Keys) {
        $v = $result[$k]
        $mark = if ($v.Ok) { 'OK' } else { 'FAIL' }
        Write-Host ("[{0}] {1}  LogonType={2} {3}" -f $mark, $k, $v.LogonType, $v.Error)
    }
}

$failed = @($result.Values | Where-Object { -not $_.Ok })
exit ($(if ($failed.Count -gt 0) { 1 } else { 0 }))

11.10 src/dmf_crawler/cli.pyonboard / doctor 커맨드 (발췌)

def cmd_onboard(args) -> int:
    """GUI 창을 띄운다. pythonw.exe 로 호출되므로 콘솔이 없다."""
    from .gui.app import launch
    return launch(mode=args.mode, focus_key=args.focus, run_now=args.run_now)


def cmd_doctor(args) -> int:
    """checks.run_all() 을 텍스트 표 또는 JSON 으로 출력한다.

    종료 코드: 0=전부 통과, 1=WARN 존재, 2=CRITICAL 존재.
    (다른 커맨드의 0/1/2 규약과 의미가 다르다 — §5 각주)
    """
    from . import checks
    from .config import load_config
    try:
        cfg = load_config()
    except Exception:
        cfg = None

    results = checks.run_all(cfg)

    if args.json:
        print(json.dumps(
            [{"key": r.key, "title": r.title, "ok": r.ok,
              "severity": str(r.severity), "detail": r.detail,
              "fix_hint": r.fix_hint, "fix_action": r.fix_action}
             for r in results], ensure_ascii=False, indent=2))
    else:
        width = max(len(r.title) for r in results)
        for r in results:
            mark = "OK  " if r.ok else ("WARN" if r.severity is Severity.WARN else "FAIL")
            print(f"[{mark}] {r.title:<{width}}  {r.detail}")
            if not r.ok and r.fix_hint:
                print(f"        → {r.fix_hint}")

    if args.fix_tasks:
        from .gui.steps import install_tasks
        install_tasks(daily_time=(cfg.schedule.daily_time if cfg else "06:00"))
        results = checks.run_all(cfg)

    return checks.verdict(results)


def doctor_text() -> int:
    """GUI 가 뜨지 못할 때의 폴백 (§3.17 계약)."""
    class _A:
        json = False
        fix_tasks = False
    return cmd_doctor(_A())

12. 재실행 시 동작

12.1 진입 경로별 화면

진입 명령 조건 뜨는 화면
bootstrap.cmd 재실행 venv 이미 존재 [1/5] 에서 기존 환경을 감지해 건너뛴다. 40초 안에 [S1] 또는 [S6]
DMF 설정.lnk onboard --mode setup 전부 통과 [S6] 준비 완료
실패 항목 있음 [S1] 체크리스트
지금 실행.lnk onboard --mode inspect --run-now CRITICAL 통과 [S7] 실행 중 으로 바로 진입
CRITICAL 실패 [S1] — 실행을 막고 무엇이 문제인지 보여준다
notify-pump 자동 기동 onboard --mode recover --focus <key> CRITICAL 알림 발생 [S1-R] 복구 모드
개발자·지원 dmf doctor 콘솔 텍스트 표

12.2 "설정 다시 열기" 경로 — 4가지

사용자가 설정을 다시 열 수 있는 길이 최소 4개 있어야 한다. 하나가 막혀도 나머지로 도달할 수 있다.

  1. 바탕화면 DMF 설정.lnk — 기본 경로.
  2. 프로젝트 루트의 같은 .lnk — 바탕화면 바로가기를 지운 사용자용. make_shortcuts.ps1 이 양쪽에 만든다.
  3. [S6] 준비 완료 화면의 [설정 다시 열기] — 이미 창이 떠 있을 때.
  4. bootstrap.cmd 재실행 — 바로가기가 전부 사라졌을 때의 최후 수단. 멱등하므로 몇 번 눌러도 안전하며, 마지막에 바로가기를 다시 만든다.

⚠️ bootstrap.cmd 를 "최초 1회만" 이라고 안내하되 재실행이 안전하다는 사실도 README.txt 에 적는다. 비개발자는 막히면 "처음부터 다시" 를 시도하는데, 그게 실제로 해결책이 되도록 만들어야 한다.

12.3 상태 변화 감지

재실행할 때마다 checks.run_all() 이 돌므로 환경 변화가 자동으로 반영된다.

변화 감지 체크 화면
사용자가 인증키를 재발급함 api_key_valid → 코드 30 [S1] 에 ✖ + [키 입력]
Google 로그인이 만료됨 agy_authagy_calls 의 AUTH ▲ + [로그인]
폴더를 옮김 tasks_registeredSTALE_PATH ▲ + [다시 등록]
Excel 이 리포트를 잡고 있음 report_writable ✖ + 파일명 표시
디스크가 찼음 disk_space ✖ + [디스크 정리]
06:00 배치가 3일째 실패 recent_runs ▲ + [로그 보기]
agy 를 수동으로 지움 agy_installed ▲ + [설치하기]

캐싱 예외: ⑤ api_key_valid 만 12시간 캐시를 쓴다(4.2 ⑤). [다시 검사] 를 누르면 캐시를 무시한다. 나머지 11개는 전부 실시간 판정이라 캐시가 없다.

12.4 폴더를 옮겼을 때의 완전한 복구 절차

가장 흔한 "조용한 고장"이다. 사용자는 폴더를 옮긴 사실과 리포트가 안 나오는 사실을 연결하지 못한다.

1. 사용자가 D:\workspace\DMF_Crawler → D:\문서\DMF_Crawler 로 폴더 이동
2. 다음날 06:00 — 작업 스케줄러가 예전 경로의 python.exe 를 실행 → 실패
   (Task Scheduler LastTaskResult = 2147942402 / 파일을 찾을 수 없음)
3. Agent 태스크도 같은 이유로 실패 → 알림조차 못 뜬다  ← 최악의 상황
4. 사용자가 언젠가 새 위치의 DMF 설정.lnk 를 더블클릭
   (바로가기도 예전 경로를 가리키므로 프로젝트 루트의 .lnk 를 써야 한다)
5. [S1] 에서 ⑨ tasks_registered 가 STALE_PATH 로 표시된다:
   "예전 폴더를 가리키고 있습니다. 프로그램 폴더를 옮기신 것 같습니다."
6. [다시 등록] → install_tasks.ps1 이 -Force 로 현재 경로를 다시 박는다
7. make_shortcuts.ps1 도 함께 돌려 바탕화면 바로가기를 갱신한다

3단계가 위험하다 — 알리미까지 죽으므로 자동 복구가 불가능하다. 그래서 README.txt 에 굵게 적는다: "폴더를 옮기셨다면 bootstrap.cmd 를 한 번 더 실행해 주세요." bootstrap.cmd 는 멱등하고, 마지막에 바로가기와 작업 등록을 모두 갱신한다.


13. 오류·예외 처리

13.1 원칙 — 막다른 골목 금지

규칙: 사용자에게 보이는 모든 실패 화면에는 다음에 할 수 있는 행동이 최소 2개 있어야 한다. 구조적 강제 장치가 3겹이다.

장치
계약 checks._register()fix_action 없는 체크의 등록을 ValueError 로 거부한다
UI show_blocked()actions 튜플을 필수 인자로 받는다
최후 수단 어떤 화면에서도 [진단 결과 복사하기] 로 도움 요청용 텍스트를 만들 수 있다

13.2 단계별 실패 대응표

# 단계 실패 사용자에게 보이는 것 다음 행동
1 bootstrap.cmd — 파이썬 없음, winget 없음 콘솔 안내 "파이썬을 직접 설치해 주세요" + 3단계 절차 [다운로드 페이지 열기] / 창 닫고 재실행
2 bootstrap.cmd — venv 생성 실패 콘솔 안내 "이 폴더에 파일을 만들 수 없습니다" 폴더 이동 안내 / 백신 확인 / 로그 경로
3 bootstrap.cmd — pip 실패 콘솔 안내 "pypi.org 접속 실패" IT 담당자 요청 문구 / 재실행
4 GUI 자체가 안 뜸 (tkinter 손상) 콘솔 폴백 doctor 텍스트 표 텍스트 결과를 복사해 도움 요청
5 ② 부품 설치 실패 [S9] "pypi.org 접속 실패" [다시 시도] / [로그 열기] / [진단 복사]
6 ③ 설정 파싱 실패 [S1] 항목 파싱 오류 원문 [설정 열기] / [기본값으로 되돌리기]
7 ④⑤ 인증키 — 코드 30/31 [S2] 상태줄 7.6 표의 문구 [마이페이지 열기] / 재입력 / [취소]
8 ⑤ 인증키 — 네트워크 실패 [S2] 상태줄 "인터넷 연결을 확인해 주세요" [다시 시도] / [주소 복사]
9 ⑤ 인증키 — 코드 22/23 [S2] 상태줄 ▲ "인증키 자체는 정상" 저장하고 진행 (실패로 취급하지 않는다)
10 ⑥ agy 설치 실패 [S9] 원인 진단 (네트워크/디스크/체크섬/권한) [다시 시도] / [직접 설치] / [로그] / [진단 복사] / [건너뛰기]
11 ⑦ 로그인 타임아웃 (5분) [S4] 상태줄 "아직 확인되지 않았습니다" [다시 기다리기] / [다 했어요] / [나중에 하기]
12 ⑦ agy 미설치인데 로그인 시도 [S4] 상태줄 "먼저 설치를 완료해 주세요" 창 닫고 [설치하기]
13 ⑧ DB 손상 [S9] "자료 파일이 손상되었습니다" [복구하기] / [백업 폴더 열기] / [진단 복사]
14 ⑨ 작업 등록 실패 (GPO) [S9] "회사 보안 정책 때문에…" [명령 복사](schtasks 수동) / [건너뛰기] / [진단 복사]
15 ⑨ S4U 실패 → Interactive 폴백 [S5] 고지 박스 "로그인되어 있을 때만 실행됩니다" 확인 (실패가 아니다)
16 ⑩ 디스크 부족 [S1] 항목 "여유 0.8 GB (최소 2 GB 필요)" [디스크 정리] / [다시 검사]
17 ⑪ Excel 이 파일을 잠금 [S1] 항목 정확한 파일명 표시 Excel 닫기 / [폴더 열기] / (닫지 않아도 실행됨을 고지)
18 실행 — 종료 코드 1 (FAILED) [S9] "수집 도중 문제가 발생했습니다" [다시 시도] / [로그 열기] / [진단 복사] / [건너뛰기]
19 실행 — 종료 코드 2 (BLOCKED) [S1-R] 4요소 메시지 해당 fix_action / [전체 상태 보기]
20 실행 — 종료 코드 0 이지만 AI 빠짐 [S8] 안내 박스 "▲ AI 요약은 만들지 못했습니다. 표와 숫자는 정상" [지금 로그인하기] / [리포트 열기]
21 체크 함수 자체가 예외 [S1] 항목 "확인하는 중 문제가 생겼습니다: <타입>" [다시 검사] / [진단 결과 복사하기]

13.3 GPO 로 PowerShell 이 막힌 환경 (14번 상세)

install_tasks.ps1 호출이 실패하면 schtasks.exe 직접 명령을 복사해 주는 폴백을 제공한다. GPO 우회는 시도하지 않는다.

def _schtasks_manual_command(project_root: Path, daily_time: str) -> str:
    """PowerShell 없이 작업을 등록하는 명령. GPO 환경의 폴백이다."""
    python = project_root / ".venv" / "Scripts" / "python.exe"
    return (
        f'schtasks /Create /TN "DMF Crawler\\Daily" /SC DAILY /ST {daily_time} '
        f'/TR "\\"{python}\\" -m dmf_crawler run --trigger scheduled" '
        f'/RL LIMITED /F'
    )

화면:

┌────────────────────────────────────────────────────────────────────┐
│ ⚠  자동 실행을 등록하지 못했습니다                                 │
├────────────────────────────────────────────────────────────────────┤
│  회사 보안 정책(그룹 정책)이 스크립트 실행을 막고 있습니다.          │
│                                                                    │
│  방법 1 — 직접 등록하기                                             │
│   1. 시작 메뉴에서 "명령 프롬프트" 를 검색해 실행합니다.            │
│   2. 아래 [명령 복사] 를 누른 뒤 붙여넣고 Enter.                    │
│   ┌──────────────────────────────────────────────────────────────┐ │
│   │ schtasks /Create /TN "DMF Crawler\Daily" /SC DAILY /ST 06:00 │ │
│   │ /TR "\"D:\...\.venv\Scripts\python.exe\" -m dmf_crawler ...  │ │
│   └──────────────────────────────────────────────────────────────┘ │
│                                                                    │
│  방법 2 — IT 담당자에게 요청하기                                    │
│   아래 [요청 문구 복사] 를 눌러 그대로 전달해 주세요.               │
│                                                                    │
│  방법 3 — 자동 실행 없이 사용하기                                   │
│   매일 아침 [지금 실행] 을 직접 누르시면 됩니다.                    │
│                                                                    │
│  ┌────────────┐ ┌──────────────────┐ ┌──────────┐ ┌─────────────┐ │
│  │ 명령 복사  │ │  요청 문구 복사  │ │ 다시 시도│ │  건너뛰기   │ │
│  └────────────┘ └──────────────────┘ └──────────┘ └─────────────┘ │
└────────────────────────────────────────────────────────────────────┘

요청 문구 (클립보드에 담기는 내용):

[IT 담당자님께]

업무용 프로그램(DMF 등록정보 수집기) 설치 중 아래 항목이 필요합니다.

1. PowerShell 스크립트 실행 허용 (RemoteSigned) 또는 아래 경로 예외 등록
   D:\workspace\DMF_Crawler\scripts\*.ps1

2. 외부 접속 허용 (HTTPS 443)
   apis.data.go.kr          - 식약처 공공데이터 API (필수)
   pypi.org                 - 파이썬 패키지 (설치 시 1회)
   files.pythonhosted.org   - 파이썬 패키지 (설치 시 1회)
   antigravity.google       - AI 요약 도구 (선택)

3. 작업 스케줄러에 현재 사용자 계정 작업 등록
   (관리자 권한 불필요, RunLevel=Limited)

감사합니다.

13.4 로그와 진단

로그 경로 무엇이 들어가는가
부트스트랩 logs\bootstrap.log venv 생성, pip 출력, Unblock-File
온보딩 logs\onboarding_YYYYMMDD.log 액션 실행 이력, agy 설치 출력, 작업 등록 결과
배치 실행 logs\run_YYYYMMDD_HHMMSS\pipeline.log 전체 파이프라인 (ADR-19)

인증키가 로그에 새어나가지 않게 하는 3중 방어

  1. http.py 가 예외에 URL 을 담되 serviceKey마스킹한다 (§3.3 계약).
  2. GUI 는 원문 대신 key_fingerprint() 만 표시한다.
  3. [진단 결과 복사하기]CheckResult 필드만 직렬화한다 — 인증키가 들어갈 자리가 애초에 없다.

14. 배포 형태

14.1 전달물

DMF_Crawler.zip   (약 200 KB — 파이썬·agy 는 포함하지 않는다)
├─ bootstrap.cmd          ★ 사용자가 더블클릭할 유일한 파일
├─ README.txt             ★ 한 장짜리 안내 (CP949 로 저장 — 메모장 호환)
├─ pyproject.toml
├─ config\
│   ├─ config.toml
│   ├─ config.default.toml          # [기본값으로 되돌리기] 의 원본
│   └─ config.local.toml.example
├─ prompts\
├─ scripts\
├─ src\dmf_crawler\
├─ tests\
├─ assets\dmf.ico
└─ docs\

포함하지 않는 것과 이유

제외 이유
파이썬 런타임 임베디드 배포판에는 tkinter 가 없다(tcl/tk 미포함) → GUI 를 못 만든다. bootstrap.cmd 가 winget 으로 설치한다
agy.exe (187 MB) zip 이 200 KB → 190 MB 로 폭증한다. 선택 기능이므로 필요할 때 받는다
.venv\ 절대 경로가 박혀 있어 다른 PC 에서 동작하지 않는다
data\ reports\ state\ logs\ 런타임 생성물. .gitignore 대상
service_key.bin 다른 PC 에서 복호화 불가(DPAPI 사용자 범위). 넣어도 무의미하고, 넣으면 안 된다
PyInstaller .exe SmartScreen 2클릭 + AV 오탐 (1.4절)

14.2 README.txt 전문

CP949(ANSI)로 저장한다. 메모장이 기본으로 여는 인코딩이며, UTF-8 BOM 없이 저장하면 한글이 깨질 수 있다.

========================================
  DMF 크롤러 - 시작하기
========================================

■ 무엇을 하는 프로그램인가요?

  매일 아침 6시에 식약처의 원료의약품 등록(DMF) 정보를 자동으로 받아
  어제와 비교한 뒤, 신규/변경/취하 내역을 엑셀 리포트로 만들어 줍니다.


■ 어떻게 시작하나요?

  1. 이 폴더 안의  bootstrap.cmd  를 더블클릭합니다.
  2. 검은 창이 뜨고 준비 작업이 진행됩니다. (처음 한 번만, 3~8분)
  3. 검은 창이 사라지고 [DMF 크롤러 설정] 창이 열립니다.
  4. 화면의 빨간 항목을 위에서부터 하나씩 눌러 해결합니다.
  5. 전부 초록색이 되면 [지금 실행] 을 눌러 보세요.


■ 준비물

  - 인터넷 연결
  - 공공데이터포털(www.data.go.kr) 회원가입
    (설정 창에서 [발급 페이지 열기] 버튼이 안내해 드립니다. 무료입니다.)

  ※ 파이썬 같은 프로그램은 설정 창이 알아서 설치합니다.
  ※ 관리자 권한은 필요하지 않습니다.


■ 두 번째부터는

  바탕화면의  [DMF 설정]  아이콘으로 언제든 상태를 확인할 수 있습니다.
  바탕화면의  [지금 실행]  아이콘으로 지금 바로 리포트를 만들 수 있습니다.

  평소에는 아무것도 하지 않으셔도 됩니다.
  매일 아침 자동으로 실행되고, 결과는 reports 폴더에 저장됩니다.


■ 자주 겪는 상황

  ● 폴더를 다른 곳으로 옮겼어요
    → bootstrap.cmd 를 한 번 더 실행해 주세요.
       여러 번 실행해도 안전하며, 바로가기와 자동 실행이 갱신됩니다.

  ● 아침에 리포트가 안 만들어졌어요
    → [DMF 설정] 을 열어 보세요. 무엇이 문제인지 화면에 나옵니다.
       이 프로그램은 컴퓨터에 로그인되어 있을 때만 자동 실행됩니다.

  ● 인증키를 다시 발급받았어요
    → [DMF 설정] > [키 입력] 에서 새 인증키를 다시 넣어 주세요.
       공공데이터포털은 인증키를 새로 만들면 예전 것이 자동으로 사라집니다.

  ● 회사 컴퓨터라 설치가 막혀요
    → 설정 창의 [진단 결과 복사하기] 를 누른 뒤,
       그 내용을 IT 담당자에게 전달해 주세요.


■ 도움이 필요하면

  [DMF 설정] 창의 [진단 결과 복사하기] 를 누르면
  현재 상태가 클립보드에 복사됩니다.
  메일이나 메신저에 붙여넣어 전달해 주세요.
  (인증키 같은 비밀 정보는 포함되지 않습니다.)


■ 만든 자료의 출처

  식품의약품안전처_원료의약품등록(DMF)현황
  공공데이터포털 https://www.data.go.kr/data/15057075/openapi.do

14.3 바탕화면 바로가기

bootstrap.cmd [4/5] 단계가 make_shortcuts.ps1 을 호출해 프로젝트 루트와 바탕화면 양쪽에 두 개씩 만든다 (11.7).

바로가기 대상 인자
DMF 설정.lnk .venv\Scripts\pythonw.exe -m dmf_crawler onboard --mode setup
지금 실행.lnk .venv\Scripts\pythonw.exe -m dmf_crawler onboard --mode inspect --run-now

양쪽에 만드는 이유: 바탕화면 바로가기는 사용자가 정리하다 지운다. 프로젝트 루트의 것은 폴더를 열면 항상 보인다 — 12.2 의 "설정 다시 열기 경로 4개" 중 두 개가 여기서 나온다.

14.4 전달 방법별 주의

방법 MOTW 대응
USB 복사 안 붙음 그대로 동작
사내 공유 폴더 🟡 Zone 에 따라 다름 bootstrap.cmd [3/5] 의 Unblock-File 이 처리
메일 첨부 · 메신저 붙음 동일. [3/5] 단계를 건너뛰면 .ps1 실행이 차단된다
다운로드 링크 붙음 동일

Unblock-File 을 몰래 하지 않는다. [3/5] 파일 차단을 해제하는 중... 을 화면에 표시하고, README.txt 에도 원리를 적어 두지는 않되 로그(logs\bootstrap.log)에는 남긴다. 감사 대상이 되는 조직에서 설명할 수 있어야 한다.


15. 테스트 시나리오

15.1 상태 재현 방법

각 실패 상태를 의도적으로 만들어 화면과 복구 경로를 검증한다. 파괴적인 조작이 있으므로 가상 머신이나 별도 Windows 계정에서 수행한다.

# 재현할 상태 재현 명령 기대 화면 복구 검증
T1 깨끗한 PC (전부 없음) 새 Windows 사용자 계정 생성 → zip 해제 → bootstrap.cmd [S0] [1/5] 설치되어 있지 않습니다 → 설치 → [S1] 에 ✖ 4개 3.1 여정 전체가 25분 내 완료
T2 인증키만 없음 del "%LOCALAPPDATA%\DMF_Crawler\service_key.bin" [S1] — ④ ✖, ⑤ 보류 [키 입력] → 저장 → 둘 다 ✔
T3 인증키가 잘못됨 service_key.bin 에 유효하지 않은 키를 저장 [S1] — ④ ✔, ⑤ ✖ "등록되지 않은 인증키입니다" 재입력 → ✔
T4 인증키 복호화 불가 다른 계정에서 만든 service_key.bin 을 복사해 넣기 ④ ✖ "다른 컴퓨터에서 복사해 온 파일…" [키 입력] → ✔
T5 agy 만 없음 ren "%LOCALAPPDATA%\agy\bin\agy.exe" agy.bak ⑥ ▲ "(없어도 됨)", [지금 실행] 은 활성 [설치하기] → ✔
T6 agy 로그인 만료 ren "%USERPROFILE%\.gemini\antigravity-cli\antigravity-oauth-token" t.bak ⑦ ▲ [로그인] → [S4] → 콘솔+브라우저 → 2초 내 자동 ✔
T7 로그인 폴링 타임아웃 [S4] 에서 [로그인 창 열기] 후 5분간 아무것도 안 함 "아직 확인되지 않았습니다" [다 했어요] / [나중에 하기] 둘 다 동작
T8 작업 미등록 schtasks /Delete /TN "DMF Crawler\Daily" /F (3종 전부) ⑨ ▲ [등록하기] → [S5] → ✔, NextRunTime 확인
T9 폴더 이동 (STALE_PATH) 작업 등록 후 폴더를 다른 경로로 이동 ⑨ ▲ "예전 폴더를 가리키고 있습니다" [다시 등록] → 새 경로로 갱신 확인
T10 디스크 부족 가상 디스크를 2 GB 미만으로 채움 ⑩ ✖, [지금 실행] 비활성 [디스크 정리] → [다시 검사] → ✔
T11 Excel 파일 잠금 오늘 자 리포트를 Excel 로 열어 둔 채 마법사 실행 ⑪ ✖ + 정확한 파일명 Excel 닫기 → [다시 검사] → ✔
T12 DB 손상 dmf.sqlite3 중간 바이트를 임의로 덮어씀 ⑧ ✖ "자료 파일이 손상되었습니다" [복구하기] → 백업 복원 → ✔
T13 설정 파일 깨짐 config.toml[[[ 삽입 ③ ✖ + 파싱 오류 원문 [기본값으로 되돌리기] → .bak 생성 확인
T14 부품 누락 .venv\Scripts\pip uninstall -y httpx ② ✖ "빠진 것: httpx" [설치하기] → ✔
T15 네트워크 차단 방화벽에서 apis.data.go.kr 아웃바운드 차단 ⑤ ✖ "인터넷 연결을 확인해 주세요" 차단 해제 → [다시 검사] → ✔
T16 쿼터 초과 (코드 22) 유효 키로 10,000회 호출 후 검증 또는 응답 목업 [S2] ▲ 이지만 저장됨 ⑤ 가 ✔ 로 남는지 확인 (실패로 처리하면 버그)
T17 GPO 로 PS 차단 로컬 GPO 로 Restricted 강제 ⑨ 등록 실패 → [S9] [명령 복사] / [요청 문구 복사] / [건너뛰기] 동작
T18 파이썬 미설치 + winget 없음 Windows Server Core 등 [S0] :manual_python 다운로드 페이지 열림, 재실행 시 정상
T19 전부 정상 (재실행) 온보딩 완료 후 DMF 설정.lnk [S6] 준비 완료 (체크리스트 아님) [지금 실행] → [S7] → [S8]
T20 복구 모드 기동 pythonw -m dmf_crawler onboard --mode recover --focus api_key_valid [S1-R] 4요소 메시지 [키 다시 입력] / [전체 상태 보기] 동작
T21 AI 만 실패 (PARTIAL) 토큰을 지운 뒤 [지금 실행] [S8] 완료 + ▲ 안내 박스 종료 코드 0, xlsx 존재, [지금 로그인하기] 동작
T22 실행 중 중단 [S7] 에서 [중단] 클릭 창이 닫히고 프로세스 종료 state\run.lock 이 해제되는지 확인
T23 창 강제 종료 [S3] 진행 중 [×] "진행 중인 작업이 있습니다" 확인 다이얼로그 자식 프로세스가 정리되는지 확인

15.2 비기능 검증

항목 방법 합격 기준
콘솔 창 미노출 .lnk 더블클릭 후 화면 녹화 (60 fps) bootstrap.cmd 와 agy 로그인 외에는 콘솔이 한 프레임도 나타나지 않음
GUI 무응답 없음 각 액션 실행 중 창을 드래그·클릭 제목에 "(응답 없음)" 이 한 번도 뜨지 않음
DPI 100/125/150/200% 디스플레이 배율 변경 후 각 화면 글자 잘림·버튼 겹침 없음
한글 렌더링 전 화면 육안 검사 물음표·네모()·깨진 글자 없음
UAC 미노출 온보딩 전 과정 방패 아이콘·승격 프롬프트가 0회
인증키 유출 findstr /S /I "<실제 키>" logs\* 0건. service_key.bin 외 어디에도 없음
[진단 복사] 안전성 복사된 JSON 을 육안 검사 인증키·토큰 원문 미포함
멱등성 bootstrap.cmd 3회 연속 실행 매번 성공, 부작용 없음, 바로가기 중복 생성 없음
소요 시간 T1 전체를 스톱워치로 25분 이내 (키 발급 시간 제외 시 12분)

15.3 자동화 가능한 부분

GUI 자체는 자동 테스트가 어렵지만 checks.py 는 전부 테스트 가능하다. 01-architecture.mdtests/test_checks.py 가 이 역할이다.

# tests/test_checks.py — 아키텍처 트리에 이미 예고된 파일
import pytest
from dmf_crawler import checks
from dmf_crawler.checks import Severity


def test_all_checks_have_fix_action():
    """막다른 골목 금지(R7.7)를 계약으로 검증한다.

    이 테스트가 깨지면 사용자가 '실패했는데 누를 버튼이 없는' 화면을 보게 된다.
    """
    for key, _fn, fix_action in checks._REGISTRY:
        assert fix_action, f"check '{key}' 에 fix_action 이 없다"
        assert fix_action in checks.REPROBE_AFTER, \
            f"fix_action '{fix_action}' 의 재검사 대상이 정의되지 않았다"


def test_registry_is_complete():
    """12종이 모두 등록되어 있고 메타데이터가 일치한다."""
    keys = [k for k, _, _ in checks._REGISTRY]
    assert len(keys) == 12
    assert set(keys) == set(checks._TITLES) == set(checks._SEVERITY)


def test_verdict_codes():
    """doctor 종료 코드 규약: 0=통과, 1=WARN, 2=CRITICAL."""
    def r(ok, sev):
        return checks.CheckResult("k", "t", ok, "", "", "a", sev)

    assert checks.verdict([r(True, Severity.CRITICAL)]) == 0
    assert checks.verdict([r(False, Severity.WARN)]) == 1
    assert checks.verdict([r(False, Severity.CRITICAL)]) == 2
    # CRITICAL 이 있으면 WARN 이 함께 있어도 2 다
    assert checks.verdict([r(False, Severity.WARN),
                           r(False, Severity.CRITICAL)]) == 2


def test_can_run_ignores_warn():
    """WARN 은 [지금 실행] 을 막지 않는다 — agy 는 전제 조건이 아니다(ADR-15)."""
    def r(ok, sev):
        return checks.CheckResult("k", "t", ok, "", "", "a", sev)

    assert checks.can_run([r(False, Severity.WARN)]) is True
    assert checks.can_run([r(False, Severity.CRITICAL)]) is False


def test_check_exception_becomes_result(monkeypatch):
    """진단기가 죽어서 진단이 안 되는 일이 없어야 한다."""
    def boom(cfg):
        raise RuntimeError("의도적 실패")

    res = checks._safe("x", "테스트", boom, "enter_api_key", None)
    assert res.ok is False
    assert "의도적 실패" in res.detail
    assert res.fix_action == "enter_api_key"      # 예외 상황에도 버튼이 있다


@pytest.mark.parametrize("raw,expected", [
    ("AbCd+Ef/GhIj==",                  "AbCd+Ef/GhIj=="),        # Decoding 그대로
    ("AbCd%2BEf%2FGhIj%3D%3D",          "AbCd+Ef/GhIj=="),        # Encoding -> 정규화
    ("AbCd%252BEf%252FGhIj%253D%253D",  "AbCd+Ef/GhIj=="),        # 이중 인코딩도 흡수
    ('  "AbCd+Ef/GhIj=="  ',            "AbCd+Ef/GhIj=="),        # 따옴표·공백 제거
])
def test_key_normalization(raw, expected):
    from dmf_crawler.keycheck import normalize_service_key
    assert normalize_service_key(raw) == expected


def test_key_normalization_is_idempotent():
    from dmf_crawler.keycheck import normalize_service_key
    once = normalize_service_key("AbCd%2BEf%2FGhIj%3D%3D")
    assert normalize_service_key(once) == once


@pytest.mark.parametrize("body,status,exp_code", [
    ('{"OpenAPI_ServiceResponse":{"cmmMsgHeader":{"errMsg":'
     '"SERVICE_KEY_IS_NOT_REGISTERED_ERROR","returnReasonCode":"30"}}}', 403, "30"),
    ('<OpenAPI_ServiceResponse><cmmMsgHeader><errMsg>SERVICE_KEY_IS_NULL</errMsg>'
     '<returnReasonCode>20</returnReasonCode></cmmMsgHeader></OpenAPI_ServiceResponse>',
     401, "20"),
])
def test_gateway_envelope_parsed(body, status, exp_code, monkeypatch):
    """GW 오류 봉투(JSON/XML 양쪽)를 정상 봉투 파서로 읽어 죽지 않는다."""
    ...     # httpx 를 목업해 _probe() 를 호출하고 code 를 검증한다


def test_quota_exceeded_is_saved():
    """코드 22/23 은 '키 유효' 로 판정해야 한다.

    이걸 실패로 처리하면 사용자가 멀쩡한 키를 하루 종일 다시 입력하게 된다.
    """
    ...     # _probe 를 목업해 code="22" 를 반환시키고 validate_service_key 가
            # key 를 None 이 아닌 값으로 돌려주는지 검증한다

15.4 회귀 방지 픽스처

tests/fixtures/ 에 이 문서가 근거로 삼은 실측 응답 원문을 저장한다. 아키텍처 트리에 이미 자리가 있다.

파일 내용 이 문서의 근거
api_error_invalid_key.xml HTTP 403 + SERVICE_KEY_IS_NOT_REGISTERED_ERROR / 30 7.4
api_error_null_key.json HTTP 401 + SERVICE_KEY_IS_NULL / 20 7.4
api_error_quota.json 코드 22 15.3 test_quota_exceeded_is_saved
agy_help_v1_1_24.txt agy --help 원문 (login 서브커맨드 부재 증거) 9.1

부록 A. 출처

제목 URL / 경로 확인
DMF Crawler 아키텍처 확정안 docs/design/01-architecture.md 구조 정본. §2 트리, §3.15 checks, §3.17 gui/app, §5 진입점·종료코드, ADR-10/12/13/15/16/20/23/24
데이터 소스 결정 docs/design/00-DATA-SOURCE-DECISION.md §4 API 명세, §9 발급 절차, §6 agy 불변 원칙
기준선 데이터 분석 docs/design/00b-baseline-data-analysis.md 전체 9,084건 · 등록번호 중복 0건
agy CLI 정본 docs/research/05a-agy-cli-ssot.md §3.2 설치 경로, §3.3 install.ps1 해부, §3.6 자동 업데이트, §4.2 토큰 파일, §4.3 헬스체크 비용, §5.2 서브커맨드, §16 배치 체크리스트
DMF 오픈API 상세 https://www.data.go.kr/data/15057075/openapi.do 요청/응답 명세, 오류코드표(2025-09-19)
공공데이터포털 FAQ (트래픽) https://www.data.go.kr/bbs/faq/selectFaqList.do "하루 API 호출 건수", 자정 초기화
공공데이터포털 FAQ (키 1개) 동상 (FAQ_0000000000000171) "재발급하면 기존 키는 자동 폐기"
Open API 에러 코드 정리 https://www.data.go.kr/cmm/cmm/fileDownload.do?atchFileId=FILE_000000001260631&fileDetailSn=0 코드 0~99 전체표
Antigravity CLI 공식 문서 https://antigravity.google/docs/cli headless 규약, 인증 경로
agy --help (v1.1.24) 로컬 실행 실측 — login 서브커맨드 부재 확인
agy --version 로컬 실행 실측 — 1.1.24
agy.exe 파일 크기 %LOCALAPPDATA%\agy\bin\agy.exe 실측 — 187,601,560 bytes
agy OAuth 토큰 ~/.gemini/antigravity-cli/antigravity-oauth-token 실측 — 504 bytes 평문 JSON
Python 런처 목록 py -0p 실측 — 3.14/3.13/3.12/3.11
Store 스텁 확인 %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe 실측 — reparse point
PowerShell 버전 $PSVersionTable 실측 — 5.1.26100.9223
Task Scheduler 보안 컨텍스트 https://learn.microsoft.com/windows/win32/taskschd/security-contexts-for-running-tasks "Logon as Batch" 권한 요구
Register-ScheduledTask https://learn.microsoft.com/powershell/module/scheduledtasks/register-scheduledtask 파라미터
New-ScheduledTaskPrincipal LogonType https://learn.microsoft.com/windows/win32/taskschd/principal-logontype S4U 설명
S4U 로그온 제약 https://learn.microsoft.com/windows/win32/api/taskschd/ne-taskschd-task_logon_type "no password is stored… no access to encrypted files"
S4U/Interactive 등록 실측 로컬 3종 시도 실측 — S4U 는 "액세스가 거부되었습니다"
ProtectedData / DPAPI https://learn.microsoft.com/dotnet/api/system.security.cryptography.protecteddata CurrentUser vs LocalMachine 경고
DPAPI 백서 https://learn.microsoft.com/previous-versions/ms995355(v=msdn.10) 마스터키 수명, LOCAL_MACHINE 경고
CryptProtectData https://learn.microsoft.com/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata ctypes 시그니처
SmartScreen https://learn.microsoft.com/windows/security/operating-system-security/virus-and-threat-protection/microsoft-defender-smartscreen/ "Checking downloaded files"
ExecutionPolicy https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_execution_policies GPO 우선순위, Bypass 가 레지스트리 미변경
Windows Python FAQ https://learn.microsoft.com/windows/python/faqs Store 스텁, py 런처 부재
tkinter 공식 문서 https://docs.python.org/3/library/tkinter.html Windows 번들, ttk 권장
High DPI (Windows) https://learn.microsoft.com/windows/win32/hidpi/setting-the-default-dpi-awareness-for-a-process "before any HWNDs have been created"
무결성 수준 (UAC) https://learn.microsoft.com/windows/win32/secauthz/mandatory-integrity-control medium/high 구분
Session 0 격리 https://learn.microsoft.com/windows/win32/services/interactive-services 서비스가 데스크톱에 접근 불가
활용기간 24개월 https://beaver-sohyun.tistory.com/38 ⚠️ 미검증 (2차 출처. 로그인 벽 뒤라 확인 불가)

부록 B. 미해결 / 실측 필요

다른 문서를 고쳐야 하는 것 (우선순위 높음)

  • 01-architecture.md ADR-10 에 "S4U 등록 실패 시 Interactive 폴백" 을 추가해야 한다. 실측 결과 비관리자 계정은 S4U 등록 자체가 "액세스가 거부되었습니다"로 실패한다. 현재 ADR-10 은 배치가 항상 S4U 로 도는 것을 전제하고 있어, 그대로 구현하면 대상 사용자의 PC 에서 온보딩이 실패한다. → 이 문서 1.6 · 10.2
  • 00-DATA-SOURCE-DECISION.md §9-6 을 개정해야 한다. .env 평문 저장 지시를 "저장 매체는 secrets_dpapi.py(ADR-13)를 따른다. .env 는 개발자 옵트인 폴백" 으로 바꾼다. 현재 두 문서가 서로 다른 저장 위치를 지시하고 있다.
  • docs/ops/02-failure-alerting.md(M4)와 이 문서의 [S1-R] 4요소 문구를 하나로 맞춰야 한다. 이 문서의 four_part_message() 는 그 규격의 렌더러여야 하며, 문구 원본을 중복 정의해서는 안 된다.
  • agy SSOT 의 버전 표기(v1.1.22)를 실측 v1.1.24 로 갱신한다.

실측이 필요한 것

  • 정상 응답 봉투의 최상위 래퍼가 response 인가 아닌가? 유효 키가 없어 확정하지 못했다. _probe() 는 양쪽을 흡수하도록 방어적으로 짰지만, 최초 검증 성공 시 실제 구조를 기록해야 한다. (00-DATA-SOURCE-DECISION.md 부록 B 와 중복 항목)
  • resultCode"00"(문자열)인가 0(숫자)인가? 현재 str(x).zfill(2) 로 양쪽을 흡수한다.
  • 개발계정 인증키 활용기간의 정확한 길이. 2차 출처는 "승인일로부터 24개월" 이라 하나 신청 폼이 로그인 벽 뒤에 있어 원문 확인 실패. 7.7의 22개월 보조 경보는 이 값에 의존하지 않도록 설계했으나, 확정되면 임계값을 조정한다.
  • agy 최초 로그인 시 브라우저가 자동으로 열리는가, 아니면 콘솔에 URL 만 표시되는가? 이 프로젝트의 로컬 환경은 이미 인증돼 있어 미인증 상태의 첫 화면을 재현하지 못했다. [S4] 의 5단계 안내 문구가 실제 화면과 맞는지 확인이 필요하다. 만약 URL 만 표시된다면 안내를 "검은 창에 보이는 주소를 복사해 브라우저에 붙여넣으세요"로 바꿔야 한다.
  • agy TUI 를 cmd /k 로 감쌌을 때 화면이 정상 렌더링되는가? TUI 는 콘솔 기능에 민감하다. Windows Terminal 이 기본 터미널인 환경과 레거시 conhost 환경 양쪽에서 확인해야 한다.
  • Agent 태스크의 15분 반복 트리거 구성이 실제로 등록되는가? 11.9 의 $agentTrigger.Repetition 대입 방식은 New-ScheduledTaskTrigger -AtLogOn 에 반복을 붙이는 우회 기법이다. 실패하면 -Once -RepetitionInterval 트리거를 별도로 추가하는 방식으로 바꿔야 한다.
  • _pipeline_phase() 가 파싱하는 STAGE n/m <name> 출력 형식을 pipeline.py 가 실제로 내보내는지 — 아직 pipeline.py 가 구현되지 않았다. M1 에서 이 계약을 확정해야 [S7] 진행률이 동작한다.
  • storage.repo.last_agy_error_kind(within_days=3)consecutive_failures() 시그니처 확정. 01-architecture.md §3.9 의 repo.py 공개 API 에 이 두 함수가 명시돼 있지 않다. M2 에서 추가하거나 이 문서의 호출부를 조정해야 한다.
  • state.py 모듈이 아키텍처 트리에 없다. 이 문서는 read_heartbeat() / touch_heartbeat() 를 쓰는데, §2 트리에는 state/heartbeat.json 파일만 있고 접근 모듈이 없다. watchdog.py 에 흡수할지 별도 모듈로 둘지 결정이 필요하다.
  • paths.py 가 노출해야 할 상수 목록 확정 — 이 문서가 쓰는 것: PROJECT_ROOT, SCRIPTS_DIR, CONFIG_PATH, REPORTS_DIR, LOGS_DIR, DB_PATH, APP_DATA_DIR, VENV_PYTHON, VENV_PYTHONW, AGY_EXE, AGY_TOKEN.
  • 깨끗한 Windows 11 클라이언트의 기본 ExecutionPolicy 확인. 개발 머신은 오염돼 있어 문서상 기본값(전 스코프 Undefined → 효과적 Restricted)을 실증하지 못했다. -ExecutionPolicy Bypass 를 항상 붙이므로 실무상 문제는 없다.
  • 회사 GPO 로 Windows Script Host 나 PowerShell 이 막힌 환경의 실제 빈도. 13.3 의 폴백이 실제로 필요한지, 아니면 과잉 설계인지 판단이 필요하다.
  • assets/dmf.ico 가 아직 없다. 현재 imageres.dll,109 시스템 아이콘으로 폴백하지만 바탕화면 식별성이 떨어진다. 256×256 포함 멀티 해상도 .ico 준비 여부 결정.
  • config/config.default.toml 이 아키텍처 트리에 없다. 13.2 의 [기본값으로 되돌리기] 가 이 파일을 필요로 한다. config.toml 자체를 배포본으로 쓰고 별도 원본을 두지 않는 방법도 있다.

설계 판단이 필요한 것

  • api_key_valid 의 12시간 캐시가 적절한가? 너무 길면 키가 폐기된 것을 늦게 알고, 너무 짧으면 호출을 낭비한다. 하루 10,000 한도 대비 매 실행 1회는 무시할 수준이므로 캐시를 아예 없애는 선택지도 있다.
  • [S4] 로그인 타임아웃 5분이 충분한가? 2단계 인증·계정 선택·회사 SSO 를 거치면 더 걸릴 수 있다. 타임아웃 후에도 [다시 기다리기] 가 있으므로 치명적이지는 않다.
  • 온보딩에서 backup.dir 을 다른 드라이브로 유도할 것인가? ADR-22 가 "온보딩에서 유도" 를 명시했으나 이 문서는 그 화면을 설계하지 않았다. 체크 항목을 13번째로 추가할지, [S5] 에 옵션으로 넣을지 결정이 필요하다.

이 문서는 온보딩·복구 GUI 에 관한 SSOT 다. 화면·문구·전이에 관한 새 결정은 여기를 갱신한다. 구조(디렉터리·파일명·CLI·종료 코드)에 관한 정본은 01-architecture.md 이며, 충돌 시 그쪽이 이긴다.