- 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 문서 지도 갱신
4862 lines
276 KiB
Markdown
4862 lines
276 KiB
Markdown
# 첫 실행 온보딩 마법사 설계 — 더블클릭으로 끝내는 설정
|
||
|
||
> **이 문서의 역할**: 이 시스템을 쓰는 사람은 **개발자가 아니다.** 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 설정.lnk` → `pythonw.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.ps1` 은 **S4U 시도 → 실패 시 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.ps1` — `install.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 필드)을 직접 읽어 확인했다.
|
||
|
||
```powershell
|
||
# 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.cmd` 가 `Unblock-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.** `ctypes` 로 `crypt32.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.zip` 을 `D:\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_action` 이 `None` 인 체크는 `checks.py` 등록 단계에서 **`ValueError` 로 거부**한다. 막다른 골목이 구조적으로 생길 수 없다.
|
||
|
||
---
|
||
|
||
### 4.2 항목별 상세
|
||
|
||
#### ① `python_venv` — 실행 환경
|
||
|
||
- **무엇을**: `.venv` 가 존재하고 그 인터프리터가 3.11 이상인가.
|
||
- **검사**:
|
||
```python
|
||
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 되는가.
|
||
- **검사**:
|
||
```python
|
||
_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` 검증을 통과하는가.
|
||
- **검사**:
|
||
```python
|
||
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 에서 복사됐으면 못 푼다.
|
||
- **정책**: 공공데이터포털 인증키는 선택이다. 실패해도 `[지금 실행]` 을 막지 않으며, 키가 없어도 기존 자료 리포트 생성은 계속된다.
|
||
- **검사**:
|
||
```python
|
||
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.json` 의 `api_key_verified_at` 이 **12시간 이내면 재호출하지 않고 ✔ 로 표시**한다. `[다시 검사]` 는 캐시를 무시하고 강제 재검증한다.
|
||
```python
|
||
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` 실물이 있고 실행되는가.
|
||
- **검사**:
|
||
```python
|
||
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단 판정:
|
||
```python
|
||
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` 이 코드가 기대하는 버전과 일치하는가.
|
||
- **검사**:
|
||
```python
|
||
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`)이 등록돼 있고, **가리키는 실행 경로가 현재 폴더와 일치**하는가.
|
||
- **검사**:
|
||
```python
|
||
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) + 스냅샷·백업 누적 여유.
|
||
- **검사**:
|
||
```python
|
||
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.toml` 의 `general.min_free_gb`).
|
||
- **버튼**: [디스크 정리] → `cleanmgr.exe`.
|
||
|
||
#### ⑪ `report_writable` — 리포트 저장 폴더
|
||
|
||
- **무엇을**: `reports\` 에 쓸 수 있는가. **그리고 오늘 자 리포트가 Excel 에 잠겨 있지 않은가.**
|
||
- **검사**:
|
||
```python
|
||
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` 이 신선한가. 연속 실패가 쌓이고 있지 않은가.
|
||
- **검사**:
|
||
```python
|
||
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` 가 실패하면 그 안에서 원인을 나눠 표시한다.
|
||
|
||
```python
|
||
# 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.py` 의 `STAGES` 를 그대로 비춘다. 진행률은 완료 스테이지 수 / 전체 스테이지 수다.
|
||
|
||
```
|
||
┌────────────────────────────────────────────────────────────────────┐
|
||
│ ▶ 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.py` 의 `fix_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)` 로 최상위를 해제한다.** 그렇게 하지 않으면 사용자가 브라우저에서 키를 복사할 때 이 창이 브라우저를 계속 가려 아무것도 못 한다.
|
||
|
||
```python
|
||
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(`A–Z a–z 0–9 + / =`)뿐이고 `%` 를 **결코 포함하지 않는다.** 반면 Encoding 형태는 `%2B`/`%2F`/`%3D` 를 반드시 포함한다. 따라서 `%[0-9A-Fa-f]{2}` 매치 = Encoding 키다.
|
||
|
||
**저장 규약**: 항상 **Decoding 형태로 정규화해 저장**한다. ADR-05 가 확정한 `httpx` 의 `params=` 자동 인코딩과 정확히 맞는다 — `params=` 는 값을 한 번 인코딩하므로 넣는 값은 반드시 Decoding 형태여야 한다.
|
||
|
||
```python
|
||
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 을 만드는가** — `httpx` 의 `params=` 나 `urlencode()` 는 값을 **한 번 더** 인코딩한다. 여기에 Encoding 키를 그대로 넣으면 `%2B` → `%252B` 로 변질돼 서버가 전혀 다른 키로 인식한다. 위 함수로 항상 원본으로 되돌린 뒤 `params=` 에 넘기면 어떤 입력이 와도 동일한 요청이 나간다.
|
||
|
||
### 7.4 입력 즉시 실시간 API 검증
|
||
|
||
**저장 버튼을 누른 순간 실제로 API 를 1회 호출한다.** 잘못된 키가 저장되면 06:00 배치가 조용히 죽고, 사용자는 며칠 뒤에야 리포트가 안 온다는 걸 알아챈다.
|
||
|
||
```python
|
||
# 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초 걸리므로 메인 스레드에서 부르면 **창이 흰색으로 굳고 제목에 "(응답 없음)"이 뜬다.** 비개발자는 그 순간 프로그램이 죽었다고 판단하고 강제 종료한다.
|
||
|
||
```python
|
||
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 | 이 자료에 대한 사용 신청이 아직 완료되지 않았습니다.<br>마이페이지 > 데이터 활용 > Open API > 활용신청 현황 에서 상태를 확인해 주세요. | ❌ | [마이페이지 열기] |
|
||
| `21` | TEMPORARILY_DISABLE_THE_SERVICEKEY_ERROR | 인증키가 일시적으로 중지된 상태입니다. | ❌ | [마이페이지 열기] |
|
||
| **`22`** | LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS | ▲ 오늘 조회 횟수를 다 썼습니다. **인증키 자체는 정상입니다.**<br>자정이 지나면 자동으로 초기화됩니다. | **✅ 저장** | 저장하고 닫음 |
|
||
| **`23`** | ..._PER_SECOND_EXCEEDS_ERROR | ▲ 잠시 뒤 다시 시도해 주세요. **인증키 자체는 정상입니다.** | **✅ 저장** | 저장하고 닫음 |
|
||
| `29` | BLACKLIST_IP_ACCESS_ERROR | 이 컴퓨터의 인터넷 주소가 차단되어 있습니다.<br>공공데이터포털 활용지원센터(1566-0025)로 문의해 주세요. | ❌ | [전화번호 복사] |
|
||
| **`30`** | SERVICE_KEY_IS_NOT_REGISTERED_ERROR | 등록되지 않은 인증키입니다.<br>**포털에서 인증키를 새로 발급하면 예전 키는 자동으로 사라집니다.**<br>마이페이지에서 현재 인증키를 다시 복사해 주세요. | ❌ | [마이페이지 열기] |
|
||
| **`31`** | DEADLINE_HAS_EXPIRED_ERROR | 인증키 사용 기간이 끝났습니다.<br>마이페이지 > 활용신청 현황 에서 '활용연장신청' 을 해주세요. | ❌ | [마이페이지 열기] |
|
||
| `32` | UNREGISTERED_IP_ERROR | 신청할 때 등록한 인터넷 주소와 지금 주소가 다릅니다. | ❌ | [마이페이지 열기] |
|
||
| `10` `11` `12` | INVALID_REQUEST / NO_OPENAPI_SERVICE | 자료 제공 방식이 바뀐 것 같습니다.<br>프로그램 업데이트가 필요할 수 있습니다. | ❌ | [자료 안내 페이지 열기] |
|
||
| `01` `04` `05` `99` | 일시 장애 | 잠시 문제가 있었습니다. 다시 시도해 주세요. | ❌ | [다시 시도] |
|
||
| — | network | 인터넷 연결을 확인해 주세요.<br>회사 PC 라면 IT 담당자에게 apis.data.go.kr (443) 허용을 요청해 주세요. | ❌ | [다시 시도] / [주소 복사] |
|
||
|
||
```python
|
||
_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.json` 의 `api_key_first_success_at` 으로부터 **22개월 경과** 시 [S1] 상단 배너 | 참고용 |
|
||
|
||
```python
|
||
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-File` → `agy install` 로 PATH 구성 → 스테이징 정리.
|
||
> 체크섬 불일치 시 `Security Halt: Checksum verification failed.` 로 중단된다. **별도 무결성 검증이 불필요하다.**
|
||
> **업데이트 목적으로는 쓸 수 없다** — 기존 바이너리가 있으면 아무것도 안 하고 빠진다. 업데이트는 `AgyUpdate` 주간 작업(`agy update`)이 담당한다.
|
||
|
||
### 8.3 진행률 표시 — 4단계 한국어 요약
|
||
|
||
`install.ps1` 은 퍼센트를 내보내지 않는다. 따라서 **출력 문자열을 패턴 매칭해 4단계로 환산**한다.
|
||
|
||
```python
|
||
_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 열기]** 는 실제 창을 띄워 준다 — 사용자가 시작 메뉴에서 찾지 못할 가능성을 없앤다.
|
||
|
||
```python
|
||
subprocess.Popen(["powershell.exe", "-NoExit", "-NoProfile"],
|
||
creationflags=subprocess.CREATE_NEW_CONSOLE)
|
||
```
|
||
|
||
### 8.5 설치 후 검증
|
||
|
||
설치 직후 곧바로 `checks.run_one("agy_installed")` 을 부른다. **PATH 갱신을 기다릴 필요가 없다** — 원래부터 절대 경로 판정이기 때문이다.
|
||
|
||
```python
|
||
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 로그인 창 실행 — 완전한 코드
|
||
|
||
```python
|
||
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()` 로 자기 자신을 다시 예약**해야 이벤트 루프가 계속 돈다.
|
||
|
||
```python
|
||
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 계획 업데이트 |
|
||
|
||
- `Daily` 는 `python.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` 은 반드시 이렇게 동작해야 한다.
|
||
|
||
```powershell
|
||
# 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] 하단 박스). 사용자가 "로그오프해도 돌겠지"라고 잘못 믿는 것이 가장 나쁘다.
|
||
|
||
```python
|
||
if result["Daily"]["LogonType"] == "Interactive":
|
||
notice = ("이 컴퓨터에 로그인되어 있을 때만 자동 실행됩니다.\n"
|
||
"아침에 PC 를 켜고 로그인해 두시면 됩니다.\n\n"
|
||
"(로그인 없이도 실행하려면 관리자 권한이 필요한데, "
|
||
"그 방식은 보안상 사용하지 않습니다.)")
|
||
```
|
||
|
||
### 10.3 설정값 고정
|
||
|
||
```powershell
|
||
$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` 로 한다.
|
||
|
||
```python
|
||
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 마지막 실행 결과 읽기
|
||
|
||
```python
|
||
_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 해제
|
||
|
||
```powershell
|
||
# 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` — 최초 설치 진입점
|
||
|
||
```bat
|
||
@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` 필수 계약**이 여기서 강제된다.
|
||
|
||
```python
|
||
"""전제조건 진단 엔진.
|
||
|
||
온보딩(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` — 온보딩=복구 단일 창
|
||
|
||
```python
|
||
"""온보딩·복구·진단 단일 창 (요구 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` — 단계별 액션
|
||
|
||
```python
|
||
"""체크리스트의 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` — 공통 위젯
|
||
|
||
```python
|
||
"""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 암·복호화
|
||
|
||
```python
|
||
"""공공데이터포털 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 이 없으면 한글 바로가기 이름이 깨진다.
|
||
|
||
```powershell
|
||
#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 으로 저장할 것.**
|
||
|
||
```powershell
|
||
#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 으로 저장할 것.**
|
||
|
||
```powershell
|
||
#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.py` — `onboard` / `doctor` 커맨드 (발췌)
|
||
|
||
```python
|
||
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_auth` → `agy_calls` 의 AUTH | ▲ + [로그인] |
|
||
| **폴더를 옮김** | ⑨ `tasks_registered` → `STALE_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 우회는 **시도하지 않는다.**
|
||
|
||
```python
|
||
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.md` 의 `tests/test_checks.py` 가 이 역할이다.
|
||
|
||
```python
|
||
# 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` 이며, 충돌 시 그쪽이 이긴다.*
|