commit 56a6e2da93cde03dac4b650921e98cd14ecca8d6 Author: Yun Chan Date: Fri Sep 4 09:25:44 2026 +0900 chore: 저장소 구조 정리 및 문서화, 첫 커밋 - src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes) - 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에 안내용 README.md 추가 - CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가 - docs/README.md 문서 지도 갱신 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e3bdf75 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +# ============================================================================= +# .gitattributes — 줄바꿈·바이너리 처리 규칙 +# ============================================================================= +# Windows 전용 프로젝트다. 텍스트는 기본 LF로 정규화하되, Windows 네이티브 +# 스크립트(.ps1/.cmd/.bat)는 CRLF를 유지한다. .ps1은 UTF-8 with BOM이어야 +# PowerShell 5.1이 한글을 깨뜨리지 않는다(AGENTS.md §6) — 이 파일은 줄바꿈만 +# 다루고 인코딩/BOM은 편집기 설정으로 지킨다. + +* text=auto eol=lf + +*.ps1 text eol=crlf +*.cmd text eol=crlf +*.bat text eol=crlf + +*.xlsx binary +*.sqlite3 binary +*.png binary +*.bmp binary +*.ico binary +*.whl binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..175f9ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# ============================================================================= +# .gitignore — 저장소에 올리지 않는 것 +# ============================================================================= +# 원칙: 실행하면 다시 만들어지는 것과 비밀 값은 올리지 않는다. + +# --- 가상환경 --------------------------------------------------------------- +.venv/ +venv/ +env/ + +# --- 런타임 산출물 (전부 재생성 가능) ---------------------------------------- +# 폴더 자체는 추적하되 안내용 README.md만 남기고 내용물은 전부 제외한다. +data/* +!data/README.md +logs/* +!logs/README.md +reports/* +!reports/README.md +state/* +!state/README.md +backup/* +!backup/README.md + +# --- PC 별 설정과 비밀 값 ----------------------------------------------------- +config.local.toml +config/config.local.toml +*oauth-token* +*.pem +*.key +service_key.bin +.env + +# --- 백업·임시 파일 ----------------------------------------------------------- +*.bak +*.tmp +*~ +~$*.xlsx +*.sqlite3 +*.sqlite3-wal +*.sqlite3-shm + +# --- 파이썬 ------------------------------------------------------------------- +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# --- 편집기·OS ---------------------------------------------------------------- +.vscode/ +.idea/ +Thumbs.db +desktop.ini + +# --- 바탕화면 바로가기 (bootstrap.cmd/make_shortcuts.ps1이 재생성) ------------- +*.lnk diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7683980 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,121 @@ +# DMF Crawler — AI Agent 작업 지침 + +> 이 파일은 이 저장소에서 일하는 모든 AI coding agent 의 진입 규칙이다. +> 자세한 근거/운영 체계는 `docs/design/07-tdd-red-system.md` 가 정본이다. + +## 0. 먼저 읽을 문서 + +작업을 시작하기 전 반드시 다음을 읽는다. + +1. `docs/HANDOFF.md` — 현재 상태/함정/미완 작업 +2. `docs/00-REQUIREMENTS.md` — 사용자 요구 SSOT +3. `docs/design/01-architecture.md` — 아키텍처 SSOT +4. `docs/design/07-tdd-red-system.md` — **TDD/RED/디자인 감사 SSOT** +5. 작업 파일과 직접 관련된 설계 문서 + - 데이터/DB: `docs/design/02-data-model.md` + - xlsx: `docs/design/03-xlsx-report-spec.md` + - GUI: `docs/design/04-onboarding-wizard.md` + - agy: `docs/research/05a-agy-cli-ssot.md` + +## 1. 최상위 법칙 — No RED, No Code + +- `src/` 기능 코드는 **실패하는 테스트(RED) 없이 작성하지 않는다.** +- 테스트가 즉시 통과하면 RED 가 아니다. 왜 통과했는지 확인하고 더 강한 테스트로 바꾼다. +- RED 실패 로그를 본 뒤 최소 구현으로 Green 을 만든다. +- Refactor 는 관련 테스트가 Green 일 때만 한다. +- 테스트를 약하게 바꿔 Green 을 만들지 않는다. +- 실패를 숨기기 위한 `skip`, `xfail`, `assert True`, 광범위한 `except: pass` 는 금지다. + +## 2. 작업 프로토콜 + +모든 코드 변경 전 짧게라도 다음을 정한다. + +```text +변경 파일: +사용자/운영 위험: +추가/수정할 RED: +먼저 돌릴 영향 테스트: +전체 smoke 명령: +``` + +표준 루프: + +```text +1. RED 작성 +2. RED 실행 — 기대한 이유로 실패하는지 확인 +3. 최소 구현 +4. 영향 테스트 Green +5. Refactor(필요할 때만) +6. compile/import/CLI/pytest smoke +7. 남은 실패를 정직하게 보고 +``` + +## 3. 필수 smoke 명령 + +Windows venv 기준: + +```powershell +.\.venv\Scripts\python.exe -m compileall -q src tests +.\.venv\Scripts\python.exe -m dmf_crawler --help +.\.venv\Scripts\python.exe -m dmf_crawler doctor --json +.\.venv\Scripts\python.exe -m pytest tests -q +``` + +네트워크/API 키가 필요한 테스트는 기본 smoke 에 넣지 않는다. fixture/mock 으로 대체하고, 실제 nedrug/API 호출은 사용자 승인 후 수동 smoke 로 분리한다. + +## 4. 디자인 감사 RED 는 빡세게 한다 + +GUI/xlsx/report/UI 관련 작업은 기능 테스트만으로 끝내지 않는다. 다음을 RED 로 잡는다. + +- 의미 없는 중앙 정렬, 시각적 치우침, 정렬선 붕괴 +- 컨테이너 밖으로 나가는 텍스트/버튼/입력창 +- 너무 작거나 대비 낮은 폰트 +- 폼 label 누락, placeholder-only label +- input 내부 padding/버튼 간격/아이콘+텍스트 vertical alignment 오류 +- 실제 입력 시나리오 누락(텍스트 입력, 붙여넣기, 버튼 클릭, 오류 표시) +- focus/tab order 문제 +- 색만으로 상태 전달 +- xlsx 시트 순서, 틀 고정, 자동필터, 내부 링크, 조건부서식, 팔레트 대비 누락 +- 빈 상태/긴 한글/긴 경로/긴 API 키에서 깨지는 레이아웃 + +구체 기준은 `docs/design/07-tdd-red-system.md` 의 R4/R5 를 따른다. + +## 5. RED 는 늘리기만 하지 않는다 + +테스트 스위트도 제품이다. + +- 같은 위험을 중복 검사하면 통합한다. +- 구현 세부에 묶여 좋은 리팩터링을 막는 테스트는 행동 중심 테스트로 교체한다. +- flaky 테스트는 안정화하거나 계층을 낮춘다/올린다. +- 삭제/완화는 이유와 대체 커버리지를 기록해야 한다. + +## 6. 이 프로젝트 고유 함정 + +- `.ps1` 은 UTF-8 with BOM 이어야 한다. +- `.cmd` 는 ASCII 만 사용한다. +- nedrug xlsx 는 openpyxl 로 읽지 않는다. sheet XML 직접 파싱. +- agy `--json-schema` 결과를 믿지 않는다. 자체 JSON 추출+검증. +- API 키/OAuth 토큰/웹훅/비밀번호는 로그·문서·저장소에 쓰지 않는다. +- **공공데이터포털 인증키는 선택**이다. 키가 있으면 공식 API 최신 수집을 사용하고, 키가 없어도 기존 자료 리포트 생성·온보딩·스케줄 설치를 막지 않는다. `source.mode=auto` 에서 키가 없으면 AGY headful Chrome xlsx 수집을 먼저 시도한다. +- **수집용 AGY와 요약용 AGY를 구분한다.** 수집용 AGY는 headful/visible Chrome 을 조작한다. 요약용 AGY는 사용자가 AI 요약을 켠 경우에만 headless/non-interactive 로 호출한다. +- **Google 로그인은 선택**이다. AI 요약 또는 browser 수집 모드를 사용자가 켠 경우에만 `agy` 설치/로그인을 안내하고, 기존 자료 xlsx 리포트를 막지 않는다. +- 비밀번호를 agy prompt 에 넣지 않는다. headful 수집 프롬프트에도 공공데이터포털 인증키/OAuth 토큰/비밀번호를 넣지 않는다. +- 수집 완결성 검증 없이 diff 하지 않는다. +- 같은 파일을 여러 agent 에게 동시에 맡기지 않는다. + +## 7. 보고 형식 + +작업 완료 보고에는 반드시 포함한다. + +```text +변경 파일: +추가/수정한 RED: +RED 실패 확인: +Green 구현: +실행한 명령: +결과: +남은 실패/미검증: +디자인 감사 결과(해당 시): +``` + +“완성”이라고 말하려면 최소 compile/import/CLI/pytest smoke 결과가 있어야 한다. 안 돌렸으면 안 돌렸다고 말한다. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c185264 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +이 프로젝트의 사용자 대상 변경 사항을 버전별로 기록한다. +형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를, +버전 번호는 [Semantic Versioning](https://semver.org/lang/ko/)을 따른다. + +버전 번호와 태그 규칙, 릴리스 절차는 `docs/ops/05-release-and-versioning.md`가 정본이다. +개발 과정의 상세한 시행착오·실측 기록은 `docs/HANDOFF.md`에 있다. 이 문서는 그중 +**사용자가 실제로 체감하는 변화만** 요약한다. + +## [Unreleased] + +### 추가 +- 저장소 최상위와 주요 폴더(`src/`, `config/`, `scripts/`, `prompts/`, `tests/`, + `data/`, `state/`, `logs/`, `reports/`, `backup/`)마다 안내용 `README.md`를 추가했다. +- `docs/ops/05-release-and-versioning.md` — 버전·태그·릴리스 절차 정본. +- `LICENSE` — 저장소 최상위에 라이선스 고지 파일을 추가했다. +- Git 저장소를 초기화하고 `git.chanpaca.net`(공개)에 배포했다. + +## [0.1.0] - 2026-09-03 + +첫 배포 가능 버전. 무인 파이프라인(수집→비교→저장→리포트)이 실제 데이터로 end-to-end 동작을 확인했다. + +### 추가 +- 공식 공공데이터포털 Open API 수집(`source_mfds.py`), 인증키는 선택 사항으로 동작. +- 인증키가 없거나 `source.mode=browser`일 때 AGY headful Chrome으로 의약품안전나라 + CCBAC03 xlsx를 내려받는 대체 수집 경로. +- SQLite 이벤트 소싱 저장소(append-only 스냅샷 + 이벤트), 신규/변경/취하 diff 판정. +- 안전장치 5종(`integrity.py`): 급격한 건수 감소·결측·중복·churn·취하 비율 이상 시 + 비교 결과를 폐기하고 리포트를 막는다. +- XlsxWriter 기반 8시트 엑셀 리포트(`report/`): 대시보드·오늘변경분·전체현황·성분별· + 업체별·워치리스트·추이·메타. 시트 간 상호 링크, 조건부서식, 틀고정, 자동필터 포함. +- tkinter 기반 온보딩=복구 통합 GUI(`gui/`)와 진단 엔진(`checks.py`), CLI `doctor` 서브커맨드. +- Windows 작업 스케줄러 자동 등록/해제, 로그온 시 알림, 놓친 작업 따라잡기 트리거 3종. +- 화면 토스트 + 웹훅(디스코드 등) 알림, 등급별 쿨다운·에스컬레이션. +- 선택 기능으로 켤 수 있는 AGY 기반 AI 한 줄 요약(diff 코멘트). +- 기존 프로토타입 xlsx(9,084건) 임포터(`importers/prototype_xlsx.py`) — 최초 기준선 수립용. +- `bootstrap.cmd` 원클릭 설치(venv 생성 → 의존성 설치 → 바탕화면 바로가기 → 온보딩 실행). + +### 검증 +- 실제 CCBAC03 headful 수집으로 9,816건 수집·정규화·저장·리포트 생성 성공. +- `pytest tests -q` 79 passed. + +[Unreleased]: https://git.chanpaca.net/yunchan/DMF_Crawler/compare/v0.1.0...HEAD +[0.1.0]: https://git.chanpaca.net/yunchan/DMF_Crawler/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fbcf6f8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +# Claude Code 지침 + +이 저장소의 실무 지침은 `AGENTS.md` 가 정본이다. Claude Code 세션은 먼저 `AGENTS.md` 를 읽고 따른다. + +특히: + +- **No RED, No Code** — 실패 테스트 없이 기능 코드 작성 금지. +- TDD/RED 운영 SSOT: `docs/design/07-tdd-red-system.md` +- 이론/근거 아카이브: `docs/research/11-tdd-red-and-design-audit-theory.md` +- 현재 핸드오프: `docs/HANDOFF.md` + +중복 정본을 만들지 않기 위해 이 파일에는 요약만 둔다. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..11335a0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +Proprietary License + +Copyright (c) 2026 Yun Chan. All rights reserved. + +이 저장소의 소스 코드와 문서는 저작권자의 사전 서면 동의 없이 복제, 수정, 배포, +2차적 저작물 작성, 상업적 이용을 할 수 없다. + +공개 저장소로 열람은 허용하지만, 이는 라이선스 부여가 아니다. 코드를 읽고 참고하는 +것은 허용하되, 재배포하거나 다른 프로젝트에 그대로 가져다 쓰는 것은 금지한다. + +이 소프트웨어는 "있는 그대로" 제공되며, 상품성, 특정 목적 적합성, 비침해에 대한 +어떠한 종류의 명시적·묵시적 보증도 하지 않는다. 저작권자는 이 소프트웨어의 사용으로 +발생하는 어떠한 손해에 대해서도 책임을 지지 않는다. + +이 프로젝트가 수집하는 데이터의 출처, 이용 범위(내부 리포트 전용, 재배포 금지)에 +대한 법적 근거는 `docs/research/04-anti-bot-and-legal.md`를 따른다. diff --git a/README.md b/README.md new file mode 100644 index 0000000..48165b2 --- /dev/null +++ b/README.md @@ -0,0 +1,238 @@ +# DMF Crawler + +식약처 **원료의약품등록(DMF) 현황**을 매일 아침 자동으로 받아와, 어제와 달라진 것(신규·변경·취하)을 찾아 +엑셀 리포트로 만들어 주는 프로그램입니다. PC 를 켜 두기만 하면 사람이 할 일은 없습니다. + +- **자료 출처**: 식품의약품안전처 「원료의약품등록(DMF)현황」. 공공데이터포털 인증키가 있으면 공식 Open API 를 우선 사용하고, 키가 없거나 브라우저 모드를 고르면 AGY가 보이는 Chrome으로 의약품안전나라 엑셀을 내려받습니다. +- **동작 환경**: Windows 11, Python 3.11 이상 +- **결과물**: `reports\DMF_리포트_최신.xlsx` (매일 갱신) + +--- + +## 1. 5분 설치 + +### 1-1. 준비물 + +| 준비물 | 확인 방법 | 없으면 | +|---|---|---| +| Python 3.11 이상 | PowerShell 에서 `python --version` | https://www.python.org/downloads/ 에서 설치. 설치 화면에서 **Add python.exe to PATH** 를 반드시 체크 | + +공공데이터포털 인증키는 **선택**입니다. 키가 없어도 설치·스케줄 등록은 막지 않습니다. 키가 없으면 AGY가 보이는 Chrome으로 의약품안전나라 DMF 엑셀을 내려받습니다. 공식 API 수집을 쓰고 싶을 때만 아래 2절에서 등록하면 됩니다. + +### 1-2. 설치 실행 + +탐색기에서 설치 폴더를 열고 **`bootstrap.cmd` 를 더블클릭**합니다. 다음 일이 순서대로 일어납니다. + +1. `.venv` 가상환경 생성 +2. 의존성 3개 설치 (`httpx`, `XlsxWriter`, `jsonschema`) +3. 바탕화면에 바로가기 2개 생성 — **DMF 설정**, **지금 실행** +4. 설정 창(온보딩 마법사) 자동 실행 + +명령줄로 하고 싶다면 PowerShell 에서: + +```powershell +cd D:\workspace\DMF_Crawler +python -m venv .venv +.\.venv\Scripts\python.exe -m pip install --upgrade pip +.\.venv\Scripts\python.exe -m pip install -e . +.\.venv\Scripts\python.exe -m dmf_crawler doctor +``` + +`doctor` 가 항목별로 통과/실패를 표로 보여 줍니다. 실패 항목은 무엇을 하면 되는지 함께 알려 줍니다. + +--- + +## 2. 공식 API 인증키 등록 (선택) + +공공데이터포털 인증키는 선택입니다. 없어도 기존 자료 리포트 생성은 계속되고, 등록하면 공식 Open API 최신 수집이 켜집니다. + +### 2-1. 발급받기 + +1. https://www.data.go.kr 회원가입·로그인 +2. https://www.data.go.kr/data/15057075/openapi.do 접속 +3. **활용신청** 클릭 → 활용 목적을 적고 신청 +4. 승인되면 **마이페이지 → 데이터활용 → 오픈API → 개발계정** 에서 인증키 확인 +5. **Decoding 키**(디코딩된 일반 인증키)를 복사합니다. Encoding 키가 아닙니다. + +> 개발계정은 하루 10,000건까지 호출할 수 있습니다. 이 프로그램은 하루 100건 남짓 쓰므로 넉넉합니다. + +### 2-2. 등록하기 + +바탕화면의 **DMF 설정** 바로가기를 더블클릭 → `공공데이터포털 인증키` 항목의 **[인증키 입력]** 버튼 → +복사한 키를 붙여넣고 저장. + +저장하면 곧바로 실제 호출 1건으로 키가 유효한지 확인합니다. 키는 **Windows DPAPI 로 암호화**되어 +`%LOCALAPPDATA%\DMF_Crawler\service_key.bin` 에 저장되며, 이 PC 의 이 계정에서만 풀립니다. +설정 파일이나 로그에는 절대 남지 않습니다. + +### 2-3. AGY headful 브라우저 수집 / AI 요약 (선택) + +공식 API 키가 없거나 `config\config.toml`의 `source.mode`가 `browser`이면 `agy`가 보이는 Chrome을 조작해 의약품안전나라 DMF 엑셀을 내려받습니다. 이 경우 AGY 설치와 Google 로그인이 필요할 수 있습니다. + +AI 한 줄 요약도 `agy`를 씁니다. 설정 창의 `AGY 수집/AI 요약` 항목에서 **[설치]** → **[로그인]** 을 차례로 누르면 됩니다. + +**AGY가 없어도 막히지는 않습니다.** 공식 API 키가 있으면 API로 수집하고, 수집이 실패하면 마지막 성공 자료로 리포트를 만듭니다. + +--- + +## 3. 수동 실행 + +바탕화면의 **지금 실행** 바로가기를 눌러도 되고, PowerShell 에서 직접 실행해도 됩니다. + +```powershell +cd D:\workspace\DMF_Crawler + +# 전체 파이프라인 1회 실행 (수집 → 비교 → 저장 → 리포트) +.\.venv\Scripts\python.exe -m dmf_crawler run + +# 오늘 이미 성공했더라도 다시 실행 +.\.venv\Scripts\python.exe -m dmf_crawler run --force + +# 수집 없이 리포트만 다시 만들기 (엑셀 파일이 깨졌을 때) +.\.venv\Scripts\python.exe -m dmf_crawler report-only + +# 진단 (설치·인증키·DB·작업 등록 상태를 한 번에 확인) +.\.venv\Scripts\python.exe -m dmf_crawler doctor + +# 자동 실행 등록 / 해제 +.\.venv\Scripts\python.exe -m dmf_crawler install-task +.\.venv\Scripts\python.exe -m dmf_crawler uninstall-task +``` + +`pip install -e .` 로 설치했다면 `dmf run`, `dmf doctor` 처럼 짧게 쓸 수도 있습니다. + +### 종료 코드 + +| 코드 | 뜻 | +|---|---| +| `0` | 정상. **리포트 파일이 만들어졌습니다** (부분 성공·건너뜀 포함) | +| `1` | 실패. 리포트가 만들어지지 않았습니다. 다시 시도할 가치가 있습니다 | +| `2` | 전제조건 미충족(설정 오류, DB 손상 등). 다시 시도해도 같은 결과입니다. 인증키 없음만으로는 `2`가 되지 않습니다 | +| `130` | 사용자가 중단(Ctrl+C) | + +### 첫 며칠은 변경분이 비어 있습니다 + +첫 실행은 **기준선을 세우는 날**입니다. 비교할 어제가 없으므로 전체 건수를 그대로 담고 +신규 0건으로 기록합니다. 다음 날부터 변경분이 채워집니다. 정상 동작입니다. + +--- + +## 4. 결과물 보는 법 + +| 경로 | 내용 | +|---|---| +| `reports\DMF_리포트_최신.xlsx` | **평소에는 이 파일만 보면 됩니다.** 항상 최신본입니다 | +| `reports\DMF_리포트_2026-09-02.xlsx` | 날짜별 보관본 | +| `logs\run_20260902_060013\pipeline.log` | 그날 무슨 일이 있었는지 사람이 읽는 기록 | +| `logs\run_20260902_060013\events.jsonl` | 같은 내용의 기계 판독용 기록 | +| `data\raw\2026-09-02\` | 그날 API 가 준 원문. 나중에 다시 해석할 수 있게 그대로 보관합니다 | +| `backup\dmf_2026-09-02.sqlite3` | 자동 백업본 | + +리포트 첫 장은 **대시보드**입니다. 오늘 몇 건이 신규·변경·취하됐는지, 무엇을 먼저 봐야 하는지가 +맨 위에 옵니다. 나머지 시트로 가는 링크도 여기 있습니다. + +--- + +## 5. 장애 대응 — 증상별 5분 가이드 + +문제가 생기면 프로그램이 먼저 알려 줍니다. 알림은 항상 네 가지를 말합니다: +**무엇이 / 왜 / 어떻게 고치는지 / 지금 무엇을 누르면 되는지.** +그래도 막히면 아래 표를 보세요. + +| 증상 | 원인 | 대응 | +|---|---|---| +| **리포트가 어제 날짜 그대로다** | 06:00 배치가 돌지 않았다 | `doctor` 실행 → `최근 실행 상태` 항목 확인. 바탕화면 **지금 실행** 을 눌러 즉시 수집 | +| **"인증키를 사용할 수 없습니다"** | 키 만료·오타·활용신청 만료 | 설정 창 → [인증키 입력] 으로 재입력. 포털 마이페이지에서 키 상태 확인 | +| **"하루 호출 한도를 넘겼습니다"** | 개발계정 10,000건 초과 | 오늘은 그대로 두면 됩니다. 내일 06:00 에 자동 재시도. 반복되면 포털에서 운영계정 신청 | +| **"오늘 받은 자료가 평소와 크게 다릅니다"** | 안전장치가 비교를 막았다 | **정상 동작입니다.** `data\raw\<날짜>\` 의 원문을 확인하고, 진짜 대량 변동이면 `config.local.toml` 의 `integrity.max_churn_ratio` 를 올린 뒤 `run --force` | +| **"리포트 파일이 열려 있어 덮어쓰지 못했습니다"** | 엑셀로 파일을 열어 둔 상태 | 엑셀을 닫고 `report-only` 실행. 그동안의 결과는 다른 이름으로 이미 저장돼 있습니다 | +| **"AGY 로그인이 필요합니다" 또는 "AI 로그인이 만료됐습니다"** | headful 브라우저 수집 또는 AI 요약에 필요한 Google 인증 만료 | 설정 창 → [로그인] → 재로그인(1분). 공식 API 키가 있거나 마지막 성공 자료가 있으면 리포트는 계속 생성됩니다 | +| **"자료 보관소 파일이 손상됐습니다"** | DB 손상 | 설정 창 → [백업으로 복원] → 최근 백업 선택. 복원 후 `run --force` | +| **"자동 실행이 등록돼 있지 않습니다"** | 작업 스케줄러 항목 삭제됨 | 설정 창 → [작업 다시 등록], 또는 `install-task` | +| **아무 창도 안 뜨고 조용하다** | 알림 에이전트가 죽었다 | `doctor` 로 확인. 로그오프 상태였다면 다음 로그온 때 밀린 알림이 한 번에 표시됩니다 | +| **파이썬 자체가 안 돌아간다** | venv 손상 | `bootstrap.cmd` 를 다시 실행. 기존 데이터는 지워지지 않습니다 | + +### 도움을 요청할 때 + +아래 세 가지를 함께 보내면 원인 파악이 훨씬 빠릅니다. + +1. `doctor` 실행 결과 (`python -m dmf_crawler doctor > 진단.txt`) +2. 문제가 난 날의 `logs\run_*\pipeline.log` +3. `state\alerts.json` + +세 파일 모두 **비밀 값은 자동으로 가려진 상태**로 기록됩니다(인증키는 `***` 로 표시). + +--- + +## 6. 설정 바꾸기 + +| 무엇을 | 어디를 | +|---|---| +| 이 PC 만의 값(백업 드라이브, 리포트 폴더, 실행 시각) | `config\config.local.toml` — `config.local.toml.example` 을 복사해 쓰세요 | +| 모든 PC 공통 기본값 | `config\config.toml` | +| 인증키·메신저 주소 같은 비밀 값 | **설정 파일이 아니라 설정 창에서** 입력합니다(암호화 저장) | + +값을 바꾼 뒤에는 `python -m dmf_crawler doctor` 로 확인하세요. 오타가 있으면 어떤 키가 잘못됐는지 +줄 단위로 알려 줍니다. + +--- + +## 7. 폴더 구조 + +``` +DMF_Crawler\ +├─ bootstrap.cmd 최초 설치 진입점 +├─ CHANGELOG.md 버전별 변경 이력 +├─ LICENSE 라이선스 고지 +├─ config\ 설정 (config.toml + PC별 config.local.toml) → config\README.md +├─ prompts\ AI 요약 프롬프트 템플릿 → prompts\README.md +├─ scripts\ 작업 스케줄러 등록·바로가기 생성 스크립트 → scripts\README.md +├─ src\dmf_crawler\ 프로그램 본체 (git 추적 대상은 여기뿐이다) → src\dmf_crawler\README.md +├─ tests\ 테스트 → tests\README.md +├─ data\ DB 와 API 원문 보관 (자동 생성, git 미추적) → data\README.md +├─ state\ 상태 파일 — 마지막 성공 시각, 미해소 알림 (자동 생성) → state\README.md +├─ logs\ 실행 로그 (자동 생성, git 미추적) → logs\README.md +├─ reports\ 엑셀 리포트 (자동 생성, git 미추적) → reports\README.md +├─ backup\ DB 백업 (자동 생성, git 미추적) → backup\README.md +├─ dist\ / build\ `python -m build` 산출물 (git 미추적, §9 참고) +└─ docs\ 설계 문서. 코드와 어긋나면 문서가 정본입니다 → docs\README.md +``` + +각 폴더의 `README.md`는 그 폴더 안에서만 통하는 세부 사항(추적 여부, 파일별 역할)을 +안내한다. 전체 아키텍처와 모듈 계약의 정본은 `docs\design\01-architecture.md`다. + +--- + +## 8. 빌드와 릴리스 + +이 저장소는 **소스(`src\`)와 빌드 산출물(`dist\`, `build\`)을 분리**한다. `dist\`는 언제나 +`src\`에서 다시 만들 수 있어야 하므로 git에 올리지 않는다. + +```powershell +.\.venv\Scripts\python.exe -m pip install --upgrade build +.\.venv\Scripts\python.exe -m build # dist\dmf_crawler-X.Y.Z-py3-none-any.whl 생성 +``` + +버전 번호 규칙, git 태그, 릴리스 절차, 최종 사용자 zip 배포 방법은 +`docs\ops\05-release-and-versioning.md`가 정본이다. 버전별 변경 이력은 +루트 `CHANGELOG.md`에 있다. + +이 저장소의 정본 원격은 `git.chanpaca.net`(공개)이다. + +--- + +## 9. 자세한 내용 + +| 알고 싶은 것 | 문서 | +|---|---| +| 무엇을 만들려는 것인가 | `docs\00-REQUIREMENTS.md` | +| 왜 크롤링이 아니라 공식 API 인가 | `docs\design\00-DATA-SOURCE-DECISION.md` | +| 전체 구조와 모듈 계약 | `docs\design\01-architecture.md` | +| 데이터 모델과 변경 탐지 규칙 | `docs\design\02-data-model.md` | +| 리포트 시트 명세 | `docs\design\03-xlsx-report-spec.md` | +| 설정 창 화면 명세 | `docs\design\04-onboarding-wizard.md` | +| 자동 실행과 장애 복구 운영 | `docs\ops\01-scheduling-and-resilience.md`, `docs\ops\02-failure-alerting.md` | +| API 이용 정책 준수 기록 | `docs\ops\03-api-usage-policy.md` | +| 버전·태그·릴리스 절차 | `docs\ops\05-release-and-versioning.md` | +| 버전별 변경 이력 | `CHANGELOG.md` | +| AI agent 작업 지침(TDD/RED) | `AGENTS.md` | diff --git a/backup/README.md b/backup/README.md new file mode 100644 index 0000000..62523d1 --- /dev/null +++ b/backup/README.md @@ -0,0 +1,15 @@ +# `backup/` — DB 백업 (런타임 생성, git 미추적) + +성공한 실행마다 `backup.py`가 SQLite `VACUUM INTO`로 완전한 사본을 만든다. 이 파일만 +저장소에 남고 나머지는 `.gitignore`로 제외된다. 다른 물리 드라이브를 쓰도록 강력히 +권장한다(`config.toml`의 `[backup].dir`). + +| 경로 | 내용 | +|---|---| +| `dmf_YYYY-MM-DD.sqlite3` | 그날 성공 실행 직후의 완전한 DB 사본. `[backup].keep_count`개만 보존 | +| `premigrate_vN_*.sqlite3` | 스키마 마이그레이션 직전 안전 백업. 마이그레이션 실패 시에만 쓴다 | +| `복원_안내.txt` | 백업이 만들어질 때마다 함께 생성되는 복원 절차(그 백업 시점 기준 실제 명령 포함). **손으로 고치지 않는다** — 다음 백업 때 덮어써진다 | + +복원 절차의 일반 원칙: 실행 중인 작업을 멈추고, 정본을 옆으로 옮긴 뒤, 백업본을 정본 +위치로 복사하고, `dmf db check`로 무결성을 확인한 다음 `dmf report-only`로 정상 동작을 +검증한다. 정확한 명령은 그때그때 `복원_안내.txt`를 따른다. diff --git a/bootstrap.cmd b/bootstrap.cmd new file mode 100644 index 0000000..1af98c2 --- /dev/null +++ b/bootstrap.cmd @@ -0,0 +1,226 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion +title DMF Crawler Install +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 Crawler First-Time Setup +echo ================================================ +echo. +echo This window closes itself when setup finishes. +echo It appears only once. +echo. + +rem =================================================================== +rem [1/5] Find Python +rem Note: python.exe on PATH does not always mean Python is installed. +rem The Microsoft Store app-execution-alias stub is always on +rem PATH and returns an error code when run with arguments +rem (per Microsoft's own FAQ). So the py launcher is tried first; +rem the Store stub ships without py. +rem =================================================================== +echo [1/5] Looking for Python... +set "PYEXE=" + +rem -- if a venv already exists, reuse it (fast path on re-run) +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 Using existing environment. + goto :have_python + ) +) + +rem -- 1st choice: py launcher +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 -- 2nd choice: python.exe on PATH (confirm it actually runs, not a stub) +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 -- 3rd choice: search well-known install paths directly +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 No Python found -> ask whether to install it +rem =================================================================== +echo Not installed. +echo. +echo Install Python now? ^(about 2-5 minutes^) +echo Administrator rights are not required. +echo. +choice /c YN /n /m " [Y] Yes, install it [N] No, I will install it myself Choice: " +if errorlevel 2 goto :manual_python + +echo. +echo Installing Python. Do not close this window... +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 -- this window's PATH is not refreshed right after install. Search directly. +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 Installed. + +:have_python +echo Found: %PYEXE% +echo. + +rem =================================================================== +rem [2/5] Virtual environment + dependencies +rem =================================================================== +echo [2/5] Preparing the runtime environment... ^(30 sec - 2 min^) +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 Done +echo. + +rem =================================================================== +rem [3/5] Remove Mark-of-the-Web +rem Files extracted from a downloaded ZIP get a Zone.Identifier stream, +rem which can block .ps1 execution. This is the same action as the +rem [Unblock] checkbox in a file's Properties dialog. +rem =================================================================== +echo [3/5] Unblocking files... +powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ^ + -Command "Get-ChildItem -LiteralPath '%ROOT%' -Recurse -File -ErrorAction SilentlyContinue | Unblock-File -ErrorAction SilentlyContinue" >>"%LOG%" 2>&1 +echo Done +echo. + +rem =================================================================== +rem [4/5] Shortcuts +rem =================================================================== +echo [4/5] Creating desktop shortcuts... +powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ^ + -File "%ROOT%\scripts\make_shortcuts.ps1" -ProjectRoot "%ROOT%" >>"%LOG%" 2>&1 +if !errorlevel! neq 0 ( + echo Skipped ^(the program still works without shortcuts^) +) else ( + echo Done +) +echo. + +rem =================================================================== +rem [5/5] Launch the onboarding GUI +rem pythonw.exe does not create a console. Launch it with start "" and +rem close this window. +rem =================================================================== +echo [5/5] Opening the setup window... +echo. +echo Setup finished. The setup window will open in a moment. +start "" "%VPYW%" -m dmf_crawler onboard --mode setup +timeout /t 3 /nobreak >nul +exit /b 0 + + +rem =================================================================== +rem Failure paths - always tell the user what to do next +rem =================================================================== +:manual_python +echo. +echo ------------------------------------------------ +echo Please install Python yourself +echo ------------------------------------------------ +echo. +echo 1. Open this address in your browser. +echo https://www.python.org/downloads/ +echo 2. Click [Download Python 3.12.x] to get the installer. +echo 3. Near the bottom of the installer screen, +echo check the box [Add python.exe to PATH] +echo 4. When installation finishes, close this window and run +echo bootstrap.cmd again. +echo. +choice /c YN /n /m " Open the download page now? [Y/N]: " +if not errorlevel 2 start "" "https://www.python.org/downloads/" +echo. +pause +exit /b 1 + +:fail_venv +echo. +echo [Error] Could not create the runtime environment. +echo. +echo Please check the following. +echo - Whether this folder allows creating files +echo ^(if it is under C:\Program Files, move it to Documents^) +echo - Whether antivirus software is blocking it +echo. +echo Details: %LOG% +echo. +pause +exit /b 1 + +:fail_deps +echo. +echo [Error] Could not download the required components. +echo. +echo Please check the following. +echo - Whether this computer is connected to the internet +echo - Whether a company firewall blocks pypi.org +echo ^(ask IT to allow pypi.org and files.pythonhosted.org^) +echo. +echo Details: %LOG% +echo. +pause +exit /b 1 diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..a4a8c69 --- /dev/null +++ b/config/README.md @@ -0,0 +1,14 @@ +# `config/` — 설정 + +| 파일 | git 추적 | 역할 | +|---|---|---| +| `config.toml` | 추적함 | 모든 PC 공통 기본값. **비밀 값을 절대 넣지 않는다** | +| `config.local.toml.example` | 추적함 | PC별 오버라이드 예시. 복사해서 실제 파일을 만든다 | +| `config.local.toml` | **추적 안 함**(`.gitignore`) | 이 PC만의 실제 값(백업 드라이브, 리포트 폴더, 실행 시각 등). `config.toml`의 같은 키를 덮어쓴다 | + +인증키·웹훅 주소·비밀번호는 이 폴더의 어떤 파일에도 넣지 않는다. 온보딩 설정 창에서 +입력하면 Windows DPAPI로 암호화되어 `%LOCALAPPDATA%\DMF_Crawler\service_key.bin`에 +저장된다(이유는 `docs/design/01-architecture.md` §3.2 `secrets_dpapi.py`). + +전체 키 스펙과 각 키의 의미는 `docs/design/01-architecture.md` §6, 값을 바꾼 뒤 확인하는 +법은 루트 `README.md` §6을 참고한다. diff --git a/config/config.local.toml.example b/config/config.local.toml.example new file mode 100644 index 0000000..6751e33 --- /dev/null +++ b/config/config.local.toml.example @@ -0,0 +1,78 @@ +# ============================================================================= +# config/config.local.toml.example — PC 별 설정 덮어쓰기 예시 +# ============================================================================= +# 사용법 +# 1) 이 파일을 같은 폴더에 config.local.toml 이라는 이름으로 복사한다. +# 2) 바꾸고 싶은 값만 남기고 나머지 줄은 지운다. 적지 않은 값은 config.toml 을 그대로 따른다. +# 3) python -m dmf_crawler doctor 로 확인한다. +# +# 규칙 +# ▸ 이 파일은 git 에 올라가지 않는다(.gitignore). PC 마다 다른 값을 여기에 둔다. +# ▸ 비밀 값(인증키·메신저 주소)은 여기에도 적지 않는다. 설정 창에서 입력하면 암호화 저장된다. +# ▸ 섹션 이름과 키 이름은 config.toml 과 똑같아야 한다. 오타는 doctor 가 알려 준다. +# ============================================================================= + + +# --- 백업을 다른 물리 드라이브로 보내기 (가장 흔한 용도) --------------------- +# 같은 디스크에 백업하면 디스크가 죽을 때 원본과 백업이 함께 사라진다. +[backup] +dir = "E:/DMF_Backup" +keep_count = 60 +min_free_gb = 5.0 + + +# --- 리포트를 공유 폴더나 OneDrive 로 내보내기 ------------------------------- +# [report] +# output_dir = "C:/Users/사용자이름/OneDrive/문서/DMF" + + +# --- 데이터베이스를 다른 위치에 두기 ----------------------------------------- +# [storage] +# sqlite_path = "D:/DMF_Data/dmf.sqlite3" + + +# --- 실행 시각 조정 ----------------------------------------------------------- +# 아침 6시가 이른 PC(수면 모드 등)라면 늦추고, 놓친 작업 따라잡기를 켜 둔다. +# [schedule] +# daily_time = "08:30" +# enable_missed_task_catchup = true + + +# --- 이 PC 는 인터넷이 느리다 ------------------------------------------------- +# [source] +# read_timeout_seconds = 60.0 +# min_interval_seconds = 1.5 + + +# --- AI 요약을 이 PC 에서만 끄기 ---------------------------------------------- +# 리포트는 AI 없이도 그대로 생성된다. +# [agy] +# enabled = false + + +# --- 알림을 조용하게 / 메신저만 쓰기 ------------------------------------------ +# [notify] +# show_info_toast = false +# max_toasts_per_hour = 3 +# webhook_enabled = true +# webhook_kind = "slack" +# webhook_min_severity = "WARN" + + +# --- 무결성 임계값 조정 (반드시 원문을 확인한 뒤에) --------------------------- +# 제도 변화로 실제 대량 변동이 일어난 날에만 일시적으로 올린다. +# 올린 뒤에는 python -m dmf_crawler run --force 로 다시 실행한다. +# [integrity] +# max_churn_ratio = 0.20 + + +# --- 관심 목록 (이 PC 담당자의 품목) ------------------------------------------ +# [watchlist] +# ingredients = ["메트포르민염산염", "아토르바스타틴칼슘"] +# applicants = ["(주)대웅제약"] +# countries = ["중국", "인도"] + + +# --- 디버깅할 때만 -------------------------------------------------------------- +# [logging] +# level = "DEBUG" diff --git a/config/config.toml b/config/config.toml new file mode 100644 index 0000000..8c4a9d6 --- /dev/null +++ b/config/config.toml @@ -0,0 +1,179 @@ +# ============================================================================= +# config/config.toml — DMF Crawler 설정 정본 +# ============================================================================= +# 이 파일 하나가 프로그램의 모든 동작을 결정한다(아키텍처 §6). +# +# ▸ 비밀 값(공공데이터포털 인증키, agy 로그인 토큰, 메신저 주소)은 여기에 절대 넣지 않는다. +# 인증키는 설정 창에서 입력하면 Windows DPAPI 로 암호화되어 사용자 계정에만 풀린다. +# ▸ PC 마다 다른 값(백업 드라이브, 리포트 폴더)은 이 파일을 고치지 말고 +# 같은 폴더의 config.local.toml 에 적는다. 같은 키를 덮어쓴다. +# (config.local.toml.example 을 복사해 쓰면 된다. 이 파일은 git 에 올라가지 않는다.) +# ▸ 상대경로는 전부 설치 폴더 기준이다. 다른 드라이브는 "E:/DMF_Backup" 처럼 절대경로로 적는다. +# ▸ 값을 바꾼 뒤 확인: python -m dmf_crawler doctor +# ============================================================================= + +[general] +timezone = "Asia/Seoul" # 실행일자를 판정하는 기준 시간대 +contact_email = "" # 요청 헤더(User-Agent)에 넣을 연락처. 공공 API 예의이자 요구 N1. + # 설정 창에서 입력하면 여기 채워진다. 비워 두어도 동작은 한다. +language = "ko" # 리포트·알림 언어 + + +[schedule] +# --- 자동 실행 트리거 3종 (사용자 확정) ------------------------------------- +# ① 매일 정해진 시각 ② 로그온 시 알림 에이전트 ③ PC 가 꺼져 있어 놓친 작업 따라잡기 +enable_daily_trigger = true # ① 매일 daily_time 에 수집 실행 +enable_logon_trigger = true # ② 로그온하면 밀린 알림을 즉시 표시 +enable_missed_task_catchup = true # ③ 06:00 에 PC 가 꺼져 있었으면 켜진 뒤 자동으로 따라잡는다 +prevent_concurrent_runs = true # 중복 실행 방지. 같은 날 이미 성공했으면 건너뛰고, + # 다른 창이 실행 중이면 기다리지 않고 조용히 종료한다. + +daily_time = "06:00" # 일일 실행 시각(요구 R5.1) +jitter_seconds = 240 # 0~이 값 사이 랜덤 지연. 서버에 정각 부하를 몰지 않는다(상한 300) +startup_delay_minutes = 5 # 부팅 직후 트리거의 대기 시간(네트워크·프로필 준비) +execution_time_limit_minutes = 30 # 이 시간을 넘기면 작업 스케줄러가 강제 종료한다 +restart_count = 3 # 실패 시 재시작 횟수 +restart_interval_minutes = 10 # 재시작 간격 +agent_repeat_minutes = 15 # 알림 에이전트가 도는 주기 +agy_update_weekday = "Sunday" # 주간 AI 도구 업데이트 요일 +agy_update_time = "14:00" # 그 시각. 사람이 깨어 있는 시간대로 둔다 + + +[source] +# 수집 경로는 2개다: ① 인증키가 있으면 공식 Open API ② 키가 없거나 browser 모드면 AGY headful Chrome. +# 사용자가 요구한 headful 브라우저 수집은 curl/headless 가 아니라 visible Chrome 을 AGY가 조작하는 방식이다. +base_url = "https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01" +mode = "auto" # auto | api | browser + # auto = 인증키가 있으면 API, 없으면 AGY headful Chrome 으로 xlsx 다운로드 + # api = 항상 API 수집. 키가 없으면 마지막 성공 자료로 리포트 생성 + # browser = 항상 AGY headful Chrome 으로 xlsx 다운로드 +response_type = "json" # json | xml. json 이 실패하면 xml 로 한 번 폴백한다 +page_size = 100 # 한 번에 받을 건수(numOfRows) +connect_timeout_seconds = 10.0 # 연결 타임아웃 +read_timeout_seconds = 30.0 # 읽기 타임아웃 +max_attempts = 4 # 실패 시 최대 시도 횟수 +backoff_base_seconds = 5.0 # 재시도 대기 기준(지수적으로 늘어난다) +backoff_cap_seconds = 300.0 # 재시도 대기 상한 +min_interval_seconds = 0.7 # 페이지 사이 최소 간격. 서버를 몰아치지 않는다 +min_body_bytes = 200 # 이보다 짧은 응답은 '정상 코드지만 빈 응답'으로 보고 실패 처리 +archive_retain_days = 180 # data/raw 에 보관한 원문의 보존 기간 + + +[integrity] +# 안전장치(요구 R2.2). 이 중 하나라도 걸리면 그날은 '변경분 비교'를 하지 않는다. +# 잘못된 응답으로 "오늘 5,000건 취하" 같은 거짓 리포트를 만드는 사고를 막는 관문이다. +max_drop_ratio = 0.05 # 전체 건수가 어제보다 이 비율 이상 줄면 차단 +max_null_ratio = 0.01 # 필수 항목(등록번호·성분명·업체명)이 빈 비율 상한 +max_duplicate_ratio = 0.02 # 등록번호 중복 비율 상한 +max_churn_ratio = 0.10 # (신규+변경+취하)/전체 가 이 비율을 넘으면 비교 결과를 폐기하고 차단 +max_withdrawn_ratio = 0.02 # 취하 비율 경고선. 차단하지 않고 리포트에 경고만 표시한다 + + +[storage] +sqlite_path = "data/dmf.sqlite3" # 정본 데이터베이스 경로 +busy_timeout_ms = 15000 # 다른 프로세스가 쓰는 중일 때 기다릴 시간(ms) +snapshot_retain_days = 0 # 0 = 영구 보존. 과거를 지우지 않는다 + + +[backup] +enabled = true # 성공한 날마다 자동 백업 +dir = "backup" # 다른 물리 드라이브를 강력히 권장한다 (예: "E:/DMF_Backup") +keep_count = 30 # 최근 몇 개를 남길지 +min_free_gb = 2.0 # 여유 공간이 이보다 적으면 백업을 건너뛰고 경고만 남긴다 + + +[report] +output_dir = "reports" # 리포트 저장 폴더 +filename_pattern = "DMF_리포트_{date}.xlsx" # 일자별 파일명. {date} 자리에 YYYY-MM-DD 가 들어간다 +latest_link_name = "DMF_리포트_최신.xlsx" # 항상 최신본을 가리키는 파일. 비우면 만들지 않는다 +dashboard_first = true # 파일을 열면 '00_대시보드' 시트가 먼저 보인다(사용자 확정) +font = "맑은 고딕" # Windows 에 기본 탑재된 한글 폰트 +palette = "okabe_ito" # 색각이상에도 구분되는 팔레트 +top_n = 10 # 성분별·업체별 시트의 상위 N +trend_days = 90 # 추이 시트가 보여줄 기간(일) +retain_days = 365 # 리포트 파일 보존 기간 +lock_retries = 3 # 파일이 엑셀로 열려 있을 때 재시도 횟수 + # (끝내 실패하면 다른 이름으로 저장하고 알려 준다) + + +[agy] +# AI 요약 계층. 이 계층이 통째로 실패해도 리포트는 항상 나온다(요구 R4.4). +# 기본은 꺼짐: Google 로그인은 사용자가 AI 요약을 켜겠다고 선택한 뒤에만 요구한다. +enabled = false +binary_path = "" # 비우면 %LOCALAPPDATA%\agy\bin\agy.exe 를 쓴다 +model = "gemini-3.7-flash-medium" # 사용할 모델 +effort = "medium" # low | medium | high +print_timeout = "10m" # 이 시간을 넘기면 포기하고 리포트만 만든다 +skip_if_diff_empty = true # 변화가 없는 날은 호출하지 않는다(비용 절약) +max_json_retries = 1 # 응답 형식이 깨졌을 때 다시 물어볼 횟수 +daily_token_cap = 300000 # 하루 토큰 상한. 넘으면 그날은 호출하지 않는다 +max_events_in_prompt = 200 # 변경이 많은 날 프롬프트에 넣을 상위 건수 +prompt_path = "prompts/daily_briefing.md" +schema_path = "prompts/daily_briefing.schema.json" + + +[health] +# 연속 실패가 쌓이면 잠시 호출을 멈추는 안전장치(서킷 브레이커). +# 고장난 상태로 매일 서버를 두드리지 않기 위한 것이다. +source_failure_threshold = 3 # 자료 수집이 이 횟수 연속 실패하면 잠시 멈춘다 +source_cooldown_hours = 24 # 그 후 다시 시도하기까지의 시간 +agy_failure_threshold = 3 # AI 요약이 이 횟수 연속 실패하면 잠시 멈춘다 +agy_cooldown_hours = 24 + + +[notify] +# 알림 채널은 두 갈래다(사용자 확정): ① 화면 창(토스트·복구 창) ② 메신저(웹훅) +# 어느 쪽이 실패해도 알림은 state/alerts.json 과 Windows 이벤트 로그에 항상 남는다. + +# --- 감지 --- +watchdog_stale_minutes = 120 # 마지막 성공이 이 시간보다 오래되면 "배치가 안 돌았다"고 판단 +pump_stamp_stale_minutes = 45 # 알림 에이전트가 이 시간 넘게 조용하면 PowerShell 폴백이 뜬다 +consecutive_failure_critical = 3 # 같은 문제가 이 횟수 반복되면 등급을 올린다 + +# --- 표시 --- +toast_seconds = 12 # 자동으로 사라지는 알림이 떠 있는 시간(초) +modal_repeat_minutes = 60 # 심각한 알림을 다시 띄우는 주기 +snooze_minutes = 60 # [나중에] 를 누르면 조용해지는 시간 +show_info_toast = false # 사소한 정보까지 창으로 띄울지 + +# --- 알림 폭주 억제 (요구 R7.8) --- +cooldown_minutes = 240 # 같은 문제를 이 시간 안에는 다시 띄우지 않는다 +merge_threshold = 2 # 이 건수 이상이면 한 장으로 묶어서 보여 준다 +max_toasts_per_hour = 6 # 시간당 알림 상한 +daily_alert_cap = 20 # 하루 알림 상한(심각한 알림은 이 상한을 적용받지 않는다) + +# --- 문제가 계속될 때 (에스컬레이션) --- +escalation_days = 3 # 3일 연속 실패 → 강제 복구 창 + 진단 자동 표시 +escalation_webhook_days = 5 # 5일 → 메신저로도 강제 발송 +escalation_stop_after_days = 7 # 7일 → 자동 실행을 멈추고 사람의 확인을 요구한다 + +# --- 기록 --- +eventlog_source = "DMF Crawler" # Windows 이벤트 로그에 표시될 이름 + +# --- 메신저(웹훅) --- +# 주소는 여기에 적지 않는다. 설정 창에서 입력하면 암호화되어 저장된다. +webhook_enabled = true # 메신저 알림 사용 +webhook_kind = "discord" # discord | slack | telegram | generic +webhook_min_severity = "CRITICAL" # 이 등급 이상만 메신저로 보낸다(알림 피로 방지) +webhook_timeout_seconds = 10.0 +deadman_enabled = false # PC 가 통째로 꺼진 것까지 외부에서 감시할지(선택) + + +[logging] +dir = "logs" # 로그 루트. 실행마다 logs/run_YYYYMMDD_HHMMSS/ 가 만들어진다 +level = "INFO" # DEBUG | INFO | WARNING | ERROR | CRITICAL +retain_days = 90 # 실행 로그 보존 기간 +console = true # 콘솔에도 출력할지(예약 실행에서는 보이지 않는다) +mask_patterns = ["serviceKey", "access_token"] # 로그에 기록하기 전에 가릴 비밀 값의 이름 + + +[watchlist] +# 관심 목록. 기능은 켜 두고 목록은 비운 채 시작한다(사용자 확정). +# 여기에 적은 값은 최초 1회 데이터베이스로 옮겨 심는 '씨앗'이고, 이후에는 설정 창에서 관리한다. +# 목록이 전부 비어 있으면 리포트의 '05_워치리스트' 시트는 만들어지지 않는다. +enabled = true +match_mode = "contains" # contains(부분 일치) | exact(완전 일치) +ingredients = [] # 예: ["메트포르민염산염", "아토르바스타틴칼슘"] +applicants = [] # 예: ["(주)대웅제약"] +manufacturers = [] # 예: ["Teva Pharmaceutical"] +countries = [] # 예: ["중국", "인도"] diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..ab94b68 --- /dev/null +++ b/data/README.md @@ -0,0 +1,13 @@ +# `data/` — 자료 보관소 (런타임 생성, git 미추적) + +이 폴더의 내용물은 **전부 실행하면 다시 만들어진다.** 이 파일(`README.md`)만 저장소에 +남고, 나머지는 `.gitignore`로 제외된다. + +| 경로 | 내용 | +|---|---| +| `dmf.sqlite3` (+ `-wal` / `-shm`) | 정본 데이터베이스. 이벤트 소싱 스토리지(append-only) | +| `raw/YYYY-MM-DD/` | 그날 공식 API가 준 원문 응답을 페이지별로 그대로 보존. 나중에 다시 해석할 수 있도록 가공하지 않는다 | +| `headful_downloads/run_*/` | AGY headful Chrome 수집 경로로 받은 원본 xlsx 다운로드 보관 | + +스키마와 이벤트 소싱 모델은 `docs/design/02-data-model.md`가 정본이다. +DB가 손상됐을 때 복구하는 법은 루트 `README.md` §5 장애 대응표를 참고한다. diff --git a/design.md b/design.md new file mode 100644 index 0000000..1b9ed76 --- /dev/null +++ b/design.md @@ -0,0 +1,78 @@ +# design.md + +## 브리프 3줄 + +- 무엇을: Windows 11용 DMF 설정/복구 앱과 매일 생성되는 DMF xlsx 리포트의 시각 언어·정보 위계·상태 표현을 개선한다. +- 누구에게: 개발자가 아닌 한국어 규제 실무자가 키 등록, 자동 실행, 오늘 리포트 확인을 막힘 없이 수행하게 한다. +- 제약: Python stdlib `tkinter`, XlsxWriter 단독, 런타임 의존성 추가 금지, 실제 9,084건 기준선, 기본 언어 ko-KR 한국어, Google/agy는 선택, No RED No Code. + +## 레퍼런스와 근거 + +- R1 구조: Microsoft Windows/Fluent 설정·접근성 원칙. 설정은 단순 그룹, 상태와 다음 행동, 접근 가능한 기본 컨트롤을 사용한다. +- R2 톤: 규제 문서/감사 가능한 원장. 차분한 잉크·종이 표면, 기준일·출처·한계 문구를 숨기지 않는다. +- R3 디테일: Excel 접근성/대시보드 관습. A1 목적문, 명확한 시트명, 표/ListObject, 동결창, 의미 있는 하이퍼링크, 색 단독 금지. + +## 한 문장 컨셉 + +**감사 가능한 조용한 계기판** — 사용자는 예쁜 화면보다 오늘 무엇이 막혔고 어떤 파일이 근거인지 즉시 알아야 한다. + +## 프리셋 + +- designpaca `swiss-minimal` 기반. +- 이유: DMF 수집은 정보가 많고 신뢰가 전제인 내부 도구다. 실험적 장식보다 정렬, 대비, 필터 가능한 표, 출처 노출이 우선이다. + +## 감수한 리스크 하나 + +- xlsx 대시보드 A1에 스크린리더용 목적문 행을 추가했다. +- 시각적으로는 한 줄이 늘어나지만, Excel 접근성상 빈 A1에서 시작하지 않고 파일 목적을 즉시 읽게 하는 편이 이 브리프에 맞다. + +## 토큰 + +### Windows 앱 + +- 폰트: `맑은 고딕` 우선. Windows 한국어 환경에서 기본 탑재되고 추가 설치가 필요 없다. +- 색 역할: + - 배경 `#FFFFFF` + - 카드 `#F6F7F8` + - 요약 표면 `#F3F6F8` + - 본문 `#1F2328` + - 보조 `#6E7781` + - 경계 `#D0D7DE` + - 강조 `#0072B2` + - 상태: OK `#167A3C`, WARN `#C77700`, BAD `#BE2828` +- 간격: 4/8px 리듬. 외곽 28/24, 카드 14/12, 버튼 padding 14/8. +- 형태: 큰 그림자 없음. 카드/요약은 1px 선과 배경 톤 차이로만 구분. +- 모션: 없음. 긴 작업은 freeze 방지를 위해 worker thread + 상태 문장으로 피드백. + +### xlsx + +- 폰트: `맑은 고딕` 10pt, 제목 14/18pt, KPI 값 28pt. +- 팔레트: Okabe-Ito 기반. 강조 `#0072B2`, 신규 `#009E73`, 변경 `#E69F00`, 취하 `#D55E00`, 워치 `#CC79A7`, 잉크 `#1F2933`. +- 상태 표현: 배경색 + 폰트색 + 텍스트 라벨 + 기호. 취하는 취소선까지 사용. +- 열 폭: 데이터 시트의 visible column은 Excel 폭 약 48.75 이하로 제한. 긴 한글 값은 줄바꿈/필터/원장 조회로 흡수한다. +- 링크: `조회` 같은 모호한 문구 금지. 외부 원문은 `원문 조회`, 원장 내부 이동은 `원장 보기`처럼 목적을 쓴다. + +## 성능·의존성 예산 + +- 새 런타임 의존성 0개. +- GUI 프레임워크 교체 없음. `tkinter` 유지. +- xlsx 생성 엔진 `XlsxWriter` 단독. openpyxl/pandas 금지 유지. +- GUI 모션/이미지/WebGL 없음. + +## 채택한 효과와 폴백 + +- 효과 없음. 표면은 색·선·타입 위계로만 구성한다. +- xlsx는 Excel을 한 번도 열지 않아도 수식 캐시값으로 값이 보이게 한다. + +## 의도적으로 하지 않은 것 + +- WinUI/PySide로 교체하지 않았다. 비개발자 PC에서 설치 의존성과 실패면이 늘어난다. +- 대시보드를 화려한 다크/네온/그라디언트로 만들지 않았다. 규제 원장과 한국어 표 읽기에는 조용한 라이트 표면이 맞다. +- Google/agy를 기본 설정 여정에 넣지 않았다. AI 요약은 선택 기능이고, 기본 수집·xlsx 생성은 막으면 안 된다. +- xlsx 데이터 시트에서 모든 긴 텍스트를 넓게 펼치지 않았다. 9,084건 원장은 스캔·필터가 우선이라 열 폭 상한을 둔다. + +## 2026-09-03 변경 기록 + +- Windows 앱: 상단 상태 요약 strip(`필수/권장/정상`), `필수 설정/선택 기능/운영 상태` 그룹, 전용 상태 badge, 행 액션 버튼 최소 폭을 추가했다. +- xlsx: 문서 속성(title/subject/category/author/comments), 대시보드 A1 목적문, dashboard 내부 링크 검증, 데이터 시트 역링크 검증, 의미 있는 링크 문구, visible column width cap을 추가했다. +- 테스트: `tests/test_gui_design_contracts.py`, `tests/test_report_e2e_design.py` 디자인 RED를 추가했고 `pytest tests -q` 58 passed로 검증했다. diff --git a/docs/00-PROJECT-OVERVIEW.md b/docs/00-PROJECT-OVERVIEW.md new file mode 100644 index 0000000..b09a02a --- /dev/null +++ b/docs/00-PROJECT-OVERVIEW.md @@ -0,0 +1,1051 @@ +# DMF Crawler — 프로젝트 개요와 개발 목표 + +> **이 문서의 역할**: 이 프로젝트의 **첫 장이자 최상위 요약**이다. 왜 만드는지, 무엇을 만드는지, 언제 끝난 것으로 볼지를 한 곳에서 답하고, 나머지 모든 문서로 가는 **관문**이 된다. +> 이 문서는 **결정을 새로 만들지 않는다.** 결정은 `docs/00-REQUIREMENTS.md`(요구 SSOT), `docs/design/00-DATA-SOURCE-DECISION.md`(데이터 소스 SSOT), `docs/design/01-architecture.md`(아키텍처 SSOT)에 있다. 충돌하면 **그 세 문서가 우선한다.** + +**작성일**: 2026-09-02 +**프로젝트 루트**: `D:\workspace\DMF_Crawler` +**대상 환경**: Windows 11 Pro 10.0.26220 / Python 3.14.6(실측) / PowerShell 7 +**현재 상태**: 설계 확정 완료, **M0 착수 가능**. 이 저장소에는 코드가 아직 없지만, **별도로 돌던 프로토타입이 이미 존재한다**(아래). + +--- + +## 0. 한눈에 보기 + +이 문서가 확정하는 것. + +- **이 프로젝트는 스크래치가 아니다.** 이미 공식 Open API로 수집해 xlsx를 만드는 **프로토타입이 돌고 있었고**(`DMF_현황.xlsx`, **9,084건**, 시트 3장), 이 작업은 그것의 **개선**이다. 기준선 실측은 [`design/00b-baseline-data-analysis.md`](./design/00b-baseline-data-analysis.md). +- **문제**: 식약처 DMF(원료의약품 등록) 현황은 **어제와 오늘을 비교해 주는 화면이 없다.** 그리고 기존 프로토타입의 산출물은 **데이터는 정확한데 읽기 도구로서의 설계가 전혀 없다** — 틀 고정·자동 필터·조건부 서식·차트가 전부 0이다. +- **해법**: 매일 06:00에 공식 Open API로 전량 스냅샷을 받아 전일과 diff 하고, **탭끼리 연동된 xlsx 리포트**를 만들어 바탕화면에 놓는다. 프로토타입의 3시트 구조(전체/신규/갱신이력)를 **버리지 않고 8시트로 확장**한다. +- **변경 탐지가 가능하다는 것이 실측으로 확인됐다.** 등록번호는 9,084건 중복 0인 **완전한 자연 키**이고, `발급일자`는 최초 등록일이 아니라 **최종 갱신일로 움직이는 필드**이며(44.5% 불일치), 웹 9,840건 vs API 9,084건의 **756건 차이**는 API가 취하 건을 빼고 정상 건만 준다는 강한 근거다. +- **결과물**: 시트 8장짜리 워크북 하나. 대시보드를 열면 **5초 안에** 오늘 무엇이 바뀌었는지 보인다. +- **제약**: 사용하는 사람은 개발자가 아니다. **더블클릭 한 번**으로 설치가 끝나야 하고, 문제가 생기면 **창이 떠서** 무엇을 어떻게 고칠지 알려줘야 한다. +- **AI**: Google Antigravity CLI(`agy`)를 headless로 하루 **최대 1회** 호출해 한국어 브리핑을 붙인다. **agy가 죽어도 리포트는 나온다** — 이건 스키마 레벨에서 강제된다. +- **범위 밖**: 웹 대시보드, 해외 소스(FDA/EDQM/PMDA), HTML 크롤링, 클라우드 배포, 다중 사용자. +- **성공 판정**: 30일 무인 운영 중 **06:00±5분 실행 성공률 95% 이상**, 수집 완결성 100%, 리포트 생성 3분 이내, 장애 인지 15분 이내. +- **일정**: M0~M4 **5개 마일스톤, 합계 10~15일**. M3(리포트)이 끝나는 시점에 이미 사용자에게 가치를 준다. +- **최대 리스크**: 데이터 자체가 재수집 불가능하다는 것. API는 **현재 스냅샷만** 주므로 **오늘 안 받은 어제는 영원히 못 받는다.** 그래서 원문 아카이브와 일자별 전량 스냅샷이 1일차 요구다. 다행히 프로토타입의 9,084건이 **2026-09-02 기준선**으로 남아 있어, 첫날부터 그것을 기준으로 diff할 수 있다. + +--- + +## 1. 왜 이걸 만드는가 + +### 1.1 문제 — DMF 현황에는 "어제와의 차이"가 없다 + +원료의약품 등록(DMF) 제도는 식약처가 「의약품 등의 안전에 관한 규칙」 제16조에 따라 등록 사실을 **인터넷 등으로 공고**하도록 정하고 있다. 공고 항목은 성분·명칭, 등록자, 등록번호와 등록 연월일, 제조소 명칭·소재지다. + +문제는 **공고가 "현황표" 형태로만 존재한다**는 것이다. + +| 사람이 알고 싶은 것 | 공식 채널이 주는 것 | +|---|---| +| 오늘 **새로** 등록된 원료는? | 전체 목록 (신규 표시 없음) | +| 어제 있던 등록이 **취하**됐나? | 전체 목록 (사라진 행을 알려주지 않음) | +| 내 관심 성분의 제조소가 **바뀌었나**? | 전체 목록 (변경 이력 없음) | +| 경쟁사가 이번 주에 뭘 등록했나? | 전체 목록 (기간 필터 없음) | + +주간 공고 게시판(`/bbs/117`)이 있었지만 **2021-02-19("2021년 2월 1,2주차")를 마지막으로 사실상 갱신이 끊겼다**(`research/01` §1). 즉 "공고를 구독한다"는 경로 자체가 이미 없어졌다. 남은 방법은 현황 테이블을 **매일 통째로 받아서 스스로 비교하는 것**뿐이다. + +### 1.2 현재의 고통 — 수동 확인은 실제로는 수행되지 않는다 + +RA(인허가) 실무자가 이걸 손으로 하려면 매일 이런 절차를 밟아야 한다. + +1. 의약품안전나라 접속 → DMF 공고 현황 메뉴 진입 +2. 페이지네이션을 넘기며 표를 훑거나, 엑셀 내보내기를 눌러 파일 저장 +3. 어제 저장한 파일을 열어 **VLOOKUP으로 대조** +4. 없어진 행·추가된 행·값이 바뀐 행을 눈으로 찾음 +5. 관심 성분·업체에 걸리는 것만 골라 팀에 공유 + +현실적으로 이 절차는 **3일을 못 간다.** 그리고 하루라도 건너뛰면 그날의 상태는 **영원히 복원되지 않는다** — API도 화면도 "오늘 현재"만 보여주기 때문이다. 어제 스냅샷이 없으면 어제자 diff는 계산 자체가 불가능하다. + +**그리고 대상이 9,084행이다.** 헤더 고정도 자동 필터도 없는 표를 스크롤로 훑는 것은 물리적으로 불가능하다. 이건 의지의 문제가 아니라 도구의 문제다. + +### 1.2b 이미 있는 것 — 프로토타입과 그 한계 + +이 프로젝트는 백지에서 시작하지 않는다. **이미 공식 Open API로 수집해 xlsx를 만드는 프로토타입이 돌고 있었다.** + +| 항목 | 실측 | +|---|---| +| 산출물 | `DMF_현황.xlsx` (757,331 bytes, 2026-09-02 21:38) | +| 시트 | `전체`(9,084행) / `신규`(헤더만) / `갱신이력`(1행) | +| 컬럼 | API 7필드 + `최초수집일` 파생 컬럼 | +| 갱신이력 | `2026-09-02 / 누적 9084 / 신규 0` — 최초 실행이라 비교 대상이 없었다 | + +**프로토타입이 이미 옳게 한 것** — 공식 API 채택, "스냅샷 + 신규 + 실행 이력" 3층 구조, `최초수집일` 파생 컬럼. 이 설계 의도는 **버리지 않고 계승한다**(기준선 분석 D8·D9). + +**프로토타입이 남긴 구멍** — 이것이 이 프로젝트가 메우는 것이다. + +| 구멍 | 실측 | 이 프로젝트의 대응 | +|---|---|---| +| **서식이 전혀 없다** | 틀 고정 0, 자동 필터 0, 조건부 서식 0규칙, 차트 0, 표 0, 탭색 0 | 시트 8종 + 대시보드 + 링크 연동 (G5) | +| **열 너비가 화면을 파괴한다** | D열(소재지) 너비 **255.6** — 473자를 담으려다 상한에 닿음 | 열 너비 상한 + 줄바꿈 (D10) | +| **변경·취하를 못 잡는다** | `신규` 시트만 있고 변경·취하 개념이 없다 | NEW/CHANGED/WITHDRAWN 3종 diff (G3) | +| **정규화가 없다** | 국가 중복 표기 496건, 성분명 흔들림 21그룹, 업체명 2그룹, 제조소명 공백 오류 42건 | 정규화 계층 4종 + 원문 보존 (D5·D6) | +| **무결성 검증이 없다** | 불완전 수집 시 전건 취하 오판을 막을 장치 없음 | 게이트 5종 (G4) | +| **무인 운영이 아니다** | 수동 실행. 실패해도 알 방법 없음 | 06:00 배치 + 워치독 + 강제 알림 (G8·G9) | +| **이력이 남지 않는다** | 매번 덮어쓰기. 어제 상태를 복원할 수 없음 | SQLite append-only 스냅샷 + 원문 아카이브 (G11) | + +### 1.3 놓치면 생기는 비용 + +| 놓친 이벤트 | 실무에서 생기는 비용 | +|---|---| +| **내가 쓰는 원료의 제조소 변경** | 완제 허가 변경(제조원 변경) 대응이 늦어진다. 최악의 경우 원료 수급이 끊긴 뒤에야 안다 | +| **내가 쓰는 원료의 등록 취하** | 해당 원료로 만드는 완제품의 허가 근거가 흔들린다. 대체 원료 탐색 시간이 0이 된다 | +| **경쟁사의 신규 DMF 등록** | 경쟁 제품 개발 착수 신호를 놓친다. 시장 진입 타이밍 판단이 늦어진다 | +| **관심 성분의 신규 제조소 진입** | 소싱 대안·가격 협상 카드를 놓친다 | +| **국가별 공급망 쏠림 변화** | 특정 제조국 의존도 상승을 늦게 인지한다 | + +이 비용은 전부 **"몰랐다"에서 온다.** 정보는 공개돼 있었고, 매일 확인만 했으면 알 수 있었다. + +### 1.4 자동화의 가치 + +자동화가 만드는 차이는 세 가지다. + +**① 빠뜨림이 0이 된다.** 사람은 바쁘면 건너뛴다. 06:00 배치는 건너뛰지 않고, 건너뛰면 **워치독이 15분 안에 창을 띄워** 그 사실 자체를 알린다. + +**② 데이터가 자산이 된다.** 매일 전량 스냅샷을 append-only로 쌓으면, 6개월 뒤에 "3월에 이 제조소가 언제 들어왔지?"를 **소급 조회**할 수 있다. 이건 오늘 시작하지 않으면 영원히 못 만드는 자산이다. + +**③ 판단만 남는다.** 표를 대조하는 일은 기계가 하고, 사람은 대시보드를 5초 보고 "이건 확인해야겠다"만 결정한다. `agy`가 붙이는 한국어 브리핑이 그 결정을 한 단계 더 앞당긴다. + +### 1.5 이 프로젝트가 선택한 길 — 크롤링이 아니라 API + +원래 계획은 nedrug 화면을 크롤링하는 것이었다. 조사 중 두 가지 사실이 확인되면서 방향이 바뀌었다. + +- `https://nedrug.mfds.go.kr/robots.txt` 는 `User-agent: * / Disallow: /` — **전 경로 자동화 금지**다. 예외도, `Crawl-delay`도, 사이트맵도 없다. +- 그런데 **식약처가 동일 데이터를 공공데이터포털에 공식 Open API로 개방**하고 있다. 무료, 자동승인, **이용허락범위 제한 없음**. + +즉 식약처의 메시지는 모순이 아니라 한 쌍이다: **"웹 화면을 긁지 말고 API를 쓰라."** 이 전환으로 봇 차단 대응·셀렉터 유지보수·법적 리스크·브라우저 자동화 의존성이 **전부 사라졌다.** 상세 비교는 `design/00-DATA-SOURCE-DECISION.md` §5. + +### 1.6 API 7필드로 정말 되는가 — 실측이 답한 것 + +조사 초기에 심각한 우려가 있었다. `research/01`은 **"API 응답이 7필드뿐이라 `최종변경일자`·`취소/취하구분` 이 없고, 따라서 API 단독으로는 변경·취하 탐지 요구를 충족할 수 없다"** 고 결론지었다. 이 우려는 프로토타입 산출물 9,084건을 전수 분석하면서 **상당 부분 해소됐다.** + +| 판정 | 무엇으로 잡는가 | 실측 근거 | +|---|---|---| +| **신규** | 오늘 등록번호가 있고 어제 없음 | 등록번호 9,084건 중 **중복 0** — 완전한 자연 키다 | +| **변경** | 등록번호는 같은데 **`발급일자`가 어제보다 최신** | 등록번호 앞 8자리(최초 등록일)와 `발급일자`가 **44.5% 불일치**하고, 불일치 건은 **전부 괄호가 붙은 갱신 건**이다. 즉 `발급일자`는 정적인 최초 등록일이 아니라 **변경 때마다 움직이는 최종 갱신일**이다 | +| **변경(보조)** | 성분명·업체명·제조소명·소재지·국가 중 하나가 다름 | 내용 변경 | +| **취하** | 어제 등록번호가 있고 오늘 없음 | 같은 날 측정한 웹 화면 **9,840건** vs API **9,084건** = **756건 차이**. 웹은 취하 건을 `취소/취하구분` 컬럼으로 표시만 하고 지우지 않는데, API에는 그만큼이 없다 → **API는 정상 건만 반환한다** | + +불일치 사례는 극적이다 — `20050831-33-A-81-08(18)` 은 최초 등록이 2005-08-31인데 `발급일자`가 **2026-08-18**이다. 21년의 간격이 곧 "그동안 18번 갱신됐다"는 기록이다. + +> ⚠️ **취하 판정은 아직 증명이 아니라 강한 정황이다.** 756건 차이의 원인을 확정하려면 **이틀 이상 연속 수집해 실제로 사라지는 레코드를 관찰**해야 한다. 그때까지는 무결성 게이트(건수 급감 시 diff 중단)를 절대 완화하지 않는다. + +**여전히 놓치는 것**도 정직하게 적는다. `최종연차보고년도`가 API에 없어 **연차보고 이벤트는 잡지 못한다**(연차보고 시 `발급일자`가 갱신되는지도 불명 — 1~2월에 관측 예정). 변경 사유·유형, 취하와 취소의 구분, 취하 일자도 없다. `대상의약품`(별표1 / 신물질)은 등록번호 포맷(`수` 접두어 1,882건)으로 **파생 가능**하므로 실질 손실이 아니다. + +부족한 6개 필드는 **크롤링으로 메우지 않는다.** 공공데이터포털 "데이터 개선요청"으로 API 항목 추가를 공식 요청하는 것이 1순위 행동이며, 채널·절차·요청 문구 초안은 [`ops/04-official-data-request-channels.md`](./ops/04-official-data-request-channels.md)에 준비돼 있다. + +--- + +## 2. 무엇을 만드는가 + +### 2.1 한 문단 요약 + +**DMF Crawler는 Windows PC에서 무인으로 도는 로컬 배치 프로그램이다.** 매일 06:00에 식약처 공식 Open API(`MdcDmfInfoService01/getMdcDmfList01`)에서 원료의약품 등록 현황 **약 9,000건 전량**을 받아(약 92회 호출, 일일 한도 10,000의 1%), SQLite에 일자별 전량 스냅샷으로 append-only 적재하고, 직전 성공 스냅샷과 **레코드 단위로 diff** 해 신규·변경·취하를 계산한다. 그 결과를 **시트 8장이 서로 하이퍼링크로 연결된 xlsx 리포트**로 만들어 `reports\` 에 떨군다. 변화가 있는 날에만 `agy`를 한 번 호출해 한국어 브리핑을 붙이되, agy가 실패해도 리포트는 그대로 나온다. 설치와 오류 복구는 **같은 tkinter 창 하나**가 담당하고, 배치가 죽으면 워치독이 15분 안에 그 사실을 화면에 띄운다. 기존 프로토타입의 3시트 구조를 계승하되, **서식이 전혀 없던 9,084행 평면 표**를 읽을 수 있는 도구로 바꾸는 것이 리포트 계층의 목표다. + +### 2.2 결과물 — 생성되는 xlsx의 텍스트 목업 + +매일 아침 `reports\DMF_리포트_2026-09-02.xlsx` 와 `reports\DMF_리포트_최신.xlsx` 가 생긴다. 파일을 열면 이렇게 보인다. + +**워크북 하단 탭 줄** (탭 색은 Okabe-Ito 팔레트, 색각이상 안전) + +``` +┌────────┬────────────┬──────────┬────────┬────────┬────────────┬────────┬──────┐ +│ 대시보드 │ 오늘 변경분 │ 전체 누적 │ 성분별 │ 업체별 │ 워치리스트 │ 추이 │ 메타 │ +└────────┴────────────┴──────────┴────────┴────────┴────────────┴────────┴──────┘ + #0072B2 #D55E00 #5B6770 #009E73 #009E73 #CC79A7 #888888 #888888 + (활성) (히트 없으면 시트 자체를 만들지 않음) +``` + +**시트 ①「대시보드」— 스크롤 없이 한 화면. 눈금선 숨김, 줌 90%** + +``` +╔══════════════════════════════════════════════════════════════════════════════════════╗ +║ DMF 일일 모니터링 리포트 — 2026-09-02(화) 18pt bold ║ +║ 오늘 신규 12건, 변경 3건, 취하 1건. 최근 30일 평균(9.4건) 대비 신규가 28% 많습니다. ║ +╠═══════════╤═══════════╤═══════════╤═══════════╤═══════════╤═══════════════════════════╣ +║ 전체 등록 │ 신규 │ 변경 │ 취하 │ 워치 히트 │ 수집 완결성 ║ +║ 9,096 │ 12 │ 3 │ 1 │ 2 │ 100.0% ║ +║ ▲ +11 │ ▲ +4 │ ▼ -2 │ – 0 │ ▲ +2 │ 정상 ║ +║ ▁▂▃▅▃▂▁▃ │ ▁▃▂▅▇▃▂▁ │ ▁▁▂▁▃▁▁▂ │ ▁▁▁▂▁▁▁▁ │ ▁▁▂▁▁▃▁▂ │ ████████████████ 게이트 5/5 ║ +╠═══════════╧═══════════╧═══════════╧═══════════╧═══════════╧═══════════════════════════╣ +║ ┌── 최근 30일 일별 신규/변경/취하 (누적 세로 막대) ──┐ ┌── 제조국 Top 8 (가로 막대) ──┐ ║ +║ │ ▉▉ ▉▉▉ │ │ 인도 ████████████ 3,351 │ ║ +║ │ ▉▉ ▉ ▉▉ ▉ ▉▉▉ ▉▉▉ ▉▉ ▉▉ ▉▉▉ │ │ 중국 ████████ 2,231 │ ║ +║ │ ▉▉ ▉▉ ▉▉ ▉▉ ▉▉▉ ▉▉▉ ▉▉▉ ▉▉ ▉▉▉ │ │ 대한민국 ███ 845 │ ║ +║ └────────────────────────────────────────────────────┘ │ 이탈리아 █ 313 │ ║ +║ ※ 인도+중국이 전체의 61.5% — 공급망 집중도가 핵심 지표 │ 스페인 ▌ 210 │ ║ +║ └──────────────────────────────┘ ║ +╠═══════════════════════╤═══════════════════════╤══════════════════════════════════════════╣ +║ Top 10 성분 │ Top 10 업체 │ 워치리스트 히트 ║ +║ 순위 성분명 오늘 누적 │ 순위 신청인 오늘 누적 │ 키워드 매칭 성분명 신청인 상태 ║ +║ 1 히알루론산나트륨1 104│ 1 (주)삼오제약 2 392│ 메트포르민 ○ 메트포르민염산염 … 신규 ║ +║ 2 메트포르민염산염0 97│ 2 (주)파마피아 1 378│ 삼오 ○ 히알루론산나트륨 … 변경 ║ +╠═══════════════════════╧═══════════════════════╧══════════════════════════════════════════╣ +║ ▸ 오늘 변경분 ▸ 전체 누적 ▸ 성분별 ▸ 업체별 ▸ 워치리스트 ▸ 추이 ▸ 메타 ║ +║ 출처: 식품의약품안전처 원료의약품등록(DMF)현황 Open API · 수집 2026-09-02 06:03 KST ║ +╚══════════════════════════════════════════════════════════════════════════════════════════╝ +``` + +**시트 ②「오늘 변경분」— 리포트의 본체. diff 결과만** + +``` +A1: ← 대시보드 (역링크, 모든 데이터 시트 공통) +A2: 정렬키│상태│등록번호│성분명│신청인│제조소명│제조국│최초등록일│발급일자(최종갱신)│변경필드│확인 + ────────────────────────────────────────────────────────────────────────────────────────── + 1 │■취하│ 20121228-168-I-169-04 │포르모테롤… │(주)대웅제약│SICOR… │이탈리아│2012-12-28│2015-02-26│ — │🔗 + 2 │●신규│ 20260902-071-K-412-01 │메트포르민… │한미약품㈜ │Zhejiang… │중국 │2026-09-02│2026-09-02│ — │🔗 + 3 │●신규│ 수6580-16-ND │신물질계열… │종근당㈜ │Aurobindo…│인도 │ — │2026-09-02│ — │🔗 + 4 │▲변경│ 20230116-200-I-647-07(A) │로수바스타… │유한양행 │Hetero… │인도 │2023-01-16│2026-09-02│발급일자, 제조소명│🔗 + ────────────────────────────────────────────────────────────────────────────────────────── + 틀 고정 freeze_panes(2,3) · Excel 표 + 자동필터 · 상태는 색+기호+텍스트 3중 코딩 + 최초등록일 = 등록번호 앞 8자리 파생 (신물질 포맷은 파싱 불가 → "—", 레코드는 정상 처리) + 발급일자 = DMF_PERMIT_DATE. 최초 등록일이 아니라 **최종 갱신일**이다 (4행이 그 예) + 🔗 = 의약품안전나라 검색 링크 (사람이 직접 확인. 자동으로 화면을 긁지 않는다) +``` + +**시트 ③「전체 누적」** — 오늘 시점 전량 원장. Excel 표 + 자동필터 + 틀고정. 각 행에서 「추이」로 이력 링크. +**시트 ④「성분별」/ ⑤「업체별」** — Top N 집계와 30일 추이. 각 행에서 「전체 누적」으로 필터 이동. +**시트 ⑥「워치리스트」** — 관심 성분·업체 매칭. **목록이 비어 있으면 이 시트를 아예 만들지 않는다.** +**시트 ⑦「추이」** — 일자별 건수 시계열. 대시보드 스파크라인·차트의 원본 데이터. +**시트 ⑧「메타」** — 수집 시각, 소스 URL, `totalCount`, 수집 건수, 콘텐츠 해시, 게이트 5종 판정, 실행 로그 경로, AI 상태(성공/스킵 사유). + +### 2.3 사용자가 만지는 것은 세 개뿐 + +| 것 | 무엇 | 언제 | +|---|---|---| +| `bootstrap.cmd` | 최초 1회 더블클릭. venv 생성 → 설치 → 바로가기 생성 → 온보딩 창 | 설치할 때 딱 한 번 | +| 바탕화면 `DMF 설정.lnk` | 온보딩·복구 창. 콘솔 창 없음(`pythonw.exe`) | 설정 바꿀 때, 오류 창이 떴을 때 | +| `reports\DMF_리포트_최신.xlsx` | 오늘의 결과물 | 매일 아침 | + +나머지(`data\`, `logs\`, `state\`, `backup\`)는 사용자가 열 일이 없다. + +--- + +## 3. 개발 목표 + +요구 SSOT(`00-REQUIREMENTS.md`)의 R1~R8을 **개발 목표 12개**로 재편했다. 각 목표에 **완료 판정 기준(Definition of Done)** 을 붙인다. DoD를 만족하지 못하면 그 목표는 끝난 것이 아니다. + +### G1. 매일 전량 수집이 자동으로 이뤄진다 (R1.1, R1.4) + +**DoD** +- [ ] `dmf run` 이 `numOfRows=1` 로 `totalCount` 를 먼저 얻고, `ceil(totalCount/page_size)` 페이지를 전부 순회한다. +- [ ] 수집 레코드 수 == `totalCount` 를 매 실행 검증하고 결과를 `fetch_stats` 에 기록한다. +- [ ] 각 페이지 원문이 `data\raw\YYYY-MM-DD\page_NNNN.json` 으로 보존된다. **파서를 고친 뒤 과거를 재파싱할 수 있다.** +- [ ] 7일 연속 실행에서 완결성 실패가 0건이다. + +### G2. 크롤링하지 않고 합법적으로 수집한다 (R1.2, R1.3, N1) + +**DoD** +- [ ] `pyproject.toml` 의 런타임 의존성에 Playwright·Selenium·BeautifulSoup·lxml이 **없다.** +- [ ] 코드 전체 grep에 HTML 파싱·셀렉터 문자열이 나오지 않는다. +- [ ] User-Agent에 프로젝트명과 `general.contact_email` 이 들어간다. +- [ ] 리포트 「메타」 시트에 출처(식약처 / 공공데이터포털)가 표기된다. +- [ ] `docs/ops/03-api-usage-policy.md` 에 호출 빈도·한도·약관 준수 기록이 작성돼 있다. + +### G3. 신규·변경·취하를 정확히 판정한다 (R2.1, R2.3) + +**DoD** +- [ ] `diff.py` 가 **DB에 접근하지 않는 순수 함수**다. 전일 스냅샷과 금일 레코드를 인자로 받는다. +- [ ] 판정 규칙이 기준선 분석 D1~D3을 그대로 구현한다 — **신규**=등록번호 출현, **변경**=`발급일자` 이동 **또는** 6개 필드 중 하나가 다름, **취하**=등록번호 소멸. +- [ ] 등록번호를 **PRIMARY KEY로 직접 쓴다.** 대체 키·복합 키를 만들지 않는다(9,084건 중복 0 실측). +- [ ] 등록번호 **파싱 실패가 레코드를 탈락시키지 않는다.** 포맷 4종(표준 41.5% / 표준+괄호 32.7% / 신물질 `수nnnn-n-ND` 20.7% / 기타 5.0%)을 관대하게 처리하고, 실패 시 파생 필드(최초 등록일, 변경 차수)만 null로 둔다. +- [ ] `대상의약품`(별표1 / 신물질)을 등록번호 포맷에서 파생한다(`수` 접두어 = 신물질, 1,882건). +- [ ] 변경 건은 **어느 필드가 바뀌었는지**까지 담는다. +- [ ] 정규화(NFKC·공백·전각·법인격 표기·국가 다중값 분해) 후 비교한다. `(주)대웅제약` 과 `㈜대웅제약` 이 다른 레코드로 잡히지 않는다. +- [ ] **원문을 반드시 보존한다.** 정규화 값은 별도 컬럼이다(D6) — 되돌릴 수 없는 손실을 만들지 않는다. +- [ ] 실측된 품질 문제 4종을 처리한다: 국가 중복 표기(`중국,중국` 등 496건), 성분명 표기 흔들림 21그룹, 업체명 흔들림 2그룹, 제조소명 공백 오류 42건. +- [ ] `test_diff.py` 와 `test_normalize.py` 가 통과한다. + +### G4. 불완전 수집으로 취하를 오판하지 않는다 (R2.2) — **이 프로젝트의 1급 안전장치** + +**DoD** — 게이트 5종이 **모두** 통과해야 diff를 수행한다. +- [ ] ① 모든 페이지가 HTTP 200 이고 `resultCode == "00"` +- [ ] ② 수집 건수 == `totalCount` +- [ ] ③ `totalCount` 가 전일 대비 `integrity.max_drop_ratio`(기본 5%) 이상 급감하지 않음 +- [ ] ④ 필수 필드 널 비율 ≤ `max_null_ratio`(기본 1%) +- [ ] ⑤ 중복 `DMF_PERMIT_NO` 비율 ≤ `max_duplicate_ratio`(기본 2%) +- [ ] 하나라도 실패하면 **스냅샷을 저장하지 않고**(기준선 오염 방지) **diff도 하지 않으며**, 마지막 성공 스냅샷으로 리포트를 만들고 대시보드 최상단에 경고 배너를 띄운다. +- [ ] `test_integrity.py` 가 게이트 5종 각각의 통과/차단 **경계값**을 검증한다. +- [ ] HTTP 200인데 본문이 비었거나 차단 HTML인 경우도 실패로 분류한다(`source.min_body_bytes`, `_.xlsx` 폴백 파일명으로 저장하고 WARN 알림을 남기며, **실행 상태는 SUCCESS**다(파일은 나왔으므로). +- [ ] `test_report_atomic.py` 가 잠긴 파일 시나리오를 검증한다. + +### G7. AI는 부가 계층이고, 죽어도 리포트는 나온다 (R4.1~R4.5) + +**DoD** +- [ ] `agy -p --output-format json` 으로 호출한다. 다른 CLI를 쓰지 않는다. +- [ ] 호출 조건이 전부 코드로 강제된다: diff가 비어 있지 않음 **AND** `agy.enabled` **AND** 서킷 CLOSED/HALF_OPEN **AND** 일일 토큰 상한 미달. +- [ ] 브리핑·이상해석·정규화 후보를 **한 프롬프트에 묶어 하루 최대 1회** 호출한다. +- [ ] `structured_output` 을 신뢰하지 않고 **자체 균형괄호 JSON 추출 + `jsonschema` 검증 + 1회 강화 재시도**를 거친다. +- [ ] `enrichment` 테이블이 `events` 와 **물리적으로 분리**돼 있다. 리포트 생성 SQL이 `enrichment` 없이도 완결된다. +- [ ] `agy.exe` 를 강제로 이름 바꾸고 실행해도 xlsx가 정상 생성되고, 대시보드에 "AI 요약 없음: 실행 파일 없음" 배지가 뜬다. +- [ ] `agy` 미설치 상태에서 온보딩을 돌리면 자동 설치가 완료된다. + +### G8. 06:00에, 재부팅해도, 놓쳐도 실행된다 (R5.1~R5.6) + +**DoD** +- [ ] Task Scheduler 작업 3종이 idempotent하게 등록된다 — `DMF_Crawler_Daily`(S4U), `DMF_Crawler_Agent`(Interactive), `DMF_Crawler_AgyUpdate`(주간). +- [ ] 7일 연속 **06:00±5분** 실행 기록이 있다(`schedule.jitter_seconds` 상한 240초). +- [ ] 재부팅 후 다음 06:00에 정상 실행된다. +- [ ] 06:00에 PC가 꺼져 있었으면 `StartWhenAvailable` + AtStartup(+5분) 트리거로 캐치업한다. +- [ ] 중복 실행이 **3중**으로 막힌다 — idempotency 가드(오늘 SUCCESS면 종료 0) + `msvcrt` 파일 락 + `MultipleInstances=IgnoreNew`. +- [ ] `ExecutionTimeLimit` 초과로 강제 종료돼도 `RestartCount` 재시도가 **체크포인트로 재개**한다. + +### G9. 죽으면 화면에 뜬다 (R7.1~R7.9, N3) + +**DoD** +- [ ] **배치 프로세스는 어떤 UI도 띄우지 않는다.** `alerts` 테이블 + `state\alerts.json` + Windows Event Log에 **의도만** 기록한다. +- [ ] 로그온 세션에서 15분마다 도는 `notify-pump` 가 WARN/INFO는 자동소멸 토스트로, CRITICAL은 **강제 모달**로 띄운다. +- [ ] 모든 알림 문구가 **4요소(무엇 / 왜 / 어떻게 / 다음 행동)** 를 담는다. +- [ ] 동일 `dedup_key` 알림은 `notify.cooldown_minutes`(기본 240분) 안에 재표시되지 않는다. +- [ ] heartbeat 나이가 `notify.watchdog_stale_minutes`(기본 120분)를 넘으면 워치독이 CRITICAL을 올린다. +- [ ] 로그오프 상태에서 발생한 알림이 **다음 로그온 시 즉시** 표시된다(AtLogOn 트리거). +- [ ] 실패를 유발한 뒤 **15분 안에** 화면에 창이 뜨는 것을 실제로 확인했다. + +### G10. 비개발자가 더블클릭으로 설치·복구한다 (R6.1~R6.9, N4) + +**DoD** +- [ ] 바로가기 더블클릭 시 **검은 콘솔 창이 보이지 않는다**(`pythonw.exe`). +- [ ] API 키가 없으면 창에서 입력받아 **즉시 실제 API 호출로 검증**하고, 통과한 것만 DPAPI로 암호화 저장한다. +- [ ] 발급 페이지(`data.go.kr`)로 가는 버튼이 있다. +- [ ] `agy` 미설치 시 설치 버튼(진행률 표시), OAuth 만료 시 로그인 창 열기 버튼이 있다. +- [ ] 준비가 끝나면 06:00 작업 등록 버튼이 뜬다. +- [ ] **온보딩과 복구가 같은 컴포넌트**다 — `checks.py` 하나를 CLI `doctor` 와 GUI `onboard` 가 렌더링할 뿐이다. +- [ ] **막다른 골목이 없다.** 모든 오류 화면에 다음 행동 버튼이 있다. + +### G11. 데이터가 손실되지 않는다 (ADR-03, ADR-04, ADR-22) + +**DoD** +- [ ] 일자별 전량 스냅샷과 이벤트가 **append-only** 로 영구 축적된다(`storage.snapshot_retain_days = 0`). +- [ ] 스키마 변경은 번호순 SQL 마이그레이션 + `schema_version` 테이블로 관리되고, **적용 직전 `VACUUM INTO` 백업본**이 곧 롤백 경로다. +- [ ] 파이프라인 성공 직후 `backup\dmf_YYYY-MM-DD.sqlite3` 가 생기고 `backup.keep_count`(기본 30) 초과분이 정리된다. +- [ ] `PRAGMA integrity_check` 가 preflight에서 돌고, 실패 시 파이프라인을 시작하지 않는다. +- [ ] DB 손상 시 GUI에 **백업본 목록과 복원 버튼**이 뜬다. + +### G12. 6개월 뒤에 본인이 읽고 고칠 수 있다 (N5, N8) + +**DoD** +- [ ] 런타임 의존성이 **3개**(`httpx`, `XlsxWriter`, `jsonschema`)를 넘지 않는다. +- [ ] 설정 파일이 **1개**(`config/config.toml`)다. PC별 차이만 `config.local.toml` 이 덮는다. +- [ ] `python -m dmf_crawler doctor` 한 줄로 12종 진단이 표로 나온다. +- [ ] 실행 1회당 `logs\run_\` 디렉터리 하나만 열면 조사가 시작된다. +- [ ] `docs/` 아래 설계 문서 7종이 전부 작성돼 있다(9절 로드맵 참조). + +--- + +## 4. 비목표 (Non-goals) + +**하지 않는 것을 적어두는 이유**는, 나중에 "이것도 넣을까?"가 나왔을 때 **이미 판단이 끝났다는 사실을 기억하기 위해서**다. + +| 하지 않는 것 | 이유 | 다시 검토할 조건 | +|---|---|---| +| **HTML 크롤링·차단 우회** | `robots.txt` 전면 금지 + 동일 데이터가 공식 API로 개방. 우회는 더 어렵고 더 취약하고 더 위험하며 결과물도 더 나쁘다 | API가 폐지되거나 필수 필드를 잃는 경우 — 그때도 우회가 아니라 **식약처 문의**가 먼저다 | +| **웹 대시보드(FastAPI 등)** | xlsx로 충분하고 요구에 없다. 서버는 "그 서버는 누가 감시하는가"라는 요구를 하나 더 만든다 | 리포트 수신자가 팀 단위로 늘고 실시간 조회가 필요해질 때 | +| **해외 소스(FDA DMF / EDQM CEP / PMDA MF)** | 명시적 비목표. 각 소스가 정책·포맷·난이도가 전부 다르다(FDA는 WAF로 봇 차단, EDQM은 TSV 전량, PMDA는 xlsx 직접) | 국내 DMF 모니터링이 30일 안정 운영된 뒤. 붙일 때 `source_mfds.py` 를 프로토콜로 승격 | +| **소스 추상화(Source 프로토콜·플러그인 레지스트리)** | 소스가 1개고 확장이 비목표다. 문자열 동적 import는 오타를 06:00 런타임까지 잠복시키고 grep 추적을 끊는다 | 두 번째 소스가 **실제로** 확정될 때. 승격 비용 0.5~1일 | +| **다중 사용자·권한 관리** | 1인 운영 도구다 | — | +| **실시간·다회 모니터링** | 요구가 일 1회다. 데이터 실제 갱신 주기도 아직 미실측 | 갱신 주기를 2~4주 관측한 결과 일 1회로 부족하다고 나올 때 | +| **클라우드 배포** | 로컬 PC 운영이 요구다. `agy` OAuth 토큰이 사용자 프로필에 있어 계정 이동이 곧 재인증이다 | — | +| **모바일·이메일·웹훅 알림** | Windows 알림으로 충분 | 리포트 수신자가 2명 이상이 될 때 | +| **`pandas` 사용** | 필요한 집계가 `GROUP BY` 수준. 의존성 하나는 곧 "6개월 뒤 `pip install` 실패 확률 하나" | 피벗 형태 집계가 SQL로 감당 안 될 만큼 복잡해질 때 | +| **LibreOffice headless 재계산** | "켠 채로 조용히 안 도는" 상태가 1인 운영 최악의 실패 모드. 모든 숫자를 파이썬이 계산하면 재계산 자체가 불필요 | — | +| **AI 제안의 자동 적용** | 틀린 제안이 조용히 잘못된 데이터를 수집하는 2차 사고를 원천 차단. `state\proposals\` 에 저장하고 사람이 승인 | — | +| **Windows Service 상주 / Airflow·Prefect** | "매일 정해진 시각 실행"은 정확히 크론이 하는 일이다. 상주 프로세스는 생명주기 관리 요구를 새로 만든다 | — | +| **테스트 커버리지 목표 수치** | 급소 4모듈(`normalize`/`integrity`/`diff`/`agy.extract`)만 촘촘히 덮는다. 숫자를 맞추는 테스트는 부채다 | — | + +--- + +## 5. 성공 기준 + +**측정 가능한 것만 적는다.** 각 지표는 `runs` / `fetch_stats` / `alerts` 테이블과 `logs\` 에서 자동으로 산출할 수 있어야 한다. + +### 5.1 운영 지표 (30일 무인 운영 기준) + +| # | 지표 | 목표 | 측정 방법 | 대응 요구 | +|---|---|---|---|---| +| S1 | **06:00±5분 실행 성공률** | ≥ 95% (30일 중 실패 1회 이하) | `runs` 테이블의 `started_at` 분포 + `status` | R5.1 | +| S2 | **수집 완결성** | 100% (수집 건수 == `totalCount`) | `fetch_stats.collected == fetch_stats.total_count` | R1.4 | +| S3 | **취하 오판 건수** | **0건** | 게이트 5종 차단 로그 + 취하 이벤트 사후 확인 | R2.2 | +| S4 | **리포트 생성 시간** | 수집 시작 → xlsx 저장 완료 **3분 이내** | `runs.finished_at - runs.started_at` | R3 | +| S5 | **파이프라인 전체 소요** | `ExecutionTimeLimit` 30분의 **50% 이내** | 동일 | R5 | +| S6 | **장애 인지 시간** | 실패 발생 → 화면 표시 **15분 이내** (에이전트 주기) | `alerts.created_at` → `alerts.shown_at` | R7.1, N3 | +| S7 | **장애 복구 시간** | 대부분의 장애를 GUI에서 **5분 이내** 복구 | 복구 시나리오 12종 리허설 측정 | N4 | +| S8 | **AI 없이도 리포트 생성률** | **100%** (agy 실패가 리포트 실패로 이어지지 않음) | `agy` 강제 실패 상태에서 xlsx 존재 확인 | R4.4 | +| S9 | **알림 폭주** | 동일 코드 알림 **4시간에 1회 이하** | `alerts` dedup_key별 표시 간격 | R7.8 | +| S10 | **백업 존재율** | 성공 실행일의 **100%** 에 백업본 존재 | `backup\` 파일 목록 vs `runs` SUCCESS 일자 | ADR-22 | +| S11 | **무인 연속 운영** | 사람 개입 없이 **30일** | 개입 이벤트 로그 | N2 | +| S12 | **API 호출 수** | 일일 한도 10,000의 **5% 이내** | `fetch_stats.request_count`. 실측 기준 `ceil(9084/100)+1 = 92회` = 한도의 **0.92%** | N1 | +| S13 | **기준선 대비 건수 정합** | 첫 수집의 `totalCount` 가 **9,084 ± 자연 증감** 범위 | 프로토타입 실측(2026-09-02)과 대조. 크게 벗어나면 게이트 3이 차단 | R2.2 | + +### 5.2 결과물 품질 지표 + +| # | 지표 | 목표 | 측정 방법 | +|---|---|---|---| +| Q1 | **5초 파악** | 처음 보는 사람이 대시보드만 보고 5초 안에 오늘의 신규/변경/취하 건수와 이상 여부를 말한다 | 사용자 3인 스톱워치 테스트 | +| Q2 | **링크 무결성** | 대시보드 ↔ 8시트 하이퍼링크 **전부 동작** | 리포트 열어 전 링크 클릭 | +| Q3 | **색각이상 안전** | 상태 정보가 색 없이도 읽힌다(기호 + 텍스트 병기) | 그레이스케일 인쇄 확인 | +| Q4 | **파일 열림 내성** | 어제/오늘 파일을 Excel로 열어둔 채 배치를 돌려도 실행이 SUCCESS로 끝난다 | 실제 리허설 | +| Q5 | **재생성 동일성** | `report-only` 로 같은 `run_id` 를 재생성하면 내용이 동일하다 | 두 파일의 시트별 셀 비교 | +| Q6 | **프로토타입 대비 개선** | 기존 `DMF_현황.xlsx` 가 0이던 항목(틀 고정·자동필터·조건부서식·차트·표·탭색)이 전부 존재 | 새 리포트를 openpyxl로 열어 항목별 카운트 | +| Q7 | **정규화 효과** | 국가 중복 표기 496건, 성분명 21그룹, 업체명 2그룹, 제조소명 42건이 집계 시트에서 **합쳐져 보인다** | 정규화 전후 고유값 수 비교 (국가 원문 225종 → 분해 후 49개국) | + +### 5.3 설치·이식 지표 + +| # | 지표 | 목표 | +|---|---|---| +| I1 | **설치 클릭 수** | `bootstrap.cmd` 더블클릭 **1회** + GUI 버튼 클릭 5회 이내로 06:00 등록까지 완료 | +| I2 | **콘솔 노출** | 최초 `bootstrap.cmd` 외에 **검은 창이 한 번도 보이지 않음** | +| I3 | **신규 PC 이전** | 폴더 복사 + 온보딩 재실행으로 **30분 이내** 동작 | +| I4 | **문서만으로 설치** | 개발자가 아닌 사람이 `README.md` 만 보고 설치 완료 | + +### 5.4 실패로 판정하는 것 + +다음 중 하나라도 발생하면 **그 마일스톤은 완료가 아니다.** + +- 취하 오판이 리포트에 한 번이라도 나갔다. +- 실패했는데 24시간 안에 사용자가 알지 못했다. +- 오류 창이 떴는데 **거기서 고칠 수 없었다**(막다른 골목). +- `agy` 실패가 리포트 미생성으로 이어졌다. +- 리포트에 수식 캐시가 비어 `0` 으로 보이는 셀이 있다. + +--- + +## 6. 시스템 한눈에 보기 + +### 6.1 아키텍처 요약 다이어그램 + +``` + ┌──────────────────────────────────────────┐ + Windows Task Scheduler │ 공공데이터포털 Open API │ + ┌──────────────────┐ │ apis.data.go.kr/1471000/ │ + │ DMF_Crawler_Daily│ │ MdcDmfInfoService01/getMdcDmfList01 │ + │ 06:00 + AtStartup │ (무료 · 자동승인 · 이용허락 제한없음) │ + │ S4U(비대화형) │ └────────────────┬─────────────────────────┘ + │ 사용자 계정 │ │ HTTPS GET (httpx) + └────────┬─────────┘ │ Retry-After 우선 · full jitter + │ python -m dmf_crawler run │ 0.7s 간격 · UA에 연락처 + ▼ │ + ╔═══════════════════════════════════════════════════════════════════════════╗ + ║ pipeline.py — STAGES 순차 실행 + 스테이지 체크포인트 ║ + ║ ║ + ║ preflight → fetch → normalize → integrity ⛔ → diff → persist ║ + ║ │ │ │ │ │ │ ║ + ║ │ │ │ 게이트 5종 순수함수 단일 트랜잭션 ║ + ║ │ │ │ 실패시 차단 DB 미접근 append-only ║ + ║ │ ▼ ▼ ▼ ▼ ▼ ║ + ║ │ data\raw\ NFKC·법인격 R2.2 안전 NEW/CHANGED ★ 여기서 ║ + ║ │ 원문 아카이브 국가분해 장치 /WITHDRAWN 리포트 완결가능 ║ + ║ │ │ ║ + ║ ▼ ▼ ║ + ║ DPAPI 키 로드 → enrich(선택) → report ║ + ║ 마이그레이션+백업 │ │ ║ + ║ integrity_check │ ▼ ║ + ║ 디스크 여유 │ → backup ║ + ╚════════════════════════════════════════════════════│════════│═════════════╝ + │ │ + ┌───────────────────────────────────────────────┘ │ + ▼ ▼ + ┌─────────────────────┐ ┌──────────────────────────┐ + │ agy.exe (headless) │ │ data\dmf.sqlite3 │ + │ -p --output-format │ │ ├ runs / stage_status │ + │ json │◀── 조건: diff 비어있지 │ ├ snapshots (일자별 전량)│ + │ 하루 최대 1회 │ 않음 AND 서킷 CLOSED │ ├ records (현재 상태) │ + │ 단일 프롬프트 │ AND 토큰 상한 미달 │ ├ events (NEW/CHG/WDR) │ + │ ↓ │ │ ├ enrichment ← AI 격리 │ + │ 자체 JSON 추출 │ │ ├ agy_calls │ + │ + jsonschema 검증 │ │ └ component_health/alerts│ + └─────────┬───────────┘ └────────┬─────────────────┘ + │ 실패해도 예외 없음(값으로 반환) │ 읽기 전용 + ▼ ▼ + enrichment 테이블 ┌──────────────────────────┐ + (없어도 리포트는 나온다) │ XlsxWriter 시트 8종 조립 │ + │ 모든 숫자 파이썬 사전계산 │ + │ os.replace 원자 교체 │ + └────────┬─────────────────┘ + ▼ + reports\DMF_리포트_2026-09-02.xlsx + reports\DMF_리포트_최신.xlsx + + ─────────────────── 알림은 발생과 표시를 분리한다 (Windows 제약의 유일한 해법) ─────────────────── + + 배치(S4U, 데스크톱 없음) 에이전트(Interactive, 로그온 세션) + ┌────────────────────────┐ ┌─────────────────────────────────┐ + │ alerts 테이블 │ ─── 15분마다 ───▶ │ DMF_Crawler_Agent │ + │ state\alerts.json │ AtLogOn │ pythonw -m dmf_crawler │ + │ Windows Event Log │ │ notify-pump │ + │ ★ UI를 절대 띄우지 않음│ │ ① watchdog: heartbeat 신선도 │ + └────────────────────────┘ │ ② WARN/INFO → tkinter 토스트 │ + │ ③ CRITICAL → 강제 모달 창 │ + state\heartbeat.json ─────────────────────────▶│ ④ 창에서 바로 복구 │ + (성공/부분성공일 때만 갱신 = dead-man switch) └─────────────┬───────────────────┘ + ▼ + ┌─────────────────────────────────┐ + │ gui\app.py — 온보딩 = 복구 │ + │ checks.py 진단 12종을 렌더링 │ + │ (CLI `doctor` 와 같은 엔진) │ + └─────────────────────────────────┘ +``` + +### 6.2 세 문장 설명 + +**첫째, 데이터는 결정론적 코드가 가져오고 AI는 뒤에 붙는다.** 수집·정규화·무결성 검증·diff·저장·리포트는 전부 순수한 파이썬 코드가 하고, `agy`는 diff와 저장이 **끝난 뒤에만** 호출되는 부가 계층이다. 이 격리는 문서가 아니라 스키마가 강제한다 — `enrichment` 테이블이 `events`와 물리적으로 분리돼 있어, 리포트를 만드는 SQL은 AI 산출물 없이도 완결된다. + +**둘째, 데이터는 지워지지 않고 쌓인다.** 매일 받은 전량이 `snapshots`에 append-only로 들어가고, 각 페이지의 API 원문이 `data\raw\`에 그대로 보존된다. 파서를 고쳐 과거 3개월을 재파싱하는 것도, 6개월 전 어느 날의 상태를 소급 조회하는 것도 가능하다 — API는 "오늘 현재"만 주므로, 오늘 쌓지 않으면 영원히 못 만드는 자산이다. + +**셋째, 알림은 발생과 표시를 분리한다.** `agy`의 OAuth 토큰이 사용자 프로필 평문 파일이라 배치는 반드시 **동일 사용자 계정(S4U)** 으로 돌아야 하는데, S4U 세션에는 데스크톱이 없어 창이 뜨지 않는다. 그래서 배치는 알림 **의도만** 기록하고, 로그온 세션에서 15분마다 도는 별도 작업이 그것을 실제 화면에 띄운다. 이것이 "죽으면 창이 뜬다"는 요구를 Windows에서 만족시키는 유일한 구조다. + +--- + +## 7. 기술 스택 확정표 + +### 7.1 런타임 의존성 — 단 3개 + +| 영역 | 선택 | 버전 | 이유 | +|---|---|---|---| +| HTTP 클라이언트 | **`httpx`** | `>=0.27,<1.0` | `params=` 자동 인코딩 전제로 **Decoding 키**를 쓴다(인코딩/디코딩 혼동은 이 API의 1위 실패 원인). 연결/읽기 타임아웃 분리 지정이 배치에 유리 | +| xlsx 생성 | **`XlsxWriter`** | `>=3.2,<4.0` | 스파크라인 API가 이것에만 있다(요구 R3.4). `openpyxl`은 `load_workbook`→`save`에서 차트·이미지·도형을 잃는다 | +| JSON 스키마 검증 | **`jsonschema`** | `>=4.21,<5.0` | `agy` 응답 자체 검증. 손으로 짜면 반드시 틀리는 부분 | + +### 7.2 개발 의존성 — 1개 + +| 영역 | 선택 | 버전 | 이유 | +|---|---|---|---| +| 테스트 | **`pytest`** | `>=8.0` | 실제 API 응답을 고정 픽스처로 박은 골든 테스트가 중심. 급소 4모듈만 촘촘히 | + +### 7.3 표준 라이브러리로 해결한 것 + +| 영역 | 선택 | 버전 | 이유 (회피한 외부 패키지) | +|---|---|---|---| +| 런타임 | **CPython** | 3.11+ (실측 3.14.6) | `tomllib`가 3.11부터 stdlib. 실측 환경이 3.14.6 | +| 설정 파싱 | **`tomllib`** | stdlib | TOML 단일 파일. ← PyYAML 제거 | +| 데이터베이스 | **`sqlite3`** | stdlib | 단일 파일 + 트랜잭션 + 인덱스. ← SQLAlchemy, alembic | +| 집계 | **SQLite `GROUP BY`** | — | 필요한 집계가 이 수준. ← pandas, polars | +| GUI·토스트 | **`tkinter`** | stdlib | `pythonw.exe`로 콘솔 없이 뜬다. ← PySide6, win11toast, BurntToast(외부 모듈 설치가 전제면 "설치 실패 시 알림도 실패"라는 순환) | +| 비밀 저장 | **`ctypes` → `crypt32.dll` DPAPI** | stdlib | 사용자 범위 암호화. 같은 계정에서만 복호화 = S4U 배치가 정확히 그 계정. ← keyring, cryptography, python-dotenv | +| 배타 락 | **`msvcrt.locking`** | stdlib | ← portalocker, filelock | +| 재시도·백오프 | **직접 구현 (약 50줄)** | — | `Retry-After` 절대 우선이라는 도메인 규칙이 있어 어차피 감싸야 한다. ← tenacity, backoff | +| 로깅 | **`logging`** | stdlib | 실행 1회당 디렉터리. ← structlog | +| CLI | **`argparse`** | stdlib | ← click, typer | +| 이벤트 로그 | **`subprocess` → `eventcreate.exe`** | — | ← pywin32 | +| XML 폴백 | **`xml.etree.ElementTree`** | stdlib | `type=json` 실패 시 경로. ← lxml, BeautifulSoup | + +### 7.4 외부 프로그램 + +| 영역 | 선택 | 버전 | 필수 여부 · 이유 | +|---|---|---|---| +| AI CLI | **Google Antigravity CLI (`agy`)** | v1.1.22+ (실측) | **선택** — 없으면 AI 브리핑만 빠진다. `%LOCALAPPDATA%\agy\bin\agy.exe`. headless `-p` 완전 지원, JSON 봉투(`status`/`response`/`usage`/`error`), 종료 코드 0/1/2 | +| 스케줄러 | **Windows Task Scheduler** | OS 내장 | **필수** — 작업 3종. 배치는 S4U, 알림 에이전트는 Interactive | +| 셸(설치·등록) | **PowerShell 7** | 7.x | 작업 등록·`agy` 설치 시에만 | +| 패키지 관리 | **`venv` + `pip` + `pyproject.toml`** | stdlib + pip | 비개발자 PC에 추가 도구를 설치시키지 않는다. ← poetry, uv, conda | +| 표 계산 | **Microsoft Excel** | — | **불필요.** 리포트 열람용일 뿐 | +| 문서 변환 | **LibreOffice** | — | **불필요.** ADR-08로 재계산 단계 자체를 제거 | + +### 7.5 데이터 소스 + +| 영역 | 선택 | 값 | 이유 | +|---|---|---|---| +| 정본 소스 | **식약처 원료의약품등록(DMF)현황 Open API** | `https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01` | 무료·자동승인·**이용허락범위 제한 없음**. 개발계정 10,000건/일 | +| 응답 포맷 | **JSON** (`type=json`) | XML 폴백 | ⚠️ `type=json` 실제 동작은 M1에서 실측 | +| 수집 필드 | **7개** | `DMF_PERMIT_NO` / `INGR_KOR_NAME` / `ENTP_NAME` / `MNFCTR_NAME` / `MNFCTR_PLACE` / `MANUF_COUNTRY_CODE_NM` / `DMF_PERMIT_DATE` | 법정 공고 항목(총리령 제16조)과 일치 | +| 인증 | **serviceKey (Decoding 키)** | DPAPI 암호화 저장 | `httpx` `params=` 가 자동 인코딩하므로 Decoding 키 | +| 1차 키 | **`DMF_PERMIT_NO`** | `20121228-168-I-169-04` | **9,084건 중복 0 실측** → PRIMARY KEY로 직접 사용. 포맷 4종 병존(표준 41.5% / 표준+괄호 32.7% / 신물질 `수nnnn-n-ND` 20.7% / 기타 5.0%) | +| 변경 신호 | **`DMF_PERMIT_DATE`** | 최종 갱신일 | 등록번호 앞 8자리(최초 등록일)와 **44.5% 불일치**. 불일치 건은 전부 괄호 갱신 건 | +| 데이터 규모 | **9,084건** (2026-09-02) | 성분 1,539 / 업체 438 / 제조소 3,051 / 국가 49 / 기간 2003-04-18~2026-09-01 | 기준선 실측. 전량 수집 92회 호출 | + +--- + +## 8. 문서 지도 + +`docs/` 아래 모든 문서. **✅ = 현재 존재, ⏳ = 해당 마일스톤에서 작성 예정.** + +### 8.1 최상위 + +| 파일 | 무엇을 결정하는 문서인가 | 언제 읽는가 | 상태 | +|---|---|---|---| +| [`00-PROJECT-OVERVIEW.md`](./00-PROJECT-OVERVIEW.md) | **이 문서.** 목표·성공 기준·로드맵·리스크·용어집. 다른 모든 문서의 관문 | **제일 먼저.** 프로젝트에 처음 올 때, 6개월 뒤 다시 볼 때 | ✅ | +| [`00-REQUIREMENTS.md`](./00-REQUIREMENTS.md) | **요구사항 SSOT.** R1~R8, N1~N8, 비목표, 온보딩=복구 원칙, 요구 추적표 | 뭔가 만들기 전에. "이게 요구에 있나?"를 확인할 때 | ✅ | +| [`README.md`](./README.md) | 문서 규칙(정보를 흘리지 않는다·한 사실은 한 문서·지어내지 않는다)과 문서 지도 | 문서를 **쓰기** 전에 | ✅ | + +### 8.2 `design/` — 설계 확정 + +| 파일 | 무엇을 결정하는 문서인가 | 언제 읽는가 | 상태 | +|---|---|---|---| +| [`design/00-DATA-SOURCE-DECISION.md`](./design/00-DATA-SOURCE-DECISION.md) | **데이터 소스 SSOT.** robots.txt 실측, 공식 API 완전 명세(파라미터·응답 7필드), 전량 수집 전략, 오탐 안전장치 5종, serviceKey 발급 절차 | 수집 코드를 쓰기 전에. API 응답이 이상할 때 | ✅ | +| [`design/00b-baseline-data-analysis.md`](./design/00b-baseline-data-analysis.md) | **기준선 사실 정본.** 기존 프로토타입 `DMF_현황.xlsx` 9,084건 전수 분석 — 등록번호 유일성, 발급일자의 의미, 포맷 4종 분포, 품질 문제 4종, 국가·업체·성분 분포, 기존 서식 상태, **확정 결정 D1~D12** | **데이터 모델·정규화·diff를 설계하기 전 필독.** "이 값이 실제로 어떻게 생겼지?" 할 때 | ✅ | +| [`design/01-architecture.md`](./design/01-architecture.md) | **아키텍처 SSOT.** ADR 25개, 디렉터리 트리 전문, 모듈 계약 18종, 데이터 흐름(정상·실패 6경로), CLI 11커맨드, 설정 키 전체, 실패 대응표 33종, 의존성, 구현 순서 | **구현 시작 전 필독.** 모듈을 새로 만들 때. 구조를 바꾸고 싶을 때 | ✅ | +| `design/02-data-model.md` | SQLite 스키마 DDL, 등록번호 파싱 규칙, 정규화 규칙, 비교 대상 필드, 변경 탐지 알고리즘, 품질 검증 | `normalize.py`·`diff.py`·마이그레이션을 쓸 때 | ⏳ M1 | +| `design/03-xlsx-report-spec.md` | 시트 8종 **셀 단위** 명세. 컬럼·서식·수식·링크, 대시보드 레이아웃, 디자인 토큰, 생성 코드 | `report/sheets/` 를 쓸 때 | ⏳ M3 | +| `design/04-onboarding-wizard.md` | GUI 화면 전이도, 단계별 문구, 버튼 액션, 복구 모드별 강조 규칙 | `gui/` 를 쓸 때 | ⏳ M4 | + +### 8.3 `ops/` — 운영 + +| 파일 | 무엇을 결정하는 문서인가 | 언제 읽는가 | 상태 | +|---|---|---|---| +| `ops/01-scheduling-and-resilience.md` | 작업 3종 등록 파라미터 전문, 재부팅·절전 시나리오 검증 절차, 워치독 판정, 로그 구조, 일상 운영·신규 PC 설치 절차 | 스케줄이 안 돌 때. 새 PC에 설치할 때 | ⏳ M2 | +| `ops/02-failure-alerting.md` | 알림 등급 기준, 문구 4요소 규격, 쿨다운·dedup 규칙, `agy` 재로그인 유도 흐름, 에스컬레이션 | 알림을 추가·수정할 때 | ⏳ M4 | +| `ops/03-api-usage-policy.md` | 공공데이터 API 이용 제약, 트래픽 계산, 인증키 만료 대응, 오류 코드 대응표, 출처 표시 문구 | 호출 빈도를 바꿀 때. 법적 검토 요청이 올 때 | ⏳ M1 | +| [`ops/04-official-data-request-channels.md`](./ops/04-official-data-request-channels.md) | **부족한 6개 필드를 크롤링 없이 공식 절차로 얻는 법.** 채널 6종 비교(데이터 개선요청 / 제공신청 / 국민신문고 / 정보공개청구 / 식약처 직접문의 / 식의약 데이터포털), 담당 연락처, **발송 가능한 요청 문구 초안** | `최종변경일자`·`취소취하구분` 등이 아쉬울 때. **크롤링이 하고 싶어질 때** | ✅ | + +### 8.4 `research/` — 조사 정본 + +| 파일 | 무엇을 결정하는 문서인가 | 언제 읽는가 | 상태 | +|---|---|---|---| +| [`research/01-dmf-domain-and-sources.md`](./research/01-dmf-domain-and-sources.md) | **도메인 정본.** DMF 제도 연혁, 법령 근거(총리령 제16조), 등록번호 체계 완전 해부(파서 코드), 화면 컬럼 실측, 해외 4개국 소스 비교, RA 실무 4축 | 등록번호를 파싱할 때. "이 필드가 무슨 뜻이지?" 할 때. 용어가 헷갈릴 때
⚠️ **§1의 "API 단독으로는 변경·취하 탐지 불가" 결론은 `design/00b` 실측으로 정정됐다** | ✅ | +| [`research/02-benchmark-github-projects.md`](./research/02-benchmark-github-projects.md) | 유사 오픈소스 프로젝트 벤치마킹, 채택/기각 결정, 모듈 경계 참고 | 모듈을 어떻게 쪼갤지 고민될 때 | ✅ | +| [`research/03-crawling-theory-and-papers.md`](./research/03-crawling-theory-and-papers.md) | 크롤러 이론, 증분 수집·변경 감지 기법, 추출 기법, 크롤링 정책 | diff 알고리즘·재방문 정책을 설계할 때 | ✅ | +| [`research/04-anti-bot-and-legal.md`](./research/04-anti-bot-and-legal.md) | 봇 탐지 원리, **정중한 크롤러 규칙**(요청 빈도·UA·백오프·조건부 요청), 판례·법적 리스크 | 법적 검토가 필요할 때. 보조 소스를 붙일 때 | ✅ | +| [`research/05-ai-cli-headless-comparison.md`](./research/05-ai-cli-headless-comparison.md) | AI CLI 대안 비교와 headless 파이프라인 설계 원칙 (**참고용** — 채택 CLI는 `agy`로 확정) | AI CLI 선택 근거를 다시 볼 때 | ✅ | +| [`research/05a-agy-cli-ssot.md`](./research/05a-agy-cli-ssot.md) | **`agy` SSOT.** 설치·인증(OAuth 토큰 위치)·headless 플래그·JSON 봉투·권한·오버헤드 실측(28k토큰/33.7초)·함정 | `agy`를 부를 때 **반드시 먼저**. 인증이 풀렸을 때 | ✅ | +| [`research/06-xlsx-linking-and-formatting.md`](./research/06-xlsx-linking-and-formatting.md) | **xlsx SSOT.** 시트 연동 문법, 서식 레시피, XlsxWriter vs openpyxl 실측, 수식 캐시 문제, 파일 잠금(`~$`) 실측 | 리포트 코드를 쓸 때 | ✅ | +| [`research/07-pharma-excel-dashboard-design.md`](./research/07-pharma-excel-dashboard-design.md) | **대시보드 SSOT.** 의약 정보 시각화 원칙, 시트 8종 구성 확정안, 탭색·링크 방향, KPI 레이아웃 와이어프레임 | 대시보드를 그릴 때 | ✅ | +| [`research/08-windows-scheduling-and-resilience.md`](./research/08-windows-scheduling-and-resilience.md) | Windows 스케줄링 파라미터, 재부팅 내성, S4U vs Interactive, 알림 기법 | 작업을 등록할 때. 재부팅 후 안 돌 때 | ✅ | +| `research/09-agy-bootstrap-and-provisioning.md` | `agy` 자동 설치·인증 부트스트랩·로그인 유도 창 설계 | `bootstrap_agy.ps1` 을 쓸 때 | ⏳ M4 | +| `research/10-agy-agent-integration-patterns.md` | `agy` 통합 패턴, 프롬프트 인젝션 방어, 유스케이스별 호출 설계 | `agy/prompt.py` 를 쓸 때 | ⏳ M4 | +| `research/_raw/` | 정제 전 웹 리서치 원본 덤프 | 출처 추적이 필요할 때만. **전량 읽지 말고 grep** | ✅ (디렉터리) | + +### 8.5 읽는 순서 + +| 역할 | 순서 | +|---|---| +| **처음 오는 사람** | `00-PROJECT-OVERVIEW.md`(이 문서) → `00-REQUIREMENTS.md` → `design/00b-baseline-data-analysis.md` → `design/01-architecture.md` | +| **구현하는 사람** | `design/01-architecture.md` → `design/00-DATA-SOURCE-DECISION.md` → **`design/00b-baseline-data-analysis.md`** → `design/02-data-model.md` → `design/03-xlsx-report-spec.md` → `research/05a-agy-cli-ssot.md` | +| **운영하는 사람** | `ops/01-scheduling-and-resilience.md` → `ops/02-failure-alerting.md` → `design/01-architecture.md` §7(실패 대응표 33종) | +| **법적 검토** | `design/00-DATA-SOURCE-DECISION.md` §2·§5 → `research/04-anti-bot-and-legal.md` → `ops/03-api-usage-policy.md` → `ops/04-official-data-request-channels.md` | +| **"필드가 부족한데 긁으면 안 되나?"** | `ops/04-official-data-request-channels.md` → `design/00b` §6.4(놓치는 것) → `design/00-DATA-SOURCE-DECISION.md` §5 | +| **도메인이 궁금한 사람** | `research/01-dmf-domain-and-sources.md` → `design/00b` §5(등록번호 실측) → 이 문서 §11(용어집) | + +--- + +## 9. 구현 로드맵 + +마일스톤은 **"끝났을 때 실제로 무엇이 동작하는가"** 로 나눈다. 앞 단계가 동작하지 않으면 다음으로 넘어가지 않는다. + +**난이도 표기**: ★=단순(1~2시간) / ★★=보통(반나절) / ★★★=까다로움(하루) / ★★★★=이 프로젝트의 급소 + +### M0 — 뼈대와 진실 (예상 1~2일) + +| 작업 | 난이도 | 의존 | +|---|---|---| +| - [ ] 디렉터리 트리 전체 생성 (빈 모듈 포함, `design/01-architecture.md` §2 그대로) | ★ | — | +| - [ ] `pyproject.toml` (의존성 3개 상한 고정, 엔트리포인트 `dmf`, pytest 설정) | ★ | 트리 | +| - [ ] `bootstrap.cmd` (venv 생성 → `pip install -e .` → 바로가기 → 온보딩 기동) | ★★ | pyproject | +| - [ ] `.gitignore` (`.venv/ data/ logs/ reports/ state/ backup/ config.local.toml *oauth-token*`) | ★ | — | +| - [ ] `paths.py` · `errors.py` (예외 계층 6종) | ★ | 트리 | +| - [ ] `config.py` + `config/config.toml` (§6.1 키 전체, `config.local.toml` 병합·검증) | ★★ | paths | +| - [ ] `logging_setup.py` (run_id 디렉터리, `pipeline.log` + `events.jsonl`, 마스킹) | ★★ | paths, config | +| - [ ] `runlock.py` (`msvcrt` 배타 락, 컨텍스트 매니저) | ★★ | paths | +| - [ ] `secrets_dpapi.py` (`ctypes` → `CryptProtectData`/`CryptUnprotectData`) + 단위 테스트 | ★★★ | — | +| - [ ] `storage/db.py` + `migrations/0001_init.sql` (runs/stage_status/fetch_stats/snapshots/records/events) | ★★★ | config | +| - [ ] `storage/repo.py` 의 run·checkpoint 계열 | ★★ | db | +| - [ ] `cli.py` 의 `version` / `db` / `doctor`(체크 ①②③④만) | ★★ | 전부 | + +**완료 시 동작하는 것**: `bootstrap.cmd` 더블클릭 → venv 생성 → `dmf version` / `dmf db migrate` / `dmf doctor` 실행. API 키를 DPAPI로 저장·복호화할 수 있다. **DB 파일과 설정이 진실로 존재한다.** + +### M1 — 수집에서 diff까지 (예상 2~3일) · 의존: M0 + +| 작업 | 난이도 | 의존 | +|---|---|---| +| - [ ] **serviceKey 확보** — 프로토타입이 이미 API 수집에 성공했으므로 **키가 이미 존재할 가능성이 높다.** 사용자에게 위치를 먼저 확인하고, 없으면 data.go.kr 활용신청(자동승인) | ★ | — (사람이 수동) | +| - [ ] **프로토타입 수집 스크립트 확보·검토** — 있으면 재사용·개선의 출발점이 된다 | ★ | 사용자 | +| - [ ] `http.py` (Retry-After 절대 우선, full jitter, 조건부 요청, UA 고정) + `test_http_retry.py` | ★★★ | config | +| - [ ] `source_mfds.py` (totalCount 취득 → 페이지 순회 → 원문 아카이브 → 본문 시그니처 검사) | ★★★ | http, secrets | +| - [ ] **`DMF_현황.xlsx` 9,084건을 2026-09-02 기준선 스냅샷으로 임포트** — 첫날부터 진짜 diff가 나온다 | ★★ | 스키마 | +| - [ ] `normalize.py` (NFKC·공백·전각·법인격·국가 다중값 분해, 국가 코드 매핑 49개국) + `test_normalize.py` | ★★★★ | — | +| - [ ] 등록번호 파서 — 포맷 4종 + 파싱 실패 허용(파생 필드만 null). `대상의약품` 파생 | ★★★ | — | +| - [ ] `integrity.py` (게이트 5종) + `test_integrity.py` (경계값) | ★★★★ | normalize | +| - [ ] `diff.py` (순수 함수, NEW/CHANGED/WITHDRAWN + 변경 필드 추출) + `test_diff.py` | ★★★★ | normalize | +| - [ ] `health.py` (3상태 서킷 CLOSED/OPEN/HALF_OPEN) | ★★ | repo | +| - [ ] `pipeline.py` 의 preflight~persist 스테이지 + 체크포인트 | ★★★ | 위 전부 | +| - [ ] `docs/design/02-data-model.md` 작성 (기준선 D1~D12를 스키마로 반영) | ★★ | 구현 확정 후 | +| - [ ] `docs/ops/03-api-usage-policy.md` 작성 | ★ | 실측 후 | +| - [ ] **데이터 소스 SSOT 부록 B 잔여 실측 기록** (아래 부록 참조 — `totalCount`·중복은 기준선 분석으로 이미 해소) | ★★ | 첫 수집 성공 | +| - [ ] **`ops/04` 1순위 행동 실행** — 공공데이터포털 "데이터 개선요청"으로 6개 필드 추가 요청 발송 | ★ | 사람이 수동 | + +**완료 시 동작하는 것**: `dmf run --skip-agy` 가 실제 API에서 전량(약 9,000건 / 92회 호출)을 수집해 SQLite에 적재한다. 기준선을 임포트했다면 **첫 실행부터** diff가 나오고, 아니어도 **이틀 연속 돌리면 둘째 날에** 나온다. 게이트가 불완전 수집을 막는 것도 확인된다. **여기서 취하 판정 가설(756건 = 취하 건)이 실제 소멸 레코드 관찰로 검증된다.** 리포트는 아직 없다. + +### M2 — 무인 운영 (예상 1~2일) · 의존: M1 + +| 작업 | 난이도 | 의존 | +|---|---|---| +| - [ ] `scripts/install_tasks.ps1` / `uninstall_tasks.ps1` (작업 3종 idempotent 등록) | ★★★ | — | +| - [ ] `alerts.py` (기록·조회·dedup·쿨다운·해소) | ★★ | repo | +| - [ ] `watchdog.py` (heartbeat 신선도 판정) | ★★ | alerts | +| - [ ] `notify/eventlog.py` (`eventcreate.exe`) · `notify/messages.py` (4요소 템플릿) | ★★ | — | +| - [ ] `backup.py` (`VACUUM INTO` + 보존 개수 정리 + 복원 안내) | ★★ | db | +| - [ ] `pipeline.py` 의 backup·finalize 스테이지, heartbeat 기록 | ★★ | backup | +| - [ ] idempotency 가드 + `--force` | ★★ | repo | +| - [ ] `docs/ops/01-scheduling-and-resilience.md` 작성 | ★★ | 등록 검증 후 | +| - [ ] **재부팅 리허설** (끄고 켜서 다음 06:00 실행 확인) | ★★ | 작업 등록 | +| - [ ] **S4U에서 tkinter 창이 정말 안 뜨는지 실측** (ADR-10·11의 전제) | ★★ | 작업 등록 | + +**완료 시 동작하는 것**: 06:00에 **사람이 없어도** 배치가 돈다. 재부팅해도, 06:00을 놓쳐도 캐치업한다. 중복 실행은 흡수된다. 성공하면 백업본이 생기고 heartbeat가 갱신된다. 실패는 `alerts`와 Event Log에 남는다 — **아직 화면에는 안 뜬다.** + +### M3 — 리포트 (예상 3~4일) · 의존: M1 (M2와 병렬 가능) + +| 작업 | 난이도 | 의존 | +|---|---|---| +| - [ ] `report/theme.py` (Okabe-Ito 팔레트, 맑은 고딕, 상태 매핑, Format 캐시) | ★★ | — | +| - [ ] `report/widgets.py` (KPI 타일, 델타 화살표 서식, 목차 링크, **한글 열너비 계산**) | ★★★ | theme | +| - [ ] `report/atomic.py` (임시파일 → `os.replace`, 잠김 재시도·폴백) + `test_report_atomic.py` | ★★★ | — | +| - [ ] `report/data.py` (시트별 SQL 상수 모음 → `ReportData`) | ★★★ | M1 스키마 | +| - [ ] `report/build.py` (워크북 조립 오케스트레이션) | ★★ | 위 전부 | +| - [ ] `sheets/s00_dashboard.py` (KPI 6타일·차트 2종·스파크라인·링크·상태 배너) | ★★★★ | widgets, data | +| - [ ] `sheets/s01_changes.py` (오늘 변경분. **리포트의 본체.** 상태 3중 코딩·히든 정렬키) | ★★★ | widgets | +| - [ ] `sheets/s02_ledger.py` (전체 누적. Excel 표·자동필터·틀고정·nedrug 검색 링크) | ★★ | widgets | +| - [ ] `sheets/s03_ingredient.py` · `s04_company.py` (Top N + 추이) | ★★ | data | +| - [ ] `sheets/s05_watchlist.py` (**목록이 비면 시트를 만들지 않는다**) | ★★ | data | +| - [ ] `sheets/s06_trend.py` (일자별 시계열, 차트·스파크라인 원본) | ★★ | data | +| - [ ] `sheets/s99_meta.py` (수집 시각·소스 URL·건수·해시·게이트 판정·로그 경로·AI 상태) | ★ | data | +| - [ ] `cli report-only` / `cli backfill` | ★★ | build | +| - [ ] `docs/design/03-xlsx-report-spec.md` 작성 | ★★★ | 구현 확정 후 | + +**완료 시 동작하는 것**: 매일 06:00에 **탭별로 연동된, 디자인이 예쁜 xlsx**가 나온다(요구 R3 전부). 파일을 열어둔 상태에서도 배치가 실패하지 않는다. **이 시점에서 프로젝트는 이미 사용자에게 가치를 준다.** + +### M4 — 사람과의 접점 (예상 3~4일) · 의존: M2 + M3 + +| 작업 | 난이도 | 의존 | +|---|---|---| +| - [ ] `agy/client.py` (subprocess, **예외 대신 `AgyEnvelope` 값으로 반환**) | ★★★ | — | +| - [ ] `agy/prompt.py` (diff+지표 → 템플릿 렌더링, **데이터를 구분자로 감싸기**) | ★★ | M1 diff | +| - [ ] `agy/extract.py` (균형괄호 JSON 추출 + jsonschema 검증) + `test_agy_extract.py` | ★★★★ | — | +| - [ ] `agy/budget.py` (일일 토큰 상한 강제) | ★★ | repo | +| - [ ] `prompts/daily_briefing.md` + `daily_briefing.schema.json` | ★★★ | — | +| - [ ] `migrations/0002_enrichment.sql` · `0003_ops.sql` 적용 | ★★ | M0 러너 | +| - [ ] `pipeline.py` 의 enrich 스테이지 + 대시보드 AI 배지 | ★★ | client, M3 | +| - [ ] `checks.py` 진단 12종 완성 + `test_checks.py` | ★★★ | 전부 | +| - [ ] `gui/app.py` · `steps.py` · `widgets.py` (온보딩 = 복구 단일 창) | ★★★★ | checks | +| - [ ] `notify/pump.py` · `toast.py` (워치독 → 알림 → 복구 GUI 기동) | ★★★ | alerts, gui | +| - [ ] `scripts/make_shortcuts.ps1` · `bootstrap_agy.ps1` | ★★ | — | +| - [ ] `docs/design/04-onboarding-wizard.md` · `docs/ops/02-failure-alerting.md` 작성 | ★★ | 구현 후 | +| - [ ] `docs/research/09` · `10` 작성 | ★★ | 실측 후 | +| - [ ] **복구 시나리오 12종 리허설** (각각 유발 → 창 확인 → 5분 내 복구 확인) | ★★★ | 전부 | + +**완료 시 동작하는 것**: 비개발자가 바로가기를 더블클릭하면 창이 뜨고, 키가 없으면 입력받아 검증·저장하고, `agy`가 없으면 설치하고, 로그인이 풀렸으면 로그인 창을 띄우고, 06:00 등록까지 마친다(R6 전부). 배치가 실패하면 **15분 안에 창이 떠서** 무엇이 왜 안 됐고 어떻게 고치는지 알려주고 **그 창에서 바로 고칠 수 있다**(R7 전부). + +### M5 — 안정화 (예상 30일, 개발 아님) · 의존: M4 + +| 작업 | 난이도 | 의존 | +|---|---|---| +| - [ ] 30일 무인 운영 관측. §5 지표 S1~S12 실측 | ★ | M4 | +| - [ ] **데이터 실제 갱신 주기 실측** (`totalCount` + 콘텐츠 해시 일별 기록) | ★ | 운영 | +| - [ ] 워치리스트 초기 목록 확정 (사용자 확인 필요) | ★ | 사용자 | +| - [ ] 게이트 임계값 실측 기반 재조정 (`max_drop_ratio` 등) | ★★ | 30일 데이터 | +| - [ ] 오염 응답을 픽스처로 승격해 회귀 테스트 추가 | ★★ | 실제 실패 | + +### 마일스톤 후 (범위 밖, 기록만) + +| 항목 | 착수 조건 | +|---|---| +| 워치리스트 UI | 사용자가 관심 성분·업체 목록을 확정한 뒤 | +| 이메일·웹훅 알림 | 리포트 수신자가 2명 이상이 된 뒤 | +| 해외 소스(FDA/EDQM/PMDA) | 명시적 비목표. 붙일 때 `source_mfds.py` 를 프로토콜로 승격 | +| 웹 대시보드 | 명시적 비목표 | + +--- + +## 10. 리스크 등록부 + +**담당 표기**: `코드` = 구현으로 방어 / `운영` = 운영 절차로 방어 / `사용자` = 사람이 해야 함 / `외부` = 우리 통제 밖(감지만 가능) + +| # | 리스크 | 가능성 | 영향 | 완화책 | 담당 | +|---|---|---|---|---|---| +| **R-01** | **취하 오판** — 불완전 수집으로 멀쩡한 등록이 전부 "취하"로 리포트에 나감 | 중 | **치명** | 무결성 게이트 5종을 **1급 스테이지**로. 하나라도 실패하면 스냅샷 저장도 diff도 하지 않는다. `totalCount` 5% 급감은 CRITICAL. 경계값 테스트 필수 | 코드 | +| **R-02** | **API 스키마 변경** — 필드명·구조가 바뀌어 파싱이 깨짐 | 중 | 높음 | 필드 부재 → 널 비율 급증 → 게이트 4가 차단. 원문 아카이브가 남아 있어 **재파싱 가능**. `agy` A7이 변경점을 진단하되 **자동 반영 금지, 사람 승인**(ADR-25) | 코드 + 사용자 | +| **R-03** | **API 서비스 중단·폐지** — 엔드포인트가 사라지거나 유료화 | 낮 | **치명** | 감지: 연속 실패 → 서킷 OPEN → CRITICAL 모달. 대안: 식약처 문의 → 다른 데이터셋 → **최후에도 크롤링은 하지 않는다**. 그동안 쌓은 스냅샷은 남는다 | 외부 | +| **R-04** | **일일 트래픽 10,000건 초과** | 낮 | 중 | 전량 수집이 `ceil(totalCount/100)+1` 호출이라 한도의 5% 이내. 초과 시 WARN + 그날 스킵. 반복되면 운영계정 전환. ⚠️ 한도 카운트 단위(호출 수 vs 레코드 수) M1 실측 필요 | 코드 | +| **R-05** | **serviceKey 만료·무효화** | 중 | 높음 | `resultCode != "00"` 또는 401/403 감지 → CRITICAL **강제 창** → GUI에서 재입력 → 즉시 실호출 검증. 기존 키는 **앞 8자만** 표시해 혼동 방지 | 코드 + 사용자 | +| **R-06** | **`agy` OAuth 로그인 만료** — 토큰이 풀려 AI 단계 실패 | **높음** | 낮 | AI는 **부가 계층**이라 리포트에 영향 없음(스키마 레벨 격리). `classify_error == AUTH` → CRITICAL 창 → "로그인 창 열기" 버튼. ⚠️ 정확한 error 문자열 M4 실측 필요 | 코드 + 사용자 | +| **R-07** | **`agy` 쿼터 소진 / 모델 변경 / CLI 파괴적 업데이트** | 중 | 낮 | 하루 1회·diff 있을 때만 호출로 소모 최소화. 일일 토큰 상한. `AGY_CLI_DISABLE_AUTO_UPDATE=true` 를 배치 중 주입해 **실행 중 바이너리 교체 예방**, 업데이트는 일요일 14:00 별도 작업 | 코드 | +| **R-08** | **`agy` 응답 오염** — JSON이 4회 반복되고 산문이 섞임 (SSOT §7.2 실측) | **높음** | 낮 | `structured_output` 불신. 균형괄호 추출 + `jsonschema` 검증 + 강화 프롬프트 1회 재시도. 실패해도 리포트는 나옴. 오염 응답은 `agy.stdout.json` 에 보존해 픽스처로 승격 | 코드 | +| **R-09** | **프롬프트 인젝션** — API 응답의 성분명·제조소명에 지시문이 섞임 | 낮 | 중 | `--dangerously-skip-permissions` 를 쓰지 않음. `--disable-slash-commands` 부착. 데이터를 명확한 구분자로 감싸고 출력은 스키마 검증. AI에 파일 쓰기·명령 실행 권한 없음 | 코드 | +| **R-10** | **PC 전원 꺼짐 / 절전 / 06:00 부재** | **높음** | 중 | `StartWhenAvailable` + AtStartup(+5분) 캐치업. 중복은 idempotency 가드가 흡수. ⚠️ `WakeToRun` 설정은 **PC가 밤에 꺼지는지 사용자 확인 필요** | 코드 + 사용자 | +| **R-11** | **재부팅으로 스케줄 소실 / 사용자가 작업을 지움** | 낮 | 높음 | `checks` ⑨ 가 `Get-ScheduledTask` 로 읽기 전용 점검. **자동 재등록하지 않는다**(의도적으로 껐을 수 있음) → CRITICAL 창의 "작업 다시 등록" 버튼 | 코드 + 사용자 | +| **R-12** | **배치가 죽었는데 아무도 모름** (조용한 실패) | 중 | **치명** | heartbeat는 **성공/부분성공일 때만** 갱신 = dead-man switch. 나이 > 120분이면 워치독 CRITICAL. 배치는 UI를 띄우지 않고 로그온 세션 에이전트가 15분마다 표시 | 코드 | +| **R-13** | **알림이 화면에 도달하지 않음** (로그오프·잠금 상태) | 중 | 높음 | 배치는 `alerts` + `state\alerts.json` + Event Log **3중 기록**. AtLogOn 트리거가 다음 로그온 시 밀린 알림을 즉시 표시 | 코드 | +| **R-14** | **SQLite 파일 손상 / 디스크 장애** — 유일 정본 소실 | 낮 | **치명** | 성공 직후 `VACUUM INTO` 날짜별 백업 30개 보존. `PRAGMA integrity_check` 를 preflight에. 손상 시 GUI에 **백업본 목록 + 복원 버튼**. ⚠️ `backup.dir` 을 **다른 드라이브**로 지정하도록 온보딩이 유도 | 코드 + 사용자 | +| **R-15** | **데이터 품질 불량** — 실측 확인: 국가 중복 표기 **496건**(`중국,중국`), 성분명 표기 흔들림 **21그룹**, 업체명 **2그룹**, 제조소명 공백 오류 **42건**(`CORTICOSTER OIDO`), 제조국가명 결측 5건 | **확실** | 중 | 정규화 계층 4종이 비교 전에 돈다(D5). **원문은 별도 컬럼으로 반드시 보존**(D6). 국가 코드 매핑 테이블 49개국(D12). 규칙으로 못 잡는 것은 `agy` A2/A5가 후보를 제시하되 **사람 승인 후 반영**. `company_aliases.csv` 수동 큐레이션은 기각(1인 운영에서 부도난다) | 코드 | +| **R-16** | **등록번호 포맷 예외** — 포맷 **4종 이상** 병존. "기타" 455건에는 `수` 없는 `1962-17-ND`, 이중 괄호 `(1)-A(1)` 이 섞여 있다 | **확실** | 낮 (**완화됨**) | **중복은 0으로 실측됐다** — 등록번호를 PRIMARY KEY로 직접 쓴다(D1). **파싱 실패를 치명적으로 만들지 않는다**(D7) — 문자열 자체가 키이고 파싱은 파생 정보를 얻는 부가 작업이므로, 실패해도 레코드는 정상 처리하고 파생 필드만 null. 중복 비율 게이트 5가 이상 급증을 계속 감시 | 코드 | +| **R-17** | **법적 이슈** — 수집·재배포의 적법성 논란 | 낮 | 중 | 공식 API + **이용허락범위 제한 없음** + robots.txt 무관(크롤링 안 함). UA에 연락처 명시, 리포트에 출처 표시, `ops/03-api-usage-policy.md` 에 준수 기록. `research/04` 를 폐기하지 않고 **근거 자료로 보존** | 코드 + 문서 | +| **R-18** | **xlsx 파일 잠김** — 사용자가 리포트를 열어둔 채 06:00 도래 | **높음** | 낮 | 임시파일 → `os.replace` 원자 교체. `PermissionError` 3회 재시도 → 폴백 파일명 저장 + WARN. **실행 상태는 SUCCESS** | 코드 | +| **R-19** | **리포트 숫자가 0으로 보임** — 수식 캐시 미기록 | 중 | 높음 | **모든 숫자를 파이썬이 계산해 값으로 쓴다.** LibreOffice 재계산 단계 없음. 동적 배열 함수(FILTER/UNIQUE/XLOOKUP) 금지. 수식이 필요하면 `write_formula(..., value=사전계산값)` | 코드 | +| **R-20** | **초기 며칠 diff가 비어 있음** — API가 현재 스냅샷만 주므로 과거 재구성 불가 | 낮 (**완화됨**) | 낮 | **프로토타입의 9,084건이 2026-09-02 기준선으로 존재한다.** 이것을 임포트하면 첫 실행부터 diff가 나온다. 임포트하지 않아도 둘째 날부터 나온다. 첫 실행은 "기준선 수립"으로 표시 | 코드 | +| **R-27** | **취하 판정 가설이 틀림** — 756건 차이가 취하가 아니라 다른 원인(필터링 조건, 데이터 동기화 지연 등)일 수 있다 | 중 | **높음** | 아직 **정황이지 증명이 아니다.** M1에서 이틀 이상 연속 수집해 실제 소멸 레코드를 관찰하고, 표본을 웹 화면과 대조해 확정한다. 그때까지 게이트 3(건수 급감 차단)을 절대 완화하지 않는다. 틀렸다면 취하를 "목록에서 사라짐"이라는 **약한 표현**으로만 리포트하고 사람 확인 링크를 강조한다 | 코드 | +| **R-28** | **연차보고를 못 잡는다** — `최종연차보고년도`가 API에 없고, 연차보고 시 `발급일자`가 갱신되는지 불명 | **확실** | 중 | 실질적으로 아쉬운 유일한 필드다. **1~2월에 관측**해 `발급일자` 갱신 여부를 확인한다(연차보고는 1월 말에 몰린다). 병행해 `ops/04` 채널로 필드 추가를 공식 요청한다 | 코드 + 외부 | +| **R-29** | **연도별 집계를 사용자가 오해함** — `발급일자` 기준 집계는 "최종 갱신 연도"인데 "신규 등록 연도"로 읽힌다 | **높음** | 중 | 2025년 1,185건은 신규 폭증이 아니라 갱신이 많았던 것이다. 리포트에 **"갱신 연도 기준"을 명시**하고(D11), 가능하면 등록번호 앞 8자리 기준 "최초 등록 연도" 집계를 **별도 계열로 병기**한다 | 코드 | +| **R-21** | **Python/venv 손상** — 인터프리터나 패키지가 깨져 GUI조차 못 뜸 | 낮 | 높음 | `checks` ①② 가 import 실패를 감지. **GUI도 못 뜰 수 있으므로 Event Log가 유일한 흔적** — 반드시 기록. 복구는 `bootstrap.cmd` 재실행 | 코드 + 사용자 | +| **R-22** | **의존성 설치 실패** — 6개월 뒤 `pip install` 이 안 됨 | 중 | 중 | 런타임 의존성 **3개**로 최소화. `pyproject.toml` 에 상한 버전 명시(lock 파일 대체). 나머지는 전부 stdlib | 코드 | +| **R-23** | **비개발자가 설치를 포기함** | 중 | 높음 | 더블클릭 1회 + GUI 버튼 5회 이내. 콘솔 창 노출 0. **막다른 골목 금지** — 모든 오류 화면에 다음 행동 버튼. 온보딩과 복구가 같은 화면이라 **기억할 화면이 하나뿐** | 코드 | +| **R-24** | **알림 폭주로 사용자가 무시하기 시작함** | 중 | 높음 | dedup_key + `cooldown_minutes`(기본 240) 억제. 등급 분리(CRITICAL만 강제 창, WARN/INFO는 토스트). 서킷 OPEN 전환 시 **1회만** 알림 | 코드 | +| **R-25** | **필드 부족** — API 7필드에 `최종변경일자`·`최종연차보고년도`·`취소/취하구분`·`취소/취하일자`·`문서번호`·`대상의약품` 6개가 없다 | **확실** | 중 (**완화됨**) | 실측 결과 **변경·취하는 `발급일자` 이동과 레코드 소멸로 잡히고**(§1.6), `대상의약품`은 등록번호 포맷으로 파생된다. 실질 손실은 연차보고 하나(R-28). 나머지는 리포트 각 행의 **nedrug 검색 링크**로 사람이 확인. **크롤링으로 메우지 않고** `ops/04` 공식 채널로 필드 추가를 요청한다 | 코드 + 사용자 | +| **R-26** | **6개월 뒤 본인이 못 고침** | 중 | 중 | 설정 1개·의존성 3개·단일 진입점·`doctor` 커맨드·실행 단위 로그 디렉터리. 모든 결정을 ADR에 근거와 함께 기록. 기각한 선택지도 이유와 함께 보존 | 문서 | + +--- + +## 11. 용어집 + +### 11.1 도메인 용어 — 제도 + +| 용어 | 원어 / 약어 | 뜻 | +|---|---|---| +| **DMF** | Drug Master File | 원료의약품 등록 제도. 완제의약품에 쓰이는 원료의 제조·품질 자료를 규제기관에 미리 등록해 두는 제도. 한국은 **KDMF**로 부르기도 한다 | +| **원료의약품** | API (Active Pharmaceutical Ingredient) | 완제의약품의 약효를 내는 주성분. "성분명" 컬럼이 가리키는 것 | +| **완제의약품** | Finished Product / FP | 환자가 실제로 복용·투여하는 최종 제품. 원료 + 부형제 + 제형 | +| **CEP** | Certificate of Suitability (COS) | 유럽(EDQM)의 원료 적합성 인증서. DMF와 목적이 같은 유럽식 제도. `extranet.edqm.eu` 에서 TSV 전량 다운로드 가능 | +| **MF** | 原薬等登録原簿 (Master File) | 일본(PMDA)의 원약등 등록원부. `pmda.go.jp` 에서 xlsx 직접 다운로드 가능(약 5,023행 실측) | +| **ASMF** | Active Substance Master File | 유럽에서 DMF에 해당하는 명칭. CEP와 병존하는 다른 경로 | +| **RA** | Regulatory Affairs | 인허가 업무. 이 리포트의 주 사용자. "내 성분 / 내 제조원 / 경쟁사 / 상태 변화" 4축으로 정보를 본다 | +| **제조소** | Manufacturing Site | 원료를 실제로 만드는 공장. `MNFCTR_NAME` / `MNFCTR_PLACE` | +| **신청인 / 업체명** | Applicant | 등록을 신청한 국내 업체. `ENTP_NAME` | +| **공고** | — | 식약처가 등록 사실을 인터넷 등으로 공개하는 행위. 「의약품 등의 안전에 관한 규칙」 제16조 후단이 근거 | +| **연차보고** | Annual Report | DMF 등록 유지를 위한 정기 보고. 상태 변화의 한 종류 | +| **허여서** | 자료공유허여서 | 등록자가 제3자에게 자료 참조를 허용하는 문서. 등록번호 끝 괄호 `(1)~(9)` 가 이것의 순번 | + +### 11.2 도메인 용어 — 등록번호 + +`DMF_PERMIT_NO` 샘플 `20121228-168-I-169-04` 의 구조. + +| 자리 | 예 | 뜻 | +|---|---|---| +| 1 | `20121228` | **최초 등록일(등록수리일자)** 8자리. **고정된다.** 발급일자와 **44.5%가 다르다** — 반드시 별도 필드로 저장 | +| 2 | `168` | 별표1(대상 원료의약품 목록) **성분 일련번호** | +| 3 | `I` | **시행일 알파벳군** (A~K). 제도 확대 시점마다 부여 | +| 4 | `169` | **접수 순번** | +| 5 | `04` | **동일 성분 일련번호** | +| (괄호) | `(1)`, `(A)`, `(18)` | **변경/갱신 표식.** 괄호가 붙은 건은 전부 `발급일자`가 최초 등록일보다 나중이다 | + +**실측 포맷 분포 (9,084건)** + +| 포맷 | 건수 | 비율 | 예 | +|---|---|---|---| +| 표준 `YYYYMMDD-n-A-n-n` | 3,774 | 41.5% | `20260901-86-D-173-26` | +| 표준 + 괄호 | 2,973 | 32.7% | `20230116-200-I-647-07(A)` | +| **신물질** `수nnnn-n-ND` | 1,882 | 20.7% | `수6580-16-ND(20)` | +| 기타 | 455 | 5.0% | `1962-17-ND`, `20100616-122-G-60-22(1)-A(1)` | + +> **파싱 실패는 치명적이지 않게 설계한다.** 등록번호 문자열 자체가 키이므로, 파싱은 파생 정보(최초 등록일, 대상의약품 구분)를 얻기 위한 부가 작업이다. 실패해도 레코드는 정상 처리하고 파생 필드만 null로 둔다. 상세는 `design/00b-baseline-data-analysis.md` §5, `research/01-dmf-domain-and-sources.md` §3. + +### 11.3 프로젝트 용어 + +| 용어 | 뜻 | +|---|---| +| **run** | 파이프라인 1회 실행. `run_id` 로 식별하고 `runs` 테이블에 상태(SUCCESS/PARTIAL/FAILED/SKIPPED/BLOCKED)를 기록 | +| **스냅샷 (snapshot)** | 특정 실행 시점의 **전량** 레코드 집합. `snapshots` 테이블에 append-only로 영구 축적 | +| **이벤트 (event)** | diff 결과 한 건. `NEW` / `CHANGED` / `WITHDRAWN` 세 종류. `events` 테이블에 append-only | +| **`dmf_key`** | diff의 1차 키. `DMF_PERMIT_NO` 를 정규화한 값. **9,084건 실측에서 중복이 0이었으므로** 사실상 원문 그대로다 | +| **발급일자 (`DMF_PERMIT_DATE`)** | **최초 등록일이 아니라 최종 갱신일.** 변경이 있을 때마다 움직인다. 이 프로젝트의 **변경 판정 1차 신호** | +| **최초 등록일** | 등록번호 앞 8자리에서 파생. 고정 값. 발급일자와 짝을 이뤄야 "언제 등록돼 언제 마지막으로 바뀌었나"가 나온다 | +| **기준선 (baseline)** | 프로토타입 `DMF_현황.xlsx` 의 9,084건 / 2026-09-02 스냅샷. 첫 diff의 비교 대상 | +| **`content_hash`** | 비교 대상 필드를 정규화·직렬화해 만든 해시. **변경 판정의 실체** | +| **무결성 게이트 (integrity gate)** | diff 수행 전 통과해야 하는 5종 검사. 하나라도 실패하면 **스냅샷 저장도 diff도 하지 않는다** | +| **스테일 폴백 (stale fallback)** | 수집 실패 시 마지막 성공 스냅샷으로 리포트를 만드는 것. 대시보드에 경고 배너가 함께 뜬다 | +| **enrichment** | `agy` 가 생성한 AI 산출물. `events` 와 **물리적으로 분리된 테이블**에 저장돼 리포트가 이것 없이도 완결된다 | +| **서킷 브레이커 (circuit breaker)** | 3상태(CLOSED/OPEN/HALF_OPEN). 소스와 `agy` 에 각각 적용. 3회 연속 실패 시 24시간 호출 자체를 건너뛴다 | +| **체크포인트 (checkpoint)** | 스테이지별 성공 기록. 재시작 시 성공한 스테이지를 건너뛰고 재개 | +| **idempotency 가드** | 오늘 이미 SUCCESS로 끝난 run이 있으면 즉시 종료 0. `--force` 로만 무시 | +| **heartbeat** | `state\heartbeat.json`. **성공/부분성공일 때만** 갱신 = dead-man switch. 워치독의 유일한 판단 근거 | +| **워치독 (watchdog)** | heartbeat 나이가 120분을 넘으면 CRITICAL을 올리는 판정 로직. 알림 에이전트 안에서 돈다 | +| **알림 의도 (alert intent)** | 배치가 `alerts` 테이블에 기록하는 것. **표시가 아니라 의도**다 — 배치는 UI를 절대 띄우지 않는다 | +| **notify-pump** | 로그온 세션에서 15분마다 도는 작업. 워치독 판정 + 알림 표시 + 복구 GUI 기동 | +| **온보딩 = 복구** | 설치 마법사와 오류 복구 창이 **같은 컴포넌트**. `checks.py` 하나를 CLI `doctor` 와 GUI `onboard` 가 렌더링할 뿐 | +| **S4U** | Task Scheduler의 "사용자가 로그온했는지와 무관하게 실행". **데스크톱이 없어 창이 뜨지 않는다** — 이것이 알림 분리 설계의 원인 | +| **워치리스트 (watchlist)** | 관심 성분·업체 목록. 매칭되면 리포트에 별도 시트로 강조. **목록이 비면 시트를 만들지 않는다** | +| **4요소 알림** | 모든 알림 문구가 담아야 할 것 — **무엇이 / 왜 / 어떻게 고치는지 / 다음 행동(버튼)** | +| **ADR** | Architecture Decision Record. 되돌리기 어려운 결정 하나 = 표의 한 행. `design/01-architecture.md` §1에 25개 | +| **SSOT** | Single Source of Truth. 한 사실은 한 문서에만 산다. 다른 문서는 링크로 참조하고 복사하지 않는다 | + +### 11.4 기술 용어 + +| 용어 | 뜻 | +|---|---| +| **`agy`** | Google Antigravity CLI. `%LOCALAPPDATA%\agy\bin\agy.exe`. headless는 `-p --output-format json` | +| **JSON 봉투 (envelope)** | `agy` 의 출력 구조 — `status` / `response` / `usage` / `error`. `structured_output` 은 **실측에서 채워지지 않았다** | +| **DPAPI** | Windows Data Protection API. `CryptProtectData` / `CryptUnprotectData`. **같은 사용자 계정에서만** 복호화된다 | +| **Decoding 키 / Encoding 키** | 공공데이터포털 인증키 2종. 라이브러리가 자동 인코딩하면 **Decoding 키**, 직접 URL을 조립하면 Encoding 키. **혼동이 이 API의 1위 실패 원인** | +| **`Retry-After`** | HTTP 429/503 응답 헤더. 이 프로젝트는 자체 백오프보다 **이 값을 절대 우선**한다 | +| **full jitter** | 백오프 지연을 `[0, base*2^n]` 범위에서 균등 랜덤 추출. 동시 재시도 몰림 방지 | +| **`VACUUM INTO`** | SQLite 내장 명령. 일관된 스냅샷 백업본을 다른 파일로 만든다. 이 프로젝트에서는 **백업본이 곧 마이그레이션 롤백 경로** | +| **WAL** | Write-Ahead Logging. SQLite 저널 모드. 읽기와 쓰기가 서로를 막지 않는다 | +| **원자적 교체 (atomic replace)** | 임시 파일에 완전히 쓴 뒤 `os.replace` 로 바꿔치기. 중간 상태의 깨진 파일이 보이지 않는다 | +| **Okabe-Ito 팔레트** | 색각이상(색맹) 안전 8색 팔레트. 이 프로젝트의 고정 팔레트 | +| **`pythonw.exe`** | 콘솔 창을 만들지 않는 Python 실행 파일. GUI 기동에 사용. `.bat`/`.cmd` 는 반드시 콘솔을 띄우므로 사용자에게 노출하지 않는다 | + +--- + +## 12. 다음 액션 + +지금 당장 무엇부터 하면 되는지. **위에서부터 순서대로.** + +### ① 기존 프로토타입의 자산을 회수한다 (사람, 10분) — **가장 먼저** + +프로토타입이 이미 API 수집에 성공했다. 즉 **serviceKey와 수집 스크립트가 이미 어딘가에 있다.** 새로 만들기 전에 이것부터 찾는다. + +- [ ] **`DMF_현황.xlsx` 를 만든 수집 스크립트는 어디 있는가?** → 있으면 재사용·개선의 출발점 +- [ ] **serviceKey는 어디에 저장돼 있는가?** → 발급을 다시 할 필요가 없다 +- [ ] `DMF_현황.xlsx` 원본을 프로젝트로 복사 → **2026-09-02 기준선 스냅샷**으로 임포트할 원자료 +- [ ] 이 파일을 본인이 만들었는가, 다른 사람이 만들었는가? → 유지보수 책임 소재 + +키를 못 찾았을 때만 ②로 간다. 찾았다면 ③으로 건너뛴다. + +### ② serviceKey를 발급받는다 (사람, 10분) — **①에서 못 찾았을 때만** + +1. https://www.data.go.kr 회원가입·로그인 +2. https://www.data.go.kr/data/15057075/openapi.do 접속 → **활용신청** +3. **자동승인**이므로 즉시 승인된다 (개발계정, 일 10,000건) +4. 마이페이지 → 오픈API → 개발계정에서 **일반 인증키(Decoding)** 확보 +5. 아래 명령으로 **즉시 검증하고 `totalCount` 를 기록**한다 + +```powershell +$key = Read-Host "Decoding 인증키" +$url = "https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01" +$resp = Invoke-RestMethod -Uri $url -Body @{ + serviceKey = $key + pageNo = 1 + numOfRows = 1 + type = 'json' +} +$resp | ConvertTo-Json -Depth 6 +``` + +기대: `resultCode` 가 `00`, `totalCount` 가 **9,084 근처**(기준선 실측값, 2026-09-02). 크게 다르면 그 자체가 조사 대상이다. **이 숫자를 `design/00-DATA-SOURCE-DECISION.md` 부록 B에 기록하라.** + +> ⚠️ 키를 문서·로그·저장소에 남기지 마라. 최종적으로는 온보딩 GUI가 DPAPI로 암호화 저장한다. + +### ③ 사용자에게 확인받는다 (사람, 15분) + +설계에 직접 영향을 주는 것들이다. 답이 없으면 기본값으로 진행하되, 나중에 재작업이 생긴다. + +- [ ] **PC가 밤에 꺼지는가? 절전 모드인가?** → `WakeToRun` 설정과 AtStartup 캐치업 전략 결정 +- [ ] **워치리스트(관심 성분·업체)가 필요한가? 초기 목록은?** → 시트 ⑥ 생성 여부. 실측 상위 성분(히알루론산나트륨 104, 메트포르민염산염 97…)·상위 업체((주)삼오제약 392, (주)파마피아 378…)를 후보로 제시하면 답하기 쉽다 +- [ ] **리포트를 몇 명이 보는가? 공유 방식은?** (공유 폴더 / 이메일 / 수동) → `report.output_dir` 과 알림 확장 여부 +- [ ] **백업을 어느 드라이브에 둘 것인가?** → `backup.dir`. 같은 디스크면 디스크 장애를 못 막는다 +- [ ] **`신규` 시트의 기대 동작이 "당일 신규만"인가 "최근 N일"인가?** → 프로토타입이 남긴 미확정 항목 +- [ ] **`갱신이력`에 추가로 기록하고 싶은 항목은?** (변경건수·취하건수·소요시간·오류) → 「메타」 시트 스펙 +- [ ] **과거 데이터가 필요한가?** → 2026-09-02 이전은 **불가능하다.** 다만 프로토타입의 9,084건이 그날의 기준선으로 남아 있어 **거기서부터는 이어진다** + +### ④ M0을 착수한다 (개발, 1~2일) + +`design/01-architecture.md` §2의 디렉터리 트리를 **그대로** 만들고, §9 M0의 산출물 목록을 채운다. 완료 판정은 이 문서 §9 M0의 체크박스 12개다. + +**첫 커밋 전에 반드시**: `.gitignore` 에 `.venv/ data/ logs/ reports/ state/ backup/ config.local.toml *oauth-token*` 를 넣는다. 프로젝트 루트는 현재 git 저장소가 아니므로 `git init` 도 함께 한다. + +### ⑤ M1 첫 수집 직후 잔여 실측을 기록한다 (개발, 30분) + +기준선 분석이 `totalCount`(9,084)와 등록번호 중복(0)을 이미 해소했다. **남은 것만** 첫 수집 성공 자리에서 확인해 각 SSOT를 갱신한다. + +- [ ] `numOfRows` 실제 최대값 (명세 3자리 → 999 추정) → `page_size` 결정 +- [ ] `type=json` 실제 동작 여부 (실패 시 XML 폴백이 기본이 된다) +- [ ] 일 10,000건 한도가 "호출 수"인가 "레코드 수"인가 +- [ ] 제조국가명 결측 5건의 등록번호와 원인 +- [ ] "기타" 포맷 455건의 하위 패턴 분류 + +### ⑥ 이틀 연속 돌려 취하 가설을 검증한다 (개발, 2일) — **M1의 진짜 관문** + +M1 완료 판정의 실체이자, 이 프로젝트에서 **아직 증명되지 않은 유일한 핵심 전제**를 확정하는 단계다. + +- [ ] 둘째 날 diff가 실제로 나온다 (기준선을 임포트했다면 첫날부터) +- [ ] **API 응답에서 실제로 사라지는 레코드가 관찰된다** → 취하 판정 성립 (D2 확정) +- [ ] 웹 9,840 − API 9,084 = 756건 표본을 웹 화면과 대조해 취하·취소 건임을 확인 +- [ ] `발급일자`가 실제로 움직이는 레코드가 관찰된다 → 변경 판정 성립 (D3 확정) +- [ ] 게이트 5종이 정상 통과한다 +- [ ] `data\raw\` 에 원문이 날짜별로 쌓인다 +- [ ] 실행 시간이 `ExecutionTimeLimit` 30분의 절반 이내다 + +**가설이 틀리면** 취하를 "목록에서 사라짐"이라는 약한 표현으로 낮추고, 사람 확인 링크를 전면에 세운다. 게이트 3은 어느 쪽이든 유지한다. + +--- + +## 부록. 미해결 / 실측 필요 + +이 문서가 남긴 것. 해결되면 본문 또는 해당 SSOT로 옮기고 여기서 지운다. + +### 이미 해소된 것 (기록만 — 다시 조사하지 말 것) + +기준선 분석(`design/00b`)이 답한 항목들이다. + +| 항목 | 답 | +|---|---| +| 전체 `totalCount` 는? | ✅ **9,084건** (2026-09-02) | +| `DMF_PERMIT_NO` 중복이 있는가? | ✅ **0건.** 완전한 자연 키 | +| API로 변경 탐지가 되는가? | ✅ **된다.** `발급일자`가 최종 갱신일로 움직인다 (44.5% 불일치) | +| API로 취하 탐지가 되는가? | 🟡 **강한 정황.** 웹 9,840 vs API 9,084 = 756건 차이. 연속 관측으로 확정 필요 | +| `type=json` 이 동작하는가? | 🟡 프로토타입이 수집에 성공했으므로 **API 자체는 동작 확인.** json 포맷 여부는 미확정 | +| 데이터 규모·분포는? | ✅ 성분 1,539 / 업체 438 / 제조소 3,051 / 국가 49 / 2003-04-18~2026-09-01 | + +### 사용자 확인 필요 + +- [ ] **`DMF_현황.xlsx` 를 만든 수집 스크립트는 어디 있는가?** → 재사용·개선 출발점 (§12 ①) +- [ ] **serviceKey가 이미 발급돼 있는가? 어디에 저장돼 있는가?** → 프로토타입이 성공했으므로 존재할 것 +- [ ] 이 파일을 본인이 만들었는가, 다른 사람이 만들었는가 (카카오톡 전달 경위) +- [ ] `신규` 시트의 기대 동작이 "당일 신규만"인가 "최근 N일"인가 +- [ ] `갱신이력`에 추가로 기록하고 싶은 항목 (변경건수·취하건수·소요시간·오류) +- [ ] PC가 밤에 꺼지는가 / 절전 모드인가 → `WakeToRun` 결정 +- [ ] 워치리스트 초기 목록 (관심 성분·업체) +- [ ] 리포트 수신자 수와 공유 방식 → 이메일·웹훅 알림 필요 여부 +- [ ] 백업 드라이브 경로 (`backup.dir`) +- [ ] 리포트 보관 기간·위치 (`report.retain_days` 기본 365가 적절한가) +- [ ] 해외 소스(FDA/EDQM/PMDA)를 언제 붙일 계획인가 → 현재는 명시적 비목표 + +### M1에서 실측 — 데이터 + +- [ ] **API 응답에서 실제로 소멸하는 레코드 관찰** (취하 판정 확정 — 이 프로젝트 최대 미검증 전제) +- [ ] 756건 차이가 정말 취하·취소 건인지 표본 대조 +- [ ] `발급일자`가 실제로 움직이는 레코드 관찰 (변경 판정 확정) +- [ ] 변경 시 등록번호의 괄호가 바뀌는가, 번호는 그대로이고 발급일자만 바뀌는가 +- [ ] 제조국가명 결측 5건의 등록번호와 원인 +- [ ] "기타" 포맷 455건의 하위 패턴 분류 +- [ ] 연차보고 시 `발급일자`가 갱신되는가 (**1~2월에 관측** — 연차보고는 1월 말에 몰린다) + +### M1에서 실측 — API + +- [ ] `numOfRows` 실제 최대값 (명세 3자리 → 999 추정) +- [ ] `type=json` 실제 동작 여부 (실패 시 XML 폴백이 기본이 된다) +- [ ] 일 10,000건 한도의 카운트 단위 (호출 수 vs 레코드 수) +- [ ] `entp_name` / `ingr_kor_name` 검색 파라미터가 부분 일치인가 완전 일치인가 +- [ ] 데이터 실제 갱신 주기 (2~4주 관측 후 스케줄 재검토) +- [ ] 참고문서 `IROS_76_원료의약품(DMF)현황_v1.1.docx` 에 명세 외 정보가 있는가 + +### M2에서 실측 + +- [ ] **S4U 작업에서 tkinter 창이 정말 안 뜨는가** — ADR-10·11의 전제. 떠도 설계는 유효하지만, 안 뜨는 것이 확인되면 알림 분리의 가치가 증명된다 +- [ ] `StartWhenAvailable` 캐치백 실제 동작 시각 (부팅 후 몇 분에 도는가) + +### M4에서 실측 + +- [ ] `agy` 쿼터 소진 시 정확한 `error` 문자열 → `classify_error` 의 QUOTA 패턴 +- [ ] `agy` OAuth 만료 시 정확한 `error` 문자열 → `classify_error` 의 AUTH 패턴 +- [ ] 등록된 MCP 서버가 배치 실행 지연을 유발하는가 +- [ ] `--add-dir` 로 워크스페이스를 좁히면 첫 호출 28k 토큰이 줄어드는가 + +### 문서 간 정합성 확인 필요 + +- [ ] **시트 ⑦의 이름이 두 문서에서 다르다.** `research/07` §8.9는 `스냅샷·로그`, `design/01-architecture.md` §2의 모듈명은 `s06_trend.py`(일자별 건수 시계열)다. 이 문서는 탭 이름을 `추이` 로 적었다. **`design/03-xlsx-report-spec.md`(M3)에서 최종 확정**하고 세 문서를 일치시킬 것 +- [ ] **워크북 파일명이 두 문서에서 다르다.** `research/07` §8.0은 `DMF_Daily_YYYYMMDD.xlsx`, `design/01-architecture.md` §6은 `report.filename_pattern = "DMF_리포트_{date}.xlsx"` 다. **설정 파일 값(아키텍처 SSOT)이 우선**하나, `research/07` 에 각주를 달아둘 것 +- [ ] **`research/01` §1의 "API 단독으로는 변경·취하 탐지 불가" 결론이 `design/00b` 실측으로 반증됐다.** 그 주장은 웹 화면의 `최종변경일자`·`취소/취하구분` 컬럼이 있어야만 탐지가 가능하다는 전제에 서 있었으나, 실제로는 **`발급일자`의 이동과 레코드 소멸이 같은 정보를 담고 있다.** `research/01` §1에 정정 각주를 달아 6개월 뒤 혼동을 막을 것 (기준선 분석 부록 B가 같은 항목을 지적) +- [ ] **`research/07` 의 시트 컬럼 스펙을 API 7필드 + 파생 필드 기준으로 재작성**해야 한다(기준선 분석 부록 B). 현재 스펙 일부가 웹 화면 15컬럼을 전제로 쓰여 있다 +- [ ] **`design/01-architecture.md` 의 `normalize.py` 계약이 `dmf_key` 중복 시 접미사 로직을 전제**하나, 실측 중복이 0이므로 이 로직은 방어 코드로만 남는다. M1에서 계약 문구를 조정할 것 +- [ ] `research/05-ai-cli-headless-comparison.md.bak` 백업 파일이 남아 있다. 정본 확정 후 삭제할 것 +- [ ] `design/00-DATA-SOURCE-DECISION.md` §9가 `.env` 저장을 안내하나, ADR-13이 **DPAPI + GUI 입력**으로 확정했다. 해당 절에 "최종 저장은 DPAPI, `.env` 는 개발 중 임시 수단" 각주를 달 것 +- [ ] `docs/README.md` 가 `ops/03-api-usage-policy.md` 를 지도에 올려두었으나 파일이 아직 없다(M1 작성 예정). 작성 전까지는 지도에 ⏳ 표시가 필요하다 + +--- + +*이 문서는 프로젝트의 관문이다. 목표·성공 기준·로드맵·리스크가 바뀌면 여기를 먼저 고치고, 상세는 각 SSOT로 내려보낸다. 새 결정을 여기서 만들지 않는다.* diff --git a/docs/00-REQUIREMENTS.md b/docs/00-REQUIREMENTS.md new file mode 100644 index 0000000..8088289 --- /dev/null +++ b/docs/00-REQUIREMENTS.md @@ -0,0 +1,247 @@ +# DMF Crawler — 요구사항 정본 + +> **이 문서의 역할**: 사용자가 제시한 모든 요구를 한 곳에 모은 **요구사항 SSOT**. 설계·구현은 이 문서를 만족해야 하고, 이 문서에 없는 기능은 만들지 않는다. 새 요구가 나오면 여기에 먼저 추가한다. + +**최종 갱신**: 2026-09-03 +**요구 출처**: 대화 세션 (2026-09-02~2026-09-03) + +--- + +## 0. 한눈에 보기 + +- 매일 **06:00** 자동 실행. **재부팅해도 살아남는다.** +- 데이터는 인증키가 있을 때 **식약처 공식 Open API** 로 받는다. 인증키가 없거나 사용자가 `browser` 모드를 고르면 **AGY가 headful/visible Chrome 으로 의약품안전나라 xlsx 를 다운로드**한다. +- **공공데이터포털 인증키는 선택**이다. 키가 없어도 기존 자료 리포트 생성·온보딩·스케줄 설치는 막지 않는다. 키가 없으면 최신 수집은 AGY headful 브라우저 경로를 먼저 시도하고, 실패하면 마지막 성공 자료로 리포트를 만든다. +- 결과는 **탭별로 연동된, 디자인이 예쁜 xlsx**. 의약 정보를 **한눈에** 볼 수 있어야 한다. +- `agy` 사용은 두 갈래다. **수집용 AGY는 headful/visible Chrome** 을 조작하고, **AI 요약은 선택 기능**으로 사용자가 켠 경우에만 headless/non-interactive 로 호출한다. **Google 로그인은 선택**이며 최초 실행/기본 온보딩에서 먼저 요구하지 않는다. +- **쓰는 사람은 개발자가 아니다.** 더블클릭 한 번으로 설치·설정이 끝나야 한다. +- **문제가 생기면 창이 떠서 무엇이 왜 안 됐고 어떻게 고치는지 알려준다.** 조용히 실패하지 않는다. +- 온보딩 마법사와 오류 복구 창은 **같은 컴포넌트**다. +- **이 프로젝트는 스크래치가 아니다.** 이미 API 로 수집해 xlsx 를 만드는 프로토타입이 돌고 있었고(`DMF_현황.xlsx`, 9,084건), 이 작업은 그것의 개선이다. 기준선 분석은 [`design/00b-baseline-data-analysis.md`](./design/00b-baseline-data-analysis.md). + +--- + +## 1. 기능 요구사항 + +### R1. 데이터 수집 + +| ID | 요구 | 완료 판정 기준 | 상태 | +|---|---|---|---| +| R1.1 | 식약처 DMF(원료의약품 등록) 데이터를 매일 수집한다 | 매일 전량 스냅샷이 저장소에 적재됨 | 설계 완료 | +| R1.2 | 인증키가 있으면 공식 Open API 를 우선 소스로 쓴다 | API 키가 있으면 API 전량 수집이 동작함 | ✅ 확정 | +| R1.3 | 인증키가 없거나 사용자가 `browser` 모드를 고르면 AGY가 **headful/visible Chrome** 으로 의약품안전나라 xlsx 를 다운로드한다 | `source.mode=auto` + 키 없음 또는 `source.mode=browser` 에서 AGY headful 수집 경로가 실행됨. 2026-09-03 수동 smoke 9,816건 성공 | ✅ 구현+수동 smoke | +| R1.4 | headful 수집은 차단 우회·스텔스·curl/headless 직접 호출을 쓰지 않는다 | AGY 프롬프트/호출 계약에 visible Chrome, chrome-devtools MCP, curl/headless 금지가 들어감 | ✅ 구현 | +| R1.5 | 수집 완결성을 검증한다 | API 는 `totalCount`, headful xlsx 는 다운로드 완료·zip/XML·행 수 검증 통과 후에만 diff 수행 | ✅ 구현 | + +> **주의**: 기존 설계는 공식 API 단일 소스였으나, 사용자 요구에 따라 AGY headful 브라우저 xlsx 수집 경로를 복구한다. 봇 차단은 실측상 강하지 않았지만, headful 방식이 차단 가능성을 0으로 보장하지는 않는다. 차단 우회·스텔스 기법은 구현하지 않는다. + +### R2. 변경 탐지 + +| ID | 요구 | 완료 판정 기준 | +|---|---|---| +| R2.1 | 전일 대비 **신규 / 변경 / 취하** 를 탐지한다 | 세 유형이 각각 별도 목록으로 산출됨 | +| R2.2 | 불완전 수집 시 **취하 오판을 하지 않는다** | 안전장치 5개 조건을 모두 통과해야 diff 수행 | +| R2.3 | 성분명·업체명 표기 흔들림을 정규화해 동일 레코드를 오탐하지 않는다 | 정규화 규칙 통과 후 비교 | + +### R3. 리포트 (xlsx) + +| ID | 요구 | 완료 판정 기준 | +|---|---|---| +| R3.1 | **탭(시트)별로 서로 연동된** 워크북을 생성한다 | 목차↔각 시트 하이퍼링크 양방향 동작 | +| R3.2 | **디자인이 예쁘다** | 색 팔레트·폰트·여백이 문서 토큰과 일치, 기본 서식 잔재 없음 | +| R3.3 | 의약 정보를 **한눈에** 파악할 수 있다 | 대시보드 시트에서 5초 안에 오늘의 상황 파악 가능 | +| R3.4 | 시각화가 들어간다 | KPI 타일, 추이 차트, 스파크라인, 조건부 서식 | +| R3.5 | 사용자가 파일을 열어둔 상태에서도 배치가 실패하지 않는다 | 원자적 교체 또는 대체 파일명 폴백 | + +### R4. AI 통합 (`agy`) + +| ID | 요구 | 완료 판정 기준 | +|---|---|---| +| R4.1 | **Google Antigravity CLI (`agy`)** 를 쓴다. 다른 CLI 가 아니다 | 수집용/요약용 모두 `agy -p --output-format json` 호출 | +| R4.2 | 수집용 `agy` 는 headful/visible Chrome 을 조작한다 | `source.mode=browser` 또는 auto+키 없음에서 chrome-devtools MCP 기반 프롬프트 사용. 권한은 `mcp(chrome-devtools/*)`, `execute_url(nedrug.mfds.go.kr)`만 허용 | +| R4.3 | AI 요약을 켠 경우 headless / non-interactive 로 동작한다 | 사용자가 AI 요약을 활성화한 뒤 사람 개입 없이 배치에서 실행 | +| R4.4 | `agy` 가 없으면 **사용자에게 먼저 물어본 뒤** 부트스트랩·설치한다 | 수집용 browser 모드 또는 AI 요약 사용 선택 시 설치 진행, 거절/나중에는 기존 자료 리포트 생성 계속 | +| R4.5 | AI/AGY 가 실패해도 **리포트는 생성된다** | `agy` 강제 실패 상태에서도 마지막 성공 자료 또는 API 자료로 xlsx 산출 | +| R4.6 | AI 산출물은 실무자용 한국어 요약·해석이다 | 변경 브리핑, 이상 해석, 정규화 보조 | + +### R5. 스케줄링과 내구성 + +| ID | 요구 | 완료 판정 기준 | +|---|---|---| +| R5.1 | 매일 **06:00** 에 실행된다 | 7일 연속 06:00±5분 실행 기록 | +| R5.2 | **PC 를 재부팅해도** 스케줄이 유지되고 자동 실행된다 | 재부팅 후 다음 06:00 정상 실행 | +| R5.3 | 실행 시각에 PC 가 꺼져 있었다면 켜진 뒤 **가능한 한 빨리 실행**한다 | 놓친 작업 실행 옵션 활성 | +| R5.4 | 배치가 죽어도 스스로 재시도한다 | 실패 시 재시작 정책 동작 | +| R5.5 | 중복 실행되지 않는다 | 동시 기동 시 후발 인스턴스 종료 | +| R5.6 | 워치독이 배치 미실행을 감지한다 | heartbeat 미갱신 시 경보 | + +### R6. 온보딩 — 비개발자용 설치·설정 + +> **전제**: 이 시스템을 쓰는 사람은 개발자가 아니다. API 키가 뭔지, 환경변수가 뭔지 모른다. + +| ID | 요구 | 완료 판정 기준 | +|---|---|---| +| R6.1 | **더블클릭 한 번**으로 실행되는 파일이 있다 | 탐색기에서 더블클릭 시 GUI 기동 | +| R6.2 | 실행하면 **Windows 다이얼로그(GUI)** 가 뜬다. 콘솔 명령을 치게 하지 않는다 | 검은 콘솔 창이 보이지 않음 | +| R6.3 | **API 키가 없으면 다이얼로그에서 입력**받고 안전하게 저장한다 | 키 미설정 상태에서 입력→저장→검증 완료 | +| R6.4 | API 키 **발급 페이지로 가는 버튼**이 있다 | 클릭 시 브라우저로 발급 페이지 열림 | +| R6.5 | 입력한 키를 **즉시 실제 API 호출로 검증**한다 | 잘못된 키는 저장되지 않고 안내 표시 | +| R6.6 | **AI 요약 사용 여부를 먼저 묻고**, 사용자가 켠 경우에만 `agy` 설치를 진행한다 (진행률 표시) | Google 로그인을 먼저 요구하지 않는다. 거절/나중에는 WARN 없이 리포트 경로 진행 | +| R6.7 | **Google 로그인은 선택**이다. AI 요약 또는 headful 브라우저 수집을 사용할 때만 로그인/AGY 준비를 유도한다 | 공식 API 키가 있거나 기존 성공 자료만으로 리포트 생성할 때는 Google 로그인 없이 진행 가능 | +| R6.8 | 준비가 끝나면 **06:00 작업 등록**까지 제안한다 | 버튼 클릭으로 스케줄 등록 완료 | +| R6.9 | 이미 다 준비된 상태면 **간단한 화면 + "지금 실행" 버튼** | 재실행 시 체크 통과 화면 표시 | + +### R7. 오류 알림과 복구 안내 + +> **핵심**: 온보딩이 끝난 뒤에도 문제는 생긴다. OAuth 로그인이 풀리고, API 키가 만료되고, 네트워크가 끊긴다. 그때 **강제로 창이 떠서** 사용자에게 알려야 한다. + +| ID | 요구 | 완료 판정 기준 | +|---|---|---| +| R7.1 | 배치가 실패하면 **Windows 알림**이 뜬다 | 실패 유발 시 알림 표시 확인 | +| R7.2 | 심각한 오류는 **강제로 창(모달 다이얼로그)** 이 뜬다. 토스트만으로 끝내지 않는다 | OAuth 만료·API 키 무효 상황에서 창 표시 | +| R7.3 | 알림은 문제·원인·해결·다음 행동을 담되, 화면 문구는 자연스러운 한국어로 쓴다 | `무엇:/왜:/어떻게:/다음:` 같은 AI식 라벨 없이도 사용자가 지금 할 일을 이해함 | +| R7.4 | **`agy` OAuth 로그인이 풀린 경우**를 감지하고 재로그인을 유도한다 | 토큰 무효화 후 실행 시 로그인 창 유도 | +| R7.5 | **API 키가 유효하지 않게 된 경우**를 감지하고 재입력을 유도한다 | 잘못된 키로 실행 시 입력 창 유도 | +| R7.6 | 알림 창에서 **바로 고칠 수 있다**. 별도 문서를 찾게 하지 않는다 | 창의 버튼으로 복구 완료 가능 | +| R7.7 | **막다른 골목이 없다.** 모든 오류 화면은 다음 행동을 제시한다 | 모든 오류 경로에 액션 버튼 존재 | +| R7.8 | 알림이 폭주하지 않는다 | 동일 오류 반복 시 쿨다운 적용 | +| R7.9 | 서비스/배치가 내려가면 그 사실 자체를 알린다 | 워치독이 미실행 감지 시 경보 | + +### R8. 운영 편의 + +| ID | 요구 | 완료 판정 기준 | +|---|---|---| +| R8.1 | 로그가 남는다 (실행 ID, 시작·종료, 건수, 소요, 오류, 토큰 사용량) | 매 실행 로그 파일 생성 | +| R8.2 | 수동 재실행이 쉽다 | GUI 버튼 또는 단일 명령 | +| R8.3 | 특정 날짜 백필이 가능하다 | 과거 데이터 재처리 명령 동작 | +| R8.4 | 새 PC 에 설치하는 절차가 문서화돼 있다 | 문서만 보고 설치 완료 가능 | + +--- + +## 2. 비기능 요구사항 + +| ID | 요구 | 기준 | +|---|---|---| +| N1 | 법적 안전성 | robots.txt 준수, 공식 API 이용약관 준수, 출처 표시 | +| N2 | 무인 운영 | 사람 개입 없이 30일 연속 동작 | +| N3 | 실패 가시성 | 실패를 사용자가 **24시간 안에** 인지 | +| N4 | 복구 용이성 | 대부분의 장애를 **5분 안에** GUI 로 복구 | +| N5 | 의존성 최소 | 브라우저 자동화·무거운 프레임워크 배제 | +| N6 | 이식성 | 다른 Windows PC 로 폴더 복사 + 온보딩으로 이전 가능 | +| N7 | 보안 | API 키·OAuth 토큰을 로그·저장소·문서에 남기지 않음 | +| N8 | 유지보수성 | 6개월 뒤 본인이 읽고 고칠 수 있는 구조 | + +--- + +## 3. 비목표 (이번에 하지 않는 것) + +| 항목 | 이유 | +|---|---| +| 웹 대시보드 | xlsx 로 충분. 요구에 없음 | +| 다중 사용자·권한 관리 | 1인 운영 도구 | +| 실시간 모니터링 | 일 1회 배치로 충분 | +| 해외 소스(FDA/EDQM/PMDA) 연동 | 확장 지점으로만 기록. 1차 범위 밖 | +| HTML 크롤링·차단 우회 | 공식 API 로 대체됨 (`design/00-DATA-SOURCE-DECISION.md`) | +| 클라우드 배포 | 로컬 PC 운영이 요구사항 | +| 모바일 알림 | Windows 알림으로 충분. 웹훅은 선택 확장 | + +--- + +## 4. 온보딩 = 복구, 하나의 컴포넌트 + +**설계 원칙**: 온보딩 마법사(R6)와 오류 복구 창(R7)은 **분리된 두 프로그램이 아니라 같은 컴포넌트의 두 진입 모드**다. + +| 진입 모드 | 트리거 | 화면 상태 | +|---|---|---| +| **최초 설정** | 사용자가 `설정.bat` 더블클릭, 설정 파일 없음 | 전체 체크리스트, 미충족 항목 다수 | +| **재설정** | 사용자가 언제든 더블클릭 | 전체 체크리스트, 대부분 통과 | +| **복구** | 배치 실패 → 알림 클릭 또는 자동 기동 | **실패한 항목만 강조된** 체크리스트 + 원인 설명 | +| **점검** | 워치독이 이상 감지 | 진단 결과 화면 | + +이 통합이 주는 이점: +- 사용자는 **화면 하나만 기억하면 된다.** +- 진단 로직이 한 곳에 있어 온보딩과 복구가 어긋나지 않는다. +- 새 전제 조건이 생기면 한 곳만 고치면 온보딩과 복구에 동시 반영된다. + +### 복구 시나리오별 동작 + +| 실패 | 감지 방법 | 알림 등급 | 창을 강제로 띄우는가 | 창에서 할 수 있는 것 | +|---|---|---|---|---| +| `agy` OAuth 로그인 만료 | AI 요약을 켠 상태에서 `agy -p` 응답의 `status`/`error`, 종료 코드 | WARN | 아니오 | AI 요약 끄기, 나중에 로그인, 리포트는 계속 생성 | +| API 키 무효·만료 | API `resultCode` 오류 분기 | CRITICAL | **예** | 키 재입력, 발급 페이지 열기 | +| API 키 미설정 | 저장소에 키 없음 | CRITICAL | **예** | 키 입력 | +| `agy` 미설치·삭제됨 | 바이너리 경로 확인 실패 | CRITICAL | **예** | 재설치 버튼 | +| 일일 트래픽 초과 | API 오류 코드 | WARN | 아니오 (토스트) | 내일 재시도 안내 | +| `agy` 쿼터 소진 | `agy` 오류 응답 | WARN | 아니오 | AI 없이 리포트 생성됨 안내 | +| 네트워크 단절 | 연결 실패 | WARN | 아니오 | 재시도 안내 | +| xlsx 파일 잠김 | 파일 쓰기 실패 | WARN | 아니오 | 대체 파일 경로 안내 | +| 수집 완결성 실패 | 건수 불일치 | WARN | 아니오 | diff 미수행 안내 | +| 배치 미실행 (워치독) | heartbeat 미갱신 | CRITICAL | **예** | 지금 실행, 작업 상태 확인 | +| 연속 3일 실패 | 실행 로그 집계 | CRITICAL | **예** | 진단 결과 표시 | +| 작업 스케줄러 등록 소실 | 작업 조회 실패 | CRITICAL | **예** | 재등록 버튼 | + +> **강제 창 표시의 제약**: 배치가 사용자 세션에서 실행되면 창을 띄울 수 있다. 세션이 잠겨 있거나 로그오프 상태면 창이 보이지 않으므로, **창 표시 + 지속 알림 + 다음 로그온 시 재표시**를 함께 건다. 구현은 `ops/02-failure-alerting.md`. + +--- + +## 5. 요구사항 추적표 + +각 요구가 어느 문서에서 설계되는지. + +| 요구 | 설계 문서 | +|---|---| +| R1 데이터 수집 | `design/00-DATA-SOURCE-DECISION.md`, `design/00b-baseline-data-analysis.md`, `design/01-architecture.md` | +| R2 변경 탐지 | `design/02-data-model.md`, `design/00b-baseline-data-analysis.md` §6 | +| R3 리포트 | `design/03-xlsx-report-spec.md`, `research/06`, `research/07` | +| R4 AI 통합 | `research/05a-agy-cli-ssot.md`, `research/09`, `research/10` | +| R5 스케줄링 | `ops/01-scheduling-and-resilience.md`, `research/08` | +| R6 온보딩 | `design/04-onboarding-wizard.md` | +| R7 오류 알림·복구 | `ops/02-failure-alerting.md`, `design/04-onboarding-wizard.md` | +| R8 운영 편의 | `ops/01-scheduling-and-resilience.md` | +| N1 법적 안전성 | `research/04-anti-bot-and-legal.md`, `ops/03-api-usage-policy.md`, `ops/04-official-data-request-channels.md` | +| N2 무인 운영 | `ops/01-scheduling-and-resilience.md`, `ops/02-failure-alerting.md` | +| N3 실패 가시성 | `ops/02-failure-alerting.md` | +| N4 복구 용이성 | `design/04-onboarding-wizard.md` (온보딩=복구 통합, 4절) | +| N5 의존성 최소 | `design/01-architecture.md` ADR | +| N6 이식성 | `design/04-onboarding-wizard.md`, `ops/01-scheduling-and-resilience.md` | +| N7 보안 | `design/04-onboarding-wizard.md` (시크릿 저장), `research/05a-agy-cli-ssot.md` §4.2 | +| N8 유지보수성 | `design/01-architecture.md` ADR | + +--- + +## 6. 요구 변경 이력 + +| 일자 | 변경 | 사유 | +|---|---|---| +| 2026-09-02 | 초기 요구 수립 (R1~R5) | 프로젝트 시작 | +| 2026-09-02 | AI CLI 를 `agy` 로 확정, 자동 부트스트랩 요구 추가 (R4.1, R4.3) | 사용자 지정 | +| 2026-09-02 | 데이터 소스를 크롤링 → 공식 Open API 로 전환 (R1.2, R1.3) | `robots.txt` 전면 금지 확인 + 공식 API 발견 | +| 2026-09-02 | 비개발자용 온보딩 마법사 요구 추가 (R6 전체) | 사용자가 API 키 개념을 모른다는 전제 | +| 2026-09-02 | 오류 시 강제 창 표시 요구 추가 (R7.2, R7.4, R7.5) | 온보딩 후 발생하는 장애도 안내 필요 | +| 2026-09-02 | 온보딩과 복구를 단일 컴포넌트로 통합하는 원칙 수립 (4절) | 사용자 경험 일관성 | +| 2026-09-02 | **기존 프로토타입 존재 확인.** 스크래치 → 개선 작업으로 성격 변경 | 사용자가 `DMF_현황.xlsx` 제공 | +| 2026-09-02 | R2.1 변경 탐지가 API 7필드로 가능함을 실측 확인 | `발급일자` 가 최종 갱신일로 이동, 웹-API 756건 차이 | +| 2026-09-02 | N2~N8 추적표 행 추가 (누락 보정) | 문서 감사에서 지적 | + +--- + +## 부록. 미확정 / 사용자 확인 필요 + +**기존 프로토타입 관련 (우선순위 높음)** + +- [ ] `DMF_현황.xlsx` 를 만든 **수집 스크립트가 어디에 있는가?** 있다면 재사용·개선의 출발점이 된다. +- [ ] **공식 API 최신 수집을 쓸 것인가?** 공공데이터포털 인증키는 선택이다. 키가 없어도 기존 자료 리포트 생성은 계속되며, 최신 공식 API 수집이 필요할 때만 `serviceKey` 를 등록한다. +- [ ] 이 파일이 카카오톡으로 전달된 경위 — 본인이 만든 것인가, 다른 사람이 만든 것인가. +- [ ] `신규` 시트의 기대 동작이 "당일 신규만"인가 "최근 N일"인가. +- [ ] `갱신이력` 시트에 추가로 기록하고 싶은 항목 (변경건수, 취하건수, 소요시간, 오류). + +**운영 관련** + +- [ ] 리포트를 받아보는 사람이 몇 명인가? 파일 공유 방식은? (공유 폴더, 이메일, 수동) +- [ ] 워치리스트(관심 성분·업체) 가 필요한가? 있다면 초기 목록은? +- [ ] 리포트 보관 기간과 위치는? +- [ ] PC 가 밤에 꺼지는가? 절전 모드인가? (WakeToRun 설정 결정에 필요) +- [ ] 해외 소스(FDA/EDQM/PMDA)를 언제 붙일 계획인가? +- [ ] 이메일·메신저 알림이 추가로 필요한가? + +> 과거 데이터 항목은 해소됐다. 프로토타입이 이미 9,084건을 2026-09-02 기준선으로 적재해 두었으므로, 오늘부터 diff 가 바로 동작한다. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md new file mode 100644 index 0000000..5b61125 --- /dev/null +++ b/docs/HANDOFF.md @@ -0,0 +1,536 @@ +# 핸드오프 — DMF Crawler + +> **다음 세션은 이 문서부터 읽어라.** 지금까지 확정된 것, 실측으로 밝혀진 것, 진행 중인 작업, 다음에 할 일이 전부 여기 있다. + +**작성 시각**: 2026-09-03 +**프로젝트 루트**: `D:\workspace\DMF_Crawler` +**상태**: 구현 진행 중 / 실제 9,084건 기준선 import 완료 / API 키 선택 정책 복구 / xlsx·Windows GUI 디자인 RED 통과 / 온보딩 스크롤·문장 UX 수정 / AGY headful CCBAC03 xlsx 수집 수동 smoke 성공(9,816건) / 전체 79개 테스트 통과 + +**2026-09-03 추가 사용자 지적 — 스크롤·상단 안내문·AGY headful 수집** + +사용자 지적: + +- 온보딩 스크롤창이 실제 항목/텍스트 위에서 휠 스크롤되지 않는다. +- 상단 노란 안내 박스의 문제가 색/박스 자체라기보다 문장이 `무엇/왜/어떻게/다음` 식으로 AI스럽고 한국어 UX 문장으로 부자연스럽다. +- AGY가 사람처럼 headful Chrome 으로 수집해야 한다고 여러 번 말했는데, 현재 구현은 공식 API/기존 DB 중심이고 AGY는 요약용 부가 단계처럼만 동작한다. + +TDD 처리: + +- 추가 RED: + - `test_scroll_frame_mousewheel_scrolls_when_pointer_is_over_content` + - `test_onboarding_status_panel_names_blockers_instead_of_generic_yellow_info_box` + - `test_headful_browser_prompt_contract_forbids_headless_http_and_secrets` + - `test_headful_browser_collect_validates_download_and_returns_raw_records` + - `test_headful_browser_collect_rejects_incomplete_downloads` + - `test_headful_browser_collect_reports_mcp_permission_denial_from_stderr` + - `test_headful_browser_collect_reports_execute_url_permission_denial_from_stderr` + - `test_headful_mcp_allow_rule_is_scoped_and_does_not_use_dangerous_skip` + - `test_headful_browser_prompt_blocks_wrong_review_result_export` + - `test_headful_browser_accepts_public_ccbac03_xlsx_headers` + - `test_source_auto_without_api_key_routes_to_headful_browser` + - `test_source_browser_mode_routes_to_headful_even_when_api_key_exists` + - `test_agy_google_login_is_optional_when_ai_disabled_for_api_mode` + - `test_agy_is_prompted_for_headful_collection_when_auto_has_no_api_key` + - `test_agy_is_prompted_for_forced_browser_mode_even_with_api_key` +- RED 실패 확인: + - 콘텐츠 라벨 위에서 `` 발생 시 `canvas.yview()` 가 이동하지 않음. + - 상단 안내문에 `무엇:`, `왜:`, `어떻게:`, `다음:` 라벨이 실제로 노출됨. + - `dmf_crawler.agy.browser_collect` 모듈 및 `pipeline._fetch_with_headful_browser` 경로가 없음. + - 첫 실제 headful smoke 에서 AGY가 `mcp` permission auto-deny 로 빈 응답을 냄. + - `mcp(chrome-devtools/*)`를 top-level 에만 넣었을 때 실제 AGY shared config 에 매칭되지 않아, `userSettings.globalPermissionGrants.allow` 도 필요함을 확인. + - 다음 smoke 에서 `execute_url` permission auto-deny 발생. + - CCBAC03 실제 xlsx 헤더(`신청인`, `제조국가`, `최초등록일자`, `최종변경일자`)를 기존 API/baseline 헤더(`업체명`, `제조국가명`, `발급일자`)만 요구하던 파서가 거부함. + - 진단 화면이 `agy.enabled=false`만 보고 `auto+API 키 없음` 또는 `browser` 모드에서도 AGY/Google 로그인을 “필요 없음”으로 표시할 수 있었음. + - AGY가 처음에는 같은 파일명(`의약품등심사결과공개.xlsx`)을 wrong export 로 보냈다고 판단했으나, parser alias 보강 후 실제 CCBAC03 데이터 9,816건으로 확인됨. 파일명은 사이트가 generic 하게 붙이는 이름이다. +- 수정: + - `ScrollFrame` 이 canvas 뿐 아니라 내부 텍스트/카드 위의 wheel 이벤트도 처리하도록 `event.widget` 부모 체인을 검사하는 전역 wheel binding 으로 변경. + - 상단 안내문을 자연스러운 한국어 2~3문장으로 교체. 예: `먼저 자료 보관소, 리포트 저장 폴더를 확인해 주세요.` + - `src/dmf_crawler/agy/browser_collect.py` 추가. AGY 프롬프트는 visible/headful Chrome + chrome-devtools MCP 를 명시하고 curl/headless/API 직접 호출과 비밀번호/토큰 prompt 입력을 금지한다. + - `source.mode` 를 `auto | api | browser` 로 확장. `auto` 에서 API 키가 없으면 AGY headful 수집을 시도하고, `browser` 는 키가 있어도 headful 수집을 강제한다. + - AGY headful 다운로드는 `.crdownload/.tmp` 없음, xlsx zip/XML 구조, 행 수, sheet XML 파싱을 통과해야 `FetchResult` 로 인정한다. + - headful 권한 준비 함수 `ensure_headful_mcp_permission()` 추가. 실제 AGY CLI가 읽는 top-level `permissions.allow` 와 `userSettings.globalPermissionGrants.allow` 양쪽에 `mcp(chrome-devtools/*)`, `execute_url(nedrug.mfds.go.kr)`만 좁게 추가한다. `mcp(*)`, `execute_url(*)`, `--dangerously-skip-permissions`는 금지. + - 프롬프트는 ambiguous 버튼 클릭 대신 visible Chrome 에서 `https://nedrug.mfds.go.kr/pbp/CCBAC03/getExcel` 을 열도록 좁혔다. + - `prototype_xlsx.read_rows()` 는 CCBAC03 웹 xlsx 헤더 alias(`신청인`, `제조국가`, `최초등록일자`, `최종변경일자`)를 API 표준 RawRecord 필드로 매핑한다. 날짜는 `최종변경일자`를 우선하고 없으면 `발급일자`, `최초등록일자` 순으로 사용한다. + - `checks.py` 는 AGY 필요 여부를 `agy.enabled`만으로 보지 않고 `source.mode`와 API 키 존재까지 함께 본다. API 모드+AI 요약 꺼짐이면 AGY 불필요, `auto+키 없음`/`browser`면 수집용 AGY 준비를 WARN으로 안내한다. +- 검증: + - `pytest tests -q` → **79 passed** + - 실제 수동 smoke: `./.venv/Scripts/python.exe -m dmf_crawler run --trigger manual --force --skip-agy` → **SUCCESS exit=0** + - 실측 run_id: `run_20260903_130332` + - 다운로드: `data/headful_downloads/run_20260903_130332/의약품등심사결과공개.xlsx` (사이트 generic 파일명) + - 수집/정규화: 9,816건 → 9,816건, persist/report/backup 성공 + - 리포트: `reports/DMF_리포트_2026-09-03.xlsx` (9,903행급) +- 남은 확인: + - 전용 Chrome profile Preferences, CDP 포트 sentinel 은 RED backlog 에 남아 있다. + - 06:00 스케줄러가 실제 로그인 데스크톱 세션에서 headful Chrome 을 안정적으로 띄우는지는 별도 수동 smoke 가 필요하다. + +**2026-09-03 릴리즈 배포본 생성 완료** + +사용자 요청: 카카오톡으로 전달할 수 있게 원드라이브 바탕화면에 압축 배포본과 가이드를 만들어 둘 것. + +산출물: + +- 폴더: `C:\Users\encep\OneDrive\바탕 화면\DMF_Crawler_v0.1.0_20260903` +- zip: `C:\Users\encep\OneDrive\바탕 화면\DMF_Crawler_v0.1.0_20260903.zip` +- SHA256: `C:\Users\encep\OneDrive\바탕 화면\DMF_Crawler_v0.1.0_20260903.sha256.txt` +- 사용자 첫 안내: `00_먼저_읽어주세요.txt` +- 검증 요약: `릴리즈_검증.txt` + +포함: + +- `bootstrap.cmd`, `README.md`, `pyproject.toml`, `src`, `config`, `scripts`, `prompts`, `docs` 일부, fixture 샘플 리포트 1개. + +제외 감사 통과: + +- `.venv`, `data`, `logs`, 실제 `reports`, `backup`, `state`, `tests`, `__pycache__`, `*.pyc`, `*.lnk`, `config.local.toml`, `service_key`, `oauth-token` 없음. + +검증: + +- `pytest tests -q` → 79 passed +- `compileall` → exit 0 +- `dmf_crawler --help` → exit 0 +- 최종 zip contents audit → 필수 파일 누락 없음 / 금지 파일 없음 +- 최종 zip 압축 해제본 `compileall` → exit 0 +- 최종 zip 압축 해제본 `dmf_crawler --help` → exit 0 +- 현재 개발 폴더 `doctor --json` 은 exit 2: Excel 이 `DMF_리포트_2026-09-03.xlsx` 를 열고 있어 `report_writable` CRITICAL. 배포본 결함은 아님. Excel 닫으면 기존처럼 WARN 수준으로 내려갈 것. + +주의: + +- zip 안에는 실제 DB/리포트/로그가 없다. 새 사용자 PC는 압축 해제 후 `bootstrap.cmd` 를 먼저 실행해야 한다. +- fresh PC 에서 `bootstrap.cmd` 전 `doctor` 는 `.venv`가 없어 CRITICAL이 정상이다. +- 인증키 없이 최신 수집을 원하면 AGY 설치/Google 로그인 후 headful Chrome 경로를 사용한다. + +**2026-09-03 사용자 실측 UI 회귀 — RED 고정·수정 완료** + +사용자가 새로 제보한 스크린샷: + +- `C:\Users\encep\AppData\Local\Temp\pi-clipboard-5df73a57-a813-41be-b025-819e48bd73c2.png` + +관찰된 문제: + +- 온보딩/진단 창 하단 footer 버튼들이 **글자 없이 빈 버튼처럼 보임**. 이전에 “하단 버튼 최소 크기” RED를 추가했지만 실제 화면 결함을 충분히 잡지 못했다. +- 일부 버튼이 여전히 **짜부라지거나 clipped** 된다. +- “새 버전” 화면에서 보여야 할 버튼/액션이 **아예 안 보이는 경우**가 있다. +- 현재 테스트 `test_onboarding_footer_buttons_have_readable_minimum_size` 는 단순 `width`/요청 크기만 보므로, 실제 렌더링에서 텍스트가 사라지는 문제를 놓쳤다. + +TDD 처리: + +- 스크린샷 상태를 재현하는 fixture/check 결과를 주입해, `refresh()` 이후 실제 버튼 `text`, `winfo_ismapped()`, `winfo_width/height()`, `winfo_reqwidth/height()`, 창 밖 clipping, scroll viewport overflow 를 검사하도록 RED를 강화했다. +- 추가 RED: + - `test_onboarding_footer_buttons_render_visible_text_after_refresh` + - `test_onboarding_footer_buttons_keep_text_when_required_items_exist` + - `test_onboarding_row_action_buttons_are_not_clipped_inside_scroll_view` + - `test_onboarding_new_grouped_layout_keeps_primary_actions_visible` +- RED 실패 확인: + - footer 버튼 `winfo_ismapped() == 0` 으로 실제 화면에 배치되지 않음. + - 창 요청 높이 `771px` 이 실제 높이 `700px` 을 넘어 footer가 잘림. + - 긴 한글/경로 제목에서 scroll 내부 요청 폭 `1745px` 이 viewport `867px` 을 넘어 overflow. +- 수정: + - `src/dmf_crawler/gui/app.py` 의 shell layout 을 `pack` 누적 방식에서 grid 기반 고정 footer/가변 scroll 구조로 변경. + - `SCROLL_VIEW_HEIGHT=300` 으로 1366×768 노트북 예산 안에서 footer를 보존. + - 긴 행 제목/detail/fix_hint 에 `wraplength` 를 적용해 행 액션 버튼이 viewport 밖으로 밀리지 않게 함. + - CRITICAL 실패가 있으면 footer [지금 실행] 버튼을 disabled 로 유지. +- 검증: + - `pytest tests/test_ux_regressions.py tests/test_gui_design_contracts.py -q` → **14 passed** + - `pytest tests -q` → **65 passed** +- 남은 확인: 실제 Windows tkinter 창을 열어 육안 확인은 아직 하지 않았다. 스크린샷 기반 결함은 자동화 RED로 고정됐다. + +**2026-09-03 API 키 선택 정책 복구** + +- 사용자 지적에 따라 회귀 수정: **공공데이터포털 인증키는 선택**이다. +- 키가 없으면 `preflight`에서 `BLOCKED` 하지 않는다. 공식 API `fetch`만 `SKIPPED` 처리하고 `ctx.stale=True`로 마지막 성공 스냅샷 리포트 경로를 사용한다. +- `doctor`/온보딩에서도 `api_key_present`, `api_key_valid`는 `WARN` 선택 기능이다. 실패해도 [지금 실행]을 막지 않는다. +- GUI 그룹에서 인증키는 `필수 설정`이 아니라 `선택 기능`으로 이동했다. +- 추가/수정 RED: + - `test_run_without_service_key_uses_existing_baseline_instead_of_blocking` + - `test_public_data_api_key_is_optional_in_doctor` + - `test_public_data_api_key_is_grouped_as_optional_in_onboarding` + - `test_public_data_api_key_optional_policy_is_documented_in_ssot` +- 주의: **키 없는 최신 웹/엑셀 수집 소스는 AGY headful Chrome 경로로 구현·수동 smoke 성공했다.** 다만 06:00 스케줄러에서 로그인 데스크톱 세션/Chrome 프로필/9222 CDP 포트가 항상 준비되는지는 아직 별도 RED·수동 smoke 가 필요하다. + +**2026-09-03 디자인 업그레이드 완료** + +- `design.md` 생성: 컨셉은 **감사 가능한 조용한 계기판**. Windows 앱은 `tkinter`/한국어/Fluent식 그룹화, xlsx는 규제 원장/출처/접근성 중심. +- Windows 앱 개선: + - 상단 요약 strip: `필수 N` / `권장 N` / `정상 N`. + - 체크 항목을 `필수 설정` / `선택 기능` / `운영 상태`로 그룹화. + - `공식 API 인증키와 AI 요약은 선택 기능입니다. 없어도 기존 자료 리포트 생성은 막지 않습니다.` 문구를 화면 구조에 포함. + - 각 행에 `StatusBadge` + 텍스트 상태(`통과`/`필수 조치`/`권장 조치`) + 최소 폭 action button 적용. +- xlsx 개선: + - 워크북 문서 속성 추가: title/subject/author/category/keywords/comments/created. + - `00_대시보드!A1`에 스크린리더용 목적문 추가. + - dashboard 내부 링크, 각 데이터 시트의 `← 대시보드` 역링크를 RED로 검사. + - 외부 링크 문구를 `조회`에서 `원문 조회`로 변경. + - 집계 내부 링크 문구를 `원장 보기`, 워치리스트 링크를 `변경분 보기`로 변경. + - 데이터 시트 visible column width cap 검사 추가. `01_오늘변경분` 변경내용 열은 52 → 48로 줄여 긴 값은 wrap 처리. +- 새/강화 테스트: + - `tests/test_gui_design_contracts.py` + - `tests/test_report_e2e_design.py` +- 검증: + - `python -m compileall -q src tests` PASS + - `pytest tests -q` → **58 passed** +- 실제 9,084건 리포트 재생성: + - `reports/DMF_리포트_2026-09-02.xlsx` + - 크기: 1,273,793 bytes + - OOXML 확인: A1 목적문 존재, dashboard internal links 8개, `원문 조회` 포함, core title 포함, visible max column width <= 48.71. + - 주의: `reports/~$DMF_리포트_최신.xlsx` 잠금 파일이 있어 `reports/DMF_리포트_최신.xlsx`는 갱신 실패. Excel에서 최신본을 닫고 재실행해야 최신본 복사가 된다. + +--- + +## 1. 프로젝트가 무엇인가 + +Windows 11 PC 에서 **매일 06:00 에 한국 식약처 원료의약품 등록(DMF) 데이터를 수집**하고, 전일 대비 **신규·변경·취하**를 탐지해, **탭별로 연동된 보기 좋은 xlsx 리포트**를 만든다. + +AI CLI 는 **Google Antigravity CLI (`agy`)** 를 두 역할로 쓴다. 수집용 AGY는 visible/headful Chrome 을 조작하고, 요약용 AGY는 사용자가 AI 요약을 켰을 때만 headless/non-interactive 로 호출한다. Claude Code 가 아니다. +**쓰는 사람은 개발자가 아니다.** 더블클릭 한 번으로 설치·설정이 끝나야 하고, 문제가 생기면 창이 떠서 고치는 법을 알려줘야 한다. + +전체 요구사항: `docs/00-REQUIREMENTS.md` (R1~R8, N1~N8) + +--- + +## 2. 사용자가 확정한 것 (인터뷰 답변) + +| 항목 | 사용자 결정 | +|---|---| +| **PC 상태** | **매일 다르다.** 켜둘 때도 끌 때도 있음 → 트리거 3개(매일 06:00 + 로그온 시 + 놓친 작업 따라잡기), **중복 실행 방지 잠금 필수** | +| **관리자 승격** | **설치 시 1회 허용** | +| **API 키** | **아직 없음.** 발급받은 적 없음. **공공데이터포털 인증키는 선택**이며 없어도 기존 자료 리포트 생성은 막지 않는다. | +| **수집 방식** | `source.mode=auto`: API 키가 있으면 공식 API, 없으면 AGY headful Chrome 으로 CCBAC03 xlsx 다운로드. `source.mode=browser`: 키가 있어도 headful 강제. `source.mode=api`: 공식 API만 사용하고 키 없으면 기존 성공 자료로 폴백. | +| **크롤링 방식** | **agy 가 headful Chrome 을 몰아 사람처럼 접근.** 실제 수동 smoke에서 `CCBAC03/getExcel` 다운로드 및 9,816건 정규화 성공. | +| **브라우저 로그인** | 프로필/세션 유지로 처리한다. 비밀번호를 agy 프롬프트에 넣거나 모델에게 입력시키지 않는다. | +| **리포트 공유** | 본인이 보되 **공유 옵션도 설정 가능해야 함** | +| **리포트 첫 화면** | **대시보드** | +| **워치리스트** | **필요함.** 목록은 나중에 등록 | +| **알림** | **화면 창 + 메신저 둘 다** | + +### 아직 사용자에게 못 받은 답 +- [ ] 의약품안전나라 계정을 갖고 있는가? 로그인 시 익명보다 더 보이는 정보가 있는가? +- [ ] 메신저는 어느 것인가 (디스코드/텔레그램/슬랙/이메일) +- [ ] 워치리스트 초기 목록 +- [ ] `DMF_현황.xlsx` 를 만든 것이 본인인가 타인인가 (카카오톡으로 받음) + +--- + +## 3. 실측으로 밝혀진 결정적 사실 + +**이것들은 전부 실제로 실행해서 확인한 것이다. 추측이 아니다.** + +### 3.1 기존 프로토타입이 이미 있었다 +사용자가 준 `C:\Users\encep\OneDrive\문서\카카오톡 받은 파일\DMF_현황.xlsx` 를 전수 분석했다. 정본: `docs/design/00b-baseline-data-analysis.md` + +- 시트 3개: 전체(9,084건) / 신규(헤더만) / 갱신이력(`2026-09-02, 9084, 0`) +- 컬럼 8개 = **API 7필드 + 최초수집일(파생)**. 즉 이미 공식 API 로 수집하고 있었다. +- **서식이 전혀 없다** — 틀 고정·자동필터·조건부서식·차트가 전부 0. "예쁘게" 요구가 겨냥하는 지점. + +### 3.2 API 7필드로 신규·변경·취하 전부 탐지 가능하다 +| 사실 | 값 | +|---|---| +| API 전체 건수 | **9,084건** | +| 웹 화면 건수 | **9,840건** | +| 차이 | **756건** → API 는 **정상 건만** 반환 → **취하 = 레코드 소멸로 판정 가능** | +| 등록번호 중복 | **0건** → 완전한 자연 키 | +| 등록번호 앞8자리 vs 발급일자 | **44.5% 불일치** → **`발급일자`는 최종 갱신일로 움직인다 = 변경의 직접 신호** | + +예: `20050831-33-A-81-08(18)` 의 발급일자가 `2026-08-18`. 괄호가 변경 차수. + +**놓치는 것**: 연차보고, 변경 사유, 취하 vs 취소 구분. `대상의약품`은 등록번호 포맷에서 파생 가능(`수` 접두어 = 신물질). + +### 3.3 nedrug 은 봇 차단이 없다 +`curl/8.10.1` UA 그대로 **200 OK, HTML 382,506 bytes 수신**. Cloudflare/Akamai/DataDome 없음. Apache + JSESSIONID + Elevisor for J2EE(WAS 모니터링). + +**즉 차단 회피 기술은 원래부터 불필요했다.** 브라우저 방식의 근거는 "블락 방지"가 아니라 robots.txt 에 대한 태도와 로그인 세션 대비다. + +### 3.4 엑셀 다운로드 실측 성공 + 함정 2개 +`POST /pbp/CCBAC03/getExcel` 로 **9,841행 / 1,379,451 bytes 1회 수신 성공**. 요청 2회(세션 GET + 엑셀 POST). + +- ⚠️ **날짜 파라미터를 비우면** 헤더만 든 3,575 bytes 빈 파일이 온다. 조용한 실패. +- ⚠️ **받은 xlsx 를 openpyxl 로 열면 0행으로 읽힌다.** `` 때문. **sheet1.xml 직접 파싱 필수.** + +### 3.5 robots.txt 는 자기모순 +`robots.txt` 는 `User-agent: * / Disallow: /` (26 bytes) 인데, `/bbs/117` HTML 에는 `` 가 있다. + +### 3.6 법적 선 +- **형사**: 대법원 2022.5.12. 2021도1533 — 정보통신망법 §48 등 **전부 무죄 확정**(공개정보 + 보호조치 없음 + 약관상 이용제한은 접근제한 아님) +- **민사**: 야놀자 사건 10억(부정경쟁방지법 성과도용), 잡코리아 v 사람인 2.5억(저작권법 §93) +- **두 민사 판결의 공통 결정요소는 "재배포·경쟁 이용"** +- → **지켜야 할 선: 내부 리포트 전용, 재배포 없음.** 이 선 안이면 리스크 Low + +### 3.7 agy CLI 실측 +정본: `docs/research/05a-agy-cli-ssot.md` (1,079줄) + +- v1.1.22, 경로 `%LOCALAPPDATA%\agy\bin\agy.exe` +- **OAuth 토큰이 키링이 아니라 평문 파일**: `~/.gemini/antigravity-cli/antigravity-oauth-token` → 작업 스케줄러를 **동일 사용자 계정**으로 돌리면 인증 통과. SYSTEM 금지. +- ⚠️ **`--json-schema` 를 신뢰하면 안 된다.** 실측에서 `structured_output` 이 없었고 `response` 에 JSON 이 4회 반복 + 한국어 산문 혼입, 토큰 3배. **자체 파싱·검증 필수.** +- ⚠️ **첫 호출 오버헤드 input 28,317 토큰 / 33.7초.** 호출을 묶어야 한다. +- **chrome-devtools MCP 가 이미 등록·활성화됨** → agy 가 Chrome 제어 가능 +- 배치 체크리스트는 SSOT §16 + +### 3.8 Windows 실행 방식 제약 +- **이 계정(Administrators 미소속)은 S4U 태스크 등록이 거부된다.** Interactive 만 성공. 실측 확인. +- 사용자가 관리자 승격 1회를 허용했으므로 승격하면 S4U 가능한지는 미확인 +- ⚠️ **BOM 없는 UTF-8 `.ps1` 을 Windows PowerShell 5.1 이 CP949 로 오독해 한글이 깨진다.** 실측 재현. **모든 `.ps1` 은 UTF-8 with BOM 필수.** +- 콘솔 창을 원리적으로 없애려면 GUI 서브시스템 호스트(`wscript.exe`/`pythonw.exe`)를 최상위에 둬야 한다 + +### 3.9 공공데이터 API 제약 +- 트래픽 10,000 = **호출 횟수**(레코드 아님). 하루 수십 호출이면 1% 미만. **운영계정 전환·활용사례 등록 영원히 불필요** +- ⚠️ **인증키는 포털 계정당 1개. 재발급하면 기존 키 자동 폐기.** 사용자가 무관한 목적으로 재발급하면 배치가 죽는다 +- ⚠️ **인증키에 사용 기한 있음**(오류코드 31). 24개월설은 미확정 → **하드코딩 금지**, 오류 코드 관측으로 감지 + +--- + +## 4. 확정된 아키텍처 + +정본: `docs/design/01-architecture.md` (1,380줄, ADR 18개) + +**뼈대**: 후보 3안을 독립 생성 → 3개 렌즈(운영 단순성/실패 내성/진화 가능성)로 심사 → SQLite 이벤트 소싱 안을 뼈대로 채택하고 나머지 두 안의 좋은 부분을 이식. + +핵심 결정: +- 저장소: **SQLite 단일 파일 + 이벤트 소싱**(append-only 스냅샷 + 이벤트) +- 의존성 **3개만**: `httpx`, `XlsxWriter`, `jsonschema`. **pandas·openpyxl·PyYAML 기각** +- 집계는 **SQL GROUP BY**로. pandas 안 씀 +- xlsx 는 **XlsxWriter 100%, 매일 새 파일 통째 생성** +- 모든 숫자는 **파이썬이 계산해 값으로** 씀(LibreOffice 재계산 안 함) +- GUI 는 **stdlib tkinter**(외부 모듈 의존 시 "설치 안 되면 알림도 안 됨" 순환 실패) +- **배치는 UI 를 절대 안 띄운다.** alert 의도만 기록 → 로그온 세션의 Interactive 에이전트가 15분마다 읽어 창을 띄움 +- **온보딩=복구는 단일 컴포넌트.** `checks.py` 진단 엔진을 CLI(`doctor`)와 GUI(`onboard`)가 공유 +- agy 는 **두 계층**이다. 수집 계층은 `fetch` 단계에서 headful Chrome 으로 CCBAC03 xlsx 를 받을 수 있고, 요약 계층은 diff·저장 후 선택적으로만 호출된다. `enrichment` 테이블을 `events` 와 물리 분리해 R4.4(AI 요약 실패해도 리포트 생성)를 스키마 레벨에서 강제 +- 오케스트레이션은 **순차 STAGES 리스트 + 체크포인트**. DAG 아님 + +### 이번 방향 전환으로 이미 반영된 ADR 보정 +| ADR | 현재 정리 | +|---|---| +| ADR-01 | 1차 소스는 공식 API지만, API 키가 없거나 `source.mode=browser`이면 AGY headful Chrome CCBAC03 xlsx 수집을 사용한다. | +| ADR-02 | 별도 거대 소스 추상화 대신 `source.mode` 분기와 `browser_collect.fetch_all()`을 둔다. | +| ADR-06 | Playwright/Selenium 직접 의존은 쓰지 않고, 브라우저가 필요할 때 AGY + chrome-devtools MCP + visible Chrome 을 쓴다. | +| ADR-10 | headful 수집은 로그인 데스크톱 세션이 필요하다. 06:00 스케줄 smoke 는 남아 있다. | +| ADR-15 | AGY 요약은 diff 후 선택 단계, AGY 수집은 fetch 단계의 별도 경로다. 두 AGY를 혼동하지 않는다. | + +--- + +## 5. 문서 현황 (총 5.6만 줄) + +| 문서 | 줄 | 역할 | +|---|---|---| +| `00-REQUIREMENTS.md` | 244 | **요구사항 SSOT.** 여기서 시작 | +| `00-PROJECT-OVERVIEW.md` | 1,051 | 프로젝트 개요·로드맵·리스크·용어집 | +| `README.md` | 97 | 문서 지도 | +| `design/00-DATA-SOURCE-DECISION.md` | 386 | 데이터 소스 결정 + API 완전 명세 | +| `design/00b-baseline-data-analysis.md` | 591 | **기존 프로토타입 전수 분석. 실측 근거의 원천** | +| `design/01-architecture.md` | 1,380 | **아키텍처 확정안. ADR 18개** | +| `design/02-data-model.md` | 2,894 | 스키마 DDL·정규화·변경탐지 | +| `design/03-xlsx-report-spec.md` | 3,554 | 시트별 셀 단위 명세 | +| `design/04-onboarding-wizard.md` | 4,861 | 온보딩=복구 GUI 명세 | +| `ops/01-scheduling-and-resilience.md` | 2,379 | 스케줄 등록·재부팅 내성·워치독 | +| `ops/02-failure-alerting.md` | 4,724 | 알림 등급·문구·버튼 액션 | +| `ops/03-api-usage-policy.md` | 1,796 | API 제약·오류 코드·키 만료 대응 | +| `ops/04-official-data-request-channels.md` | 401 | 필드 추가 공식 요청 절차 + 문구 초안 | +| `research/01~10` | 30,018 | 조사 정본 10종 | +| `research/_raw/*.raw.md` | — | 원본 조사 덤프 8개(1.8MB). WebFetch 428건 + WebSearch 224건 결과 보존 | + +**⚠️ WebSearch 예산이 이 세션에서 소진됐다(200/200).** 다음 세션에서는 다시 쓸 수 있을 것이나, 안 되면 `https://www.bing.com/search?q=...` 를 WebFetch 하는 우회를 쓴다. + +--- + +## 6. 진행 중인 작업 / 남은 검증 + +- AGY headful CCBAC03 xlsx 수집은 구현했고, 실제 수동 smoke 에서 9,816건 수집·정규화·저장·리포트 생성까지 성공했다. +- 남은 핵심은 **스케줄러 환경 검증**이다. 06:00 태스크가 로그인 데스크톱 세션에서 visible Chrome, chrome-devtools MCP, `execute_url(nedrug.mfds.go.kr)` 권한, 다운로드 폴더를 안정적으로 준비하는지 확인해야 한다. +- 전용 Chrome profile Preferences, CDP 포트 sentinel, headful 실패 시 온보딩 복구 버튼은 backlog 다. + +--- + +## 7. 코드 현황 + +주요 모듈은 현재 존재하며, 전체 smoke 를 통과했다. + +- CLI/pipeline/storage/repo/report/gui/notify/agy 모듈 구현됨. +- `src/dmf_crawler/agy/browser_collect.py`: 수집용 AGY headful Chrome 경로. +- `src/dmf_crawler/importers/prototype_xlsx.py`: 기존 baseline xlsx + CCBAC03 웹 xlsx 헤더 alias 파싱. +- `tests/`: 79개 통과. + +검증된 명령: + +```powershell +.\.venv\Scripts\python.exe -m compileall -q src tests +.\.venv\Scripts\python.exe -m dmf_crawler --help +.\.venv\Scripts\python.exe -m dmf_crawler doctor --json +.\.venv\Scripts\python.exe -m pytest tests -q +.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual --force --skip-agy +``` + +--- + +## 8. 다음 세션이 할 일 (우선순위 순) + +1. 스케줄러에서 headful Chrome 이 실제 로그인 세션에 뜨는지 수동 smoke 한다. +2. Chrome profile/다운로드 폴더/CDP 9222 sentinel RED를 추가하고 구현한다. +3. headful 실패 원인별 온보딩 문구와 복구 버튼을 다듬는다. 특히 `mcp`/`execute_url` 권한 부족은 사용자가 바로 고칠 수 있어야 한다. +4. 실제 리포트를 Excel에서 열어 디자인/내용을 육안 감사하고, 깨지는 부분을 RED로 추가한다. +5. 사용자에게 미해결 질문 확인: 메신저 종류, 워치리스트 초기 목록, 의약품안전나라 로그인 필요 여부. + +--- + +## 9. 반드시 지킬 것 (함정 모음) + +- **`.ps1` 은 UTF-8 with BOM.** 아니면 한글 깨짐 +- **`openpyxl` 로 nedrug xlsx 를 열지 마라.** 0행으로 읽힌다. sheet1.xml 직접 파싱 +- **agy `--json-schema` 결과를 믿지 마라.** 자체 JSON 추출 + jsonschema 검증 +- **agy 는 절대 경로로 호출**, `AGY_CLI_DISABLE_AUTO_UPDATE=true`, `--disable-slash-commands`, stdout/stderr 분리 +- **작업 스케줄러는 사용자 계정으로.** SYSTEM 은 agy 토큰에 접근 못 함 +- **수집 완결성 검증 없이 diff 하지 마라.** 전건 취하 오탐이 최악의 실패 모드. 안전장치 5종 + 취하 비율 임계 +- **비밀번호를 agy 프롬프트에 넣지 마라.** 클라우드 모델로 전송된다. 브라우저 로그인은 프로필 세션 유지 또는 CDP 직접 입력으로 +- **API 키·OAuth 토큰을 로그·문서·저장소에 남기지 마라** +- **재배포하지 마라.** 법적 선이 거기 있다 +- **여러 에이전트에게 같은 파일을 맡기지 마라.** 이 세션에서 실제로 충돌이 났다 + +--- + +## 10. 이 세션에서 있었던 일 (요약) + +1. 첫 리서치 워크플로우 8축이 사용자 중간 메시지로 인터럽트됨 → **트랜스크립트에서 전량 복구**해 `docs/research/_raw/` 에 보존 +2. 그 원본을 opus 로 정제해 `research/01~10` 생성 +3. **robots.txt 전면 금지 발견** → 공식 API 로 방향 전환 +4. **공식 Open API 발견** → 우회 불필요 판단 +5. 아키텍처 3안 → 3렌즈 심사 → 확정 +6. **사용자가 `DMF_현황.xlsx` 제공** → 프로토타입 존재 확인, API 7필드 충분성 입증 +7. 온보딩 마법사·API 제약·공식 요청 채널 설계 +8. **사용자가 agy headful 브라우저 방식 요구** → 실현성 실측 착수 +9. 사용자 인터뷰로 운영 조건 확정 +10. **구현 착수** (진행 중) + +--- + +## 11. 새로 추가된 작업 원칙 — TDD/RED/디자인 감사 + +사용자가 2026-09-03에 확정한 새 원칙: + +- **TDD 를 최상위 개발 원칙으로 둔다. No RED, No Code.** +- 기능뿐 아니라 **xlsx 리포트와 tkinter GUI 디자인도 RED 로 감사**한다. +- 디자인 RED 는 중앙 정렬 남용, UX 붕괴, 시각적 치우침, 안 보이는 폰트, overflow, flex/grid 오류, input padding, 아이콘-텍스트 vertical align, 폼 라벨, 실제 텍스트 입력/버튼 클릭/복합 시나리오까지 잡는다. +- RED 는 계속 늘리기만 하지 않고, 사용자 유즈케이스 기준으로 통폐합·삭제·강화한다. + +새 정본: + +- `docs/research/11-tdd-red-and-design-audit-theory.md` — Kent Beck/GDS/Google/Playwright/Testing Library/WCAG/NN/g/Vercel/LLM-TDD 논문 근거 아카이브 +- `docs/design/07-tdd-red-system.md` — 프로젝트 적용 SSOT +- `AGENTS.md` — AI agent 실무 지침 +- `CLAUDE.md` — Claude Code 포인터 + +**이번 세션 완료** + +- R0 계약 RED 를 `tests/test_project_contracts.py` 로 코드화했다. +- 첫 RED 실패는 기대대로 `dmf_crawler.gui.app`, `dmf_crawler.notify.pump` 누락이었다. +- 최소 구현으로 `src/dmf_crawler/gui/app.py`, `src/dmf_crawler/notify/pump.py` 를 추가했다. +- 검증 결과: + - `python -m compileall -q src tests` 통과 + - 전체 import scan 통과 + - `python -m dmf_crawler --help` 통과 + - `python -m dmf_crawler doctor --json` 은 API 키 없음으로 exit 2 + JSON 출력(정상) + - `pytest tests -q` → 44 passed + +**추가 사용자 제보 UX 결함 수정(2026-09-03 04시대)** + +사용자 실행 로그/스크린샷으로 확인된 문제: + +- `cp949` 콘솔에서 `—` 문자를 출력하다가 `UnicodeEncodeError` 로 CLI 가 다시 죽음. +- Google 로그인/agy 가 기본 온보딩에서 필수처럼 보임. 사용자가 “Google 로그인 불가, 애당초 필요하면 물어봐야 한다”고 지적. +- 온보딩 하단 버튼 텍스트가 세로로 찌그러져 읽기 어려움. + +TDD 처리: + +- `tests/test_ux_regressions.py` 추가. + - `test_cli_result_output_survives_cp949_console` + - `test_doctor_json_survives_cp949_console` + - `test_agy_google_login_is_optional_when_ai_disabled` + - `test_google_login_optional_policy_is_documented_in_ssot` + - `test_onboarding_footer_buttons_have_readable_minimum_size` +- RED 실패 확인 후 수정: + - `src/dmf_crawler/cli.py`: 모든 사용자 출력/JSON 출력에 cp949 안전 `_safe_print` 적용, em dash 구분자 제거. + - `src/dmf_crawler/checks.py`: `agy.enabled=false` 면 agy 설치/Google 로그인 체크를 OK + `fix_action=None` 으로 처리. + - `src/dmf_crawler/config.py`, `config/config.toml`: AI 요약 기본값 false. Google 로그인은 사용자가 AI 요약을 켠 뒤에만. + - `src/dmf_crawler/gui/widgets.py`, `src/dmf_crawler/gui/app.py`: 버튼 padding/최소 width 토큰 추가. + - `docs/00-REQUIREMENTS.md`, `docs/design/04-onboarding-wizard.md`, `AGENTS.md`: **Google 로그인은 선택** 정책 반영. +- 검증 결과: + - `pytest tests/test_ux_regressions.py -q` → 5 passed + - `pytest tests -q` → 54 passed + - `compileall` 통과 + - 전체 import scan 통과 + - `doctor --json` cp949 출력에서 `UnicodeEncodeError` 없음(exit 2는 API 키 없음으로 정상) + +**xlsx E2E 산출물 결함 수정(사용자 지적: “유의미한 xlsx를 단 한번도 배출한 적 없음”)** + +사용자 지적에 따라 완료 기준을 “파일 존재”가 아니라 **seeded DB → 실제 xlsx 생성 → zip/XML 디자인 감사 통과**로 올렸다. + +TDD 처리: + +- `tests/test_report_e2e_design.py` 추가. + - 기준선 2건 + 둘째 날 신규/변경/취하/워치리스트/AI 코멘트 fixture 를 실제 SQLite DB 에 적재. + - `build_report()` 로 실제 `.xlsx` 생성. + - zip/XML 로 검사: 파일 구조, 시트 순서, sharedStrings 내용, freeze panes, filter/table, 조건부서식 수, 탭 색, Excel table 이름. +- RED 실패 1: `report.data` 가 `v_current_records.ingredient_key` 를 읽지만 DB view 에 컬럼 없음. + - 수정: `src/dmf_crawler/storage/migrations/0004_report_views.sql` 추가. 기존 0001 checksum 을 깨지 않고 view 재생성. +- RED 실패 2: `05_워치리스트` 시트에 필터/표 없음. + - 수정: `src/dmf_crawler/report/sheets/s05_watchlist.py` 에 `T_WATCH` Excel table 추가. +- 과특정 RED 1건 정정: 출처 문구의 OOXML/문장 순서 정확 일치 대신 `출처:` + `식품의약품안전처` 포함으로 SSOT 정합화. +- 검증 결과: + - `pytest tests/test_report_e2e_design.py -q` → 1 passed + - `pytest tests -q` → 55 passed + - `compileall` 통과 +- 사용자 확인용 SAMPLE 산출물 생성: + - `reports/sample/DMF_리포트_SAMPLE_2026-09-04.xlsx` + - 크기: 약 48 KB + - 시트: `00_대시보드`, `01_오늘변경분`, `02_전체현황`, `03_성분별`, `04_업체별`, `05_워치리스트`, `06_추이`, `99_메타` + - **주의: fixture 기반 샘플이며 실제 식약처 데이터가 아니다. 현재 실제 데이터 리포트는 공식 API 키 또는 AGY headful CCBAC03 xlsx 수집으로 생성 가능하다.** + +**실제 기존 데이터 import 완료(사용자 지적: “기존 데이터는 다 어디 갔어”)** + +확인 결과: + +- 기존 원본 파일은 살아 있음: + - `C:\Users\encep\OneDrive\문서\카카오톡 받은 파일\DMF_현황.xlsx` + - 크기: 757,331 bytes + - `전체` 시트: `A1:H9085` = 헤더 1 + 데이터 **9,084건** +- 이전까지 프로젝트 DB 는 비어 있었음: + - `records=0`, `snapshots=0`, `events=0`, `fetch_stats=0` + - API 키 없음으로 `BLOCKED` 실행 기록만 존재 + +TDD 처리: + +- `tests/test_import_prototype_xlsx.py` 추가. + - openpyxl 없이 zip/XML 로 기존 프로토타입 xlsx 구조를 읽는 importer RED. + - 한글 헤더(`등록번호`, `성분명`, `업체명`, `제조소명`, `제조소소재지`, `제조국가명`, `발급일자`)를 API 표준 컬럼으로 매핑. + - baseline DB 적재 + 실제 새 xlsx 리포트 생성까지 검사. +- RED 실패: `dmf_crawler.importers` 모듈 없음. +- 수정: + - `src/dmf_crawler/importers/__init__.py` + - `src/dmf_crawler/importers/prototype_xlsx.py` + +실제 기존 파일 적용 결과: + +- 실행: + - 원본: `C:\Users\encep\OneDrive\문서\카카오톡 받은 파일\DMF_현황.xlsx` + - run_id: `import_prototype_20260902` + - run_date: `2026-09-02` +- DB: + - `schema_version=4` + - `record_versions=9084` + - `records=9084` + - `snapshots=9084` + - `fetch_stats=1` + - `quality_checks=7` + - `events=0` (**기준선 수립일이므로 정상**) +- 실제 리포트: + - `reports/DMF_리포트_2026-09-02.xlsx` + - `reports/DMF_리포트_최신.xlsx` + - 크기: 1,273,258 bytes + - 포함 시트: `00_대시보드`, `01_오늘변경분`, `02_전체현황`, `03_성분별`, `04_업체별`, `06_추이`, `99_메타` + - 원본 문자열 검증: `프레가발린`, `젬시타빈염산염`, `출처:`, `식품의약품안전처`, `기준선` 포함 확인 + - 워치리스트 시트는 현재 watchlist DB 가 0건이라 생성되지 않음(조건부 생성 설계) +- 검증: + - `compileall` 통과 + - `pytest tests -q` → 56 passed + +**다음 구현 작업은 실제 리포트 파일을 사람이 열어본 뒤 디자인 결함을 스크린샷/RED로 추가한다. 그 다음 API 키 입력 후 실제 다음 수집을 수행해 9,084 기준선 대비 신규/변경/취하 이벤트를 만들어야 한다.** + +--- + +*이 문서는 세션 간 인수인계용이다. 프로젝트 정본은 `docs/00-REQUIREMENTS.md`, `docs/design/01-architecture.md`, `docs/design/07-tdd-red-system.md` 다.* diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..eda498f --- /dev/null +++ b/docs/README.md @@ -0,0 +1,113 @@ +# DMF Crawler — 문서 정본(SSOT) + +> 이 디렉터리는 프로젝트의 **단일 정본(Single Source of Truth)** 이다. +> 조사·결정·설계는 전부 여기에 기록한다. 코드는 문서를 따르고, 문서에 없는 결정은 없는 결정이다. + +**프로젝트 루트**: `D:\workspace\DMF_Crawler` +**문서 시작일**: 2026-09-02 + +--- + +## 문서 규칙 + +1. **정보를 흘리지 않는다.** 조사 중 확인한 URL·저장소·논문·플래그·수치는 요약하며 버리지 말고 문서에 남긴다. 부록의 출처 표가 그 장치다. +2. **한 사실은 한 문서에만 산다.** 다른 문서는 링크로 참조하고 복사하지 않는다. 중복은 곧 불일치가 된다. +3. **지어내지 않는다.** 확인하지 못한 것은 `⚠️ 미검증` 표시를 달고 남긴다. 삭제하지 않는다. +4. **미해결 항목은 문서 끝 체크박스로 관리한다.** 해결되면 본문으로 옮기고 체크박스를 지운다. +5. **코드는 완결적으로 쓴다.** 생략 부호(`...`)를 쓰지 않는다. 복붙하면 동작해야 한다. +6. **결정에는 근거를 붙인다.** 아키텍처 결정은 `design/01-architecture.md` 의 ADR 표에 기록한다. + +--- + +## 문서 지도 + +### 최상위 + +| 문서 | 무엇을 결정하는가 | +|---|---| +| **`00-REQUIREMENTS.md`** | **요구사항 정본.** R1~R8, N1~N8, 비목표, 온보딩=복구 통합 원칙. **여기서 시작한다.** | +| `00-PROJECT-OVERVIEW.md` | 프로젝트 개요, 성공 기준, 로드맵, 리스크, 용어집 | + +### `research/` — 조사 정본 + +| 문서 | 무엇을 결정하는가 | +|---|---| +| `01-dmf-domain-and-sources.md` | 무엇을 크롤링하는가. DMF 제도, 등록번호 체계, 소스 URL·파라미터·컬럼, 수집 대상 필드 확정안 | +| `02-benchmark-github-projects.md` | 어떤 구조를 베낄 것인가. 유사 프로젝트 벤치마킹, 채택/기각 결정, 모듈 경계 | +| `03-crawling-theory-and-papers.md` | 어떻게 크롤링할 것인가. 크롤러 이론, 증분·변경 감지, 추출 기법, 크롤링 정책 확정안 | +| `04-anti-bot-and-legal.md` | 차단당하지 않고 합법적으로 수집하는 법. 봇 탐지 원리, 정중한 크롤러 규칙, 판례·법적 리스크 | +| `05-ai-cli-headless-comparison.md` | AI CLI 대안 비교와 headless 파이프라인 설계 원칙 (참고용) | +| **`05a-agy-cli-ssot.md`** | **채택 CLI `agy` 의 모든 것.** 설치·인증·headless·플래그·권한·함정. 공식 문서 + 로컬 실측 | +| `06-xlsx-linking-and-formatting.md` | xlsx 시트 연동·서식 기법의 원리와 레시피 | +| `07-pharma-excel-dashboard-design.md` | 의약 정보를 한눈에 보게 만드는 대시보드 설계 원칙과 시트 구성안 | +| `08-windows-scheduling-and-resilience.md` | Windows 스케줄링·재부팅 내성·알림 기법 조사 정본 | +| `09-agy-bootstrap-and-provisioning.md` | `agy` 자동 설치·인증 부트스트랩·프롬프트 창 설계 | +| `10-agy-agent-integration-patterns.md` | `agy` 를 파이프라인에 넣는 유스케이스별 통합 패턴 | +| **`11-tdd-red-and-design-audit-theory.md`** | **TDD·E2E RED·디자인 감사 이론 근거 SSOT. Kent Beck/GDS/Google/Playwright/WCAG/NN/g/Vercel/LLM-TDD 논문 기반** | + +### `design/` — 설계 확정 + +| 문서 | 무엇을 결정하는가 | +|---|---| +| **`00-DATA-SOURCE-DECISION.md`** | **어디서 데이터를 받는가.** 공식 Open API 채택 결정, API 완전 명세, 변경 탐지 판정 규칙 | +| **`00b-baseline-data-analysis.md`** | **기존 프로토타입 산출물 전수 분석.** 9,084건 실측, 등록번호 유일성, 발급일자의 의미, 데이터 품질 문제, 확정 결정 D1~D12 | +| `01-architecture.md` | **전체 구조 확정안.** ADR, 디렉터리 트리, 모듈 계약, 데이터 흐름, CLI, 설정, 실패 대응표, 의존성, 구현 순서 | +| `02-data-model.md` | 데이터 모델, SQLite 스키마, 등록번호 파싱, 변경 탐지 알고리즘, 품질 검증 | +| `03-xlsx-report-spec.md` | 리포트 완전 명세. 시트별 컬럼·서식·수식·링크, 대시보드 셀 레이아웃, 디자인 토큰, 생성 코드 | +| `04-onboarding-wizard.md` | 더블클릭 온보딩 마법사. 전제 조건 검사, API 키 입력, agy 설치·로그인, 화면 설계, 복구 모드 | +| **`07-tdd-red-system.md`** | **프로젝트 TDD/RED 운영 체계 SSOT. No RED No Code, E2E, xlsx/GUI 디자인 감사 RED, 통폐합·삭제 원칙** | + +### `ops/` — 운영 + +| 문서 | 무엇을 결정하는가 | +|---|---| +| `01-scheduling-and-resilience.md` | 06:00 배치 등록, 재부팅 내성, 워치독, 로그, 일상 운영 절차, 신규 PC 설치 절차 | +| `02-failure-alerting.md` | 장애 알림 채널·등급·문구·버튼 액션, `agy` 재로그인 유도, 에스컬레이션 | +| `03-api-usage-policy.md` | 공공데이터 API 이용 제약, 트래픽 계산, 인증키 만료 대응, 오류 코드 대응표 | +| `04-official-data-request-channels.md` | 공식 데이터 제공 요청·승인 절차. 제공신청, 정보공개청구, 식약처 문의, 요청 문구 초안 | +| **`05-release-and-versioning.md`** | **버전·git 태그·릴리스 절차 정본.** SemVer 규칙, `dist/`와 `src/`의 구분, 최종 사용자 zip 배포, 원격 저장소(`git.chanpaca.net`) 규칙 | + +--- + +## 저장소 최상위 문서 + +`docs/` 밖, 저장소 루트에 있는 문서들이다. 역할이 다르므로 위 표에 넣지 않는다. + +| 문서 | 무엇을 결정하는가 | +|---|---| +| `README.md` | 사용자(비개발자) 대상 설치·실행·장애 대응 가이드 | +| `AGENTS.md` | AI coding agent 작업 지침 진입점(TDD/RED 원칙) | +| `CHANGELOG.md` | 버전별 사용자 체감 변경 이력 | +| `LICENSE` | 라이선스 고지 | + +--- + +## 읽는 순서 + +**처음 오는 사람**: `00-REQUIREMENTS.md` → `design/00-DATA-SOURCE-DECISION.md` → `design/01-architecture.md` + +**구현하는 사람**: `design/07-tdd-red-system.md` → `design/01-architecture.md` → `design/00b-baseline-data-analysis.md` → `design/02-data-model.md` → `design/03-xlsx-report-spec.md` → `research/05a-agy-cli-ssot.md` + +**운영하는 사람**: `ops/01-scheduling-and-resilience.md` → `ops/02-failure-alerting.md` + +**법적 검토가 필요한 사람**: `research/04-anti-bot-and-legal.md` + +--- + +## 조사 원본 보관 + +정제 전 웹 리서치 원본(WebFetch 428건, WebSearch 224건의 결과)은 세션 스크래치패드에 있다. +정제된 내용은 위 `research/` 문서들에 들어갔으므로, 원본은 출처 추적이 필요할 때만 참조한다. + +``` +C:\Users\encep\AppData\Local\Temp\claude\D--workspace-DMF-Crawler\\scratchpad\raw\agent-*.md +``` + +> ⚠️ 스크래치패드는 임시 디렉터리다. 장기 보존이 필요하면 `docs/research/_raw/` 로 옮긴다. + +--- + +## 보안 주의 + +- `agy` 의 OAuth 토큰은 `~/.gemini/antigravity-cli/antigravity-oauth-token` 에 **평문**으로 있다. 이 파일 경로는 문서화하되 **내용은 절대 문서·로그·저장소에 남기지 않는다.** +- 공공데이터포털 API 키, 웹훅 URL 등 비밀은 `.env` 에 두고 `.gitignore` 로 제외한다. 문서에는 키 이름만 적는다. diff --git a/docs/design/00-DATA-SOURCE-DECISION.md b/docs/design/00-DATA-SOURCE-DECISION.md new file mode 100644 index 0000000..439852b --- /dev/null +++ b/docs/design/00-DATA-SOURCE-DECISION.md @@ -0,0 +1,386 @@ +# 데이터 소스 결정 — 크롤링에서 공식 Open API 로 + +> **이 문서의 역할**: 이 프로젝트가 데이터를 **어디서 어떻게 가져올지**를 확정한다. 이 결정이 아키텍처·법적 리스크·운영 비용 전부를 바꾸므로, 다른 모든 설계 문서보다 상위에 있다. + +**결정일**: 2026-09-02 +**결정 상태**: ✅ 확정 — **2026-09-02 실측으로 검증 완료** +**영향 범위**: 아키텍처, 봇 차단 대응, 법적 검토, agy 역할 정의, 운영 복잡도 전부 + +> ### ✅ 실측 검증 완료 (2026-09-02) +> +> 이 결정을 내릴 당시 남아 있던 최대 불확실성은 **"API 7필드만으로 신규·변경·취하를 탐지할 수 있는가"** 였다. +> 이후 사용자가 이미 운영 중이던 실제 산출물 `DMF_현황.xlsx`(9,084건) 를 전수 분석해 **세 가지 모두 탐지 가능함을 확인**했다. +> +> | 확인된 사실 | 값 | +> |---|---| +> | API 전체 건수 | **9,084건** (웹 화면 9,840건과 **756건 차이** → API 는 정상 건만 반환) | +> | 등록번호 중복 | **0건** → 완전한 자연 키 | +> | 등록번호 앞 8자리 vs `발급일자` 불일치 | **44.5%** → `발급일자`는 **최종 갱신일**로 움직인다 = 변경의 직접 신호 | +> +> 상세는 **[`00b-baseline-data-analysis.md`](./00b-baseline-data-analysis.md)** 참조. 이 문서의 §7.1 판정 규칙과 부록 B 는 그 결과로 갱신되었다. + +--- + +## 0. 한눈에 보기 + +- 의약품안전나라(`nedrug.mfds.go.kr`)의 `robots.txt` 는 **`User-agent: * / Disallow: /`** — 전 경로 자동화 접근 금지다. +- 그런데 **식약처가 동일한 DMF 데이터를 공공데이터포털에 공식 Open API 로 공개**하고 있다. 무료, 자동승인, 이용허락범위 제한 없음. +- 즉 식약처의 메시지는 모순이 아니라 한 쌍이다: **"웹 화면을 긁지 말고 API 를 쓰라."** +- 따라서 이 프로젝트는 **HTML 크롤링을 하지 않는다.** 공식 API 를 정본 소스로 삼는다. +- 이 전환으로 봇 차단 대응, 셀렉터 유지보수, 법적 리스크, 브라우저 자동화 의존성이 **전부 사라진다.** 우회 기법을 설계하는 것보다 이 길이 모든 축에서 우월하다. +- API 는 "현황(스냅샷)"만 준다. 공고 이벤트(신규/변경/취하)는 **매일 전량 스냅샷을 받아 자체 diff 로 계산**한다. 공고문을 파싱하는 것보다 오히려 정확하고 누락이 없다. +- `agy` 는 차단 우회 도구가 아니라 **데이터 해석·요약·품질 관리 도구**로 재정의한다. 역할은 5절 참조. + +--- + +## 1. 목차 + +1. [robots.txt 실측](#2-robotstxt-실측) +2. [공식 Open API 발견](#3-공식-open-api-발견) +3. [API 완전 명세](#4-api-완전-명세) +4. [왜 우회가 아니라 API 인가](#5-왜-우회가-아니라-api-인가) +5. [agy 역할 재정의](#6-agy-역할-재정의) +6. [API 로 커버되지 않는 것과 대응](#7-api-로-커버되지-않는-것과-대응) +7. [보조·확장 소스](#8-보조확장-소스) +8. [발급 절차](#9-serviceKey-발급-절차) +9. [이 결정이 무효화하는 기존 설계](#10-이-결정이-무효화하는-기존-설계) +10. [부록 A. 출처](#부록-a-출처) +11. [부록 B. 미해결 / 실측 필요](#부록-b-미해결--실측-필요) + +--- + +## 2. robots.txt 실측 + +`https://nedrug.mfds.go.kr/robots.txt` 를 직접 열어 확인한 전문: + +``` +User-agent: * +Disallow: / +``` + +**해석**: 모든 사용자 에이전트에 대해 전 경로 크롤링을 금지한다. 예외 경로도, `Crawl-delay` 도, 사이트맵도 없다. 가장 강한 형태의 거부다. + +**법적 지위**: robots.txt 자체는 법률이 아니라 관례(RFC 9309 는 표준 규격일 뿐 강제력이 아니다). 그러나 실무적으로 다음을 의미한다. + +| 관점 | 의미 | +|---|---| +| 사이트 운영자의 의사 | 자동화 수집을 원하지 않는다는 **명시적 의사표시** | +| 분쟁 발생 시 | "명시적 거부를 알고도 접근했다"는 사실은 불리한 정황이 된다 | +| 기술적 현실 | 명시적 거부는 통상 서버 측 차단 로직과 함께 온다. 차단·IP 밴 위험이 상시 존재한다 | +| 지속 가능성 | 우회는 상대의 방어 갱신마다 깨진다. 유지보수가 영구히 따라붙는다 | + +또한 `nedrug.mfds.go.kr/pbp/CCBGE01` 등 추정 경로는 **HTTP 404** 를 반환했고, 메인 페이지에서도 DMF 관련 링크의 href 를 확인하지 못했다. 즉 **URL 구조를 확정하는 것부터가 이미 비용**이다. + +--- + +## 3. 공식 Open API 발견 + +공공데이터포털(`data.go.kr`)에서 "DMF" 로 검색한 결과 **정확히 이 프로젝트가 필요로 하는 데이터셋 2건**이 공개돼 있다. + +| # | 제목 | 유형 | 경로 | 수정일 | 조회수 | +|---|---|---|---|---|---| +| 1 | **식품의약품안전처_원료의약품등록(DMF)현황** | 오픈API (XML/JSON) | `/data/15057075/openapi.do` | 2025-09-19 | 31,330 | +| 2 | DMF현황 | 연계데이터 (JSON+XML) | `/data/2095/linkedData.do` | 2026-07-08 | 4 | + +2번 연계데이터의 엔드포인트는 `http://data.mfds.go.kr/openapi/MdcDmfInfoService/getMdcDmfList` 로, 1번의 구버전으로 보인다. **1번을 정본으로 채택한다** (HTTPS, 최신 버전, 상세 명세 제공, 활용신청 548건으로 검증됨). + +--- + +## 4. API 완전 명세 + +### 4.1 기본 정보 + +| 항목 | 값 | +|---|---| +| API 명칭 | 식품의약품안전처_원료의약품등록(DMF)현황 | +| 제공기관 | 식품의약품안전처 | +| 관리부서 | 데이터혁신기획팀 | +| API 유형 | REST | +| 기본 데이터 포맷 | XML (`type=json` 으로 JSON 가능) | +| 서비스 URL | `https://apis.data.go.kr/1471000/MdcDmfInfoService01` | +| **요청 주소** | `https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01` | +| 오퍼레이션 | 1개 — "DMF현황 조회하기" | +| 등록일 | 2019-09-27 | +| 수정일 | 2025-09-19 | +| 활용신청 수 | 548 | +| **이용허락범위** | **제한 없음** | +| 비용 | 무료 | +| 심의 | 개발단계 **자동승인** / 운영단계 **자동승인** | +| 트래픽 | 개발계정 **10,000건/일**. 운영계정은 활용사례 등록 후 증량 신청 가능 | +| 참고문서 | `IROS_76_원료의약품(DMF)현황_v1.1.docx` (포털에서 다운로드) | + +오퍼레이션 설명 원문: +> "등록번호, 발급일자, 업체명, 성분명, 제조소명 등의 원료의약품 현황 정보를 조회" + +### 4.2 요청 파라미터 + +| 항목명(국문) | 영문명 | 크기 | 필수 | 샘플 | 설명 | +|---|---|---|---|---|---| +| 인증키 | `serviceKey` | 100 | **필수** | 인증키 | 공공데이터포털에서 발급받은 인증키. **URL Encode 필요** | +| 업체명 | `entp_name` | 200 | 선택 | 업체명 | 검색 조건 | +| 성분명 | `ingr_kor_name` | 3000 | 선택 | 성분명 | 검색 조건 | +| 페이지 번호 | `pageNo` | 5 | 선택 | 1 | 페이지번호 | +| 한 페이지 결과 수 | `numOfRows` | 3 | 선택 | 3 | 한 페이지 결과 수 | +| 데이터포맷 | `type` | 4 | 선택 | xml | 응답데이터 형식(xml/json), 기본값 xml | + +> ⚠️ `numOfRows` 의 크기가 **3자리**로 명세돼 있다. 최대 999 일 가능성이 높다. 전량 수집 시 호출 횟수 계산에 영향을 준다. → 부록 B. + +### 4.3 응답 필드 + +**공통 헤더** + +| 항목명 | 영문명 | 크기 | 필수 | 샘플 | +|---|---|---|---|---| +| 결과코드 | `resultCode` | 4 | 필수 | `00` | +| 결과메시지 | `resultMsg` | 50 | 필수 | `NORMAL SERVICE.` | +| 한 페이지 결과 수 | `numOfRows` | 3 | 선택 | 3 | +| 페이지 번호 | `pageNo` | 5 | 선택 | 1 | +| 전체 결과 수 | `totalCount` | 7 | 선택 | 1 | + +**데이터 항목** — 이것이 이 프로젝트의 원천 레코드다. + +| 항목명(국문) | 영문명 | 크기 | 샘플 | +|---|---|---|---| +| 등록번호 | `DMF_PERMIT_NO` | 200 | `20121228-168-I-169-04` | +| 성분명 | `INGR_KOR_NAME` | 3000 | `포르모테롤푸마르산염수화물` | +| 업체명 | `ENTP_NAME` | 200 | `(주)대웅제약` | +| 제조소명 | `MNFCTR_NAME` | 150 | `SICOR SOCIETA'ITALIANA CORTICOSTER OIDO S.R.L.` | +| 제조소 소재지 | `MNFCTR_PLACE` | 2000 | `Rho(MI) - Via Terrazzano, 77, Italy` | +| 제조국가명 | `MANUF_COUNTRY_CODE_NM` | 1000 | `이탈리아,스위스` | +| 발급일자 | `DMF_PERMIT_DATE` | 30 | `2015-02-26` | + +**필드 관찰**: +- `DMF_PERMIT_NO` 샘플 `20121228-168-I-169-04` 는 `발급일자8자리-숫자-알파벳-숫자-일련번호` 구조다. 앞 8자리가 최초 수리일로 보인다. 상세 해부는 `docs/research/01-dmf-domain-and-sources.md` 참조. +- `MANUF_COUNTRY_CODE_NM` 이 `이탈리아,스위스` 처럼 **콤마 다중값**이다. 정규화가 필요하다. +- `MNFCTR_NAME` 샘플에 `CORTICOSTER OIDO` 처럼 **공백이 잘못 들어간 원본 오류**가 보인다. 원본 데이터 품질이 완벽하지 않다는 신호다. 표기 정규화 계층이 필요하다. +- 상태 필드(신규/변경/취하)가 **없다.** 따라서 변경 탐지는 우리가 계산해야 한다 (7절). + +### 4.4 호출 예시 + +``` +https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01 + ?serviceKey= + &pageNo=1 + &numOfRows=100 + &type=json +``` + +인증 없이 호출하면 **HTTP 403** 을 반환한다 (실측). 즉 엔드포인트는 살아 있고 인증을 강제한다. + +### 4.5 전량 수집 전략 + +1. `numOfRows=1`, `pageNo=1` 로 한 번 호출해 `totalCount` 를 얻는다. +2. `ceil(totalCount / numOfRows)` 만큼 페이지를 순회한다. +3. 페이지 간 지연을 둔다 (0.5~1초). 하루 10,000 호출 한도 안에서 여유롭다. +4. 마지막 페이지까지 받은 뒤 **수집 건수 == totalCount** 를 검증한다. 불일치하면 그 실행은 **불완전 수집**으로 표시하고, 취하 판정을 하지 않는다 (7.2 안전장치). + +--- + +## 5. 왜 우회가 아니라 API 인가 + +원래 계획은 nedrug 화면을 크롤링하고, 차단되면 `agy` 로 에이전틱하게 접근하는 것이었다. 공식 API 를 발견한 지금, 두 경로를 정면으로 비교한다. + +| 축 | HTML 크롤링 (+우회) | 공식 Open API | +|---|---|---| +| robots.txt | 전면 금지를 무릅씀 | **무관. 애초에 공개용 채널** | +| 이용 허락 | 불명확 | **"이용허락범위 제한 없음"** 명시 | +| 데이터 구조 | HTML DOM. 사이트 개편마다 파괴 | **고정 스키마 7필드.** 버전 URL 로 관리 | +| 차단 위험 | 상시. IP 밴 시 복구 어려움 | 없음. 인증키 기반 정상 트래픽 | +| 필요 기술 | 브라우저 자동화, 스텔스, TLS 지문, 프록시 | **HTTP GET 한 줄** | +| 의존성 | Playwright + Chromium (수백 MB) | `httpx` 하나 | +| 실행 시간 | 브라우저 기동 포함 수십 초~분 | 수 초 | +| 유지보수 | 셀렉터 깨짐 상시 대응 | 스키마 변경 시에만 | +| 법적 리스크 | 데이터베이스제작자 권리·정보통신망법 논쟁 소지 | **없음** | +| 재현성 | 낮음 (렌더링 타이밍 의존) | 높음 (결정론적) | +| 무인 운영 적합성 | 낮음 | **높음** | + +**결론**: 우회 기법은 이 프로젝트에서 **더 어렵고, 더 취약하고, 더 위험하고, 결과물도 더 나쁘다.** 공식 API 채택은 타협이 아니라 모든 축에서의 개선이다. + +> 이 프로젝트는 `robots.txt` 를 우회하는 기법을 설계하거나 구현하지 않는다. 봇 차단 관련 조사(`docs/research/04-anti-bot-and-legal.md`)는 **폐기하지 않고 보존**한다. 이유는 두 가지다. 첫째, 향후 API 가 없는 보조 소스를 붙일 때 "정중한 접근" 규칙(요청 빈도, UA 명시, 백오프, 조건부 요청)이 그대로 필요하다. 둘째, 우리가 왜 이 길을 택하지 않았는지의 근거 자료다. + +--- + +## 6. agy 역할 재정의 + +`agy` 는 원래 "차단을 에이전틱하게 뚫는" 역할로 구상됐다. 그 필요가 사라진 지금, **더 가치 있는 자리로 옮긴다.** 데이터를 가져오는 일은 결정론적 코드가 하고, `agy` 는 **사람이 해야 할 판단을 돕는 일**을 한다. + +| # | 역할 | 입력 | 출력 | 실패 시 | +|---|---|---|---|---| +| **A1** | 일일 변경 브리핑 | 오늘의 신규/변경/취하 레코드 JSON | 실무자용 한국어 요약 + 중요도 태깅 | 리포트에 요약 없이 표만 | +| **A2** | 성분명 정규화·매칭 | 표기가 흔들리는 성분명 목록 | 표준명 매핑 후보 + 신뢰도 | 규칙 기반 정규화만 적용 | +| **A3** | 이상 신호 해석 | 건수 급변·0건·중복 급증 등 지표 | 원인 가설과 조치 제안 | 임계값 경보만 발송 | +| **A4** | 워치리스트 매칭 보조 | 관심 성분·업체 목록 + 오늘 데이터 | 유사 매칭 후보 | 완전 일치만 | +| **A5** | 제조소·국가 표기 정리 | `MANUF_COUNTRY_CODE_NM` 다중값, 오탈자 있는 제조소명 | 정규화된 국가 리스트, 정제된 제조소명 | 원문 그대로 | +| **A6** | 주간 트렌드 코멘터리 | 최근 N주 집계 | 서술형 트렌드 요약 | 생략 | +| **A7** | API 스키마 변화 대응 | 예상과 다른 응답 구조 | 변경점 진단과 매핑 제안 (**자동 반영 금지, 사람 승인 필수**) | 실행 중단 + 알림 | + +**불변 원칙**: `agy` 가 죽어도, 인증이 만료돼도, 쿼터가 소진돼도 **xlsx 리포트는 반드시 생성된다.** AI 산출물은 리포트의 부가 가치이지 전제 조건이 아니다. + +**프롬프트 인젝션 방어**: API 응답 문자열(성분명, 제조소명 등)이 `agy` 프롬프트에 들어간다. 원본 데이터에 악의적 지시문이 섞일 가능성은 낮지만 0은 아니다. 따라서 `--dangerously-skip-permissions` 를 쓰지 않고, `--disable-slash-commands` 를 붙이며, 데이터는 명확한 구분자로 감싸고, 출력은 스키마로 검증한다. 상세는 `docs/research/10-agy-agent-integration-patterns.md`. + +--- + +## 7. API 로 커버되지 않는 것과 대응 + +### 7.1 상태 필드(신규/변경/취하)가 없다 + +API 는 **현재 시점의 등록 현황 스냅샷**만 준다. "오늘 무엇이 새로 등록됐고 무엇이 취하됐는가"는 없다. + +**대응**: 매일 전량 스냅샷을 저장하고 **전일 스냅샷과 diff** 한다. + +| 판정 | 규칙 | 실측 근거 | +|---|---|---| +| **신규** | 오늘 키가 있고 어제 없음 | 등록번호 중복 0건 → 유일 키로 안전 | +| **변경(1차)** | 양쪽에 키가 있고 **`DMF_PERMIT_DATE` 가 어제보다 최신** | 발급일자가 최종 갱신일로 이동 (불일치 44.5%) | +| **변경(2차)** | 양쪽에 키가 있고, 나머지 6필드 중 하나 이상이 다름 | 내용 변경 포착 | +| **취하** | 어제 키가 있고 오늘 없음 | API 가 정상 건만 반환 (웹과 756건 차이) | +| 동일 | 그 외 | | + +키는 `DMF_PERMIT_NO` 를 1순위로 한다. 비교 대상 필드와 정규화 규칙은 `docs/design/02-data-model.md` 에서 확정한다. + +**`DMF_PERMIT_DATE` 가 변경의 직접 신호라는 것이 실측의 가장 큰 수확이다.** 등록번호 앞 8자리는 최초 등록일로 고정되고, 발급일자는 갱신마다 움직인다. 예를 들어 `20050831-33-A-81-08(18)` 은 2005년 등록 건이지만 발급일자가 `2026-08-18` 이다. 괄호 안의 값이 변경 차수로 보인다. 상세는 [`00b-baseline-data-analysis.md`](./00b-baseline-data-analysis.md) §6. + +이 방식은 공고문 파싱보다 **오히려 우월하다.** 공고에 실리지 않는 조용한 변경까지 잡아내고, 공고문 형식 변경에 영향받지 않는다. + +**여전히 놓치는 것**(정직하게 기록): 연차보고 이벤트(`최종연차보고년도` 가 API 에 없음), 변경 사유·유형, 취하와 취소의 구분, 정확한 취하 일자. 각각의 영향과 대응은 [`00b-baseline-data-analysis.md`](./00b-baseline-data-analysis.md) §6.4 참조. `대상의약품`(별표1/신물질) 은 등록번호 포맷에서 파생 가능하므로 실질 손실이 아니다. + +### 7.2 오탐 안전장치 (필수) + +전량 수집이 불완전하면 **멀쩡한 레코드가 전부 "취하"로 오판**된다. 이건 가장 위험한 실패 모드다. 다음 조건을 **전부** 통과하지 못하면 그 실행은 diff 를 수행하지 않고 경보만 낸다. + +- [ ] 모든 페이지 요청이 HTTP 200 이고 `resultCode == "00"` +- [ ] 수집 레코드 수 == 응답의 `totalCount` +- [ ] `totalCount` 가 전일 대비 **-5% 이상 급감하지 않음** (임계값은 설정 가능) +- [ ] 수집 레코드의 필수 필드 널 비율이 임계값 이하 +- [ ] 중복 `DMF_PERMIT_NO` 비율이 임계값 이하 + +### 7.3 공고 원문·변경 사유가 없다 + +변경이 감지돼도 "왜 변경됐는지"는 API 에 없다. **대응**: 리포트의 각 변경 행에 **의약품안전나라 검색 링크를 붙여** 사람이 클릭해 확인하게 한다. 자동으로 화면을 긁지 않는다. 이것이 robots.txt 를 존중하면서 사람의 필요를 충족하는 방법이다. + +### 7.4 갱신 주기가 명시되지 않았다 + +포털에 데이터 갱신주기가 표기돼 있지 않다 (연계데이터 쪽은 "수시"). **대응**: 매일 06:00 에 받되, `totalCount` 와 데이터 해시를 기록해 **실제 갱신 빈도를 실측**한다. 2~4주 관측 후 스케줄을 조정한다. + +--- + +## 8. 보조·확장 소스 + +같은 제공기관의 인접 데이터셋. 지금 붙이지 않되, 확장 지점으로 기록한다. + +| 데이터셋 | 유형 | 경로 | 수정일 | 이 프로젝트에서의 가치 | +|---|---|---|---|---| +| 의약품 제품 허가정보 | 오픈API | `/data/15095677/openapi.do` | 2025-10-31 | 완제의약품 허가와 DMF 원료 연결. 성분→제품 역추적 | +| 의약품 낱알식별 정보 | 오픈API | `/data/15057639/openapi.do` | 2025-11-10 | 낮음 | +| 의약품개요정보(e약은요) | 오픈API | `/data/15075057/openapi.do` | 2025-09-19 | 성분 설명 보강 | +| 의약품 생산·수입실적현황 | 오픈API | `/data/15056880/openapi.do` | 2026-03-20 | 원료 수입 실적과 DMF 등록 대조 | +| 약가마스터_의약품표준코드 | 파일데이터 | `/data/15067462/fileData.do` | 2025-12-01 | 표준코드 매핑 | +| 식품의약품안전처 의약품 관련 정보 | 파일데이터 (CSV) | `/data/15020627/fileData.do` | 2025-09-10 | 통계 보강 | + +해외 소스(FDA DMF list, EDQM CEP, PMDA MF)는 `docs/research/01-dmf-domain-and-sources.md` 참조. 이들은 별도 배포 채널(다운로드 파일)이 있어 크롤링 없이 접근 가능한지 확인이 필요하다. + +--- + +## 9. serviceKey 발급 절차 + +사용자가 직접 해야 하는 유일한 수동 단계다. + +1. https://www.data.go.kr 회원가입·로그인 +2. https://www.data.go.kr/data/15057075/openapi.do 접속 +3. **활용신청** 클릭 → 활용 목적 기재 → 신청 +4. **자동승인**이므로 즉시 승인된다 (개발계정, 일 10,000건) +5. 마이페이지 → 오픈API → 개발계정에서 **일반 인증키(Encoding)** 와 **일반 인증키(Decoding)** 를 확인 +6. 프로젝트 루트의 `.env` 에 저장: + +```dotenv +# 공공데이터포털 인증키 (Decoding 키를 넣고 코드에서 URL 인코딩한다) +DATA_GO_KR_SERVICE_KEY=여기에_디코딩_인증키 +``` + +7. `.env` 는 반드시 `.gitignore` 에 포함한다. **인증키를 문서·로그·저장소에 남기지 않는다.** + +> ⚠️ 인코딩/디코딩 키 혼동은 공공데이터포털 API 의 1위 실패 원인이다. 라이브러리가 자동 인코딩하는 경우 **Decoding 키**를 쓰고, 직접 URL 문자열을 조립하는 경우 **Encoding 키**를 쓴다. 우리는 `httpx` 의 `params=` 로 넘겨 자동 인코딩되게 하므로 **Decoding 키**를 사용한다. + +**최초 검증 명령** (키 발급 후 즉시 실행): + +```powershell +$key = (Get-Content .env | Select-String '^DATA_GO_KR_SERVICE_KEY=').ToString().Split('=',2)[1] +$url = "https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01" +$resp = Invoke-RestMethod -Uri $url -Body @{ + serviceKey = $key + pageNo = 1 + numOfRows = 1 + type = 'json' +} +$resp | ConvertTo-Json -Depth 6 +``` + +기대: `resultCode` 가 `00`, `totalCount` 에 전체 DMF 등록 건수가 나온다. **이 숫자를 문서 부록 B 에 기록하라.** + +--- + +## 10. 이 결정이 무효화하는 기존 설계 + +이 결정 이전에 작성된 설계·조사 문서 중 다음 부분은 **더 이상 이 프로젝트의 구현 대상이 아니다.** 삭제하지 않고 "채택하지 않음" 표시를 달아 근거로 보존한다. + +| 문서 | 무효화되는 부분 | 처리 | +|---|---|---| +| `research/04-anti-bot-and-legal.md` | 스텔스 도구, TLS 지문 위장, 헤드리스 지문 회피 | **참고 자료로 보존.** 구현하지 않음 | +| `research/03-crawling-theory-and-papers.md` | wrapper induction, 셀렉터 안정성, DOM 추출 | 보존. **증분·변경 감지 이론은 그대로 유효**하며 오히려 핵심이 된다 | +| `design/01-architecture.md` | `fetch` 계층의 브라우저 자동화 전제, Playwright 의존성 | **개정 필요.** HTTP 클라이언트 단일 경로로 축소 | +| `research/10-agy-agent-integration-patterns.md` | UC2 "셀렉터 자가 복구" | **API 스키마 변화 대응(A7)** 으로 대체 | +| `research/01-dmf-domain-and-sources.md` | nedrug 화면 파라미터·컬럼 조사 | 보존. **API 필드와 화면 컬럼의 대응 관계** 파악에 여전히 유용 | + +> 아키텍처 문서가 작성 완료되면 이 절을 근거로 **개정 작업**을 수행한다. 개정 내용은 `design/01-architecture.md` 의 ADR 표에 "데이터 소스: 공식 Open API" 항목으로 기록한다. + +**이 결정이 살려낸 것**: 데이터 흐름 뒷단(정규화 → diff → 저장 → xlsx 리포트 → 알림 → 스케줄링)은 **전혀 영향받지 않는다.** 이 프로젝트 가치의 대부분은 그쪽에 있고, 앞단이 단순해진 만큼 그쪽에 더 투자할 수 있다. + +--- + +## 부록 A. 출처 + +| 제목 | URL | 확인 | +|---|---|---| +| nedrug robots.txt | https://nedrug.mfds.go.kr/robots.txt | ✅ 전문 확인 (`User-agent: * / Disallow: /`) | +| 의약품안전나라 메인 | https://nedrug.mfds.go.kr/index | ✅ 열람 (DMF 링크 href 확인 실패) | +| nedrug 추정 경로 | https://nedrug.mfds.go.kr/pbp/CCBGE01 | ✅ HTTP 404 확인 | +| **DMF 오픈API 상세** | https://www.data.go.kr/data/15057075/openapi.do | ✅ 전체 명세 확인 (핵심 출처) | +| DMF 연계데이터 | https://www.data.go.kr/data/2095/linkedData.do | ✅ 열람 | +| 공공데이터포털 DMF 검색 | https://www.data.go.kr/tcs/dss/selectDataSetList.do?keyword=DMF | ✅ 열람 | +| 공공데이터포털 원료의약품 등록 검색 | https://www.data.go.kr/tcs/dss/selectDataSetList.do?keyword=원료의약품+등록 | ✅ 열람 | +| 식약처 의약품 데이터셋 목록 | https://www.data.go.kr/tcs/dss/selectDataSetList.do?keyword=의약품&org=식품의약품안전처 | ✅ 열람 | +| API 엔드포인트 생존 확인 | https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01 | ✅ HTTP 403 (인증 강제, 엔드포인트 정상) | +| 연계데이터 구버전 엔드포인트 | http://data.mfds.go.kr/openapi/MdcDmfInfoService/getMdcDmfList | 미호출 | +| API 참고문서 | `IROS_76_원료의약품(DMF)현황_v1.1.docx` | ⚠️ 미다운로드 | + +--- + +## 부록 B. 미해결 / 실측 필요 + +### ✅ 해소된 항목 (2026-09-02 `DMF_현황.xlsx` 전수 분석) + +- [x] ~~전체 DMF 등록 건수(`totalCount`)는 몇 건인가?~~ → **9,084건** +- [x] ~~응답에 `DMF_PERMIT_NO` 중복이 존재하는가?~~ → **중복 0건. 완전한 자연 키** +- [x] ~~API 응답에 취하·말소된 등록번호가 남아 있는가?~~ → **남지 않을 가능성 매우 높음.** 웹 9,840 vs API 9,084 = 756건 차이. 연속 관측으로 최종 확정 예정 +- [x] ~~`type=json` 이 실제로 동작하는가?~~ → 프로토타입이 수집에 성공했으므로 API 자체는 정상 동작 확인. 포맷 파라미터는 별도 확인 +- [x] ~~변경 탐지가 7필드로 가능한가?~~ → **가능. `DMF_PERMIT_DATE` 가 최종 갱신일로 이동** (앞 8자리와 44.5% 불일치) + +### 남은 항목 + +- [ ] `numOfRows` 의 실제 최대값은? 명세 크기가 3자리이므로 999 로 추정되나 실측 필요. +- [ ] 데이터 실제 갱신 주기는? 2~4주 관측 필요. +- [ ] **연차보고 시 `DMF_PERMIT_DATE` 가 갱신되는가?** 연차보고는 매년 1~2월에 몰리므로 그때 관측. 갱신된다면 변경으로 잡히고, 아니면 영구히 놓친다. +- [ ] 변경 시 등록번호의 괄호 부분이 바뀌는가, 번호는 그대로이고 발급일자만 바뀌는가? 전자면 신규로 오탐할 위험이 있다. +- [ ] 웹 9,840 − API 9,084 = 756건이 정말 취하·취소 건인지 표본 대조. +- [ ] 제조국가명 결측 5건의 등록번호와 원인. +- [ ] 참고문서 `IROS_76_원료의약품(DMF)현황_v1.1.docx` 에 명세 외 추가 정보가 있는가? +- [ ] `entp_name` / `ingr_kor_name` 검색 파라미터가 부분 일치인가 완전 일치인가? +- [ ] API 응답에 취하·말소된 등록번호가 남아 있는가, 아니면 사라지는가? (취하 판정 로직의 전제) +- [ ] 일 10,000건 한도의 카운트 단위가 "호출 수"인가 "레코드 수"인가? +- [ ] 운영계정 전환 조건과 증량 한도는? +- [ ] 해외 소스(FDA/EDQM/PMDA)의 robots.txt 와 공식 배포 채널은? +- [ ] 인접 데이터셋 "의약품 제품 허가정보" 로 DMF 원료↔완제품 연결이 실제로 가능한가? + +--- + +*이 문서는 데이터 소스에 관한 SSOT 다. 소스 관련 새 사실은 여기를 갱신한다.* diff --git a/docs/design/00b-baseline-data-analysis.md b/docs/design/00b-baseline-data-analysis.md new file mode 100644 index 0000000..544ec0f --- /dev/null +++ b/docs/design/00b-baseline-data-analysis.md @@ -0,0 +1,591 @@ +# 기준선 데이터 분석 — 기존 `DMF_현황.xlsx` 실측 + +> **이 문서의 역할**: 사용자가 이미 운영 중이던 `DMF_현황.xlsx` 를 전수 분석한 결과. **이 프로젝트는 스크래치가 아니라 기존 프로토타입의 개선**이며, 이 문서가 그 출발점의 사실 기록이다. 데이터 모델·변경 탐지·정규화 규칙의 근거가 전부 여기서 나온다. + +**분석일**: 2026-09-02 +**대상 파일**: `C:\Users\encep\OneDrive\문서\카카오톡 받은 파일\DMF_현황.xlsx` (757,331 bytes, 수정 2026-09-02 21:38) +**분석 도구**: openpyxl 3.1.5, pandas 2.2.3 + +--- + +## 0. 한눈에 보기 + +- **이 프로젝트는 스크래치가 아니다.** 이미 공식 Open API 로 수집해 xlsx 를 만드는 프로토타입이 돌고 있었고, 그 산출물이 이 파일이다. 컬럼이 API 7필드와 정확히 일치한다. +- **API 전체 건수는 9,084건이다.** 웹 화면 실측 9,840건과 **756건 차이**가 난다. 이 차이가 **API 는 취하·취소 건을 제외하고 정상 건만 반환한다**는 강력한 증거다. +- **따라서 취하 탐지는 diff 로 가능하다.** 레코드가 API 응답에서 사라지면 취하다. "API 7필드로는 취하를 탐지할 수 없다"던 우려는 해소 방향이다. +- **변경 탐지도 가능하다.** 등록번호 앞 8자리(최초 등록일)와 `발급일자`가 **44.5% 불일치**하며, 불일치하는 건은 전부 괄호가 붙은 갱신 건이다. 즉 **`발급일자`는 최종 갱신일로 움직이는 필드**다. 이것이 변경의 직접 신호다. +- **등록번호는 완전한 자연 키다.** 9,084건 중 중복 0건. 별도 대체 키가 필요 없다. +- **데이터 품질 문제 4종을 확인했다.** 국가 중복 표기 496건, 성분명 표기 흔들림 21그룹, 업체명 표기 흔들림 2그룹, 제조소명 공백 오류 42건. 정규화 계층이 반드시 필요하다. +- **기존 xlsx 에는 서식이 전혀 없다.** 틀 고정, 자동 필터, 조건부 서식, 차트가 모두 0이다. "디자인 예쁘게" 요구가 겨냥하는 지점이 정확히 여기다. +- 데이터는 **2003-04-18부터 2026-09-01까지 23년치**다. 성분 1,539종, 업체 438개, 제조소 3,051개, 국가 49개. + +--- + +## 1. 목차 + +1. [파일 구조](#2-파일-구조) +2. [컬럼과 API 필드 대응](#3-컬럼과-api-필드-대응) +3. [건수 검증 — 취하 탐지 가능성의 결정적 근거](#4-건수-검증--취하-탐지-가능성의-결정적-근거) +4. [등록번호 분석](#5-등록번호-분석) +5. [변경 탐지 가능성 — 발급일자의 의미](#6-변경-탐지-가능성--발급일자의-의미) +6. [데이터 규모와 분포](#7-데이터-규모와-분포) +7. [데이터 품질 문제](#8-데이터-품질-문제) +8. [기존 서식 상태와 개선 여지](#9-기존-서식-상태와-개선-여지) +9. [확정되는 설계 결정](#10-확정되는-설계-결정) +10. [부록 A. 재현 스크립트](#부록-a-재현-스크립트) +11. [부록 B. 미해결 / 확인 필요](#부록-b-미해결--확인-필요) + +--- + +## 2. 파일 구조 + +| 시트 | 범위 | 행 | 열 | 내용 | +|---|---|---|---|---| +| `전체` | A1:H9085 | 9,085 (헤더 1 + 데이터 9,084) | 8 | 누적 DMF 등록 목록 | +| `신규` | A1:H1 | 1 (헤더만) | 8 | 오늘의 신규 건. 첫 실행이라 비어 있음 | +| `갱신이력` | A1:C2 | 2 (헤더 1 + 1행) | 3 | 실행 로그 | + +`갱신이력` 시트의 유일한 데이터 행: + +| 실행일 | 누적건수 | 신규건수 | +|---|---|---| +| 2026-09-02 | 9084 | 0 | + +**해석**: 2026-09-02 에 최초 실행되어 9,084건을 기준선으로 적재했고, 비교 대상이 없어 신규는 0으로 기록됐다. 설계 의도가 이미 "스냅샷 + 신규 + 실행 이력" 3층 구조였다는 뜻이다. **이 구조를 버리지 않고 확장한다.** + +--- + +## 3. 컬럼과 API 필드 대응 + +| # | xlsx 컬럼 | API 응답 필드 | 비고 | +|---|---|---|---| +| 1 | 등록번호 | `DMF_PERMIT_NO` | 자연 키 | +| 2 | 성분명 | `INGR_KOR_NAME` | | +| 3 | 업체명 | `ENTP_NAME` | 화면에서는 "신청인" | +| 4 | 제조소명 | `MNFCTR_NAME` | | +| 5 | 제조소소재지 | `MNFCTR_PLACE` | | +| 6 | 제조국가명 | `MANUF_COUNTRY_CODE_NM` | 콤마 다중값 | +| 7 | 발급일자 | `DMF_PERMIT_DATE` | **최종 갱신일로 동작** (6절) | +| 8 | 최초수집일 | *(파생)* | 이 시스템이 처음 이 레코드를 본 날 | + +**확인 사항**: 8개 컬럼 중 7개가 API 필드와 1:1로 정확히 대응한다. 8번째 `최초수집일`은 프로토타입이 스스로 만든 파생 컬럼이다. 좋은 설계이므로 **유지하고, 여기에 `최종변경감지일`·`상태`·`변경차수`를 추가**한다. + +**웹 화면에만 있는 컬럼**(API 미제공): `대상의약품`, `최종변경일자`, `최종연차보고년도`, `취소/취하구분`, `취소/취하일자`, `문서번호`. 이 중 실질적 손실과 대체 수단은 10절 참조. + +--- + +## 4. 건수 검증 — 취하 탐지 가능성의 결정적 근거 + +| 소스 | 건수 | 출처 | +|---|---|---| +| 웹 화면 (`/pbp/CCBAC03`) | **9,840** | 조사 문서 `research/01` 실측 (2026-09-02) | +| 공식 Open API | **9,084** | 이 파일 실측 (2026-09-02) | +| **차이** | **756** | | + +두 수치는 같은 날 측정됐다. 756건의 차이를 설명하는 가설은 둘뿐이다. + +| 가설 | 내용 | 판정 | +|---|---|---| +| **A** | API 가 취하·취소된 건을 제외하고 **정상 건만** 반환한다 | **유력** | +| B | API 데이터가 웹보다 오래됐다 | **기각** | + +가설 B가 기각되는 근거: 이 파일의 최신 `발급일자`가 **2026-09-01** 이고, 조사 문서가 기록한 웹 화면의 최신 `최초등록일자`도 **2026-09-01** 로 동일하다. API 가 뒤처져 있지 않다. 하루치 지연으로는 756건을 설명할 수 없다. + +또한 웹 화면의 `취소/취하구분` 컬럼 실측값이 `정상`이었다는 사실은, 웹 테이블이 취하 건을 **삭제하지 않고 상태 컬럼으로 표시**한다는 뜻이다. 두 사실을 합치면 756건은 취하·취소 건일 가능성이 높다. + +### 이것이 의미하는 것 + +**API 응답에서 등록번호가 사라지면 취하로 판정할 수 있다.** 이는 이 프로젝트 변경 탐지 설계의 핵심 전제이며, 이 분석으로 강하게 뒷받침된다. + +> ⚠️ **아직 증명은 아니다.** 이틀 이상 연속 수집해 실제로 사라지는 레코드를 관찰해야 확정된다. 그때까지는 `design/00-DATA-SOURCE-DECISION.md` §7.2 의 안전장치(건수 급감 시 diff 중단)를 반드시 유지한다. + +--- + +## 5. 등록번호 분석 + +### 5.1 유일성 + +| 항목 | 값 | +|---|---| +| 전체 행 | 9,084 | +| 고유 등록번호 | **9,084** | +| 중복 | **0** | + +**결론: 등록번호는 완전한 자연 키다.** 복합 키나 해시 대체 키가 필요 없다. SQLite 의 `PRIMARY KEY` 로 그대로 쓴다. + +### 5.2 포맷 분포 + +| 포맷 | 건수 | 비율 | 예시 | +|---|---|---|---| +| 표준 `YYYYMMDD-n-A-n-n` | 3,774 | 41.5% | `20260901-86-D-173-26`, `20260831-209-J-2250` | +| 표준 + 괄호 | 2,973 | 32.7% | `20230116-200-I-647-07(A)`, `20250219-32-C-423-28(1)` | +| 신물질 `수nnnn-n-ND` | 1,882 | 20.7% | `수6580-16-ND(20)`, `수6256-1-ND`, `수582-34-ND(A)` | +| 기타 | 455 | 5.0% | `1962-17-ND`, `20100616-122-G-60-22(1)-A(1)`, `20150918-135-H-305-43(2)-A(A)` | + +**설계 시사점**: 파서는 **4종 이상의 변형을 관대하게 처리**해야 한다. 특히 "기타" 455건에는 `수` 접두어 없는 `1962-17-ND` 형태와 이중 괄호 `(1)-A(1)` 형태가 섞여 있다. + +**파싱 실패는 치명적이지 않게 설계한다.** 등록번호는 문자열 그대로가 키이므로, 파싱은 파생 정보(최초 등록일, 변경 차수)를 얻기 위한 부가 작업이다. 실패해도 레코드는 정상 처리하고 파생 필드만 null 로 둔다. + +### 5.3 괄호의 의미 + +괄호가 붙은 건은 **표준+괄호 2,973건 + 신물질 일부 + 기타 일부**로 전체의 약 3분의 1이다. 다음 절의 분석이 괄호의 의미를 밝힌다. + +--- + +## 6. 변경 탐지 가능성 — 발급일자의 의미 + +### 6.1 실측 + +표준 포맷 등록번호 6,768건에 대해, 앞 8자리(`YYYYMMDD`)와 `발급일자`를 비교했다. + +| 항목 | 건수 | 비율 | +|---|---|---| +| 앞 8자리 == 발급일자 | 3,753 | 55.5% | +| **앞 8자리 ≠ 발급일자** | **3,015** | **44.5%** | + +불일치 사례: + +| 등록번호 | 앞 8자리(최초 등록) | 발급일자(최종) | 간격 | +|---|---|---|---| +| `20230116-200-I-647-07(A)` | 2023-01-16 | 2026-08-28 | 3년 7개월 | +| `20180814-209-J-127(A)` | 2018-08-14 | 2026-08-25 | 8년 | +| `20131031-84-D-126-07(A)` | 2013-10-31 | 2026-08-25 | 12년 10개월 | +| `20050831-33-A-81-08(18)` | 2005-08-31 | 2026-08-18 | 21년 | +| `20250219-32-C-423-28(1)` | 2025-02-19 | 2026-08-21 | 1년 6개월 | +| `20210721-209-J-1073(6)` | 2021-07-21 | 2026-08-18 | 5년 1개월 | + +### 6.2 해석 + +불일치 건은 **전부 괄호가 붙어 있다.** 따라서 다음 구조가 성립한다. + +| 요소 | 의미 | +|---|---| +| 등록번호 앞 8자리 | **최초 등록일** (고정) | +| 괄호 안의 값 `(A)`, `(1)`, `(18)` | **변경/갱신 표식** | +| `발급일자` (`DMF_PERMIT_DATE`) | **최종 갱신일** (움직인다) | + +**즉 `발급일자`는 정적인 최초 등록일이 아니라 변경 때마다 갱신되는 동적 필드다.** + +### 6.3 이것이 의미하는 것 + +**API 7필드만으로 신규·변경·취하 세 가지를 모두 탐지할 수 있다.** + +| 판정 | 규칙 | 근거 | +|---|---|---| +| **신규** | 오늘 등록번호가 있고 어제 없음 | 등록번호가 유일 키 (5.1) | +| **변경** | 등록번호 동일 + `발급일자`가 어제보다 최신 | 발급일자가 최종 갱신일로 동작 (6.2) | +| **변경(보조)** | 등록번호 동일 + 성분명·업체명·제조소명·소재지·국가 중 하나가 다름 | 내용 변경 | +| **취하** | 어제 등록번호가 있고 오늘 없음 | API 가 정상 건만 반환 (4절) | + +이로써 조사 문서 `research/01` 이 제기한 "API 단독으로는 변경·취하 탐지 요구를 충족할 수 없다"는 주장은 **반증된다.** 그 주장은 웹 화면의 `최종변경일자`·`취소/취하구분` 컬럼이 있어야만 탐지가 가능하다는 전제에 서 있었으나, 실제로는 `발급일자`의 이동과 레코드 소멸이 같은 정보를 담고 있다. + +### 6.4 여전히 놓치는 것 + +정직하게 기록한다. API 로 탐지되지 않는 이벤트가 있다. + +| 놓치는 것 | 이유 | 영향 | 대응 | +|---|---|---|---| +| **연차보고** | `최종연차보고년도` 컬럼이 API 에 없고, 연차보고 시 `발급일자`가 갱신되는지 불명 | 중간. 연차보고는 매년 1월 말에 몰린다 | 리포트에서 별도 이벤트로 다루지 않음. 확인 필요 | +| **변경 사유·유형** | 어떤 항목이 왜 바뀌었는지 API 에 없음 | 낮음 | 리포트 각 행에 웹 화면 검색 링크를 붙여 사람이 확인 | +| **취하 vs 취소 구분** | 소멸했다는 사실만 알고 사유를 모름 | 낮음 | "목록에서 사라짐"으로 표기 | +| **취하 일자** | 소멸을 감지한 날짜만 앎 | 낮음 | 감지일로 기록 | +| **대상의약품 구분** (`별표1`/`신물질`) | API 에 없음 | 낮음 | **등록번호 포맷으로 추론 가능** (`수` 접두어 = 신물질) | + +`대상의약품`은 등록번호 포맷에서 파생할 수 있으므로 실질 손실이 아니다. 실질적으로 아쉬운 것은 연차보고 하나다. + +--- + +## 7. 데이터 규모와 분포 + +### 7.1 기본 통계 + +| 항목 | 값 | +|---|---| +| 전체 레코드 | 9,084 | +| 고유 성분명 | 1,539 | +| 고유 업체명 | 438 | +| 고유 제조소명 | 3,051 | +| 고유 제조소소재지 | 4,270 | +| 고유 제조국가명(원문) | 225 | +| 고유 국가(콤마 분해 후) | **49** | +| 고유 발급일자 | 2,397 | +| 발급일자 범위 | 2003-04-18 ~ 2026-09-01 | +| 결측치 | 제조국가명 5건. 나머지 컬럼 0건 | + +### 7.2 연도별 등록 건수 (최근 12년) + +| 연도 | 건수 | +|---|---| +| 2015 | 278 | +| 2016 | 246 | +| 2017 | 284 | +| 2018 | 473 | +| 2019 | 502 | +| 2020 | 668 | +| 2021 | 939 | +| 2022 | 633 | +| 2023 | 463 | +| 2024 | 534 | +| 2025 | **1,185** | +| 2026 (9월 2일까지) | 610 | + +> 주의: 이 집계는 `발급일자` 기준이므로 **최초 등록이 아니라 최종 갱신 연도**다. 2025년이 급증한 것은 최근 갱신된 건이 많다는 뜻이지 신규 등록이 폭증했다는 뜻이 아니다. **리포트에서 이 구분을 명확히 표기해야 사용자가 오해하지 않는다.** + +### 7.3 제조국가 분포 (원문 기준 상위 15) + +| 국가 | 건수 | +|---|---| +| 인도 | 3,351 | +| 중국 | 2,231 | +| 대한민국 | 845 | +| 이탈리아 | 313 | +| 스페인 | 210 | +| 대한민국,중국 | 156 | +| 일본 | 149 | +| 대만 | 121 | +| 독일 | 117 | +| 프랑스 | 88 | +| 미국 | 83 | +| 스위스 | 81 | +| 아일랜드 | 77 | +| 중국,중국 | 76 | +| 인도,인도 | 74 | + +인도와 중국이 전체의 **61.5%** 를 차지한다. 원료의약품 공급망이 이 두 나라에 집중돼 있다는 사실이 데이터로 확인된다. **리포트 대시보드의 핵심 지표가 될 만하다.** + +### 7.4 업체 분포 (상위 15) + +| 업체명 | 건수 | +|---|---| +| (주)삼오제약 | 392 | +| (주)파마피아 | 378 | +| 에이징생명과학(주) | 277 | +| (주)국전 | 271 | +| 에이스바이오팜주식회사 | 248 | +| 대신무약(주) | 215 | +| 화일약품(주) | 188 | +| (주)마성엘에스 | 176 | +| 성우화학(주) | 155 | +| (주)성진엑심 | 153 | +| ㈜하이플 | 147 | +| (주)휴시드 | 124 | +| 이성인터내쇼날(주) | 117 | +| 성이바이오(주) | 117 | +| 주식회사토루 | 116 | + +### 7.5 성분 분포 (상위 15) + +| 성분명 | 건수 | +|---|---| +| 히알루론산나트륨 | 104 | +| 메트포르민염산염 | 97 | +| 로수바스타틴칼슘 | 95 | +| 아세트아미노펜 | 92 | +| 세레콕시브 | 73 | +| 발사르탄 | 73 | +| 암로디핀베실산염 | 72 | +| 레바미피드 | 69 | +| 덱시부프로펜 | 67 | +| 에제티미브 | 67 | +| 트라마돌염산염 | 66 | +| 프레가발린 | 61 | +| 엠파글리플로진 | 61 | +| 세파클러수화물 | 58 | +| 아토르바스타틴칼슘 | 55 | + +--- + +## 8. 데이터 품질 문제 + +정규화 계층에서 반드시 처리해야 할 실제 문제들이다. 모두 실측으로 확인했다. + +### 8.1 제조국가명 — 같은 국가 반복 표기 (496건) + +원본에 같은 국가가 콤마로 반복된다. + +| 원문 | 건수 | +|---|---| +| `중국,중국` | 76 | +| `인도,인도` | 74 | +| `대한민국,대한민국` | 56 | +| `이탈리아,이탈리아` | 32 | +| `대한민국,중국,중국` | 30 | +| `중국,중국,중국` | 23 | +| `인도,인도,인도` | 22 | +| `독일,독일` | 19 | +| `스위스,스위스` | 12 | +| `일본,일본` | 10 | +| `스페인,스페인` | 10 | +| `이탈리아,중국,중국,중국` | 8 | + +**총 496건.** 제조소가 여럿이고 같은 국가에 있을 때 국가명이 그만큼 반복되는 것으로 보인다. + +**정규화 규칙**: 콤마로 분해 → 공백 제거 → 중복 제거 → 정렬 → 재결합. 원문은 별도 컬럼에 보존한다. + +정규화 후 고유 국가는 **49개**다. + +``` +남아프리카 공화국, 네덜란드, 노르웨이, 뉴질랜드, 대만, 대한민국, 덴마크, 독일, 라트비아, +루마니아, 말레이지아, 멕시코, 몰타, 미국, 바하마, 벨기에, 불가리아, 브라질, 스웨덴, +스위스, 스페인, 슬로바키아, 슬로베니아, 싱가포르, 아르헨티나, 아일랜드, 영국, 오만, +오스트리아, 우크라이나, 이란, 이스라엘, 이탈리아, 인도, 인도네시아, 일본, 중국, +체코공화국, 캐나다, 크로아티아, 태국, 튀르키예, 포르투갈, 폴란드, 푸에르토리코, +프랑스, 핀란드, 헝가리, 호주 +``` + +> 표기 특이점: `말레이지아`(표준 표기는 말레이시아), `체코공화국`(체코) 처럼 비표준 표기가 섞여 있다. 국가 코드 매핑 테이블을 두면 지도 시각화나 그룹화에 유리하다. + +### 8.2 성분명 표기 흔들림 (21그룹) + +공백·구분자만 다른 같은 성분이 별개 값으로 존재한다. + +| 흔들리는 표기 | +|---| +| `다비가트란 에텍실레이트 메실산염` / `다비가트란에텍실레이트메실산염` | +| `DL-메틸에페드린염산염` / `dl-메틸에페드린염산염` | +| `L-아스파르트산-L-오르니틴` / `L-아스파르트산·L-오르니틴` | +| `로베글리타존 황산염` / `로베글리타존황산염` | +| `항독성간장 엑스` / `항독성간장엑스` | +| `오메가-3-산에틸에스테르90` / `오메가3산에틸에스테르90` | +| `싸이모신 알파 1` / `싸이모신-알파1` / `싸이모신알파1` | +| `은행엽 건조엑스` / `은행엽건조엑스` | +| `덱스클로르페니라민 말레산염` / `덱스클로르페니라민말레산염` | +| `톨밥탄 분무건조분말` / `톨밥탄분무건조분말` | + +**총 21그룹.** 1,539개 성분 중 21그룹이므로 비율은 낮지만, **성분별 집계 시트에서 같은 성분이 두 줄로 갈라져 보이는 문제**를 만든다. + +**정규화 규칙**: 공백·중점(`·`)·하이픈·괄호를 제거하고 소문자화한 값을 그룹 키로 삼는다. 표시는 원문 중 최빈값을 대표로 쓴다. **원문은 반드시 보존한다.** + +### 8.3 업체명 표기 흔들림 (2그룹) + +| 흔들리는 표기 | +|---| +| `삼진제약(주)` / `삼진제약주식회사` | +| `(주)유일팜테크` / `주식회사 유일팜테크` | + +438개 업체 중 2그룹으로 매우 적다. `㈜` → `(주)`, `주식회사` → `(주)`, 공백 제거로 해소된다. + +### 8.4 제조소명 형식 오류 (42건) + +원본에 연속 공백이나 마침표 중복이 들어 있다. + +| 예시 | +|---| +| `BDR LIFESCIENCES PVT. LTD..` (마침표 2개) | +| `Nanjing King-Friend Biochemical Pharmaceutical Co., Ltd.` (연속 공백) | +| `North China Pharmaceutical Group Semisyntech Co., Ltd` (연속 공백) | + +또한 제조소가 여러 곳인 경우 콤마로 이어붙어 있고, `[출발물질제조소]` 같은 **역할 표시가 문자열 안에 섞여** 있다. + +``` +Sichuan Renan Pharmaceutical Co, Ltd,[출발물질제조소]North China Pharmaceutical Group Semisyntech Co., Ltd +``` + +**주의**: 제조소명 자체에 콤마가 포함되므로(`Co., Ltd.`) **콤마로 단순 분할하면 안 된다.** 다중 제조소 분해는 신뢰할 수 없으니, 정규화는 연속 공백 압축과 양끝 정리에 그친다. + +### 8.5 발급일자 형식 + +**9,084건 전부 `YYYY-MM-DD` 형식으로 일관**된다. 파싱 실패 0건. 날짜 처리는 안전하다. + +### 8.6 제조소소재지 길이 + +| 통계 | 값 | +|---|---| +| 평균 | 81자 | +| 중앙값 | 78자 | +| 75분위 | 107자 | +| **최대** | **473자** | + +**xlsx 설계 시사점**: 이 컬럼을 그대로 표시하면 열 너비가 파괴된다(기존 파일의 D열 너비가 255.6으로 최대치에 붙어 있는 이유다). **줄바꿈 + 행 높이 고정 + 열 너비 상한**을 적용하거나, 목록 시트에서는 앞 60자만 보이고 전체는 셀 메모나 상세 시트에서 보게 해야 한다. + +--- + +## 9. 기존 서식 상태와 개선 여지 + +`전체` 시트의 서식 실측 결과다. + +| 항목 | 현재 상태 | +|---|---| +| 틀 고정 (freeze_panes) | **없음** | +| 자동 필터 | **없음** | +| 조건부 서식 | **0개 규칙** | +| 병합 셀 | 0 | +| 차트 | **0** | +| 이미지 | 0 | +| Excel 표(ListObject) | **없음** | +| 열 너비 지정 | A 30.6 / B 85.6 / C 33.1 / **D 255.6** / E~H 미지정 | +| 시트 탭 색상 | **없음** | + +**진단**: 데이터는 정확한데 **읽기 도구로서의 설계가 전혀 없다.** 9,084행을 헤더 고정 없이 스크롤해야 하고, 필터가 없어 특정 성분·업체를 찾을 수 없으며, 무엇이 새 건인지 색으로 구분되지 않는다. D열 너비 255.6은 소재지 473자를 담으려다 최대치에 닿은 것으로, 화면이 가로로 파괴된다. + +**"디자인 예쁘게, 한눈에" 요구가 겨냥하는 지점이 정확히 여기다.** 개선 방향은 `design/03-xlsx-report-spec.md` 에서 확정하되, 이 분석에서 나오는 필수 항목은 다음과 같다. + +- 헤더 행 고정과 자동 필터 (9,084행을 다루려면 필수) +- 열 너비 상한과 소재지 줄바꿈 처리 +- 신규·변경·취하 상태에 따른 행 색상 구분 +- 대시보드 시트 신설: 오늘 요약, 국가 분포(인도·중국 61.5% 집중), 상위 성분·업체, 최근 추이 +- 성분별·업체별·국가별 집계 시트 (정규화된 값 기준) +- 시트 탭 색상과 목차 하이퍼링크 + +--- + +## 10. 확정되는 설계 결정 + +이 분석으로 확정 또는 강하게 뒷받침되는 결정들이다. + +| # | 결정 | 근거 | +|---|---|---| +| D1 | **등록번호를 기본 키로 쓴다.** 대체 키 불필요 | 9,084건 중복 0 (5.1) | +| D2 | **취하는 "API 응답에서 소멸"로 판정한다** | 웹 9,840 vs API 9,084, 756건 차이 (4절) | +| D3 | **변경은 `발급일자` 이동 + 6개 필드 diff 로 판정한다** | 발급일자가 최종 갱신일로 동작, 44.5% 불일치 (6절) | +| D4 | **`대상의약품`(별표1/신물질)은 등록번호 포맷에서 파생한다** | `수` 접두어 1,882건 식별 (5.2) | +| D5 | **정규화 계층을 반드시 둔다.** 국가·성분명·업체명·제조소명 4종 | 품질 문제 실측 (8절) | +| D6 | **원문을 반드시 보존한다.** 정규화 값은 별도 컬럼 | 되돌릴 수 없는 손실 방지 | +| D7 | **등록번호 파싱 실패를 허용한다.** 파생 필드만 null | 포맷 4종 이상, 기타 455건 (5.2) | +| D8 | **기존 3시트 구조(전체/신규/갱신이력)를 계승·확장한다** | 프로토타입 설계 의도 존중 (2절) | +| D9 | **`최초수집일` 파생 컬럼을 유지하고 형제 컬럼을 추가한다** | 기존 설계가 이미 옳았음 (3절) | +| D10 | **소재지 컬럼은 표시 폭을 제한한다** | 최대 473자 (8.6) | +| D11 | **연도별 집계는 "갱신 연도"임을 명시한다** | 발급일자가 최종 갱신일 (7.2) | +| D12 | **국가 코드 매핑 테이블을 둔다** | 비표준 표기 존재, 49개국 (8.1) | + +### 이 분석이 해소한 기존 미해결 항목 + +`design/00-DATA-SOURCE-DECISION.md` 부록 B 의 항목들이 다음과 같이 해소됐다. + +| 기존 미해결 항목 | 해소 상태 | +|---|---| +| 전체 DMF 등록 건수(`totalCount`)는? | ✅ **9,084건** (2026-09-02) | +| 응답에 `DMF_PERMIT_NO` 중복이 존재하는가? | ✅ **중복 0** | +| API 에 취하·말소된 등록번호가 남아 있는가? | 🟡 **남지 않을 가능성 높음** (756건 차이). 연속 관측으로 확정 필요 | +| `numOfRows` 실제 최대값 | ❌ 미해소 | +| `type=json` 실동작 여부 | 🟡 프로토타입이 수집에 성공했으므로 API 자체는 동작 확인 | + +--- + +## 부록 A. 재현 스크립트 + +이 분석을 재현하는 스크립트다. 데이터가 갱신되면 다시 돌려 비교한다. + +```python +# -*- coding: utf-8 -*- +"""DMF_현황.xlsx 기준선 분석 재현 스크립트""" +import re +import collections +import pandas as pd +import openpyxl + +XLSX = r"C:\Users\encep\OneDrive\문서\카카오톡 받은 파일\DMF_현황.xlsx" + + +def inspect_structure(path: str) -> None: + """시트 구조와 서식 상태를 출력한다.""" + wb = openpyxl.load_workbook(path, data_only=True) + for ws in wb.worksheets: + rules = sum(len(r.rules) for r in ws.conditional_formatting) + print(f"[{ws.title}] rows={ws.max_row} cols={ws.max_column} " + f"freeze={ws.freeze_panes} filter={ws.auto_filter.ref} " + f"cf_rules={rules} charts={len(ws._charts)}") + + +def normalize_countries(value: str) -> str: + """콤마 다중값에서 중복을 제거하고 정렬해 재결합한다.""" + if not isinstance(value, str) or not value.strip(): + return "" + parts = [p.strip() for p in value.split(",") if p.strip()] + return ",".join(sorted(set(parts))) + + +def normalize_ingredient(value: str) -> str: + """성분명 그룹 키. 공백·중점·하이픈·괄호를 제거한다.""" + return re.sub(r"[\s\u00a0()()·・\-–—,]", "", str(value)).lower() + + +def normalize_entity(value: str) -> str: + """업체명 그룹 키. 법인 표기를 통일한다.""" + s = str(value).replace("㈜", "(주)") + s = re.sub(r"주식회사", "(주)", s) + return re.sub(r"[\s\u00a0().]", "", s).lower() + + +def classify_permit_no(value: str) -> str: + """등록번호 포맷을 4종으로 분류한다.""" + if re.match(r"^\d{8}-\d+-[A-Z]+-\d+(-\d+)?$", value): + return "std" + if re.match(r"^\d{8}-\d+-[A-Z]+-\d+(-\d+)?\([^)]*\)$", value): + return "std_paren" + if re.match(r"^수\d+-\d+-[A-Z]+", value): + return "new_substance" + return "other" + + +def analyze(path: str) -> None: + df = pd.read_excel(path, sheet_name="전체", dtype=str) + print(f"행수={len(df)} 고유등록번호={df['등록번호'].nunique()}") + + issued = pd.to_datetime(df["발급일자"], errors="coerce") + print(f"발급일자 {issued.min().date()} ~ {issued.max().date()}") + + # 등록번호 앞 8자리 vs 발급일자 + std = df[df["등록번호"].str.match(r"^\d{8}-")] + head = pd.to_datetime(std["등록번호"].str[:8], format="%Y%m%d", errors="coerce") + same = (head == pd.to_datetime(std["발급일자"], errors="coerce")).sum() + print(f"std {len(std)}건 중 앞8자리==발급일자 {same}건 ({same / len(std) * 100:.1f}%)") + + # 국가 중복 표기 + redundant = df["제조국가명"].fillna("").apply( + lambda v: "," in v and len({p.strip() for p in v.split(",")}) != len(v.split(",")) + ) + print(f"국가 중복 표기 {redundant.sum()}건") + + # 표기 흔들림 + for name, fn, col in ( + ("성분명", normalize_ingredient, "성분명"), + ("업체명", normalize_entity, "업체명"), + ): + groups = collections.defaultdict(set) + for v in df[col].fillna(""): + groups[fn(v)].add(v) + collisions = {k: v for k, v in groups.items() if len(v) > 1} + print(f"{name} 표기 흔들림 그룹 {len(collisions)}개") + + # 포맷 분포 + counts = collections.Counter(classify_permit_no(v) for v in df["등록번호"].fillna("")) + print("등록번호 포맷:", dict(counts)) + + +if __name__ == "__main__": + inspect_structure(XLSX) + analyze(XLSX) +``` + +--- + +## 부록 B. 미해결 / 확인 필요 + +**사용자에게 확인해야 할 것** + +- [ ] 이 xlsx 를 만든 **수집 스크립트가 어디에 있는가?** 있다면 재사용·개선의 출발점이 된다. +- [ ] **`serviceKey` 를 이미 발급받았는가?** 프로토타입이 API 수집에 성공했으므로 키가 존재할 것이다. 어디에 저장돼 있는가. +- [ ] 이 파일이 카카오톡으로 전달된 경위 — 본인이 만든 것인가, 다른 사람이 만든 것인가. +- [ ] `신규` 시트가 비어 있는데, 기대하는 동작이 "당일 신규만" 인가 "최근 N일" 인가. +- [ ] `갱신이력` 시트에 추가로 기록하고 싶은 항목이 있는가 (변경건수, 취하건수, 소요시간, 오류). + +**데이터로 확인해야 할 것** + +- [ ] **API 응답 건수 9,084 가 실제로 정상 건만인지** 연속 2일 이상 수집해 소멸 레코드를 관찰 +- [ ] 웹 9,840 − API 9,084 = 756건이 정말 취하·취소 건인지 표본 대조 +- [ ] 연차보고가 일어날 때 `발급일자`가 갱신되는가 (1~2월에 관측) +- [ ] 변경 시 등록번호의 괄호 부분이 실제로 바뀌는가, 아니면 번호는 그대로이고 발급일자만 바뀌는가 +- [ ] `numOfRows` 최대값과 전량 수집에 필요한 호출 횟수 +- [ ] 제조국가명 결측 5건의 등록번호와 원인 +- [ ] "기타" 포맷 455건의 하위 패턴 분류 + +**설계에 반영해야 할 것** + +- [ ] `research/01` 의 "API 로는 변경·취하 탐지 불가" 결론을 이 분석 결과로 정정 +- [ ] `research/07` 의 시트 컬럼 스펙을 API 7필드 + 파생 필드 기준으로 재작성 +- [ ] 국가 코드 매핑 테이블 작성 (49개국, 비표준 표기 포함) + +--- + +*이 문서는 기준선 사실의 정본이다. 데이터가 갱신되면 부록 A 스크립트로 재분석하고 수치를 갱신한다.* diff --git a/docs/design/01-architecture.md b/docs/design/01-architecture.md new file mode 100644 index 0000000..8466ab4 --- /dev/null +++ b/docs/design/01-architecture.md @@ -0,0 +1,1384 @@ +# DMF Crawler 아키텍처 확정안 + +> **이 문서의 역할**: 후보 3안과 3개 렌즈 심사를 종합해 **구현이 그대로 시작될 수 있는 단일 설계 정본**을 확정한다. 이 문서의 디렉터리 트리를 그대로 만들고 모듈 계약대로 채우면 프로젝트가 완성된다. +> +> **상위 문서**: `docs/00-REQUIREMENTS.md`(요구 SSOT), `docs/design/00-DATA-SOURCE-DECISION.md`(데이터 소스 SSOT), `docs/research/05a-agy-cli-ssot.md`(agy SSOT), `docs/research/06-xlsx-linking-and-formatting.md`(xlsx SSOT), `docs/research/07-pharma-excel-dashboard-design.md`(대시보드 SSOT). **충돌 시 이 다섯 문서가 우선한다.** + +**확정일**: 2026-09-02 +**대상 환경**: Windows 11 Pro 10.0.26220, Python 3.14.6 (실측), PowerShell 7 +**상태**: ✅ 확정 — M0 착수 가능 + +**2026-09-03 정정**: 사용자 요구에 따라 공식 API 단일 소스 전제를 수정한다. 수집 경로는 ① 인증키가 있으면 공식 Open API 우선 ② 인증키가 없거나 `source.mode=browser` 이면 **AGY headful/visible Chrome 으로 의약품안전나라 CCBAC03 xlsx 다운로드**다. 수동 smoke `run_20260903_130332` 에서 visible Chrome + `https://nedrug.mfds.go.kr/pbp/CCBAC03/getExcel` 다운로드, 9,816건 정규화·저장·리포트 생성까지 성공했다. AGY 권한은 `mcp(chrome-devtools/*)`, `execute_url(nedrug.mfds.go.kr)`만 좁게 허용한다. AI 요약용 headless `agy` 호출과 수집용 headful `agy` 호출은 별개다. 아래 ADR 중 ADR-01/02/06/15 의 원문은 이 정정이 우선한다. + +--- + +## 0. 확정 요약 + +1. **뼈대는 후보 B(SQLite 이벤트 소싱 + 계층 분리)** 다. 3개 렌즈 중 2개(실패 내성 7.5, 진화 7.0)에서 1위이며, 운영 렌즈가 A를 뽑으면서도 "A 채택의 조건"으로 요구한 다섯 개 이식 항목이 전부 B의 핵심이었다. +2. **A(단일 파이프라인)에서 이식**: idempotency 가드, 서브커맨드 체계(`run`/`backfill`/`report-only`/`doctor`), 설정 파일 **1개**(TOML, stdlib `tomllib`), 의존성 최소주의, `agy` 조건부 호출(변화 없으면 미호출). +3. **C(플러그인+오케스트레이터)에서 이식**: `Retry-After` 절대 우선 + full jitter 백오프 + 조건부 요청, 3상태 서킷 브레이커(CLOSED/OPEN/HALF_OPEN), 스테이지 체크포인트 재개, Event Log 병행 기록, AI 제안 자동 적용 금지 원칙. +4. **C에서 이식하지 않은 것**: DAG 위상정렬, 문자열 동적 import 레지스트리, 소스 플러그인 3종 스텁, 설정 5분할. 요구사항이 **단일 공식 API 소스**를 확정했고 해외 소스는 명시적 비목표이므로 소스 추상화는 근거를 잃었다. +5. **세 후보 모두가 뚫려 있던 구멍을 신규로 메웠다**: HTTP 200 + 빈/오염 응답 판정, diff 안전장치 5종(요구 R2.2), xlsx 원자적 교체, 자동 백업(`VACUUM INTO`), 디스크 여유 점검, 마이그레이션 롤백 경로. +6. **세 후보 모두가 몰랐던 요구를 반영했다**: 요구 SSOT의 R6(비개발자용 GUI 온보딩 마법사)·R7(강제 모달 복구 창). 배치는 **UI를 절대 띄우지 않고** 알림 의도만 기록하며, 대화형 세션에서 도는 별도 작업이 그것을 화면에 띄운다 — 이것이 S4U 세션에 데스크톱이 없다는 Windows 제약(3개 렌즈가 모두 지적)의 유일한 구조적 해법이다. +7. **온보딩과 복구는 같은 컴포넌트**다(요구 4절). `checks.py`의 진단 엔진 하나를 CLI(`doctor`)와 GUI(`onboard`)가 각각 렌더링할 뿐이다. +8. **런타임 의존성은 3개**(`httpx`, `XlsxWriter`, `jsonschema`). `pandas`·`openpyxl`·LibreOffice 재계산·PyYAML은 전부 기각했다(부록 참조). +9. **`agy`는 두 용도다.** 수집용 `agy` 는 `source.mode=browser` 또는 auto+키 없음에서 headful Chrome 으로 xlsx 를 받는다. 요약용 `agy` 는 diff 이후 부가 계층이며 죽어도 xlsx는 나온다. 스키마 레벨에서 `enrichment` 테이블을 `events`와 분리해 이를 강제한다. +10. 과잉 설계 억제: 소스 추상화·DAG 엔진·웹 대시보드·해외 소스는 **"향후 확장 지점"으로만 표시**하고 코드를 만들지 않는다. + +--- + +## 1. 설계 결정 기록(ADR) + +각 행은 하나의 되돌리기 어려운 결정이다. "대안"은 실제로 검토한 것만 적었다. + +| # | 결정 | 대안 | 선택 이유 | 트레이드오프 | +|---|---|---|---|---| +| **ADR-01** | **데이터 소스는 공식 API 우선 + AGY headful browser 폴백/강제 모드** | 공식 API 단일, nedrug HTML 파싱, 다중 플러그인 | 인증키가 있으면 공식 API가 가장 단순하고 안정적이다. 단, 사용자가 여러 차례 **AGY가 headful Chrome 으로 사람처럼 수집해야 한다**고 확정했으므로, `source.mode=auto`+키 없음 또는 `source.mode=browser` 에서는 AGY가 visible Chrome 으로 `CCBAC03/getExcel` xlsx 를 다운로드한다 | 공식 API 7필드와 nedrug xlsx 필드가 다르므로 sheet XML 검증·헤더 alias 매핑이 필요하다. CCBAC03 xlsx 의 파일명은 generic 하게 `의약품등심사결과공개.xlsx` 일 수 있어 파일명만으로 wrong export 라고 판단하지 않는다. 봇 차단 가능성은 0으로 보장하지 않으며 스텔스/우회 기법은 쓰지 않는다 | +| **ADR-02** | **무거운 플러그인 registry 는 만들지 않지만 수집 분기는 둔다.** `source_mfds.py`(API) + `agy/browser_collect.py`(headful xlsx) | 후보 B의 `SourceAdapter`, 후보 C의 `sources.yaml` + 문자열 동적 import | 소스는 2개가 됐지만 동적 import registry 는 여전히 과하다. 파이프라인 `stage_fetch` 가 `source.mode` 와 키 존재 여부로 명시 분기한다 | 세 번째 소스가 생기면 그때 `fetch_all` 프로토콜 승격을 검토한다 | +| **ADR-03** | **저장소는 SQLite 단일 파일 + 이벤트 소싱.** 일자별 전량 스냅샷과 이벤트를 **append-only**로 영구 축적 | CSV/Parquet 파일, 최신 상태만 덮어쓰는 단순 테이블, PostgreSQL | diff의 정확성은 "직전 상태와의 완전한 비교"를 요구하고, RA 실무는 몇 달 전 상태 소급 조회를 요구한다. 트랜잭션·인덱스·부분쓰기 방어가 파일 하나로 해결된다 | 파일 하나가 단일 장애점이다 → ADR-22(자동 백업)로 상쇄. DB가 연 단위로 커진다 → 원문 아카이브에 보존 기간을 둔다 | +| **ADR-04** | **스키마 진화는 번호순 SQL 마이그레이션 러너 + `schema_version` 테이블.** 적용 직전 `VACUUM INTO`로 백업본을 뜬다 | alembic, 수작업 `ALTER`(후보 A·C), 마이그레이션 없음 | 앞으로 올 변경 다수가 스키마 변경이다(진화 렌즈). alembic은 ORM 없는 프로젝트에 과하다. **백업본이 곧 롤백 경로**여서 세 후보가 공통으로 빠뜨린 "롤백 없음" 결함이 수십 줄로 해소된다 | 다운 마이그레이션 SQL은 쓰지 않는다. 되돌리기 = 백업본 복원 + 코드 되돌리기. 1인 운영에는 충분하다 | +| **ADR-05** | **HTTP 클라이언트는 `httpx`** (동기 `Client`) | `requests`, stdlib `urllib.request`, `aiohttp`, Scrapy | 데이터 소스 SSOT §9가 `params=` 자동 인코딩을 전제로 **Decoding 키 사용**을 확정했다(인코딩 키 혼동은 이 API의 1위 실패 원인). `httpx`는 타임아웃을 연결/읽기로 분리해 지정할 수 있어 배치에 유리하다 | 의존성 1개 추가. `requests`와 실질 차이는 작지만 SSOT가 이미 이 전제로 쓰여 있어 일관성을 택했다 | +| **ADR-06** | **Playwright/Selenium 은 쓰지 않고, 브라우저가 필요할 때는 AGY + chrome-devtools MCP 로 headful Chrome 만 쓴다.** | Playwright 스텔스, undetected-chromedriver, headless 브라우저 | 사용자가 headful Chrome 수집을 요구했다. 파이썬 의존성은 늘리지 않고 AGY가 이미 가진 chrome-devtools MCP 를 이용한다. curl/headless/스텔스 우회는 금지한다 | AGY/Chrome 상태와 권한(`mcp(chrome-devtools/*)`, `execute_url(nedrug.mfds.go.kr)`)에 의존하므로 실패 시 마지막 성공 자료로 리포트를 만든다 | +| **ADR-07** | **xlsx 쓰기는 `XlsxWriter` 100%, 매일 새 파일 통째 생성** | `openpyxl` 갱신, `pandas.ExcelWriter(mode='a')`, 템플릿 편집 | xlsx SSOT §3.2 결론 그대로다. 스파크라인 API는 XlsxWriter에만 있고(요구 R3.4), `openpyxl`은 `load_workbook`→`save`에서 차트·이미지·도형을 잃는다. 매일 새 파일이면 손실 문제 자체가 소멸하고 날짜별 증빙이 남는다 | 기존 파일 편집 불가. `report-only`가 항상 전체 재생성이지만, DB가 정본이므로 비용이 아니라 이점이다 | +| **ADR-08** | **리포트의 모든 숫자는 파이썬이 계산해 값으로 쓴다. LibreOffice headless 재계산 단계를 두지 않는다** | LibreOffice 재계산 파이프라인(후보 A), 수식 자유 사용 | xlsx SSOT §0·§6: XlsxWriter는 수식 결과에 `0`을 쓰고 재계산 플래그만 세운다. 재계산을 옵션으로 두면 미설치 시 **조용히 스킵**되어 수식 캐시가 빈 리포트가 몇 주 나간다(운영 렌즈가 A의 치명 결함으로 지목) | 사용자가 필터를 바꿔도 살아 움직여야 하는 셀은 `write_formula(..., value=사전계산값)`으로 캐시값을 박아 넣는다. 동적 배열 함수(FILTER/UNIQUE/XLOOKUP)는 쓰지 않는다 | +| **ADR-09** | **집계는 SQLite SQL로 한다. `pandas`를 쓰지 않는다** | `pandas` 기반 집계(후보 B·C가 전제) | 데이터가 이미 SQLite에 있고 필요한 집계는 `GROUP BY` 수준이다. 요구 N5(의존성 최소)와 부팅 시간·설치 실패 표면을 고려하면 정당화되지 않는다 | 복잡한 피벗 형태 집계는 SQL이 장황하다. 시트별 조회는 `report/data.py`에 SQL을 모아 상수로 관리한다 | +| **ADR-10** | **스케줄러는 Windows Task Scheduler, 작업 3개.** 배치는 **S4U(비대화형)**, UI를 띄우는 에이전트는 **Interactive(로그온 세션)** 로 분리 | 단일 작업(후보 A·C), Windows Service, WSL cron | `agy` OAuth 토큰이 사용자 프로필 평문 파일이므로 SYSTEM 금지·동일 사용자 계정 필수다(agy SSOT §4.2). 그런데 S4U 세션에는 데스크톱이 없어 토스트·모달이 뜨지 않는다. **알림 발생과 알림 표시를 분리**하는 것이 요구 R7.2(강제 창)를 만족하는 유일한 구조다 | 사용자가 로그오프 상태로 방치하면 화면 알림이 지연된다. Event Log·`state/alerts.json`에 흔적을 남기고 다음 로그온 시 표시한다(요구 4절 각주와 동일한 결론) | +| **ADR-11** | **배치 프로세스는 어떤 UI도 띄우지 않는다.** `alerts` 테이블 + `state/alerts.json`에 알림 **의도**만 기록한다 | 배치가 직접 BurntToast/모달 호출(후보 A) | ADR-10의 귀결. 후보 A는 `delivered=True`를 기록하면서 화면에는 아무것도 뜨지 않는 조용한 실패를 만든다(실패 내성 렌즈 최대 감점 항목) | 알림 지연이 최대 15분(에이전트 반복 주기)이다. 요구 N3(24시간 내 인지) 대비 충분한 여유다 | +| **ADR-12** | **GUI 툴킷은 stdlib `tkinter`.** 온보딩·복구·토스트를 전부 여기서 그린다 | PySide6/PyQt, WinForms(pythonnet), 웹 UI(FastAPI+브라우저), BurntToast(PowerShell 모듈) | 요구 N5(의존성 최소)와 R6.2(콘솔 창이 보이면 안 됨)를 동시에 만족한다. `pythonw.exe`로 실행하면 콘솔이 없다. BurntToast는 외부 PowerShell 모듈 설치가 전제여서 "설치가 안 되면 알림도 안 됨"이라는 순환 실패를 만든다 | 진짜 Windows 토스트(액션 센터 잔류)가 아니라 우상단 자동 소멸 창이다. 액션 센터 이력이 필요하면 `win11toast`를 선택 의존성으로 켤 수 있는 자리만 남긴다(구현하지 않음) | +| **ADR-13** | **API 키는 Windows DPAPI(`CryptProtectData`, 사용자 범위)로 암호화해 `%LOCALAPPDATA%`에 저장** | 환경변수(후보 A), 평문 파일 `secrets/*.txt`(후보 B), `.env` | 요구 R6.3의 사용자는 환경변수가 뭔지 모른다. GUI에서 입력받아 즉시 암호화 저장해야 한다. DPAPI는 `ctypes`만으로 호출 가능해 의존성이 0이고, 같은 사용자 계정에서만 복호화된다 — S4U 배치가 정확히 그 계정이다 | 다른 PC로 폴더를 복사하면 키가 복호화되지 않는다. 요구 N6(이식성)은 "폴더 복사 + 온보딩 재실행"으로 정의돼 있으므로 오히려 요구와 일치한다 | +| **ADR-14** | **설정은 `config/config.toml` 단 하나.** stdlib `tomllib`로 읽는다 | YAML 다중 파일(후보 B 4개, 후보 C 5개), JSON, INI | 운영 렌즈가 C를 5.5로 깎은 첫 번째 이유가 설정 6분할이다. 06:05에 "어느 파일을 열지"부터 정해야 하는 시스템은 5분 안에 안 고쳐진다. PyYAML 의존성도 사라진다 | 파일 하나가 길어진다(약 90줄). 섹션 주석으로 가독성을 보완한다 | +| **ADR-15** | **`agy`는 수집용(headful)과 요약용(headless)을 분리한다.** 요약용 agy 는 diff·저장 뒤 부가 계층이고, 수집용 agy 는 `stage_fetch` 의 browser 경로다 | agy를 전부 부가 계층으로만 둠, agy로 셀렉터 자가복구 | 사용자 요구상 headful 수집 경로가 필요하다. 다만 요구 R4.5("AGY/AI가 실패해도 리포트는 생성")는 유지해 browser 수집 실패도 스테일 폴백으로 처리한다. `enrichment` 테이블 분리는 요약용 산출물에만 적용된다 | agy/Chrome 문제로 최신 수집이 실패할 수 있다. 이 경우 마지막 성공 자료로 리포트를 만들고 WARN 알림을 남긴다 | +| **ADR-16** | **`agy` 호출은 하루 최대 1회, diff가 비어 있지 않을 때만.** 모든 질의(브리핑+이상해석+정규화 후보)를 한 프롬프트에 묶는다 | 역할별 다중 호출(A1~A7 각각), 매 실행 헬스체크 | agy SSOT §0·§4.3 실측: 첫 호출 오버헤드가 input 28,317 토큰 / 33.7초다. 변화 없는 날 이 비용을 태우면 쿼터 소진 시점만 앞당긴다. 인증 상태는 별도 헬스체크가 아니라 **실제 작업 호출의 결과로 판정**한다 | 프롬프트가 길어지고 한 번 실패하면 모든 AI 산출물이 함께 빠진다. graceful degradation이 이미 전제이므로 수용한다 | +| **ADR-17** | **`--json-schema`의 `structured_output`을 신뢰하지 않는다.** 자체 균형괄호 추출 + `jsonschema` 검증 + 1회 강화 재시도 | `--json-schema` 단독 신뢰 | agy SSOT §7.2 실측: `structured_output`이 아예 없었고 `response`에 JSON 조각이 4회 반복 + 한국어 산문과 혼입됐으며 토큰이 3배로 뛰었다 | 코드 약 60줄과 테스트 픽스처가 늘어난다. SSOT가 실측으로 증명한 함정을 프로덕션에 노출시키는 것보다 싸다 | +| **ADR-18** | **오케스트레이션은 `pipeline.py`의 순차 `STAGES` 리스트 + 스테이지 체크포인트.** DAG 위상정렬을 쓰지 않는다 | TaskGraph 위상정렬(후보 C), Airflow/Prefect, 오케스트레이션 없음(후보 A) | 7단계 선형 흐름에 병렬성도 동적 브랜칭도 없다. 그러나 "스테이지 추가 = 모듈 하나 + 리스트 한 줄"이라는 확장 문법과, `RestartCount` 재시도 시 성공한 스테이지를 건너뛰는 체크포인트는 실제 가치가 있다(진화·실패 내성 렌즈) | 스테이지 순서가 코드에 있어 배포 없이 못 바꾼다. 1인 운영에서 순서 변경은 코드 변경과 같은 사건이므로 문제가 아니다 | +| **ADR-19** | **로깅은 stdlib `logging`.** 실행 1회당 `logs/run_/` 디렉터리에 `pipeline.log`(사람용) + `events.jsonl`(기계용) + `agy.stdout.json` / `agy.stderr.log` 분리 | `structlog`, 단일 롤링 파일, print | agy SSOT §5.3 규약이 stdout/stderr 혼합을 금지한다. 실행 단위 디렉터리는 "가장 최근 디렉터리를 열면 끝"이라는 조사 시작점을 만든다(운영 렌즈가 A의 강점으로 꼽은 항목) | 디렉터리 수가 늘어난다. `logging.retain_days`(기본 90)로 정리한다 | +| **ADR-20** | **패키지 관리는 `pyproject.toml` + `venv` + `pip`.** 프로젝트 로컬 `.venv` | poetry, uv, conda, 시스템 파이썬 직접 사용 | 비개발자 PC에 추가 도구를 설치시키지 않는다. `bootstrap.cmd`가 `python -m venv .venv` 후 `pip install -e .` 한 줄로 끝난다 | 잠금 파일(lock)이 없다. `pyproject.toml`에 상한 버전을 명시해 대체한다 | +| **ADR-21** | **테스트는 `pytest`. 실제 API 응답을 고정 픽스처로 박아 넣는 골든 테스트가 중심** | 테스트 없음, tox/nox 매트릭스, 라이브 API 통합 테스트 | diff 오탐(요구 R2.2)과 agy 응답 오염 파싱(ADR-17)이 이 프로젝트의 두 급소다. 둘 다 픽스처만 있으면 오프라인에서 결정론적으로 검증된다 | 커버리지 목표를 세우지 않는다. 급소 4모듈(`normalize`/`integrity`/`diff`/`agy.extract`)만 촘촘히 덮는다 | +| **ADR-22** | **파이프라인 성공 직후 `VACUUM INTO`로 날짜별 백업본을 다른 경로에 생성.** 보존 개수 제한 | README 안내에만 의존(후보 A), 설계 밖으로 미룸(후보 B) | 세 후보가 공통으로 빠뜨렸고 세 렌즈가 모두 지적했다. 1인 운영에서 문서로 미룬 절차는 6개월 안에 잊힌다. `VACUUM INTO`는 SQLite 내장이고 일관된 스냅샷을 보장한다 | 백업 경로가 같은 디스크면 디스크 장애를 못 막는다. `config`의 `backup.dir`을 다른 드라이브/OneDrive 경로로 지정하도록 온보딩에서 유도한다 | +| **ADR-23** | **중복 실행 방어는 3중**: ① 코드의 idempotency 가드(오늘 SUCCESS면 즉시 종료 0) ② 파일 락(`msvcrt.locking`) ③ Task Scheduler `MultipleInstances=IgnoreNew` | 스케줄러 설정에만 의존(후보 B·C) | `IgnoreNew`는 동시 실행만 막지 06:00 실행 후 재부팅 캐치업 같은 **순차 재실행**을 못 막는다. append-only 스키마는 하필 여기에 가장 취약하다(실패 내성 렌즈가 B의 최대 구멍으로 지목) | 의도적 재실행에는 `--force`가 필요하다. `report-only`는 가드 대상이 아니다 | +| **ADR-24** | **온보딩(R6)과 오류 복구(R7)는 `checks.py` 하나를 공유하는 같은 컴포넌트.** CLI `doctor`와 GUI `onboard`는 렌더러일 뿐 | 온보딩 마법사와 복구 창을 별도 구현 | 요구 4절이 명시한 원칙이다. 진단 로직이 한 곳에 있어야 온보딩과 복구가 어긋나지 않고, 새 전제조건이 생겨도 한 곳만 고친다 | GUI와 CLI가 같은 체크 목록에 묶여 유연성이 준다. 그것이 의도다 | +| **ADR-25** | **AI가 제안한 어떤 것도 자동 적용하지 않는다.** 스키마 변경 대응(A7)·정규화 후보(A2)는 `state/proposals/`에 저장하고 사람이 승인 | 자동 반영 | 후보 C의 유일한 성숙한 안전 원칙이자 데이터 소스 SSOT §6(A7)의 명시 규칙이다. 틀린 제안이 조용히 잘못된 데이터를 수집하는 2차 사고를 원천 차단한다 | 사람이 개입할 때까지 대응이 지연된다. 그 지연이 오탐 데이터가 영구 기록되는 것보다 싸다 | + +--- + +## 2. 전체 디렉터리 구조 + +이 트리를 그대로 만들면 프로젝트가 시작된다. 런타임 생성 경로는 `(런타임)`으로 표시한다. + +``` +D:\workspace\DMF_Crawler\ +├── bootstrap.cmd # 최초 설치 진입점. venv 생성→pip install→바탕화면 바로가기 생성→온보딩 GUI 기동 +├── pyproject.toml # 패키지 메타·의존성 3개 상한 고정·엔트리포인트·pytest 설정 +├── README.md # 설치·최초 인증·수동 실행·장애 대응 5분 가이드 +├── .gitignore # .venv/ data/ logs/ reports/ state/ backup/ config.local.toml *oauth-token* +│ +├── config\ +│ ├── config.toml # 유일한 설정 정본 (6절 전체 키 스펙) +│ └── config.local.toml.example # PC별 오버라이드 예시(경로·백업 드라이브). 실제 파일은 gitignore +│ +├── prompts\ +│ ├── daily_briefing.md # agy 단일 호출 프롬프트 템플릿. "JSON 객체 하나만 출력" 강제 문구 포함 +│ └── daily_briefing.schema.json # 자체 jsonschema 검증 스키마(--json-schema는 보조 수단) +│ +├── scripts\ +│ ├── install_tasks.ps1 # Task Scheduler 작업 3종 idempotent 등록(Daily/Agent/AgyUpdate) +│ ├── uninstall_tasks.ps1 # 작업 3종 제거 +│ ├── make_shortcuts.ps1 # "DMF 설정.lnk" / "지금 실행.lnk" 생성(pythonw 대상, 콘솔 창 없음) +│ └── bootstrap_agy.ps1 # agy 존재 확인 → 미설치 시 install.ps1 무인 실행 → 버전 출력 +│ +├── docs\ +│ ├── 00-REQUIREMENTS.md # 요구사항 SSOT (기존) +│ ├── README.md # 문서 지도 (기존) +│ ├── design\ +│ │ ├── 00-DATA-SOURCE-DECISION.md # 데이터 소스 SSOT (기존) +│ │ ├── 01-architecture.md # 이 문서 — 아키텍처 확정안 +│ │ ├── 02-data-model.md # 스키마 DDL·정규화 규칙·비교 필드 확정 (M1에서 작성) +│ │ ├── 03-xlsx-report-spec.md # 시트 8종 셀 단위 명세 (M3에서 작성) +│ │ └── 04-onboarding-wizard.md # GUI 화면 전이·문구 명세 (M4에서 작성) +│ ├── ops\ +│ │ ├── 01-scheduling-and-resilience.md # 작업 등록 파라미터·재부팅 시나리오 검증 절차 (M2) +│ │ ├── 02-failure-alerting.md # 알림 등급·문구 4요소 규격·쿨다운 (M4) +│ │ └── 03-api-usage-policy.md # 호출 빈도·출처 표시·이용약관 준수 기록 (M1) +│ └── research\ # 리서치 정본 (기존, 01/03/04/05/05a/06/07) +│ +├── src\ +│ └── dmf_crawler\ +│ ├── __init__.py # __version__ 상수만 +│ ├── __main__.py # `python -m dmf_crawler` 진입. cli.main(sys.argv[1:]) 위임 +│ ├── cli.py # argparse 서브커맨드 정의 → 각 커맨드 함수 매핑. 로직 없음 +│ ├── paths.py # 프로젝트 루트·data·logs·reports·state·backup 경로 상수 계산 +│ ├── config.py # config.toml + config.local.toml 병합 → 불변 Config 데이터클래스, 검증 +│ ├── errors.py # 예외 계층: DmfError → ConfigError/FetchError/IntegrityError/StorageError/ReportError/AgyError +│ ├── logging_setup.py # run_id별 로그 디렉터리·핸들러(pipeline.log + events.jsonl) 구성 +│ ├── runlock.py # msvcrt 기반 배타 파일 락. 컨텍스트 매니저 +│ ├── secrets_dpapi.py # ctypes로 CryptProtectData/CryptUnprotectData 호출. 키 저장·읽기·삭제 +│ ├── pipeline.py # STAGES 순차 실행기. 체크포인트 조회/기록, 스테이지 예외 흡수 +│ ├── http.py # httpx 얇은 래퍼. Retry-After 우선·full jitter 백오프·조건부 요청·UA 고정 +│ ├── source_mfds.py # 공식 API 전량 수집. totalCount 확인·페이지네이션·원문 아카이브 저장 +│ ├── normalize.py # 원본 7필드 → DmfRecord 표준화. dmf_key·content_hash 산출 +│ ├── integrity.py # 요구 R2.2 안전장치 5종 판정. 통과 못하면 diff 차단 +│ ├── diff.py # 순수 함수. 전일 스냅샷 vs 금일 → NEW/CHANGED/WITHDRAWN +│ ├── health.py # 3상태 서킷 브레이커(CLOSED/OPEN/HALF_OPEN). 소스·agy 공용 +│ ├── checks.py # 진단 엔진. Check 12종 정의·실행. doctor CLI와 GUI가 공유 +│ ├── backup.py # VACUUM INTO 날짜별 백업·보존 개수 정리·복원 안내 생성 +│ ├── watchdog.py # heartbeat 신선도 판정. 미갱신 시 alerts에 CRITICAL 기록 +│ ├── alerts.py # 알림 의도 기록/조회/중복 억제(dedup_key + 쿨다운)/해소 표시 +│ │ +│ ├── storage\ +│ │ ├── __init__.py +│ │ ├── db.py # 연결 생성(WAL·foreign_keys=ON·busy_timeout)·마이그레이션 러너 +│ │ ├── repo.py # 유일한 읽기/쓰기 경로. run/snapshot/event/enrichment/agy_call/health/alert +│ │ └── migrations\ +│ │ ├── 0001_init.sql # runs/stage_status/fetch_stats/snapshots/records/events 최초 스키마 +│ │ ├── 0002_enrichment.sql # enrichment/enrichment_run/agy_calls (AI 계층 분리) +│ │ └── 0003_ops.sql # component_health/alerts/watchlist/schema_version 인덱스 +│ │ +│ ├── agy\ +│ │ ├── __init__.py +│ │ ├── client.py # agy.exe subprocess 실행. 예외 대신 AgyEnvelope 값으로 반환 +│ │ ├── prompt.py # diff+지표 → daily_briefing.md 템플릿 렌더링. 데이터 구분자 감싸기 +│ │ ├── extract.py # response에서 균형괄호 JSON 추출 + jsonschema 검증 (SSOT §7.3 포팅) +│ │ └── budget.py # 일일 토큰 상한 강제. agy_calls 누적 조회·초과 판정 +│ │ +│ ├── report\ +│ │ ├── __init__.py # build_report(run_id) 단일 공개 함수 +│ │ ├── data.py # DB → ReportData 조회 전담. 시트별 SQL 상수 모음 +│ │ ├── build.py # 워크북 조립 오케스트레이션. 시트 생성 순서·저장 호출 +│ │ ├── theme.py # Okabe-Ito 팔레트·맑은 고딕·상태 매핑·Format 객체 캐시 +│ │ ├── widgets.py # KPI 타일·델타 화살표 서식·목차 링크·한글 열너비 계산 유틸 +│ │ ├── atomic.py # 임시파일 → os.replace 원자 교체. 잠김 시 재시도·폴백 파일명 +│ │ └── sheets\ +│ │ ├── __init__.py # SHEET_BUILDERS 순서 리스트 +│ │ ├── s00_dashboard.py # 대시보드: KPI 6타일·차트 2종·스파크라인·각 시트 링크·상태 배너 +│ │ ├── s01_changes.py # 오늘 변경분: 신규/변경/취하 통합 표. 상태 3중 코딩·행 강조 +│ │ ├── s02_ledger.py # 전체 누적 원장. Excel 표·자동필터·틀고정·nedrug 검색 링크 +│ │ ├── s03_ingredient.py # 성분별 집계 Top N + 추이 +│ │ ├── s04_company.py # 업체·제조국별 집계 Top N +│ │ ├── s05_watchlist.py # 워치리스트 히트. 목록이 비면 시트 자체를 만들지 않음 +│ │ ├── s06_trend.py # 일자별 건수 시계열(스파크라인·차트 원본) +│ │ └── s99_meta.py # 수집 시각·소스 URL·건수·해시·실행 로그 경로·AI 상태 +│ │ +│ ├── notify\ +│ │ ├── __init__.py +│ │ ├── pump.py # 대화형 에이전트 본체. 워치독 판정 → 알림 표시 → 복구 GUI 기동 +│ │ ├── toast.py # tkinter 우상단 자동소멸 알림 창(WARN/INFO용) +│ │ ├── eventlog.py # eventcreate.exe로 Windows 이벤트 로그 병행 기록 +│ │ └── messages.py # 알림 문구 4요소(무엇/왜/어떻게/다음행동) 템플릿 렌더러 +│ │ +│ └── gui\ +│ ├── __init__.py +│ ├── app.py # 온보딩=복구 단일 창. checks.py 결과를 체크리스트로 렌더 +│ ├── steps.py # 단계별 액션: 키 입력·발급페이지 열기·agy 설치·재로그인·작업 등록 +│ └── widgets.py # 공통 위젯: 상태 뱃지·진행률 바·스크롤 프레임·복사 가능 로그 영역 +│ +├── tests\ +│ ├── conftest.py # 임시 DB·설정 픽스처. 네트워크 차단 픽스처 +│ ├── fixtures\ +│ │ ├── api_page_ok.json # 정상 API 응답(실제 응답 저장본) +│ │ ├── api_page_empty.json # resultCode 00 + items 0건 (200-빈응답 케이스) +│ │ ├── api_error_invalid_key.xml # 인증키 무효 응답 원문 +│ │ ├── api_html_block.html # 200인데 본문이 HTML 안내 페이지인 케이스 +│ │ ├── agy_envelope_clean.json # agy 정상 봉투 +│ │ ├── agy_envelope_polluted.json # SSOT §7.2 실측 오염 사례(JSON 4회 반복+산문 혼입) +│ │ └── snapshot_prev.json # diff 비교용 전일 스냅샷 +│ ├── test_normalize.py # dmf_key 안정성·중복 등록번호 처리·국가 다중값 분해 +│ ├── test_integrity.py # 안전장치 5종 각각의 통과/차단 경계 +│ ├── test_diff.py # NEW/CHANGED/WITHDRAWN 판정·필드별 변경 추출 +│ ├── test_agy_extract.py # 오염 응답에서의 JSON 추출·스키마 검증 실패 경로 +│ ├── test_http_retry.py # Retry-After 우선·지터 범위·조건부 요청 304 처리 +│ ├── test_report_atomic.py # 잠긴 파일 시 폴백 파일명 생성·원자 교체 +│ ├── test_repo_idempotency.py # 같은 run_id 재삽입 시 append-only 무결성 +│ └── test_checks.py # 진단 12종의 통과/실패 판정 +│ +├── data\ # (런타임) git 미추적 +│ ├── dmf.sqlite3 # 정본 저장소 +│ └── raw\YYYY-MM-DD\ # API 원문 응답 페이지별 보존(회귀 픽스처·재파싱 원자료) +│ +├── state\ # (런타임) 잠금 없이 읽는 가벼운 상태 파일 +│ ├── heartbeat.json # 마지막 성공 실행 시각·run_id·리포트 경로 (워치독 전용) +│ ├── alerts.json # 미해소 알림 미러 (에이전트가 SQLite 잠금 없이 읽음) +│ ├── run.lock # 배타 실행 락 파일 +│ └── proposals\ # AI 제안 보관소(자동 적용 금지, 사람 승인 대기) +│ +├── reports\ # (런타임) +│ ├── DMF_리포트_YYYY-MM-DD.xlsx # 일자별 산출물 +│ └── DMF_리포트_최신.xlsx # 최신본 고정 링크(원자적 복사) +│ +├── backup\ # (런타임, 다른 드라이브 권장) +│ └── dmf_YYYY-MM-DD.sqlite3 # VACUUM INTO 백업본 +│ +└── logs\ # (런타임) + └── run_YYYYMMDD_HHMMSS\ + ├── pipeline.log # 사람이 읽는 전체 로그 + ├── events.jsonl # 기계가 읽는 구조화 이벤트 + ├── agy.stdout.json # agy JSON 봉투 원문(감사용) + ├── agy.stderr.log # agy 진단 출력(stdout과 절대 섞지 않음) + └── agy_cli.log # --log-file로 지정한 agy 내부 로그 +``` + +**트리에 없는 것과 그 이유** + +| 없는 것 | 이유 | +|---|---| +| `sources/` 디렉터리와 플러그인 스텁 | ADR-02. 소스가 1개이고 확장은 비목표 | +| `config/*.yaml` 5분할, `permissions.agy.json` | ADR-14. agy 권한은 `~/.gemini/antigravity-cli/settings.json`에 온보딩이 병합 배포한다(별도 파일 두면 병합 스텝이 조용히 실패한다는 운영 렌즈 지적) | +| `orchestrator/graph.py`, `checkpoint.py` 별도 모듈 | ADR-18. `pipeline.py` 하나에 스테이지 실행과 체크포인트가 함께 있다 | +| `normalize/dictionaries/company_aliases.csv` | 수동 큐레이션은 영구 사람 부채다(운영 렌즈). 규칙 기반 정규화 + agy 제안(승인제, ADR-25)으로 대체 | +| `report/recalc.py` | ADR-08 | +| 웹 대시보드, FastAPI | 요구 3절 비목표 | + +--- + +## 3. 모듈별 계약 + +모든 시그니처는 파이썬 3.11+ 타입 힌트 기준이다. 데이터클래스는 전부 `@dataclass(frozen=True, slots=True)`다. + +### 3.1 `config.py` + +- **책임**: `config.toml` + (있으면) `config.local.toml`을 병합해 불변 `Config`를 만들고, 실행 전에 필수 키·경로·타입을 검증한다. **비밀 값은 담지 않는다**(경로와 키 이름만). +- **의존**: `paths`, `errors` +- **공개 API** + +```python +def load_config(path: Path | None = None) -> Config: ... +def validate(cfg: Config) -> list[str]: # 사람이 읽는 문제 목록. 빈 리스트면 정상 + ... +@dataclass(frozen=True, slots=True) +class Config: + general: GeneralCfg + schedule: ScheduleCfg + source: SourceCfg + integrity: IntegrityCfg + storage: StorageCfg + backup: BackupCfg + report: ReportCfg + agy: AgyCfg + notify: NotifyCfg + logging: LoggingCfg +``` + +- **입력**: TOML 파일 경로. **출력**: `Config` +- **실패 시**: 파일 없음·파싱 실패·타입 불일치 → `ConfigError`. 파이프라인은 시작하지 않고 종료 코드 2 + CRITICAL 알림 기록. + +### 3.2 `secrets_dpapi.py` + +- **책임**: 공공데이터포털 serviceKey를 사용자 범위 DPAPI로 암·복호화한다. **평문은 메모리에만 존재하고 로그·예외 메시지에 절대 넣지 않는다**(요구 N7). +- **의존**: `ctypes`(stdlib), `paths` +- **공개 API** + +```python +def save_service_key(plaintext: str) -> Path: ... # %LOCALAPPDATA%\DMF_Crawler\service_key.bin +def load_service_key() -> str | None: ... # 없거나 복호화 실패 시 None +def delete_service_key() -> None: ... +def key_fingerprint() -> str | None: ... # sha256 앞 8자. 로그·GUI 표시용(원문 아님) +``` + +- **실패 시**: 복호화 실패는 예외 없이 `None`. 호출부(`checks.py`)가 "키 미설정"과 동일하게 취급해 GUI 복구 경로로 보낸다. + +### 3.3 `http.py` + +- **책임**: 이 프로젝트의 **유일한 네트워크 창구**. 타임아웃·재시도·정중함 정책을 강제한다. +- **의존**: `httpx`, `config`, `errors` +- **공개 API** + +```python +@dataclass(frozen=True, slots=True) +class HttpResult: + status_code: int + text: str + headers: dict[str, str] + elapsed_seconds: float + from_cache: bool # 304 Not Modified 였는가 + +class HttpClient: + def __init__(self, cfg: SourceCfg, logger: logging.Logger) -> None: ... + def get(self, url: str, params: dict[str, str | int], + etag: str | None = None, + last_modified: str | None = None) -> HttpResult: ... + def close(self) -> None: ... +``` + +- **정책(고정)**: 최대 4회 시도, base 5s · factor 2 · cap 300s · **full jitter**. `429`/`503`의 `Retry-After`가 있으면 **절대 우선**(계산된 백오프를 무시). `If-None-Match`/`If-Modified-Since` 조건부 요청. UA는 `DMF-Crawler/ (+contact: )` 고정. 요청 간 최소 간격 `min_interval_seconds`(기본 0.7s) + 0~0.3s 지터. +- **실패 시**: 재시도 소진 → `FetchError(reason=...)`. 예외에 URL은 담되 `serviceKey`는 **마스킹**한다. + +### 3.4 `source_mfds.py` + +- **책임**: 공식 API에서 DMF 현황 **전량**을 받아오고, 원문을 그대로 아카이브하며, 수집 통계를 남긴다. +- **의존**: `http`, `secrets_dpapi`, `config`, `paths`, `errors` +- **공개 API** + +```python +@dataclass(frozen=True, slots=True) +class RawRecord: + fields: dict[str, str] # 원본 필드명 그대로 (DMF_PERMIT_NO 등 7개) + +@dataclass(frozen=True, slots=True) +class FetchResult: + records: list[RawRecord] + total_count_reported: int # 응답의 totalCount + pages_fetched: int + http_calls: int + elapsed_seconds: float + archive_dir: Path + body_signature_ok: bool # 200인데 빈/차단 본문이 아닌가 + +def fetch_all(cfg: SourceCfg, client: HttpClient, run_id: str) -> FetchResult: ... +def parse_body(text: str, fmt: str) -> tuple[list[RawRecord], int, str]: + """(records, totalCount, resultCode) 반환. JSON/XML 양쪽 지원.""" +``` + +- **동작**: ① `numOfRows=1` 로 `totalCount` 취득 → ② `ceil(totalCount/page_size)` 페이지 순회 → ③ 각 페이지 원문을 `data/raw//page_%04d.json`에 그대로 저장 → ④ 페이지 간 정중 지연. +- **본문 시그니처 검사(신규)**: HTTP 200이어도 다음이면 실패로 간주한다 — 본문 길이 < `min_body_bytes`(기본 200), `resultCode != "00"`, `Content-Type`이 예상과 다름, 본문에 ` tuple[list[DmfRecord], NormalizeStats]: ... +def normalize_one(raw: RawRecord, dup_index: int = 0) -> DmfRecord: ... +def make_dmf_key(permit_no: str, dup_index: int) -> str: ... +def compute_content_hash(rec: DmfRecord) -> str: ... +``` + +- **정규화 규칙**: 유니코드 NFKC, 연속 공백 1개로 축약, 앞뒤 공백 제거, 전각→반각, `(주)`/`㈜` 통일, 국가 다중값은 콤마 분해 후 공백 제거·정렬(순서 흔들림으로 인한 오탐 방지 — 요구 R2.3). +- **중복 등록번호**: 한 스냅샷에 같은 `DMF_PERMIT_NO`가 둘 이상이면 `제조소명` 사전순 정렬 후 `#2`, `#3` 접미사를 붙인다(결정론적). `NormalizeStats.duplicate_permit_no`에 건수를 남겨 `integrity`가 임계값 판정에 쓴다. + > ⚠️ **미검증**: API가 실제로 중복 등록번호를 반환하는지는 데이터 소스 SSOT 부록 B의 미해결 항목이다. serviceKey 발급 직후 실측하고, 중복이 없으면 이 접미사 로직은 무해한 no-op으로 남는다. +- **실패 시**: 개별 레코드 파싱 실패는 예외를 던지지 않고 `NormalizeStats.rejected`에 적재한다. 널 비율 판정은 `integrity`가 한다. + +### 3.6 `integrity.py` + +- **책임**: 요구 R2.2의 **안전장치 5종**을 판정한다. 하나라도 실패하면 그 실행은 **diff를 수행하지 않는다**. 이 프로젝트에서 가장 위험한 실패 모드(전건 취하 오탐)를 막는 단일 관문이다. +- **의존**: `storage.repo`(전일 통계 조회), `config` +- **공개 API** + +```python +@dataclass(frozen=True, slots=True) +class Gate: + name: str + passed: bool + detail: str + +@dataclass(frozen=True, slots=True) +class IntegrityVerdict: + ok: bool + gates: tuple[Gate, ...] + blocking_reason: str | None + +def evaluate(fetch: FetchResult, records: list[DmfRecord], + stats: NormalizeStats, prev: PrevSnapshotStats | None, + cfg: IntegrityCfg) -> IntegrityVerdict: ... +``` + +- **게이트 5종(요구 R2.2와 1:1)** + 1. 모든 페이지가 HTTP 200 이고 `resultCode == "00"` 이며 본문 시그니처 통과 + 2. `len(records) == total_count_reported` + 3. `total_count_reported` 가 직전 성공 스냅샷 대비 `max_drop_ratio`(기본 0.05) 이상 급감하지 않음 + 4. 필수 필드(`permit_no`, `ingredient_name`, `applicant`) 널 비율 ≤ `max_null_ratio`(기본 0.01) + 5. 중복 `DMF_PERMIT_NO` 비율 ≤ `max_duplicate_ratio`(기본 0.02) +- **실패 시**: `ok=False`. 파이프라인은 **스냅샷 INSERT도 하지 않는다**(기준선 오염 방지). 리포트는 마지막 성공 스냅샷으로 생성하되 대시보드에 "오늘 수집 실패 — 마지막 성공: YYYY-MM-DD" 배너를 넣고 WARN 알림을 기록한다. 실행 상태는 `PARTIAL`. + +### 3.7 `diff.py` + +- **책임**: 전일 스냅샷과 금일 레코드를 비교해 이벤트를 만든다. **DB에 접근하지 않는 순수 함수**(테스트 용이성의 핵심). +- **의존**: `normalize` +- **공개 API** + +```python +@dataclass(frozen=True, slots=True) +class FieldChange: + field: str + before: str + after: str + +@dataclass(frozen=True, slots=True) +class DiffEvent: + dmf_key: str + event_type: Literal["NEW", "CHANGED", "WITHDRAWN"] + changes: tuple[FieldChange, ...] + before: dict[str, str] | None + after: dict[str, str] | None + +@dataclass(frozen=True, slots=True) +class DiffResult: + new: tuple[DiffEvent, ...] + changed: tuple[DiffEvent, ...] + withdrawn: tuple[DiffEvent, ...] + unchanged_count: int + @property + def is_empty(self) -> bool: ... + +def compute_diff(previous: dict[str, DmfRecord], + current: dict[str, DmfRecord]) -> DiffResult: ... +def diff_fields(before: DmfRecord, after: DmfRecord) -> tuple[FieldChange, ...]: ... +``` + +- **판정 규칙**: 키는 `dmf_key`. NEW = 오늘만 존재, WITHDRAWN = 어제만 존재, CHANGED = 양쪽 존재 + `content_hash` 상이(그 후 `COMPARE_FIELDS`로 필드별 차이 추출). +- **첫 실행**: `previous`가 비어 있으면 **모든 레코드를 NEW로 만들지 않는다.** `DiffResult`를 빈 결과로 반환하고 `unchanged_count`에 전량을 넣는다(기준선 수립일). 요구 부록의 "첫 며칠은 diff가 비어 있다"와 일치한다. +- **실패 시**: 순수 함수라 실패 경로가 없다. 입력이 비정상이면 `integrity`가 이미 막았다. + +### 3.8 `storage/db.py` + +- **책임**: 연결 생성과 마이그레이션 적용. **이 모듈 밖에서는 `sqlite3.connect`를 직접 호출하지 않는다.** +- **의존**: `sqlite3`, `paths`, `backup`, `errors` +- **공개 API** + +```python +def connect(db_path: Path, *, read_only: bool = False) -> sqlite3.Connection: ... + # PRAGMA journal_mode=WAL; foreign_keys=ON; busy_timeout=15000; synchronous=NORMAL +def current_version(conn: sqlite3.Connection) -> int: ... +def apply_migrations(conn: sqlite3.Connection, migrations_dir: Path, + backup_dir: Path) -> list[int]: + """적용 전 VACUUM INTO 백업 → 번호순 적용 → schema_version 기록. 적용된 버전 목록 반환.""" +def integrity_check(conn: sqlite3.Connection) -> tuple[bool, str]: ... +``` + +- **실패 시**: 마이그레이션 도중 예외 → 롤백 + `StorageError`. 안내 문구에 **백업본 절대경로**를 포함한다(ADR-04의 롤백 경로). + +### 3.9 `storage/repo.py` + +- **책임**: 정본 테이블에 대한 유일한 읽기·쓰기 경로. 하나의 실행이 만드는 쓰기를 트랜잭션으로 묶는다. +- **의존**: `storage.db`, `normalize`, `diff` +- **공개 API(발췌)** + +```python +def start_run(conn, run_id: str, run_date: str, trigger: str) -> None: ... +def finish_run(conn, run_id: str, status: str, exit_code: int, notes: str) -> None: ... +def last_success_run_on(conn, run_date: str) -> RunRow | None: ... # idempotency 가드 +def latest_success_run(conn) -> RunRow | None: ... +def save_checkpoint(conn, run_id: str, stage: str, status: str, + artifact_path: str | None, error: str | None) -> None: ... +def is_stage_done(conn, run_id: str, stage: str) -> bool: ... +def insert_snapshot(conn, run_id: str, records: list[DmfRecord]) -> int: ... +def load_snapshot(conn, run_id: str) -> dict[str, DmfRecord]: ... +def insert_events(conn, run_id: str, diff: DiffResult) -> int: ... +def upsert_current_records(conn, run_id: str, records: list[DmfRecord]) -> None: ... +def record_fetch_stats(conn, run_id: str, fetch: FetchResult) -> None: ... +def prev_snapshot_stats(conn) -> PrevSnapshotStats | None: ... +def insert_agy_call(conn, run_id: str, call: AgyCallRow) -> None: ... +def tokens_used_today(conn, day: str) -> int: ... +def insert_enrichment(conn, run_id: str, run_level: dict, per_event: list[dict]) -> None: ... +def get_health(conn, component: str) -> HealthRow: ... +def set_health(conn, row: HealthRow) -> None: ... +``` + +- **불변식**: `snapshots`·`events`·`agy_calls`·`alerts`는 **절대 UPDATE/DELETE 하지 않는다**(보존 정책에 따른 오래된 파티션 삭제만 예외). `records`만 현재 상태를 UPDATE한다. +- **실패 시**: `StorageError`로 승격하고 트랜잭션 롤백. `database is locked`는 `busy_timeout` 15초로 흡수하고, 그래도 실패하면 파이프라인 FAILED + CRITICAL 알림. + +### 3.10 `health.py` + +- **책임**: 3상태 서킷 브레이커. 소스(`source_mfds`)와 AI(`agy`) **두 컴포넌트에 같은 코드를 재사용**한다. +- **의존**: `storage.repo`, `config` +- **공개 API** + +```python +class CircuitState(StrEnum): + CLOSED = "CLOSED"; OPEN = "OPEN"; HALF_OPEN = "HALF_OPEN" + +def allow(conn, component: str, cfg: HealthCfg, now: datetime) -> tuple[bool, CircuitState, str]: ... +def record_success(conn, component: str, run_id: str) -> CircuitState: ... +def record_failure(conn, component: str, reason: str, cfg: HealthCfg, + now: datetime) -> tuple[CircuitState, bool]: + """(새 상태, 상태가 방금 전환됐는가) — 전환 시에만 알림을 쏘기 위한 플래그.""" +``` + +- **전이 규칙**: 연속 실패가 `failure_threshold`(소스 3, agy 3)에 도달하면 OPEN + `cooldown_until = now + cooldown`(소스 1일, agy 24시간). 쿨다운 경과 후 첫 호출은 HALF_OPEN으로 **1회만** 시도, 성공하면 CLOSED 복귀, 실패하면 OPEN 유지 + 쿨다운 갱신. +- **알림 정책**: **상태 전환 시 1회만** 알림을 기록한다. OPEN 상태가 유지되는 동안에는 매일 반복 알림을 내지 않는다(운영 렌즈의 "학습된 무시" 방지). + +### 3.11 `agy/client.py` + +- **책임**: `agy.exe`를 SSOT §16 체크리스트대로 호출한다. **어떤 실패도 예외로 던지지 않고 값으로 반환한다.** +- **의존**: `subprocess`, `config`, `paths` +- **공개 API** + +```python +@dataclass(frozen=True, slots=True) +class AgyEnvelope: + status: str # SUCCESS/ERROR/CANCELED/INTERRUPTED/INVALID/WAITING/RUNNING/LAUNCH_FAILED + response: str + error: str | None + exit_code: int + duration_seconds: float + usage: dict[str, int] + conversation_id: str | None + stdout_path: Path + stderr_path: Path + +class ErrorKind(StrEnum): + NONE="NONE"; AUTH="AUTH"; QUOTA="QUOTA"; TIMEOUT="TIMEOUT"; NOT_FOUND="NOT_FOUND"; OTHER="OTHER" + +def build_args(cfg: AgyCfg, prompt_path: Path, log_file: Path) -> list[str]: ... +def run_agy(cfg: AgyCfg, prompt: str, run_id: str, log_dir: Path) -> AgyEnvelope: ... +def classify_error(env: AgyEnvelope) -> ErrorKind: ... +``` + +- **고정 호출 규약**: 절대경로 `%LOCALAPPDATA%\agy\bin\agy.exe`, `--output-format json`, `--model`/`--effort` 설정값, `--print-timeout 10m`, `--disable-slash-commands`, `--log-file `. 환경변수 `AGY_CLI_DISABLE_AUTO_UPDATE=true`를 **자식 프로세스 환경에 주입**한다. `--continue`·`--dangerously-skip-permissions`는 **쓰지 않는다**. +- **스트림 분리**: stdout은 `agy.stdout.json`, stderr는 `agy.stderr.log`로 각각 리다이렉트하고 절대 합치지 않는다(SSOT §5.3). +- **프롬프트 전달**: 프롬프트가 길어 명령줄 길이 제한(약 32KB)에 걸릴 수 있으므로 `-p`에 넘기기 전 크기를 검사하고, 초과하면 diff 항목 수를 상위 N건으로 잘라 재구성한다. +- **실패 시**: 실행 자체가 안 되면 `status="LAUNCH_FAILED"`. 호출부는 이것을 정상 입력의 하나로 취급한다. + +### 3.12 `agy/extract.py` + +- **책임**: agy `response`에서 JSON을 견고하게 추출하고 자체 스키마로 검증한다(ADR-17). +- **의존**: `jsonschema` +- **공개 API** + +```python +def extract_json_object(text: str) -> dict | None: ... # SSOT §7.3 균형괄호 스캐너 +def validate(obj: dict | None, schema: dict) -> tuple[bool, str | None]: ... +def get_briefing(env: AgyEnvelope, schema: dict) -> BriefingResult: ... + +@dataclass(frozen=True, slots=True) +class BriefingResult: + ok: bool + headline: str | None + summary_md: str | None + anomalies: tuple[str, ...] + per_event: tuple[dict, ...] # {dmf_key, comment, importance} + failure_reason: str | None +``` + +- **실패 시**: `ok=False`. 리포트는 그대로 진행하고 대시보드에 "AI 요약 없음: <사유>" 배지를 표시한다. + +### 3.13 `report/build.py` · `report/atomic.py` + +- **책임**: DB만 읽어 xlsx를 조립하고, **사용자가 파일을 열어둔 상태에서도 실패하지 않게** 저장한다(요구 R3.5). +- **의존**: `xlsxwriter`, `report.data`, `report.theme`, `report.sheets` +- **공개 API** + +```python +def build_report(conn, run_id: str, cfg: ReportCfg) -> ReportOutcome: ... + +@dataclass(frozen=True, slots=True) +class ReportOutcome: + path: Path + latest_link_path: Path | None + used_fallback_name: bool + sheets: tuple[str, ...] + warnings: tuple[str, ...] + +# atomic.py +def atomic_write(build_fn: Callable[[Path], None], target: Path, + retries: int = 3, backoff_seconds: float = 2.0) -> tuple[Path, bool]: + """임시 파일에 생성 → os.replace 로 원자 교체. + PermissionError(파일 잠김) 시 재시도, 끝내 실패하면 + DMF_리포트_YYYY-MM-DD_HHMMSS.xlsx 폴백 파일명으로 저장하고 (경로, True) 반환.""" +``` + +- **불변식**: `build_report`는 AI 산출물 유무와 무관하게 **항상 완주한다**. `enrichment` 조인 결과가 없으면 해당 셀을 "(AI 요약 없음)"으로 렌더링할 뿐이다. +- **시트 구성**(xlsx SSOT §4 + 대시보드 SSOT §0 통합 확정, 8시트): `00_대시보드` / `01_오늘변경분` / `02_전체현황` / `03_성분별` / `04_업체별` / `05_워치리스트`(비면 생략) / `06_추이` / `99_메타`. +- **실패 시**: `ReportError`. DB는 이미 커밋됐으므로 알림 문구에 "`report-only --run-id ` 로 재생성 가능"을 넣는다. + +### 3.14 `alerts.py` + +- **책임**: 알림 **의도**를 기록하고, 중복을 억제하고, 표시 여부를 추적한다. **표시는 하지 않는다**(ADR-11). +- **의존**: `storage.repo`, `paths`, `notify.messages` +- **공개 API** + +```python +class Severity(StrEnum): + INFO="INFO"; WARN="WARN"; CRITICAL="CRITICAL" + +def raise_alert(conn, *, run_id: str | None, severity: Severity, code: str, + what: str, why: str, how: str, action: str, + log_dir: Path | None, cooldown_minutes: int) -> bool: + """dedup_key = code(+run_date). 쿨다운 안이면 기록만 갱신하고 False 반환.""" +def pending(conn) -> list[AlertRow]: ... +def mark_shown(conn, alert_id: int) -> None: ... +def resolve(conn, code: str, note: str) -> int: ... +def mirror_to_file(conn, path: Path) -> None: ... # state/alerts.json 갱신 +``` + +- **문구 규격(요구 R7.3)**: 모든 알림은 **무엇이 / 왜 / 어떻게 고치는지 / 다음 행동** 4요소를 필수로 갖는다. `raise_alert`가 하나라도 비면 `ValueError`를 던진다 — 규격을 문자열 생성기가 아니라 **계약**으로 승격시킨다. +- **실패 시**: 알림 기록 실패는 파이프라인을 막지 않는다. 최후 수단으로 `state/alerts.json`에 직접 추가 기록하고 Event Log에 남긴다. + +### 3.15 `checks.py` + +- **책임**: 온보딩·복구·진단이 공유하는 **단일 진단 엔진**(ADR-24). +- **의존**: 거의 모든 모듈(읽기 전용) +- **공개 API** + +```python +@dataclass(frozen=True, slots=True) +class CheckResult: + key: str + title: str # "공공데이터포털 API 키" + ok: bool + detail: str # 사람이 읽는 현재 상태 + fix_hint: str # 무엇을 하면 되는지 + fix_action: str | None # GUI 버튼이 호출할 액션 키 ("enter_api_key" 등) + severity: Severity + +def run_all(cfg: Config | None) -> list[CheckResult]: ... +def run_one(key: str, cfg: Config | None) -> CheckResult: ... +``` + +- **체크 12종**: ① Python·venv ② 의존성 3종 import ③ `config.toml` 파싱·검증 ④ API 키 존재(DPAPI 복호화) ⑤ API 키 실호출 검증(`numOfRows=1`) ⑥ `agy.exe` 존재 ⑦ `agy` 인증 상태(최근 `agy_calls` 기반 판정, 헬스체크 호출 안 함) ⑧ DB 존재·`integrity_check`·스키마 버전 ⑨ 작업 스케줄러 3종 등록 상태 ⑩ 디스크 여유 공간 ⑪ 리포트 출력 디렉터리 쓰기 권한 + 오늘 파일 잠김 여부 ⑫ 최근 실행 상태(heartbeat 신선도·연속 실패 횟수). +- **실패 시**: 체크 자체의 예외는 잡아 `ok=False, detail="<예외 요약>"`으로 변환한다. **진단기가 죽어서 진단이 안 되는 일이 없어야 한다.** + +### 3.16 `notify/pump.py` + +- **책임**: 로그온 세션에서 15분마다 도는 대화형 에이전트. **UI를 띄울 권한을 가진 유일한 프로세스**다. +- **의존**: `alerts`, `watchdog`, `notify.toast`, `notify.eventlog`, `gui.app`, `checks` +- **공개 API** + +```python +def pump_once(cfg: Config) -> int: + """1) 워치독 판정 → 필요 시 alerts 기록 + 2) state/alerts.json + DB에서 미표시 알림 조회 + 3) WARN/INFO → 자동소멸 토스트 창 + 4) CRITICAL → 복구 GUI를 해당 체크에 포커스해 기동(모달) + 5) mark_shown 기록. 종료 코드 반환.""" +``` + +- **동시성**: 자체 락 파일로 중복 pump를 막는다. GUI가 이미 떠 있으면 새로 띄우지 않고 기존 창을 전면으로 올린다. +- **실패 시**: 어떤 예외도 사용자에게 보이지 않게 삼키되 Event Log에 남긴다. 알리미가 죽어서 조용해지는 것이 최악이므로, `pump_once`가 예외로 끝나면 다음 주기에 다시 시도한다. + +### 3.17 `gui/app.py` + +- **책임**: 요구 R6·R7의 단일 창. `checks.run_all()` 결과를 체크리스트로 그리고, 각 항목의 `fix_action`을 버튼으로 노출한다. +- **의존**: `tkinter`, `checks`, `gui.steps` +- **공개 API** + +```python +def launch(mode: Literal["setup", "recover", "inspect"] = "setup", + focus_key: str | None = None) -> int: ... +``` + +- **진입 모드**(요구 4절 표와 1:1): `setup`(전체 체크리스트) / `recover`(실패 항목 강조 + 원인 설명) / `inspect`(진단 결과만). +- **막다른 골목 금지(R7.7)**: 모든 실패 항목은 반드시 액션 버튼을 가진다. 액션이 정의되지 않은 체크는 `checks.py` 단계에서 등록을 거부한다(계약으로 강제). +- **실패 시**: GUI가 뜨지 못하면(예: tkinter 손상) `cli doctor`의 텍스트 출력을 콘솔로 폴백하고 Event Log에 기록한다. + +### 3.18 `pipeline.py` + +- **책임**: 스테이지 순차 실행, 체크포인트 조회·기록, 스테이지 경계에서의 예외 흡수. +- **의존**: 위 모든 것 +- **공개 API** + +```python +@dataclass(frozen=True, slots=True) +class Stage: + name: str + fn: Callable[[RunContext], None] + required: bool # False면 실패해도 파이프라인 계속(best-effort) + timeout_seconds: int + +STAGES: tuple[Stage, ...] = ( + Stage("preflight", stage_preflight, required=True, timeout_seconds=60), + Stage("fetch", stage_fetch, required=True, timeout_seconds=900), + Stage("normalize", stage_normalize, required=True, timeout_seconds=120), + Stage("integrity", stage_integrity, required=True, timeout_seconds=60), + Stage("diff", stage_diff, required=True, timeout_seconds=120), + Stage("persist", stage_persist, required=True, timeout_seconds=180), + Stage("enrich", stage_enrich, required=False, timeout_seconds=720), + Stage("report", stage_report, required=True, timeout_seconds=300), + Stage("backup", stage_backup, required=False, timeout_seconds=300), + Stage("finalize", stage_finalize, required=True, timeout_seconds=60), +) + +def run_pipeline(ctx: RunContext) -> RunResult: ... +``` + +- **`required=False` 스테이지의 실패는 상태를 FAILED로 끌어내리지 않는다.** `enrich`(AI)와 `backup`이 여기 해당한다. +- **체크포인트**: 각 스테이지 시작·종료를 `stage_status`에 기록한다. 같은 `run_id`로 재실행하면 `status="SUCCESS"`인 스테이지를 건너뛴다 — Task Scheduler `RestartCount` 재시도가 소스를 다시 두드리지 않게 한다. +- **실패 시**: 미처리 예외는 `run_pipeline`이 최상위에서 잡아 `finish_run(status="FAILED")` + CRITICAL 알림을 기록한다. + +--- + +## 4. 데이터 흐름 + +### 4.1 정상 경로 + +``` +[06:00 ± 0~240초 지터] DMF_Crawler_Daily (S4U, 사용자 계정) + │ 또는 [부팅 후 5분] 같은 작업의 AtStartup 트리거 + ▼ + python -m dmf_crawler run --trigger scheduled + │ + ├─ runlock.acquire() ── 실패 시 종료 0 (다른 인스턴스 실행 중) + ├─ config.load_config() + validate() ── 실패 시 종료 2 + CRITICAL 알림 + ├─ repo.last_success_run_on(오늘) ── 있으면 "SKIPPED" 기록 후 종료 0 ★ idempotency 가드 + └─ logging_setup: logs/run_/ 생성 + │ + ▼ +┌─ STAGE preflight ─────────────────────────────────────────────────┐ +│ db.connect(WAL) → apply_migrations(백업 후) → integrity_check │ +│ secrets_dpapi.load_service_key() → 없으면 API는 건너뛰고 headful/폴백 │ +│ 디스크 여유 공간 확인 → 임계 미만이면 WARN 알림 │ +└──────────────────────────────────────────────────────────────────┘ + ▼ +┌─ STAGE fetch ─────────────────────────────────────────────────────┐ +│ source.mode=auto + API 키 있음 또는 source.mode=api │ +│ · health.allow("source_mfds") 확인 (OPEN이면 스테일 폴백) │ +│ · HttpClient.get(numOfRows=1) → totalCount 취득 │ +│ · ceil(totalCount/page_size) 페이지 순회 │ +│ · 각 페이지 원문 → data/raw//page_NNNN.json │ +│ source.mode=auto + API 키 없음 또는 source.mode=browser │ +│ · AGY + chrome-devtools MCP + visible Chrome │ +│ · https://nedrug.mfds.go.kr/pbp/CCBAC03/getExcel 다운로드 │ +│ 공통: 본문/파일 시그니처, total/zip/XML/행 수 검증 → FetchResult │ +└──────────────────────────────────────────────────────────────────┘ + ▼ +┌─ STAGE normalize ─────────────────────────────────────────────────┐ +│ RawRecord[] → DmfRecord[] (NFKC·공백·전각·법인격·국가 다중값 정렬) │ +│ dmf_key 부여(중복 시 결정론적 접미사), content_hash 산출 │ +└──────────────────────────────────────────────────────────────────┘ + ▼ +┌─ STAGE integrity ─── ★ 요구 R2.2 관문 ─────────────────────────────┐ +│ 게이트 5종 판정. 하나라도 실패하면 → 4.2절 [실패 경로 C]로 분기 │ +└──────────────────────────────────────────────────────────────────┘ + ▼ +┌─ STAGE diff ─── (순수 함수, DB 미접근) ────────────────────────────┐ +│ repo.load_snapshot(가장 최근 성공 run) 을 인자로 받아 비교 │ +│ → DiffResult{new, changed, withdrawn} │ +│ (첫 실행이면 빈 결과 + 기준선 수립 표시) │ +└──────────────────────────────────────────────────────────────────┘ + ▼ +┌─ STAGE persist ─── 단일 트랜잭션 ──────────────────────────────────┐ +│ insert_snapshot(오늘 전량, append-only) │ +│ insert_events(diff, append-only) │ +│ upsert_current_records(records 현재 상태) │ +│ record_fetch_stats │ +│ ★ 이 시점에서 리포트는 이미 완결 가능하다. AI는 순수 후행 단계다. │ +└──────────────────────────────────────────────────────────────────┘ + ▼ +┌─ STAGE enrich (required=False) ───────────────────────────────────┐ +│ 조건: diff.is_empty == False AND agy.enabled │ +│ AND health.allow("agy") == True │ +│ AND budget.tokens_used_today < daily_token_cap │ +│ prompt.render(diff + 지표) → 단일 프롬프트 │ +│ client.run_agy(...) → AgyEnvelope (예외 없음) │ +│ extract.get_briefing → 실패 시 강화 프롬프트로 1회 재시도 │ +│ repo.insert_agy_call(성공/실패 무관 usage·duration 기록) │ +│ 성공 시 repo.insert_enrichment │ +│ classify_error == AUTH → CRITICAL 알림 기록(창은 안 띄움) │ +│ health.record_success/failure("agy") │ +└──────────────────────────────────────────────────────────────────┘ + ▼ +┌─ STAGE report ─── (DB 읽기 전용, AI 유무와 무관하게 항상 완주) ─────┐ +│ data.fetch_report_data(run_id) → ReportData │ +│ XlsxWriter로 8시트 조립(모든 숫자는 파이썬 사전 계산값) │ +│ atomic.atomic_write: 임시파일 → os.replace │ +│ · PermissionError 시 3회 재시도 → 폴백 파일명 + WARN 알림 │ +│ DMF_리포트_최신.xlsx 원자적 갱신 │ +└──────────────────────────────────────────────────────────────────┘ + ▼ +┌─ STAGE backup (required=False) ───────────────────────────────────┐ +│ VACUUM INTO backup/dmf_.sqlite3 → 보존 개수 초과분 삭제 │ +└──────────────────────────────────────────────────────────────────┘ + ▼ +┌─ STAGE finalize ──────────────────────────────────────────────────┐ +│ finish_run(status=SUCCESS|PARTIAL) │ +│ state/heartbeat.json 갱신 ← ★ 성공/부분성공일 때만 갱신 │ +│ alerts.mirror_to_file(state/alerts.json) │ +│ eventlog.write(요약 1줄) │ +│ 로그 보존 기간 초과 디렉터리 정리 │ +└──────────────────────────────────────────────────────────────────┘ + ▼ + 종료 코드 0 + ┊ + ┊ (독립 트리거) [로그온 시 + 15분마다] DMF_Crawler_Agent (Interactive) + ▼ + pythonw -m dmf_crawler notify-pump + 1) watchdog: heartbeat 나이 > stale_after_minutes → CRITICAL 알림 기록 + 2) alerts.pending() 조회 + 3) WARN/INFO → tkinter 자동소멸 토스트 + 4) CRITICAL → gui.launch(mode="recover", focus_key=...) 모달 창 ★ 요구 R7.2 + 5) mark_shown +``` + +### 4.2 실패 경로 + +**[A] 네트워크·API 실패 (fetch 단계)** +``` +HTTP 오류/타임아웃 → http.py 재시도 4회(Retry-After 우선) + ├─ 회복 → 정상 흐름 복귀 + └─ 소진 → FetchError + → health.record_failure("source_mfds") + ├─ CLOSED 유지(1~2회차): WARN 알림, 오늘은 스테일 폴백 + └─ OPEN 전환(3회차): CRITICAL 알림 1회(전환 시에만) + → integrity 게이트 1 실패로 이어짐 → [C]로 합류 +``` + +**[B] agy 실패 (enrich 단계)** +``` +LAUNCH_FAILED / status=ERROR / 타임아웃 / JSON 검증 실패 + → 예외 없이 값으로 반환 → agy_calls에 실패 기록 + → health.record_failure("agy") (3회 연속이면 24h OPEN) + → classify_error: + AUTH → CRITICAL 알림(code=AGY_AUTH). 에이전트가 재로그인 창을 띄운다 + QUOTA → WARN 알림. 그날 AI 스킵 + 기타 → INFO 로그만 + → report 단계는 그대로 실행. 대시보드에 "AI 요약 없음: <사유>" 배지 + → 실행 상태는 FAILED가 아니라 SUCCESS(요구 R4.4) +``` + +**[C] 무결성 게이트 차단 (integrity 단계)** +``` +게이트 5종 중 하나라도 실패 + → 스냅샷 INSERT 하지 않음 (기준선 오염 방지) + → diff 수행하지 않음 (전건 취하 오탐 원천 차단) + → report는 "마지막 성공 스냅샷"으로 생성 + 대시보드 최상단 경고 배너 + → WARN 알림(code=INTEGRITY_BLOCKED, 4요소 문구 포함) + → 실행 상태 PARTIAL, 종료 코드 0 (리포트가 나왔으므로) + → heartbeat는 갱신하되 status=PARTIAL을 함께 기록 +``` + +**[D] 리포트 파일 잠김 (report 단계)** +``` +os.replace → PermissionError (사용자가 어제/오늘 파일을 Excel로 열어둠) + → 2초 백오프로 3회 재시도 + → 끝내 실패 → DMF_리포트__.xlsx 폴백 파일명으로 저장 + → WARN 알림: "리포트를 다른 이름으로 저장했습니다 / 원본이 열려 있습니다 / + Excel을 닫고 report-only 를 실행하세요 / [지금 실행] 버튼" + → 실행 상태 SUCCESS (파일은 나왔다) +``` + +**[E] 프로세스가 아예 시작되지 못함 / 강제 종료** +``` +ExecutionTimeLimit 30분 초과 → Task Scheduler가 강제 종료 → finally도 못 돔 + → heartbeat 미갱신 + → Task Scheduler RestartCount 3회 재시도(10분 간격) + → 체크포인트 덕에 성공한 스테이지는 건너뛰고 재개 + → 그래도 실패하면 heartbeat가 계속 오래됨 + → 다음 pump 주기(≤15분)에서 워치독이 감지 → CRITICAL 모달 + 문구: "06:00 배치가 실행되지 않았습니다 / 마지막 성공 <시각> / + [지금 실행] [작업 상태 확인] [로그 폴더 열기]" +``` + +**[F] 알림을 표시할 세션이 없음** +``` +사용자가 로그오프 상태 + → 배치는 alerts 테이블 + state/alerts.json + Windows Event Log에 기록 + → 다음 로그온 시 DMF_Crawler_Agent의 AtLogOn 트리거가 즉시 실행 + → 미표시 알림을 그때 표시 (요구 4절 각주의 "다음 로그온 시 재표시") +``` + +--- + +## 5. 실행 진입점 + +단일 진입점 `python -m dmf_crawler <서브커맨드>`. 배포된 콘솔 스크립트 `dmf` 도 등록한다(`pyproject.toml`). + +| 커맨드 | 인자 | 동작 | 주 사용자 | +|---|---|---|---| +| `run` | `--trigger {scheduled,startup,manual}` (기본 manual), `--force`(idempotency 가드 무시), `--dry-run`(DB 쓰기·리포트 저장 없이 로그만), `--skip-agy` | 전체 파이프라인 1회 실행 | Task Scheduler, GUI "지금 실행" | +| `report-only` | `--run-id ` 또는 `--date YYYY-MM-DD`(기본: 마지막 성공 run) | 수집·diff 없이 DB에서 리포트만 재생성 | 사람(리포트 깨짐 복구) | +| `backfill` | `--from YYYY-MM-DD --to YYYY-MM-DD`, `--reparse`(원문 아카이브에서 재파싱), `--rediff`, `--rereport` | 과거 구간 재처리. 원문 아카이브가 있는 날짜만 대상 | 사람(파서 수정 후) | +| `doctor` | `--json`(기계 판독 출력), `--fix-tasks`(스케줄 작업만 재등록) | `checks.run_all()`을 텍스트 표로 출력. 종료 코드로 정상/이상 구분 | 사람, GUI 내부 | +| `onboard` | `--mode {setup,recover,inspect}`, `--focus ` | GUI 창 기동(요구 R6·R7). `pythonw`로 호출 | 바로가기 더블클릭 | +| `notify-pump` | `--once`(기본), `--interval <초>`(디버깅용 루프) | 워치독 판정 + 알림 표시 + 필요 시 복구 GUI 기동 | Task Scheduler(Interactive) | +| `install-task` | `--time 06:00`, `--user <계정>` | `scripts/install_tasks.ps1` 호출로 작업 3종 등록 | GUI 버튼, 최초 설치 | +| `uninstall-task` | — | 작업 3종 제거 | 사람 | +| `backup` | `--now`, `--prune` | 즉시 백업 / 보존 정책 적용 | 사람, 파이프라인 내부 | +| `db` | `migrate` \| `vacuum` \| `check` \| `version` | 마이그레이션 적용 / VACUUM / integrity_check / 스키마 버전 | 사람 | +| `version` | — | 패키지·Python·의존성·agy 버전 출력 | 사람 | + +**종료 코드 규약(전 커맨드 공통)** + +| 코드 | 의미 | Task Scheduler 재시작 유발 | +|---|---|---| +| `0` | SUCCESS / PARTIAL / SKIPPED — **리포트 파일이 존재한다** | 아니오 | +| `1` | FAILED — 리포트 미생성. 일시적 원인일 수 있어 재시도 가치 있음 | 예(최대 3회) | +| `2` | BLOCKED — DB 손상, 설정 오류처럼 재시도해도 같은 전제조건 미충족. API 키 없음은 선택 기능이라 BLOCKED가 아니다 | 예(무해, idempotent + 알림 dedup으로 흡수) | +| `130` | 사용자 중단(Ctrl+C) | 아니오 | + +`doctor`만 예외적으로 `0`(전부 통과) / `1`(WARN 존재) / `2`(CRITICAL 존재)를 쓴다. + +**바로가기(요구 R6.1·R6.2)**: `bootstrap.cmd`가 `scripts/make_shortcuts.ps1`을 호출해 프로젝트 루트와 바탕화면에 두 개의 `.lnk`를 만든다. + +| 바로가기 | 대상 | +|---|---| +| `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` | + +`pythonw.exe`는 콘솔을 만들지 않는다. `.bat`/`.cmd`는 반드시 콘솔 창을 띄우므로 **최초 설치용 `bootstrap.cmd` 외에는 배치 파일을 사용자에게 노출하지 않는다.** + +--- + +## 6. 설정 파일 + +**형식 확정: TOML 단일 파일** `config/config.toml`. stdlib `tomllib`로 읽는다(ADR-14). PC별 차이는 `config/config.local.toml`(gitignore)이 같은 키를 덮어쓴다. **비밀 값은 절대 넣지 않는다** — API 키는 DPAPI, agy 토큰은 agy가 관리한다. + +### 6.1 전체 키 스펙 + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `general.timezone` | str | `"Asia/Seoul"` | 실행일자 판정 기준 시간대 | +| `general.contact_email` | str | `""` | User-Agent에 넣을 연락처. 요구 N1(출처 표시·정중한 접근) | +| `general.language` | str | `"ko"` | 리포트·알림 언어 | +| `schedule.daily_time` | str | `"06:00"` | 일일 실행 시각(요구 R5.1) | +| `schedule.jitter_seconds` | int | `240` | 0~이 값 사이 랜덤 지연. R5.1의 ±5분 안에 들도록 상한 300 | +| `schedule.startup_delay_minutes` | int | `5` | AtStartup 트리거 지연(네트워크·프로필 준비 대기) | +| `schedule.execution_time_limit_minutes` | int | `30` | 작업 강제 종료 한계 | +| `schedule.restart_count` | int | `3` | 실패 시 재시작 횟수 | +| `schedule.restart_interval_minutes` | int | `10` | 재시작 간격 | +| `schedule.agent_repeat_minutes` | int | `15` | 알림 에이전트 반복 주기 | +| `schedule.agy_update_weekday` | str | `"Sunday"` | 주간 `agy update` 요일 | +| `schedule.agy_update_time` | str | `"14:00"` | 주간 업데이트 시각(사람이 깨어 있는 시간대) | +| `source.base_url` | str | `"https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01"` | 정본 엔드포인트 | +| `source.response_type` | str | `"json"` | `json` \| `xml`. json 실패 시 xml 폴백 | +| `source.page_size` | int | `100` | `numOfRows`. 명세 크기가 3자리라 상한 999 추정(⚠️ 미검증) | +| `source.connect_timeout_seconds` | float | `10.0` | 연결 타임아웃 | +| `source.read_timeout_seconds` | float | `30.0` | 읽기 타임아웃 | +| `source.max_attempts` | int | `4` | 재시도 최대 횟수 | +| `source.backoff_base_seconds` | float | `5.0` | 지수 백오프 기준 | +| `source.backoff_cap_seconds` | float | `300.0` | 백오프 상한 | +| `source.min_interval_seconds` | float | `0.7` | 페이지 간 최소 간격(정중함) | +| `source.min_body_bytes` | int | `200` | 이보다 짧은 200 응답은 실패로 간주 | +| `source.archive_retain_days` | int | `180` | `data/raw/` 보존 기간 | +| `integrity.max_drop_ratio` | float | `0.05` | totalCount 급감 허용 한계(요구 R2.2 게이트 3) | +| `integrity.max_null_ratio` | float | `0.01` | 필수 필드 널 비율 상한(게이트 4) | +| `integrity.max_duplicate_ratio` | float | `0.02` | 중복 등록번호 비율 상한(게이트 5) | +| `storage.sqlite_path` | str | `"data/dmf.sqlite3"` | 정본 DB 경로 | +| `storage.busy_timeout_ms` | int | `15000` | SQLite 잠금 대기 | +| `storage.snapshot_retain_days` | int | `0` | 0 = 영구 보존(이벤트 소싱 원칙) | +| `backup.enabled` | bool | `true` | 성공 후 자동 백업 | +| `backup.dir` | str | `"backup"` | **다른 드라이브 권장.** 온보딩이 유도 | +| `backup.keep_count` | int | `30` | 보존 개수 | +| `backup.min_free_gb` | float | `2.0` | 이보다 여유가 적으면 백업 스킵 + WARN | +| `report.output_dir` | str | `"reports"` | 산출물 디렉터리 | +| `report.filename_pattern` | str | `"DMF_리포트_{date}.xlsx"` | 파일명 | +| `report.latest_link_name` | str | `"DMF_리포트_최신.xlsx"` | 고정 링크 파일명. 빈 문자열이면 생성 안 함 | +| `report.font` | str | `"맑은 고딕"` | Windows Vista+ 기본 탑재 | +| `report.palette` | str | `"okabe_ito"` | 색각이상 안전 팔레트 고정 | +| `report.top_n` | int | `10` | 성분별·업체별 시트 상위 N | +| `report.trend_days` | int | `90` | 추이 시트 표시 기간 | +| `report.retain_days` | int | `365` | 리포트 보존 기간 | +| `report.lock_retries` | int | `3` | 파일 잠김 재시도 횟수 | +| `agy.enabled` | bool | `true` | AI 계층 사용 여부 | +| `agy.binary_path` | str | `""` | 빈 값이면 `%LOCALAPPDATA%\agy\bin\agy.exe` 자동 | +| `agy.model` | str | `"gemini-3.7-flash-medium"` | agy SSOT §8.2 권장 | +| `agy.effort` | str | `"medium"` | `low` \| `medium` \| `high` | +| `agy.print_timeout` | str | `"10m"` | SSOT §16 권장. 기본 5분은 부족 | +| `agy.skip_if_diff_empty` | bool | `true` | 변화 없는 날 28k 토큰을 태우지 않는다 | +| `agy.max_json_retries` | int | `1` | 스키마 검증 실패 시 강화 프롬프트 재시도 | +| `agy.daily_token_cap` | int | `300000` | 일일 누적 토큰 상한 | +| `agy.max_events_in_prompt` | int | `200` | 명령줄 길이·토큰 절약을 위한 상위 N건 절단 | +| `agy.prompt_path` | str | `"prompts/daily_briefing.md"` | 템플릿 경로 | +| `agy.schema_path` | str | `"prompts/daily_briefing.schema.json"` | 자체 검증 스키마 | +| `health.source_failure_threshold` | int | `3` | 소스 서킷 OPEN 임계(연속 실행 횟수) | +| `health.source_cooldown_hours` | int | `24` | 소스 쿨다운 | +| `health.agy_failure_threshold` | int | `3` | agy 서킷 OPEN 임계 | +| `health.agy_cooldown_hours` | int | `24` | agy 쿨다운 | +| `notify.watchdog_stale_minutes` | int | `120` | heartbeat 신선도 한계(06:00 + 2시간) | +| `notify.toast_seconds` | int | `12` | 자동 소멸 알림 표시 시간 | +| `notify.cooldown_minutes` | int | `240` | 동일 코드 알림 억제 시간(요구 R7.8) | +| `notify.consecutive_failure_critical` | int | `3` | 연속 실패 이 횟수부터 CRITICAL 승격 | +| `notify.eventlog_source` | str | `"DMF Crawler"` | Windows 이벤트 로그 원본 이름 | +| `logging.dir` | str | `"logs"` | 로그 루트 | +| `logging.level` | str | `"INFO"` | `DEBUG` \| `INFO` \| `WARNING` \| `ERROR` | +| `logging.retain_days` | int | `90` | 실행 로그 디렉터리 보존 | +| `logging.mask_patterns` | list[str] | `["serviceKey", "access_token"]` | 로그 기록 전 마스킹 대상 키 | + +### 6.2 완전한 예시 파일 + +```toml +# config/config.toml — DMF Crawler 설정 정본 +# 비밀 값(API 키, OAuth 토큰)은 이 파일에 절대 넣지 않는다. +# API 키는 온보딩 GUI에서 입력하면 Windows DPAPI 로 암호화 저장된다. + +[general] +timezone = "Asia/Seoul" +contact_email = "" # User-Agent 에 들어갈 연락처. 온보딩에서 입력받는다 +language = "ko" + +[schedule] +daily_time = "06:00" +jitter_seconds = 240 +startup_delay_minutes = 5 +execution_time_limit_minutes = 30 +restart_count = 3 +restart_interval_minutes = 10 +agent_repeat_minutes = 15 +agy_update_weekday = "Sunday" +agy_update_time = "14:00" + +[source] +base_url = "https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01" +response_type = "json" +page_size = 100 +connect_timeout_seconds = 10.0 +read_timeout_seconds = 30.0 +max_attempts = 4 +backoff_base_seconds = 5.0 +backoff_cap_seconds = 300.0 +min_interval_seconds = 0.7 +min_body_bytes = 200 +archive_retain_days = 180 + +[integrity] +max_drop_ratio = 0.05 +max_null_ratio = 0.01 +max_duplicate_ratio = 0.02 + +[storage] +sqlite_path = "data/dmf.sqlite3" +busy_timeout_ms = 15000 +snapshot_retain_days = 0 # 0 = 영구 보존 + +[backup] +enabled = true +dir = "backup" # 다른 드라이브 경로를 권장한다 (예: "E:/DMF_Backup") +keep_count = 30 +min_free_gb = 2.0 + +[report] +output_dir = "reports" +filename_pattern = "DMF_리포트_{date}.xlsx" +latest_link_name = "DMF_리포트_최신.xlsx" +font = "맑은 고딕" +palette = "okabe_ito" +top_n = 10 +trend_days = 90 +retain_days = 365 +lock_retries = 3 + +[agy] +enabled = true +binary_path = "" # 빈 값이면 %LOCALAPPDATA%\agy\bin\agy.exe +model = "gemini-3.7-flash-medium" +effort = "medium" +print_timeout = "10m" +skip_if_diff_empty = true +max_json_retries = 1 +daily_token_cap = 300000 +max_events_in_prompt = 200 +prompt_path = "prompts/daily_briefing.md" +schema_path = "prompts/daily_briefing.schema.json" + +[health] +source_failure_threshold = 3 +source_cooldown_hours = 24 +agy_failure_threshold = 3 +agy_cooldown_hours = 24 + +[notify] +watchdog_stale_minutes = 120 +toast_seconds = 12 +cooldown_minutes = 240 +consecutive_failure_critical = 3 +eventlog_source = "DMF Crawler" + +[logging] +dir = "logs" +level = "INFO" +retain_days = 90 +mask_patterns = ["serviceKey", "access_token"] +``` + +--- + +## 7. 실패 시나리오 대응표 + +심사에서 제기된 모든 시나리오 + 요구 SSOT 4절의 복구 표를 하나로 합쳤다. + +| # | 실패 | 감지 방법 | 자동 대응 | 사용자 알림 | 복구 절차 | +|---|---|---|---|---|---| +| 1 | API 키 미설정 | `secrets_dpapi.load_service_key() is None` | 파이프라인 시작 안 함, 종료 2 | **CRITICAL, 강제 창** | GUI에서 키 입력 → 즉시 실호출 검증 → 저장. 발급 페이지 열기 버튼 제공 | +| 2 | API 키 무효·만료 | `resultCode != "00"` 또는 HTTP 401/403 | fetch 중단, 스테일 폴백 리포트 | **CRITICAL, 강제 창** | GUI에서 키 재입력. 기존 키 지문(앞 8자)만 표시해 혼동 방지 | +| 3 | 일일 트래픽 10,000 초과 | API 오류 코드 `LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR` | 그날 fetch 포기, 스테일 폴백 | WARN 토스트 | 내일 재시도 안내. 반복되면 운영계정 전환 안내 | +| 4 | 네트워크 단절 | 연결 예외 | 재시도 4회 → 서킷 카운터 증가 | WARN 토스트(1~2회차) | 자동 회복. 3회 연속이면 #5로 승격 | +| 5 | 소스 서킷 OPEN 전환 | `health.record_failure` 3회 연속 | 24시간 소스 호출 자체를 스킵 | **CRITICAL 1회(전환 시에만)** | GUI "지금 실행"으로 HALF_OPEN 강제 시도. 원인 조사 후 자동 복귀 | +| 6 | HTTP 429/503 + `Retry-After` | 응답 헤더 | 헤더 값을 **절대 우선**해 대기 후 재시도 | 없음(로그만) | 자동 | +| 7 | HTTP 200인데 빈/차단 본문 | `min_body_bytes` 미달, `= cap` | 호출 자체를 하지 않음 | INFO 로그만 | 상한 조정 또는 다음 날 대기 | +| 19 | `agy` 자동 업데이트 충돌 | 배치 중 바이너리 교체 | `AGY_CLI_DISABLE_AUTO_UPDATE=true`를 자식 환경에 주입해 **예방** | 없음 | 주간 별도 작업(일요일 14:00)에서만 `agy update` | +| 20 | xlsx 파일 잠김 | `os.replace` → `PermissionError` | 3회 재시도 → 폴백 파일명 저장 | WARN 토스트 | Excel 닫고 `report-only` 실행. 알림 문구에 명령이 포함됨 | +| 21 | 디스크 여유 부족 | `checks` ⑩, 백업 전 재확인 | 백업 스킵(리포트는 생성) | WARN 토스트 | 오래된 백업·로그·원문 아카이브 정리 안내. GUI에 "정리" 버튼 | +| 22 | `database is locked` | `sqlite3.OperationalError` | `busy_timeout` 15초 대기 → 실패 시 FAILED | **CRITICAL** | 다른 인스턴스 확인. `runlock`이 이미 대부분 예방한다 | +| 23 | SQLite 파일 손상 | `PRAGMA integrity_check` 실패 | 파이프라인 시작 안 함, 종료 2 | **CRITICAL, 강제 창** | GUI에 최신 백업본 목록 표시 → "이 백업으로 복원" 버튼 | +| 24 | 마이그레이션 실패 | 적용 중 예외 | 트랜잭션 롤백, 종료 2 | **CRITICAL** | 알림 문구에 **적용 직전 백업본 절대경로** 포함. 복원 후 코드 되돌리기 | +| 25 | 같은 날 중복 실행 | idempotency 가드 + `runlock` | `SKIPPED` 기록 후 종료 0 | 없음 | 의도적 재실행은 `run --force` | +| 26 | 06:00에 PC 꺼져 있음 | Task Scheduler | `StartWhenAvailable` + AtStartup(+5분) 트리거로 자동 캐치업 | 없음 | 자동. 중복은 #25가 흡수 | +| 27 | `ExecutionTimeLimit` 초과 강제 종료 | heartbeat 미갱신 | `RestartCount` 3회 재시도(체크포인트로 재개) | **CRITICAL(워치독)** | GUI "지금 실행". 반복되면 `page_size` 조정·타임아웃 상향 | +| 28 | 배치가 아예 실행되지 않음 | heartbeat 나이 > 120분 | 없음(감지 전용) | **CRITICAL, 강제 창** | GUI "지금 실행" / "작업 상태 확인" / "로그 폴더 열기" | +| 29 | 작업 스케줄러 등록 소실 | `checks` ⑨ `Get-ScheduledTask` 실패 | **자동 재등록하지 않는다**(사용자가 의도적으로 껐을 수 있음) | **CRITICAL, 강제 창** | GUI "작업 다시 등록" 버튼 → `install-task` | +| 30 | 연속 3일 실패 | `runs` 집계 | 없음 | **CRITICAL, 강제 창** | 진단 화면(`doctor`) 자동 표시 + 실패 원인 상위 3개 요약 | +| 31 | 알림 표시 불가(로그오프) | 표시 대상 세션 없음 | `alerts` + `state/alerts.json` + Event Log에 축적 | 다음 로그온 시 즉시 표시 | AtLogOn 트리거가 밀린 알림을 한 번에 표시 | +| 32 | Python/venv 손상 | `checks` ①② import 실패 | 파이프라인 실행 불가 | **CRITICAL** — GUI도 못 뜰 수 있음 | `bootstrap.cmd` 재실행. Event Log가 유일한 흔적이므로 반드시 기록 | +| 33 | 미처리 예외 | `run_pipeline` 최상위 `except` | `finish_run(FAILED)` + 스택트레이스 로그 | **CRITICAL** | 알림에 로그 디렉터리 절대경로 포함. 워치독이 이중 확인 | + +**알림 문구 4요소 예시(요구 R7.3 규격)** + +``` +[무엇] 06:00 DMF 수집 배치가 어제부터 실행되지 않았습니다. +[왜] Antigravity CLI 로그인이 만료되어 AI 요약 단계에서 중단됐습니다. +[어떻게] 아래 [로그인 창 열기]를 누르고 Google 계정으로 다시 로그인하세요. 1분이면 끝납니다. +[다음] [로그인 창 열기] [지금 실행] [로그 폴더 열기] [나중에] +``` + +--- + +## 8. 의존성 목록 + +### 8.1 런타임 의존성 (3개) + +| 패키지 | 버전 제약 | 용도 | 대안과 기각 사유 | +|---|---|---|---| +| `httpx` | `>=0.27,<1.0` | 유일한 HTTP 클라이언트. `params=` 자동 인코딩(Decoding 키 전제), 연결/읽기 타임아웃 분리 | `requests`(타임아웃 분리 불가), stdlib `urllib`(재시도·타임아웃을 직접 구현해야 함), `aiohttp`(비동기 불필요) | +| `XlsxWriter` | `>=3.2,<4.0` | xlsx 생성 **전담**. 스파크라인·수식 캐시값·`autofit`은 이 라이브러리에만 있다 | `openpyxl`(스파크라인 없음, 재저장 시 차트 손실), `pandas.to_excel`(서식 제어 불가) | +| `jsonschema` | `>=4.21,<5.0` | agy 응답 자체 검증(ADR-17). 손으로 짜면 반드시 틀리는 부분 | 수동 검증(오류 메시지 품질 저하), `pydantic`(무거움, 이 프로젝트에 다른 용도 없음) | + +### 8.2 개발 의존성 (1개) + +| 패키지 | 버전 제약 | 용도 | +|---|---|---| +| `pytest` | `>=8.0` | 단위·골든 테스트 | + +### 8.3 표준 라이브러리로 해결한 것 + +| 필요 | 사용한 stdlib | 회피한 외부 패키지 | +|---|---|---| +| 설정 파싱 | `tomllib` (3.11+) | PyYAML | +| 데이터베이스 | `sqlite3` | SQLAlchemy, alembic | +| GUI·토스트 | `tkinter` | PySide6, win11toast, BurntToast | +| 비밀 암호화 | `ctypes` → `crypt32.dll` DPAPI | keyring, cryptography, python-dotenv | +| 배타 락 | `msvcrt` | portalocker, filelock | +| 재시도·백오프 | 직접 구현(약 50줄, 테스트 포함) | tenacity, backoff | +| 집계 | SQLite `GROUP BY` | pandas, polars | +| 이벤트 로그 | `subprocess` → `eventcreate.exe` | pywin32 | +| CLI 파싱 | `argparse` | click, typer | +| XML 파싱(폴백) | `xml.etree.ElementTree` | lxml, BeautifulSoup | + +### 8.4 외부 프로그램 전제 + +| 프로그램 | 필수 여부 | 확인 방법 | +|---|---|---| +| Python 3.11+ | **필수** (실측 3.14.6) | `checks` ① | +| `agy.exe` v1.1.22+ | 선택 — 없으면 AI 요약만 빠진다 | `checks` ⑥. 없으면 GUI가 설치 제안 | +| PowerShell 7 | 작업 등록·agy 설치 시에만 | `scripts/*.ps1` | +| LibreOffice | **불필요**(ADR-08로 제거) | — | +| Microsoft Excel | **불필요**. 리포트 열람용일 뿐 | — | + +### 8.5 최소로 유지한 근거 + +요구 N5(의존성 최소)와 N8(6개월 뒤 본인이 읽고 고칠 수 있는 구조)이 직접 요구한다. 실무적으로는 이 프로젝트가 **비개발자 PC에서 무인으로 도는 것**이 목표이므로, 의존성 하나는 곧 "6개월 뒤 `pip install`이 실패할 확률 하나"다. 세 후보의 의존성은 A 3개 / B 6개(+pandas·pydantic·win11toast) / C 7개였고, 확정안은 **3개**로 A의 최소주의를 유지하면서 B의 구조를 얻었다. + +--- + +## 9. 구현 순서 + +각 마일스톤 끝에서 **실제로 무엇이 동작하는지**를 기준으로 나눴다. 앞 단계가 동작하지 않으면 다음으로 넘어가지 않는다. + +### M0 — 뼈대와 진실 (예상 1~2일) + +**산출물** +- 디렉터리 트리 전체 생성(빈 모듈 포함), `pyproject.toml`, `bootstrap.cmd`, `.gitignore` +- `paths.py`, `config.py`, `errors.py`, `logging_setup.py`, `runlock.py` +- `secrets_dpapi.py` + 단위 테스트 +- `storage/db.py` + `migrations/0001_init.sql`, `repo.py`의 run/checkpoint 계열 +- `cli.py`의 `version`, `db`, `doctor`(체크 ①②③④ 만) + +**이 시점에 동작하는 것** +`bootstrap.cmd` 더블클릭 → venv 생성 → `dmf version` / `dmf db migrate` / `dmf doctor` 가 실행되고, API 키를 DPAPI로 저장·복호화할 수 있다. **DB 파일과 설정이 진실로 존재한다.** + +### M1 — 수집에서 diff까지 (예상 2~3일) + +**산출물** +- `http.py`(재시도·Retry-After·지터·조건부 요청) + `test_http_retry.py` +- `source_mfds.py`(전량 수집·원문 아카이브·본문 시그니처 검사) +- `normalize.py` + `test_normalize.py` +- `integrity.py` + `test_integrity.py` (게이트 5종 경계 테스트) +- `diff.py` + `test_diff.py` +- `health.py`(3상태 서킷) +- `migrations/0002_enrichment.sql`은 미적용, `0001`에 snapshots/records/events 포함 +- `pipeline.py`의 preflight~persist 스테이지 +- `docs/design/02-data-model.md`, `docs/ops/03-api-usage-policy.md` 작성 + +**이 시점에 동작하는 것** +`dmf run --skip-agy` 가 실제 API에서 전량을 수집해 SQLite에 스냅샷·이벤트를 적재한다. 이틀 연속 돌리면 **둘째 날에 진짜 diff가 나온다.** 게이트가 불완전 수집을 막는 것도 확인된다. 리포트는 아직 없다. +> **여기서 데이터 소스 SSOT 부록 B의 미검증 항목을 전부 실측해 문서에 기록한다** — `totalCount` 실제 값, `numOfRows` 최대값, 중복 등록번호 유무, `type=json` 동작 여부. + +### M2 — 무인 운영 (예상 1~2일) + +**산출물** +- `scripts/install_tasks.ps1` / `uninstall_tasks.ps1` +- `alerts.py`, `watchdog.py`, `notify/eventlog.py`, `notify/messages.py` +- `backup.py`(`VACUUM INTO` + 보존 정리) +- `pipeline.py`의 backup·finalize 스테이지, heartbeat 기록 +- idempotency 가드와 `--force` +- `docs/ops/01-scheduling-and-resilience.md` 작성 + +**이 시점에 동작하는 것** +06:00에 **사람이 없어도** 배치가 돈다. 재부팅해도 다음 06:00에 실행된다. 06:00을 놓치면 부팅 후 5분에 캐치업한다. 중복 실행은 흡수된다. 성공하면 백업본이 생기고 heartbeat가 갱신된다. 실패는 `alerts` 테이블과 Event Log에 남는다 — **아직 화면에는 안 뜬다.** + +### M3 — 리포트 (예상 3~4일) + +**산출물** +- `report/theme.py`, `widgets.py`(한글 열너비·델타 화살표·KPI 타일), `atomic.py` + `test_report_atomic.py` +- `report/data.py`(시트별 SQL), `build.py` +- `report/sheets/` 8종 전부 +- `cli report-only`, `cli backfill` +- `docs/design/03-xlsx-report-spec.md` 작성 + +**이 시점에 동작하는 것** +매일 06:00에 **탭별로 연동된, 디자인이 예쁜 xlsx**가 나온다(요구 R3 전부). 파일을 열어둔 상태에서도 배치가 실패하지 않는다. `report-only`로 과거 어느 날짜든 재생성된다. **이 시점에서 프로젝트는 이미 사용자에게 가치를 준다.** + +### M4 — 사람과의 접점 (예상 3~4일) + +**산출물** +- `agy/client.py`, `prompt.py`, `extract.py`, `budget.py` + `test_agy_extract.py` +- `prompts/daily_briefing.md`, `daily_briefing.schema.json` +- `migrations/0002_enrichment.sql`, `0003_ops.sql` 적용 +- `pipeline.py`의 enrich 스테이지, 대시보드 AI 배지 +- `checks.py` 12종 완성 + `test_checks.py` +- `gui/app.py`, `steps.py`, `widgets.py` +- `notify/pump.py`, `toast.py` +- `scripts/make_shortcuts.ps1`, `bootstrap_agy.ps1` +- `docs/design/04-onboarding-wizard.md`, `docs/ops/02-failure-alerting.md` 작성 + +**이 시점에 동작하는 것** +비개발자가 **바로가기를 더블클릭**하면 창이 뜨고, API 키가 없으면 입력받아 검증·저장하고, `agy`가 없으면 설치하고, 로그인이 풀렸으면 로그인 창을 띄우고, 06:00 작업 등록까지 마친다(요구 R6 전부). 배치가 실패하면 **15분 안에 창이 떠서** 무엇이 왜 안 됐고 어떻게 고치는지 알려주고, 그 창에서 바로 고칠 수 있다(요구 R7 전부). 리포트에는 한국어 AI 브리핑이 붙고, agy가 죽어도 리포트는 그대로 나온다. + +### 마일스톤 후 (범위 밖, 기록만) + +| 항목 | 조건 | +|---|---| +| 워치리스트 UI | 사용자가 관심 성분·업체 목록을 확정한 뒤(요구 부록 미확정 항목) | +| 이메일·웹훅 알림 | 리포트 수신자가 2명 이상으로 늘어난 뒤 | +| 해외 소스(FDA/EDQM/PMDA) | 명시적 비목표. 붙일 때 `source_mfds.py`를 프로토콜로 승격 | +| 웹 대시보드 | 명시적 비목표 | + +--- + +## 10. 심사 기록 + +### 10.1 후보 3안 요약 비교 + +| 축 | A — 단일 파이프라인 | B — 이벤트 소싱 + 계층 분리 | C — 소스 플러그인 + 오케스트레이터 | +|---|---|---|---| +| 철학 | 5년 뒤에도 즉시 이해 가능한 직선 | 데이터가 자산, 리포트는 순수 함수 | 소스 경계가 유일한 확장 지점 | +| 런타임 의존성 | 3개 | 6개 | 7개 | +| 설정 파일 수 | 1 | 4 | 6 | +| 모듈 수 | 약 14 | 약 30 | 약 35 | +| 스키마 마이그레이션 | 없음 | **있음** | 없음 | +| 원문 아카이브 | 없음(DB 컬럼만) | **있음(이중)** | **있음** | +| 일자별 전량 스냅샷 | SCD2 이력 | **append-only 전량** | **run별 전량** | +| 중복 실행 방어 | **코드 가드 있음** | 스케줄러 설정만 | 스케줄러 설정만 | +| 알림 세션 분리 | 없음(전부 S4U) | **있음(AtLogOn 분리)** | 절반(세션 감지만) | +| heartbeat 정책 | 성공 시 | **성공 시** | 항상 갱신(치명) | +| xlsx 엔진 | openpyxl + LibreOffice | openpyxl | **XlsxWriter** | +| Retry-After 존중 | 없음 | 없음 | **있음** | +| 서킷 브레이커 | 소스만(2상태) | agy만(2상태) | **소스만(3상태)** | +| 진단 커맨드 | **있음(doctor)** | 없음 | 없음 | +| 200-빈응답 판정 | 없음 | 없음 | 없음 | +| 자동 백업 | 없음 | 없음 | 없음 | + +### 10.2 렌즈별 점수 + +| 렌즈 | A | B | C | 렌즈의 판단 요지 | +|---|---|---|---|---| +| **운영 단순성(ops)** | **7.5** | 7.0 | 5.5 | 설치·아침 조사·6개월 뒤 재이해의 손비용. A가 최소지만 "이식 없이는 채택 불가", 특히 S4U 토스트 문제가 A의 대전제를 무너뜨린다 | +| **실패 내성(resilience)** | 5.5 | **7.5** | 6.5 | 알림이 실제로 화면에 도달하는 안은 B뿐. C는 heartbeat를 항상 갱신해 dead-man switch를 설계상 무력화 | +| **진화(evolution)** | 4.5 | **7.0** | 6.0 | "나중에 절대 복구 못 하는 것"(원문 아카이브·전량 스냅샷·마이그레이션)을 셋 다 갖춘 것은 B뿐 | +| **합계** | 17.5 | **21.5** | 18.0 | | +| **1위 렌즈 수** | 1 | **2** | 0 | | + +### 10.3 왜 이 결론인가 + +**B가 뼈대인 이유는 세 가지다.** + +첫째, **비가역 축을 지켰다.** 앞으로 올 변경은 두 종류다 — 나중에 해도 비용이 비슷한 것(알림 채널 추가, 워치리스트, 대시보드)과 지금 안 하면 영원히 복구 못 하는 것(원문 아카이브, 일자별 전량 스냅샷, 스키마 변경 절차). 재수집 불가능한 공고 데이터에서 후자는 시간이 갈수록 손실이 누적된다. B만 셋 다 갖췄다. A는 원문을 `raw_json` 컬럼에만 넣어 "파서를 고쳐 3개월치를 재파싱한다"는 시나리오 자체가 성립하지 않는다. + +둘째, **알림이 실제로 화면에 도달하는 유일한 구조**를 갖고 있었다. 요구 R7.2("강제로 창이 뜬다")와 agy SSOT §4.2(SYSTEM 금지, 사용자 계정 필수)는 정면으로 충돌한다 — S4U 세션에는 데스크톱이 없다. B만 이 충돌을 인지하고 헤드리스 워커와 로그온 세션 알리미로 작업을 쪼갰다. A는 워커도 워치독도 전부 S4U로 등록하면서 `delivered=True`를 기록하는데 화면에는 아무것도 뜨지 않는다. 세 렌즈 중 두 렌즈가 이것을 A의 치명 결함으로 지목했고, 요구 SSOT 4절이 이미 같은 제약을 각주로 적어두고 있었다. + +셋째, **AI를 스키마 레벨에서 격리**했다. `enrichment`를 `events`와 물리적으로 분리한 것은 요구 R4.4("AI가 실패해도 리포트는 생성된다")를 문서가 아니라 데이터베이스가 강제하게 만든다. + +**C를 뼈대로 삼지 않은 이유.** C의 소스 플러그인 논거는 "정책이 서로 다른 국내 소스 두 개가 이미 존재한다"였다. 그러나 그 사이 데이터 소스 SSOT가 확정됐다 — nedrug HTML은 **쓰지 않는다**(요구 R1.2·R1.3). 소스가 하나로 줄었고 해외 소스는 명시적 비목표가 됐다. 즉 C의 핵심 논거가 요구 확정으로 소멸했다. 남은 것은 문자열 동적 import, 죽은 스텁 3개, 설정 6분할, 대상 없는 셀렉터 자가복구 스테이지, 그리고 선형 7단계에 얹은 DAG 엔진뿐이다. C가 옳았던 부분(Retry-After, 3상태 서킷, 체크포인트, AI 제안 자동적용 금지, Event Log)은 전부 이식했다. + +**A를 뼈대로 삼지 않은 이유.** A의 최소주의는 진짜 가치이고 이 확정안은 그것을 상당 부분 계승했다 — 설정 1개, 의존성 3개, 단일 진입점, `doctor` 커맨드. 그러나 A가 절약한 것(마이그레이션 러너 수백 줄, 원문 아카이브 수십 줄)과 잃은 것(앞으로 올 변경 다수가 2~3배 비싸지고, 재분석 시나리오 하나가 아예 불가능해짐)의 교환비가 나쁘다. 이건 YAGNI가 아니라 되돌릴 수 없는 축을 잘못 자른 것이다. 여기에 openpyxl + LibreOffice 재계산이라는 xlsx SSOT 정면 위반이 겹쳐, "시각화가 핵심 가치"라는 요구가 커지는 방향으로 재작성 부채가 예약돼 있었다. + +**세 후보 모두가 뚫려 있어 신규로 메운 것.** ① HTTP 200 + 빈/차단 본문 판정 — 셋 다 200이면 성공으로 처리해 곧장 diff로 넘어갔고, 결과는 예외도 알림도 없이 전 레코드가 취하로 오탐된 리포트다. 요구 R2.2가 이미 안전장치 5종을 명시하고 있었으므로 `integrity.py`를 1급 스테이지로 승격시켰다. ② xlsx 원자적 교체 — xlsx SSOT §10이 `~$` 배타 잠금과 확정적 `PermissionError`를 실측했는데 원자 교체를 설계한 후보가 없었고, 하필 A·C가 권장하는 복구 절차(같은 날 `report-only` 재실행)가 정확히 이 잠금에 막혔다. ③ 자동 백업 — 셋 다 유일 정본을 파일 하나에 두면서 백업은 README나 "설계 밖"으로 미뤘다. `VACUUM INTO` 10줄을 파이프라인 스테이지로 넣었고, 이것이 동시에 마이그레이션 롤백 경로가 됐다. ④ 디스크 여유 점검. ⑤ 비개발자용 GUI 온보딩·복구(요구 R6·R7) — 세 후보가 작성될 때 존재하지 않던 요구다. + +--- + +## 부록. 기각된 선택지와 이유 + +| 기각한 것 | 어느 후보/렌즈가 제안했나 | 기각 이유 | +|---|---|---| +| **nedrug HTML 스크래핑** | A(비활성), B(비활성), C(활성) | `robots.txt`가 `Disallow: /` 전면 금지이고 요구 R1.2·R1.3가 크롤링을 금지했다. 동일 데이터가 공식 API로 개방돼 있어 우회할 이유가 없다 | +| **`selectors.yaml` + AI 셀렉터 자가복구** | C | ADR-01로 셀렉터 자체가 존재하지 않는다. 대상 없는 기능이다. 대응되는 개념은 "API 스키마 변화 대응(A7)"이며 승인제로 구현한다 | +| **Source 프로토콜 + `registry.py` 동적 import** | B, C, 진화 렌즈 | 소스 1개 + 확장 비목표. 문자열 동적 import는 오타를 런타임까지 잠복시키고 grep 추적을 끊는다. 두 번째 소스가 실제로 오면 그때 승격한다 | +| **DAG 위상정렬(TaskGraph)** | C | 병렬성도 동적 브랜칭도 없는 선형 7단계. 스택 트레이스가 오케스트레이터 프레임을 통과해 원인이 흐려진다. 선언적 스테이지 리스트만 취했다 | +| **설정 파일 다중 분할(YAML 4~6개)** | B, C | 06:05에 "어느 파일을 열지"부터 정해야 하는 시스템은 5분 안에 안 고쳐진다. PyYAML 의존성도 함께 제거했다 | +| **`permissions.agy.json` 별도 파일** | B | agy는 `~/.gemini/antigravity-cli/settings.json`만 읽는다. 별도 파일 + 병합 배포 스텝은 조용히 실패했을 때 증상과 원인의 거리가 너무 멀다. 온보딩이 표준 위치에 직접 병합한다 | +| **`company_aliases.csv` 수동 큐레이션** | B | 사람 부채는 1인 운영에서 반드시 부도난다. 규칙 기반 정규화 + agy 제안(승인제)로 대체 | +| **`openpyxl`로 리포트 쓰기** | A, B | 스파크라인 API 부재(요구 R3.4 불충족), `load_workbook`→`save` 시 차트·이미지 손실. xlsx SSOT §3.2 결론 | +| **LibreOffice headless 재계산** | A | "켠 채로 조용히 안 도는" 상태가 1인 운영 최악의 실패 모드다. 모든 숫자를 파이썬이 계산해 값으로 쓰면 재계산 자체가 불필요해진다 | +| **동적 배열 함수(FILTER/UNIQUE/XLOOKUP)** | — | xlsx SSOT §0: 값 캐시가 없어 뷰어에서 0으로 보인다. 필터링은 파이썬에서, 인터랙션은 Excel 표 + 자동필터로 | +| **`pandas`** | B, C, 07 문서 전제 | 필요한 집계가 `GROUP BY` 수준이다. 요구 N5와 설치 실패 표면을 고려하면 정당화되지 않는다 | +| **`pydantic`** | B, C | 설정은 `tomllib` + 데이터클래스, agy 응답은 `jsonschema`로 충분하다 | +| **`win11toast` / BurntToast** | B, A | 외부 모듈 설치가 전제여서 "설치가 안 되면 알림도 안 됨"이라는 순환 실패를 만든다. tkinter로 직접 그린다 | +| **환경변수로 API 키 전달** | A, C, 진화 렌즈 | 요구 R6.3의 사용자는 환경변수를 모른다. DPAPI + GUI 입력이 유일하게 요구를 만족한다 | +| **평문 파일에 서비스키 저장** | B | 요구 N7 위반. DPAPI는 의존성 0으로 같은 편의를 준다 | +| **`run_daily.ps1` 시작부의 self-healing 작업 재등록** | C, 운영 렌즈 | 논리적 자기모순이다 — 작업이 사라지면 스크립트가 안 돌고, 스크립트가 안 돌면 재등록도 안 된다. 게다가 사용자가 의도적으로 끈 작업을 되살린다. `checks` ⑨의 읽기 전용 점검 + GUI 버튼으로 옮겼다 | +| **매 배치 agy 인증 헬스체크** | — | agy SSOT §4.3: 헬스체크 한 번에 input 28k 토큰. 실제 작업 호출의 결과로만 판정한다 | +| **`--dangerously-skip-permissions`** | — | API 응답 문자열(성분명·제조소명)이 프롬프트에 들어가므로 인젝션 표면이 있다. 이 태스크는 파일 쓰기·명령 실행이 필요 없다 | +| **`--continue` / `--conversation` 재사용** | — | agy SSOT §6.5: 배치는 매 실행이 독립적이어야 한다 | +| **`--json-schema`의 `structured_output` 신뢰** | — | SSOT §7.2 실측에서 필드 자체가 없었고 응답이 4회 반복 오염됐다 | +| **agy 역할별 다중 호출(A1~A7 각각)** | — | 첫 호출 오버헤드 28k 토큰 / 33.7초. 하나로 묶는다 | +| **Windows Service로 상주** | — | "매일 정해진 시각 실행"은 정확히 크론이 하는 일이다. 상시 메모리 점유와 별도 생명주기 관리만 늘어난다 | +| **WSL cron** | — | agy 토큰이 Windows 사용자 프로필에 있다. 계층을 하나 더 넣을 이유가 없다 | +| **SYSTEM 계정으로 작업 실행** | — | agy SSOT §4.2: 사용자 프로필의 OAuth 토큰에 접근할 수 없다 | +| **워치독을 별도 3번째 작업으로 분리** | A(07:30 워치독) | 알림 에이전트가 15분마다 로그온 세션에서 도는데, 그 안에서 heartbeat 판정을 함께 하면 작업 하나가 줄고 "알림이 뜨는 세션에서 판정한다"는 성질이 공짜로 따라온다 | +| **Airflow / Prefect / Scrapy** | — | 하루 1회, 소스 1개, 5~7단계 선형. 스케줄러 데몬을 24시간 띄우는 순간 "그 데몬은 누가 감시하는가"라는 요구가 하나 더 생긴다 | +| **웹 대시보드(FastAPI)** | 진화 렌즈가 확장 시나리오로 언급 | 요구 3절 명시적 비목표 | +| **해외 소스(FDA/EDQM/PMDA)** | B, C | 요구 3절 명시적 비목표. 확장 지점으로만 기록 | +| **alembic** | 진화 렌즈 | ORM이 없는 프로젝트에 과하다. 번호순 SQL + 적용 전 백업으로 충분하고, 백업본이 곧 롤백이다 | +| **tenacity / backoff 라이브러리** | 운영 렌즈가 A의 약점으로 언급 | 재시도 규칙이 `Retry-After` 우선이라는 도메인 특수 규칙을 포함해 어차피 감싸야 한다. 50줄 + 테스트로 직접 짜는 편이 명확하다 | +| **일 2회 이상 실행** | — | 요구 R5.1이 06:00 1회다. API 갱신 주기가 실측되면(데이터 소스 SSOT §7.4) 재검토한다 | + +--- + +## 부록 B. 이 문서가 남긴 미검증 항목 + +구현 중 반드시 실측하고 해당 SSOT를 갱신해야 한다. + +- [ ] `numOfRows` 최대값(명세 3자리 → 999 추정). `page_size` 기본값에 직결 — **M1** +- [ ] 전체 `totalCount` 실제 값. 페이지 수·실행 시간·`ExecutionTimeLimit` 적정성 결정 — **M1** +- [ ] `type=json` 실제 동작 여부. 실패 시 XML 폴백 경로가 기본이 된다 — **M1** +- [ ] `DMF_PERMIT_NO` 중복 존재 여부. `normalize.make_dmf_key`의 접미사 로직 유효성 — **M1** +- [ ] 취하·말소 등록번호가 응답에서 사라지는가 남는가. WITHDRAWN 판정의 전제 — **M1** +- [ ] 일 10,000건 한도가 "호출 수"인가 "레코드 수"인가 — **M1** +- [ ] S4U 작업에서 tkinter 창이 정말 안 뜨는가(이 설계의 ADR-10·11 전제). **떠도 설계는 유효하지만, 안 뜨는 것이 확인되면 ADR-11의 가치가 증명된다** — **M2** +- [ ] `agy` 쿼터 소진 시 정확한 `error` 문자열. `classify_error`의 QUOTA 패턴 — **M4** +- [ ] `agy` OAuth 만료 시 정확한 `error` 문자열. `classify_error`의 AUTH 패턴 — **M4** +- [ ] 등록된 MCP 서버가 배치 실행 지연을 유발하는가(agy SSOT 부록 B) — **M4** +- [ ] `--add-dir`로 워크스페이스를 좁히면 첫 호출 28k 토큰이 줄어드는가 — **M4** + +--- + +*이 문서는 아키텍처 SSOT 다. 구조에 관한 새 결정은 1절 ADR 표에 행을 추가하고, 기각한 선택지는 부록에 기록한다.* diff --git a/docs/design/02-data-model.md b/docs/design/02-data-model.md new file mode 100644 index 0000000..f152eec --- /dev/null +++ b/docs/design/02-data-model.md @@ -0,0 +1,2894 @@ +# DMF Crawler 데이터 모델과 변경 탐지 스펙 + +> **이 문서의 역할**: `docs/design/01-architecture.md` 가 확정한 모듈 경계 안에서, **무엇을 하나의 레코드로 볼 것인가 · 그것을 어떻게 저장할 것인가 · 어제와 오늘을 어떻게 비교할 것인가**를 실행 가능한 코드와 SQL 로 확정한다. `normalize.py` · `integrity.py` · `diff.py` · `storage/` 이하 전부의 구현 정본이며, 이 문서와 코드가 어긋나면 **코드가 틀린 것**이다. 데이터 소스의 필드 정의는 `docs/design/00-DATA-SOURCE-DECISION.md`, 도메인 근거는 `docs/research/01-dmf-domain-and-sources.md` §3·§10 을 따른다. + +--- + +## 0. 한눈에 보기 + +이 문서가 확정하는 것. + +1. **표준 엔티티는 `DmfRecord` 하나다.** 공식 Open API 가 주는 원천 7필드에 파생 21필드를 더해 28필드로 확정했다. 아키텍처 §3.5 의 `DmfRecord` 를 **파생 필드만 추가하는 방향으로 확장**했고, 추가분은 전부 `COMPARE_FIELDS` 밖이라 diff 판정에 영향을 주지 않는다. +2. **등록번호는 자연 키가 아니다.** 의미를 6토큰으로 완전히 해부했지만(`등록수리일자-별표1성분번호-시행일군-군내순번-동일성분순번(허여서순번)`), 신물질 포맷 공존·표기 흔들림·중복 가능성 때문에 **원문(`permit_no_raw`) / 정규화값(`permit_no`) / 대체키(`dmf_key`) 3층 구조**를 채택한다. 파서는 예외를 던지지 않고 `fmt="unknown"` 으로 안전 착지한다. +3. **스냅샷은 멤버십 테이블, 내용은 버전 테이블로 분리한다.** 매일 전량 1만 행을 통째로 적재하면 연 1.4GB 인데, `record_versions`(내용) + `snapshots`(run_id↔dmf_key↔content_hash 3열) 로 쪼개면 **연 150MB 이하**로 떨어지고 필드 이력 조회가 SQL 한 줄이 된다. 아키텍처 §3.8 의 "영구 보존" 을 실현 가능하게 만드는 유일한 형태다. +4. **해시는 두 개다.** `content_hash`(표시값 기준, 스냅샷 버전 식별)와 `identity_hash`(정규화 키 기준, 실질 변경 판정). 원본 오탈자 정정 같은 표기만의 변화는 `COSMETIC` 등급으로 기록만 하고 리포트 전면에 올리지 않는다 — 요구 R2.3 의 구현체다. +5. **이벤트 타입은 `NEW` / `CHANGED` / `WITHDRAWN` 3종으로 고정한다.** 리서치가 제안한 9종(`SITE_CHANGED`, `ANNUAL_REPORT` 등)은 **공식 API 가 그 필드를 주지 않으므로 채택하지 않는다.** 대신 `CHANGED` 이벤트의 **필드별 중요도 5등급**(`CRITICAL`/`HIGH`/`MEDIUM`/`INFO`/`COSMETIC`)으로 같은 정보를 표현한다. 재등장은 `NEW` + `is_reappearance=1` 로 표현한다. +6. **취하 오판을 막는 관문은 차단 게이트 5종 + 비차단 품질 지표 13종**이다. 게이트가 하나라도 실패하면 스냅샷 INSERT 도 하지 않는다(기준선 오염 방지). 급변 임계(`churn_ratio`)를 게이트 밖 6번째 안전장치로 신설한다. +7. **중복 등록번호는 집합 매칭으로 처리한다.** 같은 `permit_no` 가 둘 이상이면 `#2`·`#3` 접미사를 붙이되, diff 는 접미사를 짝짓지 않고 **그룹 대 그룹 탐욕 매칭**을 수행한다. 접미사 순서가 흔들려도 오탐이 나지 않는다. +8. **보존 정책은 자산별로 다르다.** DB 이벤트·버전은 영구, `data/raw/` 180일, `logs/` 90일, `reports/` 365일, `backup/` 30개. 삭제는 `finalize` 스테이지에서만, 트랜잭션 밖에서, 실패해도 실행 상태를 끌어내리지 않는다. + +--- + +## 1. 도메인 엔티티 정의 + +### 1.1 원천 필드 — 공식 Open API 가 실제로 주는 것 (7개) + +`https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01` 의 데이터 항목 전부다. 이 7개가 이 프로젝트의 **유일한 원천**이며, 나머지는 전부 여기서 계산된다. + +| # | 원본 컬럼 | 한글 항목명 | 명세 크기 | 샘플값 | 널 가능 | 우리 필드로의 매핑 | +|---|---|---|---|---|---|---| +| 1 | `DMF_PERMIT_NO` | 등록번호 | 200 | `20121228-168-I-169-04` | 이론상 가능 ⚠️ | `permit_no_raw` → `permit_no` → `dmf_key` | +| 2 | `INGR_KOR_NAME` | 성분명 | 3000 | `포르모테롤푸마르산염수화물` | 이론상 가능 ⚠️ | `ingredient_name` (+`ingredient_key`, `ingredient_base`, `is_micronized`) | +| 3 | `ENTP_NAME` | 업체명 | 200 | `(주)대웅제약` | 이론상 가능 ⚠️ | `applicant` (+`applicant_key`) | +| 4 | `MNFCTR_NAME` | 제조소명 | 150 | `SICOR SOCIETA'ITALIANA CORTICOSTER OIDO S.R.L.` | 가능 | `manufacturer` (+`manufacturer_key`, `sites`) | +| 5 | `MNFCTR_PLACE` | 제조소 소재지 | 2000 | `Rho(MI) - Via Terrazzano, 77, Italy` | 가능 | `manufacture_place` | +| 6 | `MANUF_COUNTRY_CODE_NM` | 제조국가명 | 1000 | `이탈리아,스위스` | 가능 | `countries` (콤마 분해 후 정렬된 튜플) | +| 7 | `DMF_PERMIT_DATE` | 발급일자 | 30 | `2015-02-26` | 가능 | `permit_date` (ISO 정규화) | + +> **명세 크기의 이중 용도**: 위 "명세 크기" 는 데이터 품질 검증(§7)에서 **스키마 드리프트 탐지 임계값**으로 쓴다. `MNFCTR_NAME` 이 150자를 넘는 값이 갑자기 다수 등장하면 그것은 값 이상이 아니라 **원본 스키마가 바뀐 신호**다. + +> ⚠️ **널 가능성 표기 근거**: 포털 명세는 응답 데이터 항목에 필수 여부를 표기하지 않았다. 따라서 코드는 **7필드 전부 널일 수 있다고 가정**하고 방어하되, `permit_no`·`ingredient_name`·`applicant` 세 개는 §7 게이트 4 에서 널 비율 1% 상한을 강제한다. + +### 1.2 API 가 주지 않는 것 — 그리고 그 결과 + +`docs/research/01-dmf-domain-and-sources.md` §12.1 은 의약품안전나라 화면·엑셀 기준 14필드를 조사했다. 공식 API 는 그중 7개만 준다. **없는 7개가 데이터 모델에 미치는 영향을 명시적으로 기록한다.** + +| 화면 컬럼 | 화면 필드명 | API 제공 | 없어서 잃는 것 | 이 문서의 대응 | +|---|---|---|---|---| +| 대상의약품 | `noticeCode` | ✕ | `별표1` / `신물질` 구분 | 등록번호 `fmt` 로 대리 판정 (`new_substance` ⇒ 신물질) | +| 최종변경일자 | `yrycReportNDate` | ✕ | 변경등록 발생 시점 | **스냅샷 diff 로 대체.** 오히려 변경 *내용*까지 잡는다 | +| 최종연차보고년도 | `yrycReportYYear` | ✕ | 연차보고 이벤트 | **미지원.** 리서치의 `ANNUAL_REPORT` 이벤트 타입 폐기 | +| 취소/취하구분 | `cancelCodeNm` | ✕ | 명시적 취하 상태 | **목록 이탈(`WITHDRAWN`)로만 판정.** 오판 위험이 높아 §7 게이트 전면 배치 | +| 취소/취하일자 | `cancelDate` | ✕ | 취하 일자 | 이벤트 발생일(`event_date`)로 대리 | +| 문서번호 | `dmfVersion` | ✕ | 재공고 버전 추적 | **미지원.** `DOC_VERSION_CHANGED` 폐기 | +| 연계심사문서번호 | `cntcJdgmnNo` | ✕ | 원료-완제 연계심사 신호 | **미지원.** `LINKED_REVIEW_SET` 폐기 | + +**결론 3가지.** +- 취하 판정이 **목록 이탈에만 의존**한다. 이것이 이 프로젝트의 단일 최대 위험이며 §5.4 안전장치 전체가 이 한 문장 때문에 존재한다. +- 리서치의 이벤트 9종은 3종으로 줄어든다. 정보 손실은 `CHANGED` 의 **필드별 중요도 등급**(§5.2)이 상당 부분 흡수한다. +- 화면 7필드는 **버리지 않고 스키마에 예약 컬럼으로 남긴다**(§3.3 `record_versions` 의 `reserved_*` 없음 — 대신 `raw_json` 이 원본 전체를 보존하므로 소스가 확장되면 컬럼 추가 마이그레이션만 하면 된다). + +### 1.3 표준 엔티티 `DmfRecord` — 전체 필드 정의 + +| # | 필드명 (snake_case) | 한글 표시명 | 타입 | 널 | 예시값 | 원본 매핑 | 정규화 규칙 | 비교 대상 | +|---|---|---|---|---|---|---|---|---| +| 1 | `dmf_key` | 레코드 키 | `str` | ✕ | `20121228-168-I-169-04` | 파생 | §4.1 3층 키. 중복 시 `#2` 접미사 | **키** | +| 2 | `permit_no` | 등록번호(정규화) | `str` | ✕ | `20121228-168-I-169-04` | `DMF_PERMIT_NO` | NFKC · 전각괄호/하이픈 통일 · 전 공백 제거 · 대문자화 | ✕ | +| 3 | `permit_no_raw` | 등록번호(원문) | `str` | ✕ | `20121228-168-I-169-04 ` | `DMF_PERMIT_NO` | 없음(감사용 원문 보존) | ✕ | +| 4 | `ingredient_name` | 성분명 | `str` | ✕(빈문자 허용) | `포르모테롤푸마르산염수화물` | `INGR_KOR_NAME` | NFKC · 연속공백 1개 · 앞뒤 공백 제거 · 전각→반각 | **○** | +| 5 | `ingredient_key` | 성분 매칭키 | `str` | ✕ | `포르모테롤푸마르산염수화물` | 파생 | `ingredient_name` 에서 **모든 공백·구두점 제거 후 소문자화** | ✕(등급 판정용) | +| 6 | `ingredient_base` | 성분 기본명 | `str` | ✕ | `포르모테롤` | 파생 | `ingredient_key` 에서 미분화 접두 · 염/수화물/양이온 접미 반복 제거 | ✕(**집계 전용**) | +| 7 | `is_micronized` | 미분화 여부 | `bool` | ✕ | `False` | 파생 | 성분명 `미분화`/`초미분화` 접두 또는 제조소명 `[미분화공정 제조소]` | ✕ | +| 8 | `applicant` | 신청인(업체명) | `str` | ✕(빈문자 허용) | `(주)대웅제약` | `ENTP_NAME` | NFKC · 공백 축약 · `㈜`→`(주)` 통일 | **○** | +| 9 | `applicant_key` | 신청인 매칭키 | `str` | ✕ | `대웅제약` | 파생 | 법인격(`주식회사`/`(주)`/`유한회사`/`Co.,Ltd` 등) 제거 + 공백·구두점 제거 + 소문자화 | ✕(등급 판정용) | +| 10 | `manufacturer` | 제조소명 | `str` | ✕(빈문자 허용) | `SICOR SOCIETA'ITALIANA CORTICOSTER OIDO S.R.L.` | `MNFCTR_NAME` | NFKC · 공백 축약 · 다중값은 ` , ` 로 재조립 | **○** | +| 11 | `manufacturer_key` | 제조소 매칭키 | `str` | ✕ | `sicorsocietaitalianacorticosteroidosrl` | 파생 | 전 공백·구두점 제거 + 소문자화 (원본 오탈자 `CORTICOSTER OIDO` 흡수) | ✕(등급 판정용) | +| 12 | `manufacture_place` | 제조소 소재지 | `str` | ✕(빈문자 허용) | `Rho(MI) - Via Terrazzano, 77, Italy` | `MNFCTR_PLACE` | NFKC · 공백 축약 | **○** | +| 13 | `manufacture_place_key` | 소재지 매칭키 | `str` | ✕ | `rhomiviaterrazzano77italy` | 파생 | 전 공백·구두점 제거 + 소문자화 | ✕(등급 판정용) | +| 14 | `countries` | 제조국가 | `tuple[str, ...]` | ✕(빈튜플 허용) | `("스위스", "이탈리아")` | `MANUF_COUNTRY_CODE_NM` | 콤마/슬래시 분해 · 별칭 정규화 · **정렬** | **○** | +| 15 | `sites` | 제조소 분해 | `tuple[Site, ...]` | ✕(빈튜플 허용) | `(Site(name=..., role="미분화공정 제조소"),)` | 파생 | ` ,`(공백+콤마) 우선 분해 · 역할 접두어 추출 | ✕(**집계 전용**) | +| 16 | `permit_date` | 발급일자 | `str` | ✕(빈문자 허용) | `2015-02-26` | `DMF_PERMIT_DATE` | 8자리·점·슬래시 표기 → `YYYY-MM-DD`. 파싱 실패 시 빈 문자열 | **○** | +| 17 | `accepted_date` | 등록수리일자 | `str \| None` | ○ | `2012-12-28` | 등록번호 1토큰 | `permit.accept_date` 의 별칭(아키텍처 §3.5 명칭 보존) | ✕ | +| 18 | `permit.fmt` | 등록번호 포맷 | `str` | ✕ | `standard` | 파생 | `standard`/`new_substance`/`unknown` | ✕ | +| 19 | `permit.ingr_no` | 별표1 성분번호 | `int \| None` | ○ | `168` | 등록번호 2토큰 | — | ✕ | +| 20 | `permit.group` | 시행일 알파벳군 | `str \| None` | ○ | `I` | 등록번호 3토큰 | 대문자 1자 `A`~`Z` | ✕ | +| 21 | `permit.group_table` | 근거 별표 | `str \| None` | ○ | `별표1` | 파생 | `GROUP_TABLE` 조회 | ✕ | +| 22 | `permit.group_effective_from` | 군 시행일 | `str \| None` | ○ | `2013-01-01` | 파생 | `GROUP_TABLE` 조회. **알파벳순 ≠ 시행일순이므로 정렬은 이 값으로** | ✕ | +| 23 | `permit.serial` | 군내 접수순번 | `int \| None` | ○ | `169` | 등록번호 4토큰 | — | ✕ | +| 24 | `permit.sub` | 동일성분 일련번호 | `int \| None` | ○ | `4` | 등록번호 5토큰 | J군은 항상 `None` | ✕ | +| 25 | `permit.grant` | 허여서 순번 | `int \| None` | ○ | `None` | 등록번호 괄호 | 자료공유허여서 파생 등록 | ✕ | +| 26 | `permit.base_permit_no` | 기준 등록번호 | `str \| None` | ○ | `20121228-168-I-169-04` | 파생 | 괄호 제거값. 허여서 파생을 원 등록과 묶는 축 | ✕ | +| 27 | `permit.ingr_group_key` | 성분 코호트 키 | `str \| None` | ○ | `168-I` | 파생 | `{ingr_no}-{group}`. "이 성분에 제조원이 몇 개 붙었나" | ✕ | +| 28 | `content_hash` | 내용 해시 | `str` | ✕ | `9f2c1ab4e7d05631` | 파생 | `COMPARE_FIELDS` **표시값** SHA-256 앞 16자 | ✕(판정 입력) | +| 29 | `identity_hash` | 실질 해시 | `str` | ✕ | `31a0c8ee45b7f912` | 파생 | `COMPARE_KEY_FIELDS` **정규화 키** SHA-256 앞 16자 | ✕(등급 판정) | +| 30 | `raw` | 원본 필드 | `dict[str, str]` | ✕ | `{"DMF_PERMIT_NO": "...", ...}` | 응답 그대로 | 없음 | ✕ | + +**비교 대상 6필드(`COMPARE_FIELDS`)를 이 6개로 고정한 이유**: 아키텍처 §3.5 가 확정한 목록 그대로다. `dmf_key`·`permit_no` 는 키이므로 비교 대상이 아니고, 나머지는 전부 이 6개에서 계산된 파생값이라 중복 비교가 된다. `raw` 는 원본 노이즈(응답 필드 순서·공백)를 그대로 담으므로 비교하면 매일 CHANGED 가 터진다. + +### 1.4 아키텍처 §3.5 대비 변경점 + +이 문서는 아키텍처를 **확장**하되 **어떤 이름도 바꾸지 않는다.** 변경분을 전부 나열한다. + +| 항목 | 아키텍처 §3.5 | 이 문서 | 사유 | +|---|---|---|---| +| `DmfRecord.permit_no_raw` | 없음 | 추가 | 정규화 전 원문 보존. 감사·회귀 재현에 필요 | +| `DmfRecord.ingredient_key` / `applicant_key` / `manufacturer_key` / `manufacture_place_key` | 없음 | 추가 | 요구 R2.3(표기 흔들림) 판정과 워치리스트 매칭의 축 | +| `DmfRecord.ingredient_base` / `is_micronized` / `sites` | 없음 | 추가 | 리포트 `s03_ingredient` · `s04_company` 집계 전용. **diff 미참여** | +| `DmfRecord.permit` (`PermitParts`) | `accepted_date` 만 | 구조체로 확장, `accepted_date` 는 별칭으로 유지 | 등록번호 6토큰이 리포트 파생 지표 5종의 원천(리서치 §3.6) | +| `DmfRecord.identity_hash` | 없음 | 추가 | 표기만의 변화(`COSMETIC`)를 실질 변경과 분리 | +| `make_dmf_key(permit_no, dup_index)` | 2인자 | `fallback_seed` 3번째 인자 추가(기본 `None`) | 등록번호가 빈 값인 레코드의 합성키 경로 | +| `normalize.py` 의 책임 | 표준화 + 키·해시 | + 등록번호 파싱 | 파서를 별도 모듈로 만들면 아키텍처의 디렉터리 트리를 깬다. `normalize.py` 안에 둔다 | +| 마이그레이션 `0001_init.sql` 테이블 | runs/stage_status/fetch_stats/snapshots/records/events | + `schema_version`, `sources`, `integrity_gates`, `record_versions` | §3.2 의 저장량 산정과 §7 결과 조회 필요. 전부 코어 테이블이라 0001 에 둔다 | +| 보존 정리 함수 위치 | 명시 없음 | `backup.py` 의 `prune_*` 함수군 + `repo.prune_snapshots` | `backup.py` 가 이미 "보존 개수 정리" 를 맡는다(§8.2) | + +### 1.5 `DmfRecord` 정의 코드 + +```python +# src/dmf_crawler/normalize.py (1/3 — 엔티티 정의) +"""원본 7필드를 표준 DmfRecord 로 변환한다. + +이 모듈은 순수 함수만 담는다. DB·네트워크·파일에 접근하지 않는다. +표준 라이브러리 외 의존성이 없다(unicodedata, hashlib, re, dataclasses). +""" +from __future__ import annotations + +import hashlib +import re +import unicodedata +from dataclasses import dataclass, field +from typing import Iterable, Mapping, Optional + +# -------------------------------------------------------------------------- +# 원본 컬럼명 — API 응답 키. 이 상수 밖에서 문자열 리터럴로 쓰지 않는다. +# -------------------------------------------------------------------------- +COL_PERMIT_NO = "DMF_PERMIT_NO" +COL_INGREDIENT = "INGR_KOR_NAME" +COL_APPLICANT = "ENTP_NAME" +COL_MANUFACTURER = "MNFCTR_NAME" +COL_PLACE = "MNFCTR_PLACE" +COL_COUNTRY = "MANUF_COUNTRY_CODE_NM" +COL_PERMIT_DATE = "DMF_PERMIT_DATE" + +SOURCE_COLUMNS: tuple[str, ...] = ( + COL_PERMIT_NO, COL_INGREDIENT, COL_APPLICANT, COL_MANUFACTURER, + COL_PLACE, COL_COUNTRY, COL_PERMIT_DATE, +) + +# 포털 명세의 항목 크기. §7 스키마 드리프트 탐지 임계값으로 쓴다. +SOURCE_MAX_LEN: Mapping[str, int] = { + COL_PERMIT_NO: 200, + COL_INGREDIENT: 3000, + COL_APPLICANT: 200, + COL_MANUFACTURER: 150, + COL_PLACE: 2000, + COL_COUNTRY: 1000, + COL_PERMIT_DATE: 30, +} + +# 널 비율을 강제하는 필수 필드 (게이트 4) +REQUIRED_FIELDS: tuple[str, ...] = ("permit_no", "ingredient_name", "applicant") + +# 아키텍처 §3.5 확정 — 표시값 기준 비교 대상. 순서 고정(해시 입력 순서다). +COMPARE_FIELDS: tuple[str, ...] = ( + "ingredient_name", "applicant", "manufacturer", + "manufacture_place", "countries", "permit_date", +) + +# 실질 변경 판정용 — 정규화 키 기준. COMPARE_FIELDS 와 1:1 대응. +COMPARE_KEY_FIELDS: tuple[str, ...] = ( + "ingredient_key", "applicant_key", "manufacturer_key", + "manufacture_place_key", "countries", "permit_date", +) + +# 표시 필드 -> 대응하는 키 필드. §5.2 등급 판정에서 쓴다. +FIELD_KEY_MAP: Mapping[str, str] = { + "ingredient_name": "ingredient_key", + "applicant": "applicant_key", + "manufacturer": "manufacturer_key", + "manufacture_place": "manufacture_place_key", + "countries": "countries", + "permit_date": "permit_date", +} + +FIELD_LABELS_KO: Mapping[str, str] = { + "ingredient_name": "성분명", + "applicant": "신청인", + "manufacturer": "제조소명", + "manufacture_place": "제조소 소재지", + "countries": "제조국가", + "permit_date": "발급일자", +} + + +@dataclass(frozen=True, slots=True) +class Site: + """제조소 1건. manufacturer/manufacture_place/countries 를 축별로 분해한 결과.""" + seq: int + name: str + address: str + country: str + role: str = "" # '미분화공정 제조소' 등 대괄호 역할 표기 + + +@dataclass(frozen=True, slots=True) +class PermitParts: + """등록번호 파싱 결과. 어떤 입력이든 예외 없이 생성된다.""" + raw: str + normalized: str + fmt: str # standard | new_substance | unknown + accept_date: Optional[str] = None # ISO YYYY-MM-DD + ingr_no: Optional[int] = None + group: Optional[str] = None + group_table: Optional[str] = None + group_range: Optional[str] = None + group_effective_from: Optional[str] = None + serial: Optional[int] = None + sub: Optional[int] = None + grant: Optional[int] = None + is_grant_derived: bool = False + base_permit_no: Optional[str] = None + ingr_group_key: Optional[str] = None + + +@dataclass(frozen=True, slots=True) +class DmfRecord: + """DMF 등록 레코드 표준형. 아키텍처 §3.5 확장(파생 필드만 추가).""" + dmf_key: str + permit_no: str + permit_no_raw: str + ingredient_name: str + ingredient_key: str + ingredient_base: str + is_micronized: bool + applicant: str + applicant_key: str + manufacturer: str + manufacturer_key: str + manufacture_place: str + manufacture_place_key: str + countries: tuple[str, ...] + sites: tuple[Site, ...] + permit_date: str + permit: PermitParts + raw: dict[str, str] = field(default_factory=dict) + dup_index: int = 0 + content_hash: str = "" + identity_hash: str = "" + + @property + def accepted_date(self) -> Optional[str]: + """아키텍처 §3.5 의 필드명을 보존하는 별칭.""" + return self.permit.accept_date + + def compare_view(self) -> dict[str, str]: + """COMPARE_FIELDS 를 문자열로 평탄화한다. 이벤트 before/after 의 정본 형태.""" + return { + "ingredient_name": self.ingredient_name, + "applicant": self.applicant, + "manufacturer": self.manufacturer, + "manufacture_place": self.manufacture_place, + "countries": ", ".join(self.countries), + "permit_date": self.permit_date, + } + + def key_view(self) -> dict[str, str]: + """COMPARE_KEY_FIELDS 를 문자열로 평탄화한다. COSMETIC 판정 입력.""" + return { + "ingredient_key": self.ingredient_key, + "applicant_key": self.applicant_key, + "manufacturer_key": self.manufacturer_key, + "manufacture_place_key": self.manufacture_place_key, + "countries": "|".join(self.countries), + "permit_date": self.permit_date, + } + + +@dataclass(frozen=True, slots=True) +class NormalizeStats: + """정규화 통계. integrity 가 게이트 4·5 판정에 그대로 쓴다.""" + total_in: int + total_out: int + rejected: tuple[dict[str, str], ...] # 파싱 자체가 불가능했던 원본 + null_counts: Mapping[str, int] # 필수 필드별 널 건수 + duplicate_permit_no: int # 중복 등록번호로 접미사가 붙은 건수 + duplicate_groups: int # 중복이 발생한 등록번호 종류 수 + unparsed_permit_no: int # fmt == 'unknown' + new_substance_count: int # fmt == 'new_substance' + synthetic_key_count: int # 등록번호가 없어 합성키를 쓴 건수 + invalid_permit_date: int # 발급일자 파싱 실패 + oversize_fields: Mapping[str, int] # 명세 크기 초과 건수(스키마 드리프트) + replacement_char_count: int # U+FFFD 포함 건수(인코딩 사고) +``` + +--- + +## 2. 등록번호 파싱과 키 설계 + +### 2.1 등록번호 체계 해부 + +근거는 식약처 FAQ(해설서 제5개정판 Ⅳ장 Q51) 원문이다. `20110531-71-B-317-05(1)` 을 6토큰으로 해부한다. + +| 위치 | 토큰 | 예시 | 의미 | 타입 | 규칙 | 파생 활용 | +|---|---|---|---|---|---|---| +| 1 | `accept_date` | `20110531` | **등록수리일자** | date | `(19\|20)\d{2}` + 월 + 일 | 접수 시점 시계열. `permit_date` 와 별개 필드로 저장 | +| 2 | `ingr_no` | `71` | 「원료의약품 등록에 관한 규정」 **[별표1] 성분 일련번호** | int | 1~211+ | 성분 코호트 조인 키 | +| 3 | `group` | `B` | **부칙 시행일 군** | char | `A`~`K` 관측 | 등록 의무 코호트 | +| 4 | `serial` | `317` | 동일 군 내 **접수 순번** | int | 1~6자리 | 군 내 누적 순서 | +| 5 | `sub` | `05` | **동일 성분 내 일련번호** | int? | **J군은 미부여** | 성분별 등록 경쟁 강도 | +| 6 | `grant` | `(1)` | **허여서(자료공유허여서) 등록 순번** | int? | `(1)`~`(9)` | 원 등록번호와 반드시 묶어 표시 | + +**군 → 별표 → 시행일 매핑** (파서 내장 상수). 알파벳 순서와 시행일 순서가 일치하지 않으므로 **정렬 키로 알파벳을 쓰지 말고 시행일을 쓴다.** + +| 군 | 별표 | 호수 범위 | 시행일 | +|---|---|---|---| +| A~D | 별표1 | 제1호~제99호 | 2003-01-01 | +| E | 별표1 | 제1호~제99호 | 2008-01-01 | +| F | 별표1 | 제100호~제113호 | 2009-01-01 | +| G | 별표1 | 제114호~제123호 | 2010-01-01 | +| H | 별표1 | 제124호~제141호 | 2011-01-01 | +| I | 별표1 | 제142호~제208호 | 2013-01-01 | +| J | 별표1 | 제209호·제210호·제211호 | 2017-12-25 (211호는 2018-01-01) | +| K | 별표1의2 | 제1호~제19호 | 2018-01-01 | + +**두 번째 포맷 — 신물질 계열.** 실측 `수6580-16-ND(20)` (대상의약품 = 신물질, 성분 = 프레가발린). FAQ Q51 은 별표1 포맷만 설명하므로 구조 해석은 ⚠️ **미검증**이다. 파서는 이 포맷을 표준 포맷으로 강제 파싱하지 않고 `fmt="new_substance"` 로 분기해 **토큰 의미를 부여하지 않는다.** 잘못된 의미 부여보다 값 없음이 낫다. + +### 2.2 등록번호는 자연 키인가 — 판정 + +자연 키의 조건은 **유일성 · 불변성 · 존재성** 세 가지다. 각각을 검증한다. + +| 조건 | 검증 | 판정 | +|---|---|---| +| **유일성** | 등록번호는 접수 순번을 포함하므로 설계상 유일하다. 다만 API 가 실제로 중복을 반환하지 않는지는 ⚠️ **미검증**(serviceKey 발급 후 실측). 허여서 파생 `(1)` 은 괄호가 붙어 원 번호와 구별되므로 충돌하지 않는다 | **조건부 충족** | +| **불변성** | 앞 8자리가 등록수리일자로 고정돼 있고, 재공고 시에도 등록번호는 유지된다(리서치 §2.9 의 "재공고" 건이 같은 번호로 재등장). 발급일자(`permit_date`)와 수리일자가 크게 벌어지는 사례(`20121228` vs `2015-02-26`)가 있으나 **번호 자체는 바뀌지 않는다** | **충족** | +| **존재성** | 명세상 널 여부 표기가 없다. 빈 값이 올 가능성을 배제할 근거가 없다 | **미보장** | + +**결론: 등록번호는 "거의 자연 키"이지 자연 키가 아니다.** 세 가지가 걸린다. + +1. **표기 흔들림** — 전각 하이픈(`-`), 전각 괄호, 비단절 공백(U+00A0), 앞뒤 공백이 섞이면 같은 등록이 다른 키가 된다. 그 즉시 `WITHDRAWN` + `NEW` 쌍으로 오탐한다. +2. **중복 가능성** — 미검증. 중복이 실재하면 PK 제약이 INSERT 를 깨뜨리고 그날 실행 전체가 실패한다. +3. **부재 가능성** — 빈 등록번호 레코드가 하나라도 오면 PK 가 성립하지 않는다. + +따라서 **정규화 계층과 대체 키를 둔다.** 이것이 §2.3 의 3층 구조다. + +### 2.3 3층 키 구조 + +``` +┌ permit_no_raw ─ API 가 준 문자열 그대로. 절대 가공하지 않는다. 감사·회귀 재현용. +│ │ NFKC · 전각→반각 · 괄호/하이픈 통일 · 전 공백 제거 · 대문자화 +│ ▼ +├ permit_no ───── 정규화된 등록번호. 표기 흔들림이 제거된 "의미상의 등록번호". +│ │ 중복 그룹 내 결정론적 접미사(#2, #3) / 부재 시 합성키 +│ ▼ +└ dmf_key ─────── 스냅샷 안에서 유일함이 보장되는 레코드 키. diff · PK · 리포트 링크의 축. +``` + +| 층 | 유일성 보장 | 사람이 읽는가 | 저장 위치 | 용도 | +|---|---|---|---|---| +| `permit_no_raw` | ✕ | ○ | `record_versions.permit_no_raw`, `raw_json` | 원본 대조, 재파싱 | +| `permit_no` | 거의 (미검증) | ○ | 인덱스 있음 | 사람의 검색, 허여서 묶기(`base_permit_no`) | +| `dmf_key` | **○ (강제)** | 대체로 | 모든 테이블의 조인 키 | **diff 키 · PK · 이벤트 키** | + +**대체 키 규칙 3가지.** + +- **정상**: `dmf_key = permit_no` +- **중복**: 같은 `permit_no` 가 스냅샷 안에 n개면 결정론적 정렬 후 `permit_no`, `permit_no#2`, … `permit_no#n`. 정렬 키는 `(manufacturer_key, manufacture_place_key, countries 결합문자열, ingredient_key, permit_date)` 튜플이다. 아키텍처는 "제조소명 사전순" 이라고만 했는데, 제조소명 하나로는 동률이 나면 순서가 흔들리므로 **5요소 전체 튜플로 확장**한다. 그래도 흔들릴 수 있으므로 diff 에서 §4.5 그룹 매칭으로 이중 방어한다. +- **부재**: `permit_no` 가 빈 문자열이면 `dmf_key = "SYN-" + sha1(ingredient_key|applicant_key|manufacturer_key|permit_date)` 앞 12자. 접두어 `SYN-` 로 합성키임을 리포트에서 즉시 식별할 수 있게 한다. 합성키 레코드는 **`applicant` 표기만 바뀌어도 신규+취하로 오탐**하므로 §7 품질 지표에서 건수를 별도 추적하고 리포트 메타 시트에 표시한다. + +### 2.4 파서 완결 코드 + +```python +# src/dmf_crawler/normalize.py (2/3 — 등록번호 파서 · 정규화 원자 함수) + +# -------------------------------------------------------------------------- +# 포맷 A: 일반(별표1 / 별표1의2) +# 20110531-71-B-317-05 기본 +# 20110531-71-B-317-05(1) 허여서 파생 +# 20260901-209-J-2270 J군: sub 없음 +# -------------------------------------------------------------------------- +RE_PERMIT_STANDARD = re.compile( + r"^(?P(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01]))" + r"-(?P\d{1,4})" + r"-(?P[A-Z])" + r"-(?P\d{1,6})" + r"(?:-(?P\d{1,3}))?" + r"(?:\((?P\d{1,3})\))?$" +) + +# -------------------------------------------------------------------------- +# 포맷 B: 신물질(신약 원료) 계열 — 예: 수6580-16-ND(20) +# 토큰 의미는 미검증이므로 구조 인식만 하고 값 해석은 하지 않는다. +# -------------------------------------------------------------------------- +RE_PERMIT_NEW_SUBSTANCE = re.compile( + r"^(?P[가-힣]{1,2})(?P\d{3,7})" + r"-(?P\d{1,3})" + r"-(?PND)" + r"(?:\((?P\d{1,3})\))?$" +) + +# 군 -> (별표, 호수범위, 시행일) +GROUP_TABLE: Mapping[str, tuple[str, str, str]] = { + "A": ("별표1", "제1호~제99호", "2003-01-01"), + "B": ("별표1", "제1호~제99호", "2003-01-01"), + "C": ("별표1", "제1호~제99호", "2003-01-01"), + "D": ("별표1", "제1호~제99호", "2003-01-01"), + "E": ("별표1", "제1호~제99호", "2008-01-01"), + "F": ("별표1", "제100호~제113호", "2009-01-01"), + "G": ("별표1", "제114호~제123호", "2010-01-01"), + "H": ("별표1", "제124호~제141호", "2011-01-01"), + "I": ("별표1", "제142호~제208호", "2013-01-01"), + "J": ("별표1", "제209호·제210호·제211호", "2017-12-25"), + "K": ("별표1의2", "제1호~제19호", "2018-01-01"), +} + +# 전각·유사 문자 → 반각 표준 문자 +_PUNCT_FOLD = { + "(": "(", ")": ")", "[": "[", "]": "]", + "-": "-", "–": "-", "—": "-", "―": "-", + "‐": "-", "‑": "-", "ー": "-", + ",": ",", ".": ".", "/": "/", ":": ":", ";": ";", + " ": " ", " ": " ", " ": " ", " ": " ", + "": "", "​": "", "‌": "", "‍": "", +} + +_WS_RE = re.compile(r"\s+") +_NON_ALNUM_KO_RE = re.compile(r"[^0-9A-Za-z가-힣]+") + + +def fold_punct(s: str) -> str: + """전각·유사 구두점을 반각 표준으로 접는다.""" + if not s: + return "" + return "".join(_PUNCT_FOLD.get(ch, ch) for ch in s) + + +def clean_text(s: object) -> str: + """모든 문자열 필드의 1차 정규화. + + NFKC -> 구두점 접기 -> 제어문자 제거 -> 연속 공백 1개 -> 앞뒤 공백 제거. + None 이나 비문자열은 빈 문자열이 된다(널 방어). + """ + if s is None: + return "" + text = s if isinstance(s, str) else str(s) + text = unicodedata.normalize("NFKC", text) + text = fold_punct(text) + text = "".join(ch for ch in text if unicodedata.category(ch) != "Cc") + text = _WS_RE.sub(" ", text) + return text.strip() + + +def matching_key(s: str) -> str: + """매칭 전용 키. 공백·구두점을 전부 지우고 소문자화한다. + + 원본 오탈자 CORTICOSTER OIDO 와 CORTICOSTEROIDO 를 같은 키로 만든다. + """ + if not s: + return "" + text = unicodedata.normalize("NFKC", s) + text = _NON_ALNUM_KO_RE.sub("", text) + return text.lower() + + +def normalize_permit_no(raw: str) -> str: + """등록번호 정규화 — 표기 흔들림 제거. 공백은 전부 삭제한다.""" + text = clean_text(raw).replace(" ", "") + return text.upper() + + +def _is_valid_iso_date(iso: str) -> bool: + """YYYY-MM-DD 문자열이 실재하는 날짜인지 확인한다.""" + try: + y, mo, d = (int(x) for x in iso.split("-")) + except (ValueError, AttributeError): + return False + if not (1900 <= y <= 2199 and 1 <= mo <= 12): + return False + if mo in (1, 3, 5, 7, 8, 10, 12): + last = 31 + elif mo in (4, 6, 9, 11): + last = 30 + else: + leap = (y % 4 == 0 and y % 100 != 0) or (y % 400 == 0) + last = 29 if leap else 28 + return 1 <= d <= last + + +def parse_permit_no(raw: str) -> PermitParts: + """등록번호를 구조화한다. 어떤 입력에서도 예외를 던지지 않는다.""" + normalized = normalize_permit_no(raw) + if not normalized: + return PermitParts(raw=raw or "", normalized="", fmt="unknown") + + m = RE_PERMIT_STANDARD.match(normalized) + if m: + g = m.groupdict() + d = g["accept_date"] + accept_iso = f"{d[0:4]}-{d[4:6]}-{d[6:8]}" + if not _is_valid_iso_date(accept_iso): + # 20260231 처럼 형식만 맞는 날짜. 포맷은 standard 로 두되 날짜는 버린다. + accept_iso = None + table, rng, eff = GROUP_TABLE.get(g["group"], (None, None, None)) + grant = int(g["grant"]) if g["grant"] else None + ingr_no = int(g["ingr_no"]) + return PermitParts( + raw=raw or "", + normalized=normalized, + fmt="standard", + accept_date=accept_iso, + ingr_no=ingr_no, + group=g["group"], + group_table=table, + group_range=rng, + group_effective_from=eff, + serial=int(g["serial"]), + sub=int(g["sub"]) if g["sub"] else None, + grant=grant, + is_grant_derived=grant is not None, + base_permit_no=normalized.split("(")[0], + ingr_group_key=f"{ingr_no}-{g['group']}", + ) + + m = RE_PERMIT_NEW_SUBSTANCE.match(normalized) + if m: + seq = m.group("seq") + return PermitParts( + raw=raw or "", + normalized=normalized, + fmt="new_substance", + grant=int(seq) if seq else None, + is_grant_derived=seq is not None, + base_permit_no=normalized.split("(")[0], + ) + + return PermitParts( + raw=raw or "", + normalized=normalized, + fmt="unknown", + base_permit_no=normalized.split("(")[0] or None, + ) +``` + +### 2.5 파서 회귀 케이스 (`tests/test_normalize.py` 의 정본 표) + +| 입력 | `fmt` | `accept_date` | `ingr_no` | `group` | `serial` | `sub` | `grant` | `ingr_group_key` | +|---|---|---|---|---|---|---|---|---| +| `20110531-71-B-317-05` | standard | 2011-05-31 | 71 | B | 317 | 5 | None | `71-B` | +| `20110531-71-B-317-05(1)` | standard | 2011-05-31 | 71 | B | 317 | 5 | 1 | `71-B` | +| `20260901-209-J-2270` | standard | 2026-09-01 | 209 | J | 2270 | **None** | None | `209-J` | +| `20121228-168-I-169-04` | standard | 2012-12-28 | 168 | I | 169 | 4 | None | `168-I` | +| 전각 하이픈·전각 공백이 섞인 위 값 | standard | 2012-12-28 | 168 | I | 169 | 4 | None | `168-I` | +| `수6580-16-ND(20)` | new_substance | None | None | None | None | None | 20 | None | +| `20260231-1-A-1-1` (2월 31일) | standard | **None** | 1 | A | 1 | 1 | None | `1-A` | +| `20260901-209-Z-1` (미지 군) | standard | 2026-09-01 | 209 | Z | 1 | None | None | `209-Z` | +| `이상한값` | unknown | None | None | None | None | None | None | None | +| 빈 문자열 | unknown | None | None | None | None | None | None | None | + +> `Z` 군처럼 `GROUP_TABLE` 에 없는 알파벳은 **파싱은 성공시키되 `group_table`·`group_effective_from` 을 `None`** 으로 둔다. 제도 확대로 새 군이 생겨도 파이프라인이 멈추지 않는다. 대신 §7 품질 지표 `unknown_group_count` 가 잡아 리포트 메타 시트에 표시한다. + +--- + +## 3. SQLite 스키마 전문 + +### 3.1 설계 원칙 7가지 + +1. **append-only 를 기본으로 한다.** `snapshots`·`events`·`event_changes`·`agy_calls`·`alerts`·`quality_checks` 는 UPDATE·DELETE 하지 않는다. 유일한 예외는 §8 보존 정책에 따른 오래된 파티션 삭제다. +2. **현재 상태는 `records` 하나뿐이다.** UPDATE 가 허용되는 유일한 테이블이며, 그마저도 라이프사이클 컬럼(`last_seen_*`, `status`, `current_version_id`, 카운터)만 바뀐다. +3. **내용과 멤버십을 분리한다.** 같은 내용을 매일 다시 쓰지 않는다(§3.2 산정). +4. **자연 키를 조인 축으로 쓰지 않는다.** `dmf_key` 는 사람과 리포트가 쓰고, 테이블 간 조인은 정수 대리키(`record_id`·`version_id`·`run_seq`)로 한다. 등록번호가 21바이트 TEXT 라 1만 행 × 365일 조인 축으로 쓰면 그 자체가 수백 MB다. +5. **모든 시각은 ISO 8601 문자열, 모든 날짜는 `YYYY-MM-DD`.** SQLite 에 네이티브 날짜 타입이 없으므로 `CHECK ... GLOB` 로 형식을 강제한다. +6. **외래키를 실제로 켠다.** `PRAGMA foreign_keys=ON`(아키텍처 §3.8). 참조 무결성을 앱 코드가 아니라 DB 가 지킨다. +7. **비즈니스 규칙을 DB 제약으로 내린다.** idempotency 가드(하루 1건 SUCCESS)를 앱 코드의 `if` 가 아니라 **부분 유니크 인덱스**로 강제한다. 코드가 버그를 내도 데이터는 깨지지 않는다. + +### 3.2 저장량 산정 — 왜 `record_versions` 를 분리하는가 + +관측 기준 전체 등록 건수 ≈ **9,840행**(리서치 §12.4). 안전하게 10,000행으로 잡는다. + +| 설계 | 하루 증가 | 1년 누적 | 5년 누적 | +|---|---|---|---| +| **A. 매일 전량 통 적재** (모든 필드 × 10,000행 × ~850B) | 8.5 MB | **3.1 GB** | 15.5 GB | +| **B. 버전 분리 + TEXT 멤버십** (내용 델타 + `(run_id TEXT, dmf_key TEXT, hash TEXT)` ~90B) | 0.9 MB | 329 MB | 1.6 GB | +| **C. 버전 분리 + 정수 멤버십** ← **채택** (`(run_seq, record_id, version_id)` 정수 3개 ~35B) | 0.35 MB | **128 MB** | 640 MB | + +- **내용 델타**: 기준선 수립일에 `record_versions` 10,000행(≈8.5MB)이 한 번 들어가고, 이후에는 **실제로 바뀐 레코드만** 새 버전을 만든다. 하루 신규 20 + 변경 30 = 50행 가정 시 42KB/일 → **연 15MB**. +- 설계 C 는 A 대비 **24배** 작다. 아키텍처가 `storage.snapshot_retain_days = 0`(영구 보존)을 기본값으로 정한 것은 C 를 전제해야만 성립한다. +- 그래도 5년에 640MB 다. `doctor` 진단에 **DB 크기 임계(기본 2GB)** 체크를 넣고, 초과하면 `snapshot_retain_days` 를 400(전년 동기 비교 가능 최소값)으로 낮추라고 안내한다. `records` + `events` 만으로 전체 이력 재구성이 가능하므로 멤버십 삭제는 **정보 손실이 아니라 인덱스 손실**이다. + +### 3.3 `0001_init.sql` — 코어 스키마 전문 + +```sql +-- storage/migrations/0001_init.sql +-- DMF Crawler 코어 스키마. 실행 · 수집 · 스냅샷 · 이벤트. +-- 규칙: 이 파일은 한 번 배포되면 절대 수정하지 않는다. 변경은 새 번호 파일로만. + +-- --------------------------------------------------------------------------- +-- 0. 마이그레이션 원장 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL, + checksum TEXT NOT NULL, -- 적용된 SQL 파일의 sha256. 사후 변조 탐지 + app_version TEXT NOT NULL +); + +-- --------------------------------------------------------------------------- +-- 1. 소스 메타 — 출처 표시(요구 N1)와 리포트 메타 시트의 원천 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS sources ( + source_id TEXT NOT NULL PRIMARY KEY, -- 'mfds_open_api' + display_name TEXT NOT NULL, -- '식품의약품안전처_원료의약품등록(DMF)현황' + provider TEXT NOT NULL, -- '식품의약품안전처' + portal_url TEXT NOT NULL, -- data.go.kr 상세 페이지 + endpoint TEXT NOT NULL, -- apis.data.go.kr/.../getMdcDmfList01 + license TEXT NOT NULL, -- '이용허락범위 제한 없음' + attribution TEXT NOT NULL, -- 리포트에 그대로 인쇄할 출처 문구 + daily_quota INTEGER, -- 개발계정 10000 + first_used_date TEXT, + last_ok_date TEXT, + last_ok_run_seq INTEGER, + notes TEXT NOT NULL DEFAULT '', + CHECK (first_used_date IS NULL OR first_used_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'), + CHECK (last_ok_date IS NULL OR last_ok_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]') +); + +INSERT OR IGNORE INTO sources + (source_id, display_name, provider, portal_url, endpoint, license, attribution, daily_quota) +VALUES ( + 'mfds_open_api', + '식품의약품안전처_원료의약품등록(DMF)현황', + '식품의약품안전처', + 'https://www.data.go.kr/data/15057075/openapi.do', + 'https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01', + '이용허락범위 제한 없음', + '출처: 식품의약품안전처 「원료의약품등록(DMF)현황」 공공데이터포털 오픈API', + 10000 +); + +-- --------------------------------------------------------------------------- +-- 2. 실행 — 파이프라인 1회 = 1행 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS runs ( + run_seq INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, -- 조인 축 + run_id TEXT NOT NULL UNIQUE, -- 'run_20260902_060013' + run_date TEXT NOT NULL, -- KST 기준 실행일자 + trigger TEXT NOT NULL, + started_at TEXT NOT NULL, -- ISO8601 +09:00 + finished_at TEXT, + duration_ms INTEGER, + status TEXT NOT NULL, + exit_code INTEGER, + is_baseline INTEGER NOT NULL DEFAULT 0, -- 기준선 수립 실행인가 + integrity_ok INTEGER, -- NULL = 아직 판정 전 + diff_performed INTEGER NOT NULL DEFAULT 0, + report_path TEXT, + app_version TEXT NOT NULL, + notes TEXT NOT NULL DEFAULT '', + CHECK (status IN ('RUNNING','SUCCESS','PARTIAL','FAILED','SKIPPED','BLOCKED')), + CHECK (trigger IN ('scheduled','startup','manual','retry','backfill')), + CHECK (run_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'), + CHECK (is_baseline IN (0,1)), + CHECK (diff_performed IN (0,1)), + CHECK (integrity_ok IS NULL OR integrity_ok IN (0,1)) +); + +-- ★ idempotency 가드를 DB 제약으로 내린다 (아키텍처 §4.1). +-- 하루에 SUCCESS 실행은 최대 1건. 앱 코드가 last_success_run_on() 검사를 빼먹어도 막힌다. +CREATE UNIQUE INDEX IF NOT EXISTS ux_runs_success_per_day + ON runs(run_date) WHERE status = 'SUCCESS'; + +CREATE INDEX IF NOT EXISTS ix_runs_date ON runs(run_date DESC); +CREATE INDEX IF NOT EXISTS ix_runs_status ON runs(status, run_date DESC); + +-- --------------------------------------------------------------------------- +-- 3. 스테이지 체크포인트 — 재시도 시 완료 스테이지 건너뛰기의 근거 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS stage_status ( + run_seq INTEGER NOT NULL REFERENCES runs(run_seq) ON DELETE CASCADE, + stage TEXT NOT NULL, + attempt INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + duration_ms INTEGER, + artifact_path TEXT, + error TEXT, + PRIMARY KEY (run_seq, stage), + CHECK (status IN ('RUNNING','SUCCESS','FAILED','SKIPPED')), + CHECK (stage IN ('preflight','fetch','normalize','integrity','diff', + 'persist','enrich','report','backup','finalize')) +); + +-- --------------------------------------------------------------------------- +-- 4. 수집 통계 — 실행 1회당 1행. 급감 판정(게이트 3)의 전일 기준값 공급원 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS fetch_stats ( + run_seq INTEGER NOT NULL PRIMARY KEY REFERENCES runs(run_seq) ON DELETE CASCADE, + source_id TEXT NOT NULL REFERENCES sources(source_id), + endpoint TEXT NOT NULL, + page_size INTEGER NOT NULL, + total_count_reported INTEGER, -- 응답의 totalCount + records_received INTEGER NOT NULL, -- 실제로 받은 item 수 + records_normalized INTEGER NOT NULL, -- DmfRecord 로 살아남은 수 + pages_expected INTEGER, + pages_fetched INTEGER NOT NULL, + http_calls INTEGER NOT NULL, + retry_count INTEGER NOT NULL DEFAULT 0, + http_429_count INTEGER NOT NULL DEFAULT 0, + bytes_received INTEGER NOT NULL DEFAULT 0, + elapsed_seconds REAL NOT NULL DEFAULT 0, + result_code TEXT, + result_msg TEXT, + body_signature_ok INTEGER NOT NULL DEFAULT 1, + archive_dir TEXT, + payload_sha256 TEXT NOT NULL, -- 정규화 레코드 집합 전체 해시 + CHECK (body_signature_ok IN (0,1)) +); +-- payload_sha256 이 전일과 같으면 '소스 미갱신'. 요구 SSOT 7.4(갱신 주기 실측)의 계측점. +CREATE INDEX IF NOT EXISTS ix_fetch_payload ON fetch_stats(payload_sha256); + +-- --------------------------------------------------------------------------- +-- 5. 품질 검증 결과 — 차단 게이트 5종과 비차단 지표 13종을 한 테이블에 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS quality_checks ( + run_seq INTEGER NOT NULL REFERENCES runs(run_seq) ON DELETE CASCADE, + check_name TEXT NOT NULL, + blocking INTEGER NOT NULL, -- 1 = 실패 시 diff 차단 + passed INTEGER NOT NULL, + severity TEXT NOT NULL, -- OK | INFO | WARN | CRITICAL + observed REAL, -- 관측값(비율·건수) + threshold REAL, -- 임계값 + detail TEXT NOT NULL DEFAULT '', + PRIMARY KEY (run_seq, check_name), + CHECK (blocking IN (0,1)), + CHECK (passed IN (0,1)), + CHECK (severity IN ('OK','INFO','WARN','CRITICAL')) +); +CREATE INDEX IF NOT EXISTS ix_quality_failed ON quality_checks(check_name, passed); + +-- --------------------------------------------------------------------------- +-- 6. 레코드 버전 — 내용의 정본. 같은 내용은 두 번 저장하지 않는다. +-- (dmf_key, content_hash) 가 논리 키. version_id 는 조인용 대리키. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS record_versions ( + version_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + dmf_key TEXT NOT NULL, + content_hash TEXT NOT NULL, -- COMPARE_FIELDS 표시값 sha256[:16] + identity_hash TEXT NOT NULL, -- COMPARE_KEY_FIELDS 정규화키 sha256[:16] + + -- 원천 7필드의 표준화 결과 + permit_no TEXT NOT NULL, + permit_no_raw TEXT NOT NULL, + ingredient_name TEXT NOT NULL DEFAULT '', + applicant TEXT NOT NULL DEFAULT '', + manufacturer TEXT NOT NULL DEFAULT '', + manufacture_place TEXT NOT NULL DEFAULT '', + countries_json TEXT NOT NULL DEFAULT '[]', -- 정렬된 JSON 배열 + permit_date TEXT NOT NULL DEFAULT '', + + -- 매칭 키 + ingredient_key TEXT NOT NULL DEFAULT '', + ingredient_base TEXT NOT NULL DEFAULT '', + applicant_key TEXT NOT NULL DEFAULT '', + manufacturer_key TEXT NOT NULL DEFAULT '', + manufacture_place_key TEXT NOT NULL DEFAULT '', + is_micronized INTEGER NOT NULL DEFAULT 0, + sites_json TEXT NOT NULL DEFAULT '[]', + country_count INTEGER NOT NULL DEFAULT 0, + site_count INTEGER NOT NULL DEFAULT 0, + + -- 등록번호 파싱 결과 + permit_fmt TEXT NOT NULL DEFAULT 'unknown', + accept_date TEXT, + ingr_no INTEGER, + permit_group TEXT, + group_table TEXT, + group_effective_from TEXT, + permit_serial INTEGER, + permit_sub INTEGER, + grant_seq INTEGER, + is_grant_derived INTEGER NOT NULL DEFAULT 0, + base_permit_no TEXT, + ingr_group_key TEXT, + + -- 원본 보존과 계보 + raw_json TEXT NOT NULL, + first_run_seq INTEGER NOT NULL REFERENCES runs(run_seq), + first_seen_date TEXT NOT NULL, + + UNIQUE (dmf_key, content_hash), + CHECK (permit_fmt IN ('standard','new_substance','unknown')), + CHECK (is_micronized IN (0,1)), + CHECK (is_grant_derived IN (0,1)), + CHECK (permit_date = '' OR permit_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'), + CHECK (accept_date IS NULL OR accept_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]') +); +CREATE INDEX IF NOT EXISTS ix_ver_key ON record_versions(dmf_key, version_id DESC); +CREATE INDEX IF NOT EXISTS ix_ver_ingr ON record_versions(ingredient_key); +CREATE INDEX IF NOT EXISTS ix_ver_ingr_base ON record_versions(ingredient_base); +CREATE INDEX IF NOT EXISTS ix_ver_applicant ON record_versions(applicant_key); +CREATE INDEX IF NOT EXISTS ix_ver_mnf ON record_versions(manufacturer_key); +CREATE INDEX IF NOT EXISTS ix_ver_cohort ON record_versions(ingr_group_key); + +-- --------------------------------------------------------------------------- +-- 7. 레코드 현재 상태 — UPDATE 가 허용되는 유일한 테이블. 원장 시트의 원천. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS records ( + record_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + dmf_key TEXT NOT NULL UNIQUE, + permit_no TEXT NOT NULL, + base_permit_no TEXT, + is_synthetic_key INTEGER NOT NULL DEFAULT 0, -- 'SYN-' 합성키 여부 + dup_index INTEGER NOT NULL DEFAULT 0, + + current_version_id INTEGER REFERENCES record_versions(version_id), + status TEXT NOT NULL DEFAULT 'ACTIVE', + + first_seen_date TEXT NOT NULL, + first_seen_run_seq INTEGER NOT NULL REFERENCES runs(run_seq), + last_seen_date TEXT NOT NULL, + last_seen_run_seq INTEGER NOT NULL REFERENCES runs(run_seq), + withdrawn_date TEXT, + version_count INTEGER NOT NULL DEFAULT 1, + change_count INTEGER NOT NULL DEFAULT 0, + reappear_count INTEGER NOT NULL DEFAULT 0, + + CHECK (status IN ('ACTIVE','WITHDRAWN')), + CHECK (is_synthetic_key IN (0,1)), + CHECK (first_seen_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'), + CHECK (last_seen_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'), + CHECK (withdrawn_date IS NULL OR withdrawn_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]') +); +CREATE INDEX IF NOT EXISTS ix_rec_status ON records(status, last_seen_date DESC); +CREATE INDEX IF NOT EXISTS ix_rec_permit ON records(permit_no); +CREATE INDEX IF NOT EXISTS ix_rec_base ON records(base_permit_no); +CREATE INDEX IF NOT EXISTS ix_rec_first ON records(first_seen_date DESC); + +-- --------------------------------------------------------------------------- +-- 8. 스냅샷 멤버십 — "이 실행에서 이 레코드가 이 버전으로 존재했다" 3열. +-- 전량 재적재가 아니라 포인터만 쌓는다(§3.2 설계 C). +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS snapshots ( + run_seq INTEGER NOT NULL REFERENCES runs(run_seq) ON DELETE CASCADE, + record_id INTEGER NOT NULL REFERENCES records(record_id), + version_id INTEGER NOT NULL REFERENCES record_versions(version_id), + PRIMARY KEY (run_seq, record_id) +) WITHOUT ROWID; +CREATE INDEX IF NOT EXISTS ix_snap_record ON snapshots(record_id, run_seq DESC); + +-- --------------------------------------------------------------------------- +-- 9. 도메인 이벤트 — append only. 절대 UPDATE/DELETE 금지. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS events ( + event_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + run_seq INTEGER NOT NULL REFERENCES runs(run_seq) ON DELETE CASCADE, + event_date TEXT NOT NULL, + occurred_at TEXT NOT NULL, + record_id INTEGER NOT NULL REFERENCES records(record_id), + dmf_key TEXT NOT NULL, -- 비정규화(리포트 조인 절약) + permit_no TEXT NOT NULL, + event_type TEXT NOT NULL, + severity TEXT NOT NULL, + is_reappearance INTEGER NOT NULL DEFAULT 0, + change_count INTEGER NOT NULL DEFAULT 0, + changed_fields TEXT NOT NULL DEFAULT '', -- 정렬된 CSV. 'countries,manufacturer' + before_version_id INTEGER REFERENCES record_versions(version_id), + after_version_id INTEGER REFERENCES record_versions(version_id), + changes_json TEXT NOT NULL DEFAULT '[]', + before_json TEXT, + after_json TEXT, + UNIQUE (run_seq, record_id, event_type), + CHECK (event_type IN ('NEW','CHANGED','WITHDRAWN')), + CHECK (severity IN ('CRITICAL','HIGH','MEDIUM','INFO','COSMETIC')), + CHECK (is_reappearance IN (0,1)), + CHECK (event_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'), + -- NEW 는 before 가 없고, WITHDRAWN 은 after 가 없다. + CHECK (event_type <> 'NEW' OR before_version_id IS NULL), + CHECK (event_type <> 'WITHDRAWN' OR after_version_id IS NULL), + CHECK (event_type <> 'CHANGED' OR (before_version_id IS NOT NULL + AND after_version_id IS NOT NULL + AND change_count > 0)) +); +CREATE INDEX IF NOT EXISTS ix_ev_run ON events(run_seq, event_type); +CREATE INDEX IF NOT EXISTS ix_ev_date ON events(event_date DESC, event_type); +CREATE INDEX IF NOT EXISTS ix_ev_record ON events(record_id, event_date DESC); +CREATE INDEX IF NOT EXISTS ix_ev_severity ON events(severity, event_date DESC); + +-- --------------------------------------------------------------------------- +-- 10. 필드 단위 변경 — '오늘 변경분' 시트가 조인 없이 그대로 읽는다. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS event_changes ( + change_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL REFERENCES events(event_id) ON DELETE CASCADE, + run_seq INTEGER NOT NULL REFERENCES runs(run_seq) ON DELETE CASCADE, + event_date TEXT NOT NULL, + dmf_key TEXT NOT NULL, + field TEXT NOT NULL, + field_label TEXT NOT NULL, -- '제조소명' 등 한글 표시명 + change_class TEXT NOT NULL, + before_value TEXT NOT NULL DEFAULT '', + after_value TEXT NOT NULL DEFAULT '', + UNIQUE (event_id, field), + CHECK (field IN ('ingredient_name','applicant','manufacturer', + 'manufacture_place','countries','permit_date')), + CHECK (change_class IN ('CRITICAL','HIGH','MEDIUM','INFO','COSMETIC')) +); +CREATE INDEX IF NOT EXISTS ix_chg_field ON event_changes(field, event_date DESC); +CREATE INDEX IF NOT EXISTS ix_chg_run ON event_changes(run_seq, change_class); + +-- --------------------------------------------------------------------------- +-- 11. 읽기 편의 뷰 — 리포트 SQL 이 조인을 반복하지 않게 한다. +-- --------------------------------------------------------------------------- +CREATE VIEW IF NOT EXISTS v_current_records AS +SELECT r.record_id, r.dmf_key, r.permit_no, r.base_permit_no, r.status, + r.first_seen_date, r.last_seen_date, r.withdrawn_date, + r.version_count, r.change_count, r.reappear_count, r.is_synthetic_key, + v.ingredient_name, v.ingredient_base, v.is_micronized, + v.applicant, v.manufacturer, v.manufacture_place, + v.countries_json, v.country_count, v.site_count, v.sites_json, + v.permit_date, v.accept_date, + v.permit_fmt, v.ingr_no, v.permit_group, v.group_table, + v.group_effective_from, v.permit_serial, v.permit_sub, + v.grant_seq, v.is_grant_derived, v.ingr_group_key, + v.content_hash, v.identity_hash +FROM records r +JOIN record_versions v ON v.version_id = r.current_version_id; + +CREATE VIEW IF NOT EXISTS v_run_summary AS +SELECT ru.run_seq, ru.run_id, ru.run_date, ru.status, ru.duration_ms, + ru.integrity_ok, ru.diff_performed, ru.is_baseline, ru.report_path, + fs.total_count_reported, fs.records_normalized, fs.pages_fetched, + fs.http_calls, fs.elapsed_seconds, fs.payload_sha256, + (SELECT COUNT(*) FROM events e WHERE e.run_seq = ru.run_seq AND e.event_type = 'NEW') AS n_new, + (SELECT COUNT(*) FROM events e WHERE e.run_seq = ru.run_seq AND e.event_type = 'CHANGED') AS n_changed, + (SELECT COUNT(*) FROM events e WHERE e.run_seq = ru.run_seq AND e.event_type = 'WITHDRAWN') AS n_withdrawn, + (SELECT COUNT(*) FROM events e WHERE e.run_seq = ru.run_seq AND e.severity = 'CRITICAL') AS n_critical +FROM runs ru +LEFT JOIN fetch_stats fs ON fs.run_seq = ru.run_seq; +``` + +### 3.4 `0002_enrichment.sql` — AI 계층 (스키마 레벨 격리) + +`agy` 산출물은 **정본 데이터와 물리적으로 다른 테이블**에 둔다. AI 가 죽어도, 쿼터가 끊겨도, 잘못된 값을 내놔도 §3.3 의 어떤 테이블도 오염되지 않는다(아키텍처 ADR). + +```sql +-- storage/migrations/0002_enrichment.sql + +CREATE TABLE IF NOT EXISTS agy_calls ( + call_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + run_seq INTEGER NOT NULL REFERENCES runs(run_seq) ON DELETE CASCADE, + purpose TEXT NOT NULL, -- 'daily_briefing' 등 + started_at TEXT NOT NULL, + finished_at TEXT, + duration_ms INTEGER, + model TEXT NOT NULL, + effort TEXT NOT NULL, + exit_code INTEGER, + envelope_status TEXT, -- agy JSON 봉투의 status + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0, + json_extract_ok INTEGER NOT NULL DEFAULT 0, + schema_valid INTEGER NOT NULL DEFAULT 0, + retry_index INTEGER NOT NULL DEFAULT 0, + error TEXT, + stdout_path TEXT, -- logs/run_*/agy.stdout.json + CHECK (json_extract_ok IN (0,1)), + CHECK (schema_valid IN (0,1)) +); +CREATE INDEX IF NOT EXISTS ix_agy_day ON agy_calls(started_at); + +CREATE TABLE IF NOT EXISTS enrichment_run ( + run_seq INTEGER NOT NULL PRIMARY KEY REFERENCES runs(run_seq) ON DELETE CASCADE, + call_id INTEGER REFERENCES agy_calls(call_id), + status TEXT NOT NULL, -- OK | SKIPPED | FAILED + skip_reason TEXT, -- 'diff_empty' | 'circuit_open' | 'token_cap' | 'disabled' + headline TEXT, -- 대시보드 한 줄 요약 + summary_md TEXT, -- 브리핑 본문(마크다운) + risk_note TEXT, + generated_at TEXT, + CHECK (status IN ('OK','SKIPPED','FAILED')) +); + +CREATE TABLE IF NOT EXISTS enrichment ( + enrichment_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + run_seq INTEGER NOT NULL REFERENCES runs(run_seq) ON DELETE CASCADE, + event_id INTEGER REFERENCES events(event_id) ON DELETE CASCADE, + dmf_key TEXT, + kind TEXT NOT NULL, -- 'event_comment' | 'ingredient_alias' | 'anomaly_hypothesis' + payload_json TEXT NOT NULL, + confidence REAL, + applied INTEGER NOT NULL DEFAULT 0, -- 사람 승인 전에는 항상 0. 자동 적용 금지(ADR) + CHECK (applied IN (0,1)), + CHECK (confidence IS NULL OR (confidence >= 0.0 AND confidence <= 1.0)) +); +CREATE INDEX IF NOT EXISTS ix_enrich_event ON enrichment(event_id); +CREATE INDEX IF NOT EXISTS ix_enrich_run ON enrichment(run_seq, kind); +``` + +### 3.5 `0003_ops.sql` — 운영 계층 + +```sql +-- storage/migrations/0003_ops.sql + +CREATE TABLE IF NOT EXISTS component_health ( + component TEXT NOT NULL PRIMARY KEY, -- 'source_mfds' | 'agy' + state TEXT NOT NULL DEFAULT 'CLOSED', + consecutive_failures INTEGER NOT NULL DEFAULT 0, + last_success_at TEXT, + last_success_run TEXT, + last_failure_at TEXT, + last_reason TEXT, + cooldown_until TEXT, + transitions INTEGER NOT NULL DEFAULT 0, + CHECK (state IN ('CLOSED','OPEN','HALF_OPEN')) +); +INSERT OR IGNORE INTO component_health (component) VALUES ('source_mfds'), ('agy'); + +CREATE TABLE IF NOT EXISTS alerts ( + alert_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + run_seq INTEGER REFERENCES runs(run_seq) ON DELETE SET NULL, + level TEXT NOT NULL, + code TEXT NOT NULL, -- 'INTEGRITY_BLOCKED' 등 + dedup_key TEXT NOT NULL, -- 쿨다운 억제 축(요구 R7.8) + title TEXT NOT NULL, + what TEXT NOT NULL, -- 알림 문구 4요소 + why TEXT NOT NULL, + how TEXT NOT NULL, + next_action TEXT NOT NULL, + shown_at TEXT, -- 대화형 에이전트가 실제로 띄운 시각 + resolved_at TEXT, + CHECK (level IN ('INFO','WARN','CRITICAL')) +); +CREATE INDEX IF NOT EXISTS ix_alert_open ON alerts(resolved_at, created_at DESC); +CREATE INDEX IF NOT EXISTS ix_alert_dedup ON alerts(dedup_key, created_at DESC); + +CREATE TABLE IF NOT EXISTS watchlist ( + watch_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + axis TEXT NOT NULL, -- ingredient | applicant | manufacturer | country + pattern TEXT NOT NULL, -- 사람이 입력한 원문 + pattern_key TEXT NOT NULL, -- matching_key() 적용 결과 + match_mode TEXT NOT NULL DEFAULT 'contains', + label TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + UNIQUE (axis, pattern_key, match_mode), + CHECK (axis IN ('ingredient','applicant','manufacturer','country')), + CHECK (match_mode IN ('contains','exact')), + CHECK (enabled IN (0,1)) +); + +CREATE TABLE IF NOT EXISTS watchlist_hits ( + hit_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + run_seq INTEGER NOT NULL REFERENCES runs(run_seq) ON DELETE CASCADE, + watch_id INTEGER NOT NULL REFERENCES watchlist(watch_id) ON DELETE CASCADE, + event_id INTEGER REFERENCES events(event_id) ON DELETE CASCADE, + record_id INTEGER NOT NULL REFERENCES records(record_id), + matched_on TEXT NOT NULL, -- 실제로 매칭된 값 + UNIQUE (run_seq, watch_id, record_id) +); +CREATE INDEX IF NOT EXISTS ix_hit_run ON watchlist_hits(run_seq); + +-- 아키텍처 §2 가 지정한 'schema_version 인덱스' +CREATE INDEX IF NOT EXISTS ix_schema_applied ON schema_version(applied_at DESC); +``` + +### 3.6 마이그레이션 전략 + +**규칙 6가지.** + +1. 파일명은 `NNNN_snake_name.sql`. 번호는 4자리 0-패딩, 결번 없이 1씩 증가. +2. **배포된 파일은 절대 수정하지 않는다.** `schema_version.checksum` 이 sha256 을 보관하므로 수정하면 다음 실행이 `StorageError` 로 멈춘다. +3. 각 파일은 **단일 트랜잭션 안에서** 실행된다. 파일 안에 `BEGIN`/`COMMIT` 을 쓰지 않는다(러너가 감싼다). +4. `ALTER TABLE ... ADD COLUMN` 은 허용. 컬럼 삭제·타입 변경이 필요하면 **새 테이블 생성 → INSERT SELECT → DROP → RENAME** 4단계를 한 파일에 쓴다. +5. **적용 전에 항상 `VACUUM INTO` 백업을 만든다.** 이것이 유일한 롤백 경로다(아키텍처 ADR-04). 실패 안내 문구에 백업본의 **절대경로**를 반드시 포함한다. +6. 데이터 이관이 필요한 마이그레이션은 SQL 만으로 끝낸다. 파이썬 후처리를 요구하는 마이그레이션은 만들지 않는다(부분 적용 상태가 생긴다). + +```python +# src/dmf_crawler/storage/db.py +"""연결 생성과 마이그레이션. 이 모듈 밖에서 sqlite3.connect 를 직접 부르지 않는다.""" +from __future__ import annotations + +import hashlib +import re +import sqlite3 +from datetime import datetime +from pathlib import Path + +from dmf_crawler import __version__ +from dmf_crawler.errors import StorageError + +MIGRATION_RE = re.compile(r"^(?P\d{4})_(?P[a-z0-9_]+)\.sql$") + + +def connect(db_path: Path, *, read_only: bool = False, + busy_timeout_ms: int = 15000) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + if read_only: + uri = f"file:{db_path.as_posix()}?mode=ro" + conn = sqlite3.connect(uri, uri=True, timeout=busy_timeout_ms / 1000) + else: + conn = sqlite3.connect(db_path, timeout=busy_timeout_ms / 1000) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute(f"PRAGMA busy_timeout={int(busy_timeout_ms)}") + conn.execute("PRAGMA temp_store=MEMORY") + return conn + + +def current_version(conn: sqlite3.Connection) -> int: + row = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'" + ).fetchone() + if row is None: + return 0 + got = conn.execute("SELECT COALESCE(MAX(version), 0) AS v FROM schema_version").fetchone() + return int(got["v"]) + + +def _discover(migrations_dir: Path) -> list[tuple[int, str, Path]]: + found: list[tuple[int, str, Path]] = [] + for path in sorted(migrations_dir.glob("*.sql")): + m = MIGRATION_RE.match(path.name) + if not m: + raise StorageError(f"마이그레이션 파일명 규칙 위반: {path.name}") + found.append((int(m.group("num")), m.group("name"), path)) + numbers = [n for n, _, _ in found] + if numbers != list(range(1, len(numbers) + 1)): + raise StorageError(f"마이그레이션 번호에 결번 또는 중복이 있다: {numbers}") + return found + + +def _verify_checksums(conn: sqlite3.Connection, + found: list[tuple[int, str, Path]]) -> None: + """이미 적용된 마이그레이션 파일이 사후 수정되지 않았는지 확인한다.""" + if current_version(conn) == 0: + return + applied = {int(r["version"]): r["checksum"] + for r in conn.execute("SELECT version, checksum FROM schema_version")} + for num, _, path in found: + if num not in applied: + continue + digest = hashlib.sha256(path.read_bytes()).hexdigest() + if digest != applied[num]: + raise StorageError( + f"이미 적용된 마이그레이션 {path.name} 이 수정됐다. " + f"기록된 체크섬={applied[num][:12]}… 현재={digest[:12]}… " + f"배포된 마이그레이션 파일은 수정하지 말고 새 번호 파일을 추가하라." + ) + + +def apply_migrations(conn: sqlite3.Connection, migrations_dir: Path, + backup_dir: Path) -> list[int]: + """적용 전 VACUUM INTO 백업 -> 번호순 적용 -> schema_version 기록.""" + found = _discover(migrations_dir) + _verify_checksums(conn, found) + have = current_version(conn) + todo = [item for item in found if item[0] > have] + if not todo: + return [] + + backup_path: Path | None = None + if have > 0: # 최초 생성이 아니면 반드시 백업부터 + backup_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = backup_dir / f"premigrate_v{have}_{stamp}.sqlite3" + conn.execute("VACUUM INTO ?", (str(backup_path),)) + + applied: list[int] = [] + for num, name, path in todo: + sql = path.read_text(encoding="utf-8") + digest = hashlib.sha256(path.read_bytes()).hexdigest() + try: + conn.execute("BEGIN") + conn.executescript(sql) + conn.execute( + "INSERT INTO schema_version (version, name, applied_at, checksum, app_version)" + " VALUES (?, ?, ?, ?, ?)", + (num, name, datetime.now().astimezone().isoformat(timespec="seconds"), + digest, __version__), + ) + conn.execute("COMMIT") + except Exception as exc: # noqa: BLE001 — 어떤 예외든 롤백 후 승격한다 + conn.execute("ROLLBACK") + hint = (f" 복원본: {backup_path}" if backup_path + else " (최초 생성이라 백업본이 없다. data/dmf.sqlite3 를 지우고 다시 실행하라)") + raise StorageError(f"마이그레이션 {path.name} 적용 실패: {exc}.{hint}") from exc + applied.append(num) + return applied + + +def integrity_check(conn: sqlite3.Connection) -> tuple[bool, str]: + rows = conn.execute("PRAGMA integrity_check").fetchall() + messages = [r[0] for r in rows] + ok = messages == ["ok"] + fk = conn.execute("PRAGMA foreign_key_check").fetchall() + if fk: + ok = False + messages.append(f"foreign_key_check 위반 {len(fk)}건") + return ok, "; ".join(messages) +``` + +### 3.7 자주 쓰는 조회 SQL + +```sql +-- (1) 오늘 변경분 — s01_changes 시트가 그대로 읽는다. +SELECT e.event_type, e.severity, e.is_reappearance, e.dmf_key, e.permit_no, + v.ingredient_name, v.applicant, v.manufacturer, v.countries_json, + e.changed_fields, e.changes_json +FROM events e +JOIN runs r ON r.run_seq = e.run_seq +LEFT JOIN record_versions v + ON v.version_id = COALESCE(e.after_version_id, e.before_version_id) +WHERE r.run_id = :run_id +ORDER BY CASE e.severity WHEN 'CRITICAL' THEN 0 WHEN 'HIGH' THEN 1 + WHEN 'MEDIUM' THEN 2 WHEN 'INFO' THEN 3 ELSE 4 END, + e.event_type, e.dmf_key; + +-- (2) 특정 시점의 전체 상태 (temporal query) +SELECT v.* +FROM snapshots s +JOIN runs r ON r.run_seq = s.run_seq +JOIN record_versions v ON v.version_id = s.version_id +WHERE r.run_seq = (SELECT MAX(run_seq) FROM runs + WHERE status = 'SUCCESS' AND run_date <= :as_of_date); + +-- (3) 한 등록건의 전체 변경 이력 +SELECT e.event_date, e.event_type, e.severity, e.changed_fields, e.changes_json +FROM events e +JOIN records r ON r.record_id = e.record_id +WHERE r.dmf_key = :dmf_key +ORDER BY e.event_date, e.event_id; + +-- (4) 성분별 소싱 대안 폭 — s03_ingredient 시트 +SELECT ingredient_base, + COUNT(*) AS n_records, + COUNT(DISTINCT manufacturer_key) AS n_manufacturers, + COUNT(DISTINCT applicant_key) AS n_applicants, + SUM(is_micronized) AS n_micronized +FROM v_current_records +WHERE status = 'ACTIVE' +GROUP BY ingredient_base +ORDER BY n_records DESC +LIMIT :top_n; + +-- (5) 제조국가 점유 — 다중 국가를 JSON1 로 분해해 집계한다. +SELECT j.value AS country, COUNT(*) AS n_records +FROM v_current_records c, json_each(c.countries_json) j +WHERE c.status = 'ACTIVE' +GROUP BY j.value +ORDER BY n_records DESC; + +-- (6) 일자별 건수 추이 — s06_trend 시트 · 스파크라인 원본 +SELECT r.run_date, + f.total_count_reported, + f.records_normalized, + (SELECT COUNT(*) FROM events e WHERE e.run_seq = r.run_seq AND e.event_type='NEW') AS n_new, + (SELECT COUNT(*) FROM events e WHERE e.run_seq = r.run_seq AND e.event_type='CHANGED') AS n_changed, + (SELECT COUNT(*) FROM events e WHERE e.run_seq = r.run_seq AND e.event_type='WITHDRAWN') AS n_withdrawn +FROM runs r JOIN fetch_stats f ON f.run_seq = r.run_seq +WHERE r.status = 'SUCCESS' AND r.run_date >= date('now', '-' || :trend_days || ' days') +ORDER BY r.run_date; + +-- (7) 소스가 실제로 며칠마다 갱신되는가 (요구 SSOT 7.4 실측) +SELECT payload_sha256, COUNT(*) AS days_identical, + MIN(r.run_date) AS first_date, MAX(r.run_date) AS last_date +FROM fetch_stats f JOIN runs r ON r.run_seq = f.run_seq +WHERE r.status = 'SUCCESS' +GROUP BY payload_sha256 +ORDER BY first_date; +``` + +--- + +## 4. 레코드 동일성 판정 + +### 4.1 무엇을 같은 레코드로 볼 것인가 — 결정 + +**같은 레코드 = 같은 `dmf_key`.** 그 이상도 이하도 아니다. 성분·업체가 같아도 `dmf_key` 가 다르면 다른 등록이고, 성분·업체·제조소가 전부 바뀌어도 `dmf_key` 가 같으면 같은 등록의 변경이다. + +이 단순한 규칙이 성립하려면 `dmf_key` 생성이 **완전히 결정론적**이어야 한다. 같은 입력에서 언제나 같은 키가 나와야 하고, 원본의 표기가 흔들려도 키는 흔들리지 않아야 한다. §2.3 의 3층 구조와 아래 정규화 알고리즘이 그 보장이다. + +**동일성 판정에 참여하지 않는 것을 명시한다** — 실수를 막기 위해서다. + +| 참여하지 않는 것 | 이유 | +|---|---| +| `ingredient_base` (염·수화물 제거형) | `탐스로신염산염` 과 `탐스로신메실산염`은 **다른 등록**이다. 규정 제2조 2호가 염류·수화물을 각각 등록 대상으로 규정한다. 집계 축으로만 쓴다 | +| `is_micronized` 를 뺀 성분명 | `미분화부데소니드` 는 `부데소니드` 와 **별도 등록**이다(리서치 §10.6-1) | +| `base_permit_no` (허여서 괄호 제거형) | `…-05` 와 `…-05(1)` 은 **다른 등록건**이다. 묶어서 보여주기만 한다 | +| `sites` 개별 항목 | 제조소는 1:N 이지만 등록건의 하위 속성이다. 제조소 단위로 레코드를 쪼개면 취하 판정이 붕괴한다 | + +### 4.2 표기 흔들림 정규화 알고리즘 + +정규화는 **2단 파이프라인**이다. + +``` +원본 문자열 + │ + ├─[1단] clean_text() → 표시값(display). 사람이 읽고 리포트에 인쇄된다. + │ NFKC · 전각→반각 · 구두점 접기 · 제어문자 제거 · 연속공백 1개 · trim + │ + 필드별 추가 규칙(법인격 표기 통일, 국가 별칭, 날짜 ISO화) + │ + └─[2단] matching_key() → 매칭키(key). 기계가 비교하고 워치리스트가 찾는다. + 1단 결과에서 공백·구두점 전부 삭제 + 소문자화 + + 필드별 추가 규칙(법인격 어휘 제거) +``` + +**두 단을 나누는 이유**: 1단만 있으면 `CORTICOSTER OIDO`(원본 오탈자) → `CORTICOSTEROIDO`(정정) 이 변경으로 잡힌다. 2단만 있으면 리포트에 `sicorsocietaitaliana…` 같은 읽을 수 없는 문자열이 인쇄된다. 둘 다 필요하다. `content_hash` 는 1단 결과로, `identity_hash` 는 2단 결과로 만든다(§1.3). + +**흔들림 유형별 처리표.** + +| 흔들림 유형 | 실례 | 처리 단계 | 처리 방법 | +|---|---|---|---| +| 전각/반각 | `SICOR` vs `SICOR` | 1단 | `unicodedata.normalize("NFKC")` | +| 전각 하이픈·괄호 | `20121228-168` / `(1)` | 1단 | `_PUNCT_FOLD` 치환표 | +| 비단절 공백·제로폭 | U+00A0, U+200B, U+FEFF | 1단 | `_PUNCT_FOLD` + `Cc` 카테고리 제거 | +| 연속 공백·앞뒤 공백 | `(주) 대웅제약 ` | 1단 | `\s+` → 한 칸, `strip()` | +| 법인격 표기 | `㈜하이플` / `(주)하이플` / `주식회사 하이플` | 1단(표시 통일) + 2단(어휘 제거) | `㈜`→`(주)` 통일 후, 키에서는 법인격 어휘 삭제 | +| 원본 오탈자 공백 | `CORTICOSTER OIDO` | 2단 | 공백 전부 삭제 → 정정본과 동일 키 | +| 대소문자 | `S.R.L.` vs `s.r.l.` | 2단 | `lower()` | +| 구두점 | `Co., Ltd.` vs `Co.,Ltd` | 2단 | 영숫자·한글 외 전부 삭제 | +| 국가 다중값 순서 | `이탈리아,스위스` vs `스위스,이탈리아` | 1단 | 분해 후 **정렬** | +| 국가 별칭 | `미국` / `미합중국` / `USA` | 1단 | `COUNTRY_ALIASES` 치환표 | +| 날짜 표기 | `2015-02-26` / `20150226` / `2015.02.26` | 1단 | `normalize_date()` → ISO | +| 다중 제조소 구분자 | `A , B` vs `A, B` | 1단 | ` ,` 우선 분해 후 ` , ` 로 재조립 | + +### 4.3 정규화 코드 전문 + +```python +# src/dmf_crawler/normalize.py (3/3 — 필드 정규화 · 레코드 조립) + +# -------------------------------------------------------------------------- +# 법인격 어휘 — 매칭키에서 제거한다. 긴 것부터 지워야 부분 삭제가 안 생긴다. +# -------------------------------------------------------------------------- +LEGAL_FORMS_KO: tuple[str, ...] = ( + "주식회사", "유한책임회사", "유한회사", "합자회사", "합명회사", + "재단법인", "사단법인", "의료법인", "학교법인", "(주)", "(유)", "(재)", "(사)", +) +LEGAL_FORMS_EN: tuple[str, ...] = ( + "coltd", "companylimited", "limited", "ltd", "llc", "llp", "inc", "incorporated", + "corporation", "corp", "gmbh", "ag", "sa", "srl", "spa", "bv", "nv", "as", + "pvtltd", "pvt", "plc", "kg", "oy", "ab", "sas", "sl", "pte", "sdnbhd", +) + +# 표시값 단계의 법인격 표기 통일 +_LEGAL_DISPLAY_FOLD = { + "㈜": "(주)", "㈔": "(사)", "㈖": "(재)", "㈕": "(유)", +} + +# -------------------------------------------------------------------------- +# 성분명 접두/접미 — ingredient_base(집계 전용) 산출에만 쓴다. +# 동일성 판정에는 절대 쓰지 않는다(염 형태가 다르면 다른 등록이다). +# -------------------------------------------------------------------------- +MICRONIZE_PREFIXES: tuple[str, ...] = ("초미분화", "미분화") + +SALT_SUFFIXES: tuple[str, ...] = ( + "브롬화수소산염", "메탄술폰산염", "메탄설폰산염", "메실산염", "베실산염", + "토실산염", "에실산염", "이세티온산염", "파모산염", "팜산염", + "푸마르산염", "말레산염", "말산염", "타르타르산염", "주석산염", + "시트르산염", "구연산염", "숙신산염", "아세트산염", "초산염", + "락트산염", "젖산염", "글루콘산염", "글루쿠론산염", "아스파르트산염", + "팔미트산염", "스테아르산염", "벤조산염", "살리실산염", "옥살산염", + "인산염", "황산염", "질산염", "염산염", "브롬산염", + "요오드화물", "브롬화물", "염화물", +) +HYDRATE_SUFFIXES: tuple[str, ...] = ( + "일수화물", "이수화물", "삼수화물", "사수화물", "오수화물", + "육수화물", "칠수화물", "팔수화물", "반수화물", "수화물", "무수물", +) +CATION_SUFFIXES: tuple[str, ...] = ( + "나트륨", "칼륨", "칼슘", "마그네슘", "아연", "리튬", "암모늄", + "메글루민", "트로메타민", "디에탄올아민", "에탄올아민", "베타덱스", +) +_STRIPPABLE_SUFFIXES: tuple[str, ...] = tuple( + sorted(SALT_SUFFIXES + HYDRATE_SUFFIXES + CATION_SUFFIXES, key=len, reverse=True) +) + +# -------------------------------------------------------------------------- +# 국가명 별칭 — 표시값 단계에서 표준명으로 접는다. +# -------------------------------------------------------------------------- +COUNTRY_ALIASES: Mapping[str, str] = { + "대한민국": "한국", "korea": "한국", "republicofkorea": "한국", "kr": "한국", + "미합중국": "미국", "usa": "미국", "us": "미국", "unitedstates": "미국", + "중화인민공화국": "중국", "china": "중국", "cn": "중국", + "인도": "인도", "india": "인도", "in": "인도", + "일본": "일본", "japan": "일본", "jp": "일본", + "이탈리아": "이탈리아", "italy": "이탈리아", "it": "이탈리아", + "스위스": "스위스", "switzerland": "스위스", "ch": "스위스", + "독일": "독일", "germany": "독일", "de": "독일", + "스페인": "스페인", "spain": "스페인", "es": "스페인", + "프랑스": "프랑스", "france": "프랑스", "fr": "프랑스", + "영국": "영국", "unitedkingdom": "영국", "uk": "영국", "gb": "영국", + "아일랜드": "아일랜드", "ireland": "아일랜드", "ie": "아일랜드", + "이스라엘": "이스라엘", "israel": "이스라엘", "il": "이스라엘", + "대만": "대만", "taiwan": "대만", "tw": "대만", + "슬로베니아": "슬로베니아", "헝가리": "헝가리", "폴란드": "폴란드", + "오스트리아": "오스트리아", "네덜란드": "네덜란드", "벨기에": "벨기에", + "덴마크": "덴마크", "스웨덴": "스웨덴", "핀란드": "핀란드", "노르웨이": "노르웨이", + "포르투갈": "포르투갈", "그리스": "그리스", "루마니아": "루마니아", + "체코": "체코", "슬로바키아": "슬로바키아", "크로아티아": "크로아티아", + "캐나다": "캐나다", "canada": "캐나다", "멕시코": "멕시코", "브라질": "브라질", + "아르헨티나": "아르헨티나", "호주": "호주", "australia": "호주", + "뉴질랜드": "뉴질랜드", "싱가포르": "싱가포르", "말레이시아": "말레이시아", + "인도네시아": "인도네시아", "베트남": "베트남", "태국": "태국", + "터키": "튀르키예", "튀르키예": "튀르키예", "turkey": "튀르키예", +} + +_COUNTRY_SPLIT_RE = re.compile(r"[,/·;|]+") +_SITE_ROLE_RE = re.compile(r"^\[(?P[^\]]{1,40})\]\s*") +_DATE_RE = re.compile(r"(?P(?:19|20)\d{2})\D?(?P\d{1,2})\D?(?P\d{1,2})") + + +def normalize_org_display(s: object) -> str: + """업체명·제조소명의 표시값. 법인격 기호만 통일한다.""" + text = clean_text(s) + for src, dst in _LEGAL_DISPLAY_FOLD.items(): + text = text.replace(src, dst) + return text + + +def normalize_org_key(display: str) -> str: + """업체명 매칭키. 법인격 어휘를 제거한 뒤 공백·구두점을 지운다.""" + text = display + for token in LEGAL_FORMS_KO: + text = text.replace(token, " ") + key = matching_key(text) + changed = True + while changed: + changed = False + for token in LEGAL_FORMS_EN: + if len(key) > len(token) and key.endswith(token): + key = key[: -len(token)] + changed = True + if len(key) > len(token) and key.startswith(token): + key = key[len(token):] + changed = True + return key + + +def normalize_ingredient(raw: object) -> tuple[str, str, str, bool]: + """성분명 정규화. + + 반환: (표시값, 매칭키, 기본명(집계 전용), 미분화 여부) + 기본명은 염/수화물/양이온 접미와 미분화 접두를 반복 제거한 결과다. + 동일성 판정에는 절대 쓰지 않는다. + """ + display = clean_text(raw) + key = matching_key(display) + + micronized = False + base = key + for prefix in MICRONIZE_PREFIXES: + pkey = matching_key(prefix) + if base.startswith(pkey): + micronized = True + base = base[len(pkey):] + break + + changed = True + while changed and base: + changed = False + for suffix in _STRIPPABLE_SUFFIXES: + skey = matching_key(suffix) + if len(base) > len(skey) and base.endswith(skey): + base = base[: -len(skey)] + changed = True + break + return display, key, (base or key), micronized + + +def split_countries(raw: object) -> tuple[str, ...]: + """제조국가 다중값을 분해·별칭 정규화·중복 제거·정렬한다. + + 정렬하는 이유: 원본이 '이탈리아,스위스' 와 '스위스,이탈리아' 를 오가면 + 순서만 바뀐 것이 CHANGED 로 오탐된다(요구 R2.3). + """ + text = clean_text(raw) + if not text: + return () + out: set[str] = set() + for token in _COUNTRY_SPLIT_RE.split(text): + name = token.strip() + if not name: + continue + canonical = COUNTRY_ALIASES.get(matching_key(name), name) + out.add(canonical) + return tuple(sorted(out)) + + +def split_sites(name_raw: object, place_raw: object, + countries: tuple[str, ...]) -> tuple[Site, ...]: + """제조소 다중값 분해. 집계 전용이며 diff 에는 쓰지 않는다. + + 주소 안의 콤마('Rho(MI) - Via Terrazzano, 77, Italy')와 충돌하지 않도록 + '공백+콤마' 를 1순위 구분자로 쓴다(리서치 §10.6-3). + """ + names = _split_multi(clean_text(name_raw)) + places = _split_multi(clean_text(place_raw)) + n = max(len(names), len(places), 1) + sites: list[Site] = [] + for i in range(n): + raw_name = names[i] if i < len(names) else "" + role = "" + m = _SITE_ROLE_RE.match(raw_name) + if m: + role = m.group("role").strip() + raw_name = raw_name[m.end():].strip() + sites.append(Site( + seq=i, + name=raw_name, + address=places[i] if i < len(places) else "", + country=countries[i] if i < len(countries) else (countries[0] if countries else ""), + role=role, + )) + return tuple(s for s in sites if s.name or s.address) + + +def _split_multi(text: str) -> list[str]: + """'공백+콤마' 우선 분해. 그 구분자가 없으면 분해하지 않는다.""" + if not text: + return [] + if " ," in text: + return [part.strip() for part in text.split(" ,") if part.strip()] + return [text] + + +def normalize_date(raw: object) -> str: + """날짜를 ISO YYYY-MM-DD 로. 실패하면 빈 문자열(널 방어).""" + text = clean_text(raw) + if not text: + return "" + m = _DATE_RE.search(text) + if not m: + return "" + iso = f"{m.group('y')}-{int(m.group('m')):02d}-{int(m.group('d')):02d}" + return iso if _is_valid_iso_date(iso) else "" + + +# -------------------------------------------------------------------------- +# 해시와 키 +# -------------------------------------------------------------------------- +_UNIT_SEP = "\x1f" # 필드 구분자. 데이터에 절대 등장하지 않는 제어문자. + + +def _hash16(parts: Iterable[str]) -> str: + joined = _UNIT_SEP.join(parts) + return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:16] + + +def compute_content_hash(rec: DmfRecord) -> str: + """COMPARE_FIELDS 표시값 기준. 표기 변화까지 잡는다(버전 식별용).""" + view = rec.compare_view() + return _hash16(view[f] for f in COMPARE_FIELDS) + + +def compute_identity_hash(rec: DmfRecord) -> str: + """COMPARE_KEY_FIELDS 정규화키 기준. 실질 변화만 잡는다(등급 판정용).""" + view = rec.key_view() + return _hash16(view[f] for f in COMPARE_KEY_FIELDS) + + +def make_dmf_key(permit_no: str, dup_index: int = 0, + fallback_seed: Optional[str] = None) -> str: + """레코드 키 생성. + + - 정상 : permit_no + - 중복 : permit_no#2, permit_no#3 ... + - 부재 : SYN- (합성키. 리포트에서 식별 가능해야 한다) + """ + if not permit_no: + seed = fallback_seed or "" + digest = hashlib.sha1(seed.encode("utf-8")).hexdigest()[:12] + base = f"SYN-{digest}" + else: + base = permit_no + return base if dup_index == 0 else f"{base}#{dup_index + 1}" +``` + +### 4.4 레코드 조립과 중복 처리 + +```python +# src/dmf_crawler/normalize.py (계속 — 조립 진입점) + +def normalize_one(raw: Mapping[str, object], dup_index: int = 0) -> DmfRecord: + """원본 1건을 DmfRecord 로. 예외를 던지지 않는다.""" + raw_str = {col: ("" if raw.get(col) is None else str(raw.get(col))) + for col in SOURCE_COLUMNS} + + permit_no_raw = raw_str[COL_PERMIT_NO] + permit = parse_permit_no(permit_no_raw) + + ingredient_name, ingredient_key, ingredient_base, micronized = \ + normalize_ingredient(raw_str[COL_INGREDIENT]) + + applicant = normalize_org_display(raw_str[COL_APPLICANT]) + manufacturer = normalize_org_display(raw_str[COL_MANUFACTURER]) + place = clean_text(raw_str[COL_PLACE]) + countries = split_countries(raw_str[COL_COUNTRY]) + sites = split_sites(manufacturer, place, countries) + + # 제조소명에 [미분화공정 제조소] 역할 표기가 있으면 그것도 미분화 신호다. + if any(m in manufacturer for m in ("미분화공정", "미분화 공정")): + micronized = True + + permit_no = permit.normalized + fallback_seed = _UNIT_SEP.join(( + ingredient_key, normalize_org_key(applicant), + normalize_org_key(manufacturer), normalize_date(raw_str[COL_PERMIT_DATE]), + )) + + rec = DmfRecord( + dmf_key=make_dmf_key(permit_no, dup_index, fallback_seed), + permit_no=permit_no, + permit_no_raw=permit_no_raw, + ingredient_name=ingredient_name, + ingredient_key=ingredient_key, + ingredient_base=ingredient_base, + is_micronized=micronized, + applicant=applicant, + applicant_key=normalize_org_key(applicant), + manufacturer=manufacturer, + manufacturer_key=normalize_org_key(manufacturer), + manufacture_place=place, + manufacture_place_key=matching_key(place), + countries=countries, + sites=sites, + permit_date=normalize_date(raw_str[COL_PERMIT_DATE]), + permit=permit, + raw=raw_str, + dup_index=dup_index, + ) + # frozen dataclass 이므로 해시는 재생성으로 채운다. + from dataclasses import replace + return replace(rec, + content_hash=compute_content_hash(rec), + identity_hash=compute_identity_hash(rec)) + + +def _dup_sort_key(rec: DmfRecord) -> tuple[str, str, str, str, str]: + """중복 그룹 내 결정론적 정렬 키. + + 아키텍처는 '제조소명 사전순' 이라고 했으나 동률 시 순서가 흔들리므로 + 5요소 전체를 쓴다. 그래도 흔들릴 수 있어 diff 가 §4.5 로 이중 방어한다. + """ + return (rec.manufacturer_key, rec.manufacture_place_key, + "|".join(rec.countries), rec.ingredient_key, rec.permit_date) + + +def normalize_all(raws: list[Mapping[str, object]]) -> tuple[list[DmfRecord], NormalizeStats]: + """원본 전량을 표준화한다. 중복 등록번호에 결정론적 접미사를 부여한다.""" + staged: list[DmfRecord] = [] + rejected: list[dict[str, str]] = [] + replacement_chars = 0 + oversize: dict[str, int] = {col: 0 for col in SOURCE_COLUMNS} + + for raw in raws: + try: + rec = normalize_one(raw, dup_index=0) + except Exception as exc: # noqa: BLE001 — 개별 실패가 전체를 멈추지 않는다 + rejected.append({"error": f"{type(exc).__name__}: {exc}", + "raw": repr(raw)[:500]}) + continue + for col in SOURCE_COLUMNS: + value = rec.raw.get(col, "") + if "�" in value: + replacement_chars += 1 + if len(value) > SOURCE_MAX_LEN[col]: + oversize[col] += 1 + staged.append(rec) + + # 등록번호별로 묶어 중복에 접미사를 부여한다. + groups: dict[str, list[DmfRecord]] = {} + for rec in staged: + groups.setdefault(rec.permit_no, []).append(rec) + + out: list[DmfRecord] = [] + duplicate_rows = 0 + duplicate_groups = 0 + for permit_no, members in groups.items(): + if len(members) == 1: + out.append(members[0]) + continue + duplicate_groups += 1 + duplicate_rows += len(members) - 1 + for idx, rec in enumerate(sorted(members, key=_dup_sort_key)): + out.append(normalize_one(rec.raw, dup_index=idx)) + + null_counts = { + "permit_no": sum(1 for r in out if not r.permit_no), + "ingredient_name": sum(1 for r in out if not r.ingredient_name), + "applicant": sum(1 for r in out if not r.applicant), + } + stats = NormalizeStats( + total_in=len(raws), + total_out=len(out), + rejected=tuple(rejected), + null_counts=null_counts, + duplicate_permit_no=duplicate_rows, + duplicate_groups=duplicate_groups, + unparsed_permit_no=sum(1 for r in out if r.permit.fmt == "unknown"), + new_substance_count=sum(1 for r in out if r.permit.fmt == "new_substance"), + synthetic_key_count=sum(1 for r in out if r.dmf_key.startswith("SYN-")), + invalid_permit_date=sum(1 for r in out if not r.permit_date), + oversize_fields=oversize, + replacement_char_count=replacement_chars, + ) + out.sort(key=lambda r: r.dmf_key) + return out, stats +``` + +### 4.5 중복 등록번호 그룹의 대응 문제 + +접미사(`#2`, `#3`)는 **정렬 순서에 의존**한다. 정렬 키의 어느 한 요소가 바뀌면 어제의 `#2` 가 오늘의 `#1` 이 될 수 있고, 그러면 diff 가 **두 건의 CHANGED** 를 만든다. 실제 변경은 한 건인데 두 건이 뜨고, 그 내용도 뒤섞인다. + +**해법: 접미사를 짝짓지 말고 그룹을 짝짓는다.** + +``` +같은 base(접미사 제거한 permit_no)를 가진 어제 멤버 P = {p1, p2} + 오늘 멤버 C = {c1, c2} + +1. content_hash 가 완전히 같은 쌍을 먼저 확정한다 (변경 없음). +2. 남은 쌍에 대해 COMPARE_FIELDS 6개 중 몇 개가 같은지로 점수를 매긴다. + 가중치: manufacturer_key 3, manufacture_place_key 2, ingredient_key 3, + applicant_key 2, countries 1, permit_date 1 (합 12) +3. 점수 내림차순 탐욕 매칭. 점수가 임계(6, 즉 절반) 미만이면 매칭하지 않는다. +4. 매칭된 쌍 → CHANGED(또는 동일). 남은 오늘 멤버 → NEW. 남은 어제 멤버 → WITHDRAWN. +``` + +그룹 크기가 1인 절대다수 케이스에서는 이 알고리즘이 **단순 키 비교와 정확히 같은 결과**를 낸다. 비용은 O(n²)이지만 n은 사실상 2~3이다. + +```python +# src/dmf_crawler/diff.py (1/3 — 중복 그룹 매칭) + +MATCH_WEIGHTS: Mapping[str, int] = { + "ingredient_key": 3, + "manufacturer_key": 3, + "applicant_key": 2, + "manufacture_place_key": 2, + "countries": 1, + "permit_date": 1, +} +MATCH_TOTAL = sum(MATCH_WEIGHTS.values()) # 12 +MATCH_THRESHOLD = MATCH_TOTAL // 2 # 6 + + +def group_base(dmf_key: str) -> str: + """'20121228-168-I-169-04#2' -> '20121228-168-I-169-04'""" + return dmf_key.split("#", 1)[0] + + +def match_score(a: DmfRecord, b: DmfRecord) -> int: + va, vb = a.key_view(), b.key_view() + return sum(w for f, w in MATCH_WEIGHTS.items() if va[f] == vb[f]) + + +def pair_group(previous: list[DmfRecord], current: list[DmfRecord] + ) -> tuple[list[tuple[DmfRecord, DmfRecord]], list[DmfRecord], list[DmfRecord]]: + """(짝지어진 쌍, 짝 없는 오늘 = NEW 후보, 짝 없는 어제 = WITHDRAWN 후보)""" + pairs: list[tuple[DmfRecord, DmfRecord]] = [] + left = list(previous) + right = list(current) + + # 1단계: content_hash 완전 일치부터 확정한다. + by_hash: dict[str, list[DmfRecord]] = {} + for rec in left: + by_hash.setdefault(rec.content_hash, []).append(rec) + remaining_right: list[DmfRecord] = [] + for rec in right: + bucket = by_hash.get(rec.content_hash) + if bucket: + pairs.append((bucket.pop(0), rec)) + else: + remaining_right.append(rec) + remaining_left = [r for bucket in by_hash.values() for r in bucket] + + # 2단계: 남은 것끼리 점수 탐욕 매칭. + scored = sorted( + ((match_score(p, c), i, j) for i, p in enumerate(remaining_left) + for j, c in enumerate(remaining_right)), + key=lambda t: (-t[0], t[1], t[2]), + ) + used_left: set[int] = set() + used_right: set[int] = set() + for score, i, j in scored: + if score < MATCH_THRESHOLD or i in used_left or j in used_right: + continue + used_left.add(i) + used_right.add(j) + pairs.append((remaining_left[i], remaining_right[j])) + + unmatched_new = [c for j, c in enumerate(remaining_right) if j not in used_right] + unmatched_gone = [p for i, p in enumerate(remaining_left) if i not in used_left] + return pairs, unmatched_new, unmatched_gone +``` + +--- + +## 5. 변경 탐지 알고리즘 + +### 5.1 판정 규칙과 의사코드 + +| 판정 | 규칙 | 이벤트 | +|---|---|---| +| **신규** | 오늘 그룹에만 존재하고 짝을 못 찾음 | `NEW` | +| **재등장** | `NEW` 인데 `records.status = 'WITHDRAWN'` 인 이력이 있음 | `NEW` + `is_reappearance=1`, 등급 `HIGH` | +| **변경** | 짝지어졌고 `content_hash` 가 다름 | `CHANGED` + 필드별 변경 목록 | +| **취하** | 어제 그룹에만 존재하고 짝을 못 찾음 | `WITHDRAWN`, 등급 `CRITICAL` | +| **동일** | 짝지어졌고 `content_hash` 가 같음 | 없음(`unchanged_count` 만 증가) | + +``` +FUNCTION compute_diff(previous, current): + IF previous 가 비어 있음: + # 기준선 수립일. 전량을 NEW 로 만들면 첫날 리포트가 1만 건 신규가 된다. + RETURN DiffResult(new=[], changed=[], withdrawn=[], + unchanged_count=len(current), baseline=True) + + groups ← previous 와 current 를 group_base(dmf_key) 로 묶어 합집합 순회 + + FOR EACH base IN groups: + pairs, only_current, only_previous ← pair_group(prev[base], curr[base]) + + FOR EACH (before, after) IN pairs: + IF before.content_hash == after.content_hash: + unchanged_count += 1 + ELSE: + changes ← diff_fields(before, after) + IF changes 가 비어 있음: # 방어: 해시는 다른데 필드는 같다 + unchanged_count += 1 # (해시 알고리즘 변경 등) + ELSE: + changed.append(CHANGED 이벤트) + + FOR EACH rec IN only_current: new.append(NEW 이벤트) + FOR EACH rec IN only_previous: withdrawn.append(WITHDRAWN 이벤트) + + RETURN DiffResult(정렬된 세 목록, unchanged_count) +``` + +**diff 는 DB 를 모른다.** `previous` 는 `repo.load_snapshot(마지막 성공 run)` 이 만들어 인자로 넘긴다. 순수 함수라서 픽스처 두 개만 있으면 전 경로를 테스트할 수 있다(아키텍처 §3.7). + +**`is_reappearance` 는 diff 가 판정하지 않는다.** 순수 함수는 과거 이력을 모른다. `repo.insert_events()` 가 `records.status = 'WITHDRAWN'` 을 조회해 플래그를 세우고 등급을 `INFO` → `HIGH` 로 올린다. 판정 책임을 아는 계층에 둔다. + +### 5.2 필드별 변경 중요도 등급 + +**등급 결정은 2단계다.** ① 표시값이 다른가 → 이벤트가 생긴다. ② 매칭키도 다른가 → 실질 변경이다. 키까지 같으면 `COSMETIC`. + +| 필드 | 한글명 | 키도 다를 때 등급 | 근거 | 표시값만 다를 때 | +|---|---|---|---|---| +| `manufacturer` | 제조소명 | **CRITICAL** | 제조원 변경은 공급 리스크의 1순위 신호. 「의약품 등의 안전에 관한 규칙」 제17조제1항제1호의 중요 변경 축 | `COSMETIC` | +| `manufacture_place` | 제조소 소재지 | **CRITICAL** | 제조소 이전·주소 변경은 GMP 실사 대상 변경 | `COSMETIC` | +| `countries` | 제조국가 | **CRITICAL** | 제조국 변경 = 지정학 리스크·통관·실사 체계 전부 변경 | `COSMETIC`(순서 변화는 정렬로 이미 흡수) | +| `applicant` | 신청인 | **HIGH** | 양도양수 가능성. 처리기한 25일 조항이 붙는 절차 | `COSMETIC` | +| `ingredient_name` | 성분명 | **HIGH** | 성분이 바뀌는 것은 정상이 아니다. 원본 정정이거나 등록 재정의 | `COSMETIC` | +| `permit_date` | 발급일자 | **MEDIUM** | 재공고·정정. 등록 자체는 유지된다 | (키=표시값이라 해당 없음) | + +**이벤트 등급 = 그 이벤트에 속한 필드 변경 등급의 최댓값.** 단, 모든 필드가 `COSMETIC` 이면 이벤트 등급도 `COSMETIC` 이고, 리포트 '오늘 변경분' 시트에서 **기본 제외**된다(메타 시트에 건수만 표시). + +| 이벤트 타입 | 기본 등급 | 승격 조건 | +|---|---|---| +| `WITHDRAWN` | **CRITICAL** | 항상. 워치리스트 무관 | +| `NEW` | `INFO` | 워치리스트 매칭 시 `HIGH`, 재등장이면 `HIGH` | +| `CHANGED` | 필드 최댓값 | 워치리스트 매칭 시 최소 `HIGH` | + +> 워치리스트 매칭에 의한 승격은 diff 가 아니라 `repo.insert_events()` → `watchlist_hits` 계산 뒤에 수행한다. diff 의 순수성을 지키기 위해서다. + +### 5.3 diff 코드 전문 + +```python +# src/dmf_crawler/diff.py (2/3 — 판정) +"""전일 스냅샷과 금일 레코드를 비교해 도메인 이벤트를 만든다. + +DB·네트워크·파일에 접근하지 않는 순수 함수 모듈이다(아키텍처 §3.7). +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, Mapping + +from dmf_crawler.normalize import ( + COMPARE_FIELDS, FIELD_KEY_MAP, FIELD_LABELS_KO, DmfRecord, +) + +EventType = Literal["NEW", "CHANGED", "WITHDRAWN"] +ChangeClass = Literal["CRITICAL", "HIGH", "MEDIUM", "INFO", "COSMETIC"] + +SEVERITY_ORDER: Mapping[str, int] = { + "CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "INFO": 3, "COSMETIC": 4, +} + +# 실질 변경(매칭키까지 다름)일 때의 필드별 등급. +FIELD_SEVERITY: Mapping[str, ChangeClass] = { + "manufacturer": "CRITICAL", + "manufacture_place": "CRITICAL", + "countries": "CRITICAL", + "applicant": "HIGH", + "ingredient_name": "HIGH", + "permit_date": "MEDIUM", +} + + +@dataclass(frozen=True, slots=True) +class FieldChange: + field: str + label: str + before: str + after: str + change_class: ChangeClass + + +@dataclass(frozen=True, slots=True) +class DiffEvent: + dmf_key: str + permit_no: str + event_type: EventType + severity: ChangeClass + changes: tuple[FieldChange, ...] + before: dict[str, str] | None + after: dict[str, str] | None + before_hash: str | None + after_hash: str | None + + @property + def changed_fields(self) -> str: + return ",".join(sorted(c.field for c in self.changes)) + + +@dataclass(frozen=True, slots=True) +class DiffResult: + new: tuple[DiffEvent, ...] + changed: tuple[DiffEvent, ...] + withdrawn: tuple[DiffEvent, ...] + unchanged_count: int + baseline: bool = False + + @property + def is_empty(self) -> bool: + return not (self.new or self.changed or self.withdrawn) + + @property + def total_events(self) -> int: + return len(self.new) + len(self.changed) + len(self.withdrawn) + + def churn_ratio(self, population: int) -> float: + """전체 대비 변동 비율. §5.4 게이트 6 의 입력.""" + return (self.total_events / population) if population else 0.0 + + def cosmetic_count(self) -> int: + return sum(1 for e in self.changed if e.severity == "COSMETIC") + + +def classify(field: str, before: DmfRecord, after: DmfRecord) -> ChangeClass: + """표시값은 다르지만 매칭키가 같으면 COSMETIC(원본 표기 정정).""" + key_field = FIELD_KEY_MAP[field] + if before.key_view()[key_field] == after.key_view()[key_field]: + return "COSMETIC" + return FIELD_SEVERITY[field] + + +def diff_fields(before: DmfRecord, after: DmfRecord) -> tuple[FieldChange, ...]: + """COMPARE_FIELDS 를 순서대로 비교해 달라진 것만 돌려준다.""" + bv, av = before.compare_view(), after.compare_view() + out: list[FieldChange] = [] + for field in COMPARE_FIELDS: + if bv[field] == av[field]: + continue + out.append(FieldChange( + field=field, + label=FIELD_LABELS_KO[field], + before=bv[field], + after=av[field], + change_class=classify(field, before, after), + )) + return tuple(out) + + +def _worst(classes: tuple[ChangeClass, ...], default: ChangeClass) -> ChangeClass: + if not classes: + return default + return min(classes, key=lambda c: SEVERITY_ORDER[c]) + + +def compute_diff(previous: Mapping[str, DmfRecord], + current: Mapping[str, DmfRecord]) -> DiffResult: + """전일 스냅샷 vs 금일 레코드. 순수 함수.""" + if not previous: + # 기준선 수립일 — 전량을 NEW 로 만들지 않는다(아키텍처 §3.7). + return DiffResult(new=(), changed=(), withdrawn=(), + unchanged_count=len(current), baseline=True) + + prev_groups: dict[str, list[DmfRecord]] = {} + for key, rec in previous.items(): + prev_groups.setdefault(group_base(key), []).append(rec) + curr_groups: dict[str, list[DmfRecord]] = {} + for key, rec in current.items(): + curr_groups.setdefault(group_base(key), []).append(rec) + + new_events: list[DiffEvent] = [] + changed_events: list[DiffEvent] = [] + withdrawn_events: list[DiffEvent] = [] + unchanged = 0 + + for base in sorted(set(prev_groups) | set(curr_groups)): + pairs, only_current, only_previous = pair_group( + prev_groups.get(base, []), curr_groups.get(base, [])) + + for before, after in pairs: + if before.content_hash == after.content_hash: + unchanged += 1 + continue + changes = diff_fields(before, after) + if not changes: + # 해시는 다른데 필드 비교로는 같다. 해시 정의가 바뀐 경우의 방어. + unchanged += 1 + continue + changed_events.append(DiffEvent( + dmf_key=after.dmf_key, + permit_no=after.permit_no, + event_type="CHANGED", + severity=_worst(tuple(c.change_class for c in changes), "INFO"), + changes=changes, + before=before.compare_view(), + after=after.compare_view(), + before_hash=before.content_hash, + after_hash=after.content_hash, + )) + + for rec in only_current: + new_events.append(DiffEvent( + dmf_key=rec.dmf_key, permit_no=rec.permit_no, + event_type="NEW", severity="INFO", changes=(), + before=None, after=rec.compare_view(), + before_hash=None, after_hash=rec.content_hash, + )) + + for rec in only_previous: + withdrawn_events.append(DiffEvent( + dmf_key=rec.dmf_key, permit_no=rec.permit_no, + event_type="WITHDRAWN", severity="CRITICAL", changes=(), + before=rec.compare_view(), after=None, + before_hash=rec.content_hash, after_hash=None, + )) + + def sort_key(e: DiffEvent) -> tuple[int, str]: + return (SEVERITY_ORDER[e.severity], e.dmf_key) + + return DiffResult( + new=tuple(sorted(new_events, key=sort_key)), + changed=tuple(sorted(changed_events, key=sort_key)), + withdrawn=tuple(sorted(withdrawn_events, key=sort_key)), + unchanged_count=unchanged, + baseline=False, + ) +``` + +### 5.4 안전장치 — 소스가 일부만 반환했을 때 취하로 오판하지 않기 + +이것이 이 프로젝트에서 **가장 위험한 실패 모드**다. API 가 절반만 돌려주면 나머지 절반이 전부 `WITHDRAWN` 이 되고, 리포트는 "오늘 5,000건 취하" 라는 거짓말을 인쇄한다. 그리고 그 잘못된 스냅샷이 내일의 기준선이 되어 **모레는 5,000건 신규**가 뜬다. 한 번의 부분 응답이 사흘을 오염시킨다. + +**게이트 5종(차단) + 게이트 6(신설, 조건부 차단).** 하나라도 실패하면 **diff 를 수행하지 않고, 스냅샷 INSERT 도 하지 않는다.** + +| # | 게이트 | 임계값(설정 키) | 실패 시 | +|---|---|---|---| +| 1 | 모든 페이지 HTTP 200 · `resultCode == "00"` · 본문 시그니처 통과 | 고정 | **차단** | +| 2 | `len(records) == total_count_reported` | 고정(완전 일치) | **차단** | +| 3 | `total_count` 가 직전 성공 대비 급감하지 않음 | `integrity.max_drop_ratio` = 0.05 | **차단** | +| 4 | 필수 3필드 널 비율 ≤ 임계 | `integrity.max_null_ratio` = 0.01 | **차단** | +| 5 | 중복 등록번호 비율 ≤ 임계 | `integrity.max_duplicate_ratio` = 0.02 | **차단** | +| 6 | **변동률(churn) ≤ 임계** — 신설 | `integrity.max_churn_ratio` = 0.10 | **차단** | + +**게이트 6 을 신설하는 이유**: 게이트 2·3 은 **API 가 자기 `totalCount` 와 일관되게 거짓말할 때** 뚫린다. 예를 들어 백엔드가 특정 조건의 레코드를 통째로 누락한 채 `totalCount` 도 함께 줄여 응답하면 게이트 2 는 통과하고, 감소폭이 4%면 게이트 3 도 통과한다. 그런데 그 4%가 400건 취하로 리포트에 실린다. **diff 결과 자체를 보고 판단하는 마지막 관문**이 필요하다. + +- 게이트 6 은 **diff 계산 뒤에 판정**한다. 다른 게이트와 순서가 다르므로 `integrity.evaluate_post_diff()` 라는 별도 진입점을 둔다. +- 실패해도 **이미 계산된 diff 는 버리고 스냅샷은 저장하지 않는다.** 실행 상태는 `PARTIAL`, 알림은 `CRITICAL`. +- 제도 변화로 진짜 대량 변동이 일어날 수 있다(리서치 §2.9: 연도별 등록이 237→653건으로 튄 사례). 그래서 차단이 **영구 봉쇄가 아니다.** 알림 문구에 "정상 변동이라면 `config.local.toml` 의 `integrity.max_churn_ratio` 를 올리고 `python -m dmf_crawler run --force` 로 재실행하라" 는 다음 행동을 넣는다(요구 R7 의 알림 4요소). + +```python +# src/dmf_crawler/integrity.py +"""요구 R2.2 안전장치. diff 로 가는 유일한 관문.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from dmf_crawler.diff import DiffResult +from dmf_crawler.normalize import DmfRecord, NormalizeStats + + +@dataclass(frozen=True, slots=True) +class Gate: + name: str + passed: bool + blocking: bool + observed: Optional[float] + threshold: Optional[float] + detail: str + + +@dataclass(frozen=True, slots=True) +class IntegrityVerdict: + ok: bool + gates: tuple[Gate, ...] + blocking_reason: Optional[str] + + @property + def failed(self) -> tuple[Gate, ...]: + return tuple(g for g in self.gates if not g.passed) + + +@dataclass(frozen=True, slots=True) +class PrevSnapshotStats: + run_id: str + run_date: str + total_count_reported: int + records_normalized: int + payload_sha256: str + + +def evaluate(fetch, records: list[DmfRecord], stats: NormalizeStats, + prev: Optional[PrevSnapshotStats], cfg) -> IntegrityVerdict: + """게이트 1~5. diff 이전에 판정한다.""" + n = max(len(records), 1) + gates: list[Gate] = [] + + # 게이트 1 — 전송 계층 건전성 + transport_ok = bool(fetch.body_signature_ok) and fetch.result_code == "00" + gates.append(Gate( + name="transport_ok", passed=transport_ok, blocking=True, + observed=None, threshold=None, + detail=(f"resultCode={fetch.result_code!r} " + f"body_signature_ok={fetch.body_signature_ok} " + f"pages={fetch.pages_fetched}/{fetch.pages_expected}"), + )) + + # 게이트 2 — 완결성 + reported = fetch.total_count_reported + complete = reported is not None and len(fetch.records) == reported + gates.append(Gate( + name="total_count_match", passed=complete, blocking=True, + observed=float(len(fetch.records)), threshold=float(reported or 0), + detail=f"수집 {len(fetch.records)}건 vs totalCount {reported}", + )) + + # 게이트 3 — 전일 대비 급감 + if prev is None or prev.total_count_reported <= 0: + gates.append(Gate("drop_ratio", True, True, None, None, + "직전 성공 스냅샷이 없다(기준선 수립). 판정 생략")) + else: + drop = (prev.total_count_reported - (reported or 0)) / prev.total_count_reported + gates.append(Gate( + name="drop_ratio", passed=drop <= cfg.max_drop_ratio, blocking=True, + observed=round(drop, 6), threshold=cfg.max_drop_ratio, + detail=(f"{prev.run_date} {prev.total_count_reported}건 → " + f"오늘 {reported}건 (감소율 {drop:.2%})"), + )) + + # 게이트 4 — 필수 필드 널 비율 + worst_field, worst_ratio = "", 0.0 + for field, count in stats.null_counts.items(): + ratio = count / n + if ratio > worst_ratio: + worst_field, worst_ratio = field, ratio + gates.append(Gate( + name="null_ratio", passed=worst_ratio <= cfg.max_null_ratio, blocking=True, + observed=round(worst_ratio, 6), threshold=cfg.max_null_ratio, + detail=(f"최악 필드 {worst_field or '-'} 널 {worst_ratio:.3%} " + f"(전체 {dict(stats.null_counts)})"), + )) + + # 게이트 5 — 중복 등록번호 비율 + dup_ratio = stats.duplicate_permit_no / n + gates.append(Gate( + name="duplicate_ratio", passed=dup_ratio <= cfg.max_duplicate_ratio, blocking=True, + observed=round(dup_ratio, 6), threshold=cfg.max_duplicate_ratio, + detail=(f"중복 {stats.duplicate_permit_no}건 / {stats.duplicate_groups}종 " + f"({dup_ratio:.3%})"), + )) + + failed = [g for g in gates if not g.passed and g.blocking] + reason = None if not failed else " / ".join(f"{g.name}: {g.detail}" for g in failed) + return IntegrityVerdict(ok=not failed, gates=tuple(gates), blocking_reason=reason) + + +def evaluate_post_diff(diff: DiffResult, population: int, cfg) -> IntegrityVerdict: + """게이트 6 — diff 결과를 보고 내리는 마지막 판정. + + API 가 자기 totalCount 와 일관되게 축소 응답하면 게이트 2·3 은 통과한다. + 변동률 자체를 보는 관문이 필요하다. + """ + if diff.baseline: + return IntegrityVerdict( + ok=True, + gates=(Gate("churn_ratio", True, True, 0.0, cfg.max_churn_ratio, + "기준선 수립일. 판정 생략"),), + blocking_reason=None) + + churn = diff.churn_ratio(population) + withdrawn_ratio = (len(diff.withdrawn) / population) if population else 0.0 + gates = ( + Gate(name="churn_ratio", passed=churn <= cfg.max_churn_ratio, blocking=True, + observed=round(churn, 6), threshold=cfg.max_churn_ratio, + detail=(f"신규 {len(diff.new)} / 변경 {len(diff.changed)} / " + f"취하 {len(diff.withdrawn)} = 모집단 {population} 대비 {churn:.2%}")), + Gate(name="withdrawn_ratio", passed=withdrawn_ratio <= cfg.max_withdrawn_ratio, + blocking=False, + observed=round(withdrawn_ratio, 6), threshold=cfg.max_withdrawn_ratio, + detail=f"취하 {len(diff.withdrawn)}건 ({withdrawn_ratio:.2%})"), + ) + failed = [g for g in gates if not g.passed and g.blocking] + reason = None if not failed else " / ".join(f"{g.name}: {g.detail}" for g in failed) + return IntegrityVerdict(ok=not failed, gates=gates, blocking_reason=reason) +``` + +**설정 키 2개 추가** (아키텍처 §6.1 의 `[integrity]` 절에 이어 붙인다). + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `integrity.max_churn_ratio` | float | `0.10` | (신규+변경+취하)/모집단 상한. 초과 시 diff 폐기 + CRITICAL | +| `integrity.max_withdrawn_ratio` | float | `0.02` | 취하 비율 경고선. **비차단** — 초과 시 리포트 배너 + WARN | + +### 5.5 기준선·스테일·강제 실행 + +| 상황 | 판정 | 저장 | 리포트 | 알림 | +|---|---|---|---|---| +| **첫 실행** (`previous` 비어 있음) | `baseline=True`, 이벤트 0건 | 스냅샷 저장 ○ | "기준선 수립일 — 내일부터 변경이 표시됩니다" 배너 | 없음 | +| 게이트 1~5 실패 | `integrity_ok=0`, diff 미수행 | 스냅샷 저장 **✕** | **마지막 성공 스냅샷**으로 생성 + "오늘 수집 실패 — 마지막 성공: YYYY-MM-DD" 배너 | `CRITICAL` | +| 게이트 6 실패 | diff 계산됨 → **폐기** | 스냅샷 저장 **✕** | 위와 동일 + 변동률 수치 명시 | `CRITICAL` | +| 소스 서킷 OPEN | fetch 스킵 | 저장 없음 | 마지막 성공 스냅샷 + 스테일 배너 | 상태 전환 시 1회 | +| `--force` 재실행 | 게이트 6 임계를 설정으로 올린 뒤 재실행 | 정상 | 정상 | 없음 | +| 소스 미갱신(`payload_sha256` 동일) | 정상 실행, 이벤트 0건 | 정상 | "소스 데이터가 어제와 동일합니다" 표기 | 없음 | + +**연속 스테일의 처리**: `payload_sha256` 이 **7일 연속** 동일하면 소스가 갱신을 멈춘 것일 수도, 우리가 캐시된 응답을 받는 것일 수도 있다. `INFO` 알림 1회를 기록하고 리포트 메타 시트에 "소스 최종 변동: N일 전" 을 표시한다. 요구 SSOT 7.4 의 "갱신 주기 실측" 이 여기서 자동으로 이뤄진다. + +### 5.6 채택하지 않은 이벤트 타입 — 기록 + +리서치 §10.3 은 9종을 제안했다. 공식 API 확정으로 6종의 근거 필드가 사라졌다. **폐기가 아니라 보류**이며, 소스가 확장되면 되살릴 지점을 남긴다. + +| 리서치 이벤트 | 필요 필드 | 상태 | 되살리는 조건 | +|---|---|---|---| +| `CHANGED_REG` | `최종변경일자` | **보류** | 화면 컬럼을 주는 소스가 생기면 | +| `ANNUAL_REPORT` | `최종연차보고년도` | **보류** | 동상 | +| `DOC_VERSION_CHANGED` | `문서번호` | **보류** | 동상 | +| `LINKED_REVIEW_SET` | `연계심사문서번호` | **보류** | 동상 | +| `SITE_CHANGED` | 제조소 3필드 | **흡수됨** | `CHANGED` + `change_class=CRITICAL` 로 이미 표현된다 | +| `APPLICANT_CHANGED` | `신청인` | **흡수됨** | `CHANGED` + `applicant` 필드 등급 `HIGH` | +| `DISAPPEARED` | — | **통합됨** | `WITHDRAWN` 과 구분할 근거가 API 에 없다. 하나로 합치고 §5.4 게이트로 방어 | +| `REAPPEARED` | — | **채택(변형)** | `NEW` + `is_reappearance=1` | +| `NEW` / `MODIFIED` / `WITHDRAWN` | — | **채택** | — | + +--- + +## 6. 이벤트 스키마 + +### 6.1 이벤트 레코드 구조 + +이벤트는 **세 곳에 같은 내용이 다른 형태로** 남는다. 용도가 다르기 때문이다. + +| 저장소 | 형태 | 용도 | 보존 | +|---|---|---|---| +| `events` + `event_changes` 테이블 | 관계형 | 리포트 SQL, 이력 조회, 집계 | **영구** | +| `logs/run_*/events.jsonl` | JSON Lines | 기계 판독 실행 로그, 장애 분석 | 90일 | +| `agy` 프롬프트 입력 | 절단·요약된 JSON | AI 브리핑 | 미보존(호출 원문은 `agy.stdout.json`) | + +**필드 정의 (`events` 테이블 기준).** + +| 필드 | 타입 | 널 | 의미 | +|---|---|---|---| +| `event_id` | INTEGER | ✕ | 자동 증가 PK | +| `run_seq` | INTEGER | ✕ | 이 이벤트를 만든 실행 | +| `event_date` | TEXT | ✕ | `YYYY-MM-DD` (KST 실행일자) | +| `occurred_at` | TEXT | ✕ | ISO 8601 `+09:00`. **관측 시각**이지 실제 등록 변경 시각이 아니다 | +| `record_id` | INTEGER | ✕ | `records` 대리키 | +| `dmf_key` | TEXT | ✕ | 비정규화(리포트 조인 절약) | +| `permit_no` | TEXT | ✕ | 비정규화. nedrug 검색 링크 생성에 쓴다 | +| `event_type` | TEXT | ✕ | `NEW` / `CHANGED` / `WITHDRAWN` | +| `severity` | TEXT | ✕ | `CRITICAL` / `HIGH` / `MEDIUM` / `INFO` / `COSMETIC` | +| `is_reappearance` | INTEGER | ✕ | `NEW` 이면서 과거 취하 이력이 있으면 1 | +| `change_count` | INTEGER | ✕ | 변경된 필드 수. `NEW`·`WITHDRAWN` 은 0 | +| `changed_fields` | TEXT | ✕ | 정렬된 CSV. 필터·인덱스용 | +| `before_version_id` / `after_version_id` | INTEGER | ○ | 버전 테이블 포인터 | +| `changes_json` | TEXT | ✕ | 필드별 변경 배열 | +| `before_json` / `after_json` | TEXT | ○ | `COMPARE_FIELDS` 스냅샷. 버전 행이 정리된 뒤에도 이벤트만으로 읽히게 | + +**`occurred_at` 이 "관측 시각" 임을 문서에 못 박는 이유**: API 는 변경 시점을 주지 않는다. 우리가 아는 것은 "06:00 에 봤더니 달랐다" 뿐이다. 이 필드를 실제 변경 시각으로 오해하면 심사 소요일 같은 파생 지표가 전부 틀어진다. 리포트에도 "관측일" 로 표기한다. + +### 6.2 예시 JSON + +**`CHANGED` — 제조소 변경 (CRITICAL)** + +```json +{ + "event_id": 48213, + "run_id": "run_20260902_060013", + "event_date": "2026-09-02", + "occurred_at": "2026-09-02T06:01:44+09:00", + "dmf_key": "20121228-168-I-169-04", + "permit_no": "20121228-168-I-169-04", + "event_type": "CHANGED", + "severity": "CRITICAL", + "is_reappearance": 0, + "change_count": 2, + "changed_fields": "countries,manufacturer", + "before_hash": "9f2c1ab4e7d05631", + "after_hash": "c07be1d3aa914f28", + "changes": [ + { + "field": "manufacturer", + "label": "제조소명", + "before": "SICOR SOCIETA'ITALIANA CORTICOSTEROIDO S.R.L. , [미분화공정 제조소] Micro-Macinazione SA.", + "after": "SICOR SOCIETA'ITALIANA CORTICOSTEROIDO S.R.L.", + "class": "CRITICAL" + }, + { + "field": "countries", + "label": "제조국가", + "before": "스위스, 이탈리아", + "after": "이탈리아", + "class": "CRITICAL" + } + ], + "before": { + "ingredient_name": "포르모테롤푸마르산염수화물", + "applicant": "(주)대웅제약", + "manufacturer": "SICOR SOCIETA'ITALIANA CORTICOSTEROIDO S.R.L. , [미분화공정 제조소] Micro-Macinazione SA.", + "manufacture_place": "Rho(MI) - Via Terrazzano, 77, Italy , 6995 Madonna del Piano, Switzerland", + "countries": "스위스, 이탈리아", + "permit_date": "2015-02-26" + }, + "after": { + "ingredient_name": "포르모테롤푸마르산염수화물", + "applicant": "(주)대웅제약", + "manufacturer": "SICOR SOCIETA'ITALIANA CORTICOSTEROIDO S.R.L.", + "manufacture_place": "Rho(MI) - Via Terrazzano, 77, Italy", + "countries": "이탈리아", + "permit_date": "2015-02-26" + } +} +``` + +**`CHANGED` — 원본 오탈자 정정 (COSMETIC, 리포트 기본 제외)** + +```json +{ + "event_id": 48214, + "run_id": "run_20260902_060013", + "event_date": "2026-09-02", + "occurred_at": "2026-09-02T06:01:44+09:00", + "dmf_key": "20121228-168-I-169-05", + "permit_no": "20121228-168-I-169-05", + "event_type": "CHANGED", + "severity": "COSMETIC", + "is_reappearance": 0, + "change_count": 1, + "changed_fields": "manufacturer", + "before_hash": "1d4a7f90cc23b805", + "after_hash": "5b8e02c1df647a3e", + "changes": [ + { + "field": "manufacturer", + "label": "제조소명", + "before": "SICOR SOCIETA'ITALIANA CORTICOSTER OIDO S.R.L.", + "after": "SICOR SOCIETA'ITALIANA CORTICOSTEROIDO S.R.L.", + "class": "COSMETIC" + } + ], + "note": "manufacturer_key 가 동일하므로 표기 정정으로 판정. 대시보드 KPI 에 포함되지 않는다." +} +``` + +**`NEW` — 신규 등록 (재등장 아님)** + +```json +{ + "event_id": 48215, + "run_id": "run_20260902_060013", + "event_date": "2026-09-02", + "occurred_at": "2026-09-02T06:01:44+09:00", + "dmf_key": "20260901-209-J-2270", + "permit_no": "20260901-209-J-2270", + "event_type": "NEW", + "severity": "INFO", + "is_reappearance": 0, + "change_count": 0, + "changed_fields": "", + "before_hash": null, + "after_hash": "8ac3d5e0117b94f2", + "changes": [], + "before": null, + "after": { + "ingredient_name": "탐스로신염산염", + "applicant": "주식회사지맥스파마켐", + "manufacturer": "Hema Pharmaceuticals Pvt. Ltd.", + "manufacture_place": "Plot No. 6201/A & B, G.I.D.C., Gujarat State, India", + "countries": "인도", + "permit_date": "2026-09-01" + }, + "permit_parts": { + "fmt": "standard", + "accept_date": "2026-09-01", + "ingr_no": 209, + "group": "J", + "group_table": "별표1", + "group_effective_from": "2017-12-25", + "serial": 2270, + "sub": null, + "grant": null, + "ingr_group_key": "209-J" + } +} +``` + +**`WITHDRAWN` — 목록 이탈 (항상 CRITICAL)** + +```json +{ + "event_id": 48216, + "run_id": "run_20260902_060013", + "event_date": "2026-09-02", + "occurred_at": "2026-09-02T06:01:44+09:00", + "dmf_key": "20110531-71-B-317-05", + "permit_no": "20110531-71-B-317-05", + "event_type": "WITHDRAWN", + "severity": "CRITICAL", + "is_reappearance": 0, + "change_count": 0, + "changed_fields": "", + "before_hash": "44f1c8b09e2d7a61", + "after_hash": null, + "changes": [], + "before": { + "ingredient_name": "부데소니드", + "applicant": "(주)하이플", + "manufacturer": "Avik Pharmaceutical Limited", + "manufacture_place": "Plot No. 21, Gujarat, India", + "countries": "인도", + "permit_date": "2011-05-31" + }, + "after": null, + "note": "API 는 취하 사유를 제공하지 않는다. 리포트는 이 행에 nedrug 검색 링크를 붙여 사람이 확인하게 한다." +} +``` + +### 6.3 `events.jsonl` 라인 포맷 + +파이프라인 실행 로그. 도메인 이벤트뿐 아니라 **스테이지 전이·게이트 판정·알림 기록도 같은 파일에** 한 줄씩 쌓는다. 장애 분석 시 시간순 단일 뷰가 필요하기 때문이다. + +```jsonl +{"ts":"2026-09-02T06:00:13+09:00","run_id":"run_20260902_060013","kind":"stage","stage":"fetch","status":"START"} +{"ts":"2026-09-02T06:00:51+09:00","run_id":"run_20260902_060013","kind":"fetch","pages":99,"records":9841,"total_count":9841,"elapsed_s":37.8,"payload_sha256":"3b1f…"} +{"ts":"2026-09-02T06:00:52+09:00","run_id":"run_20260902_060013","kind":"stage","stage":"fetch","status":"SUCCESS","duration_ms":38912} +{"ts":"2026-09-02T06:01:02+09:00","run_id":"run_20260902_060013","kind":"gate","name":"total_count_match","passed":true,"observed":9841,"threshold":9841} +{"ts":"2026-09-02T06:01:02+09:00","run_id":"run_20260902_060013","kind":"gate","name":"drop_ratio","passed":true,"observed":0.0002,"threshold":0.05} +{"ts":"2026-09-02T06:01:44+09:00","run_id":"run_20260902_060013","kind":"event","event_type":"CHANGED","severity":"CRITICAL","dmf_key":"20121228-168-I-169-04","changed_fields":"countries,manufacturer"} +{"ts":"2026-09-02T06:01:44+09:00","run_id":"run_20260902_060013","kind":"event","event_type":"NEW","severity":"INFO","dmf_key":"20260901-209-J-2270","changed_fields":""} +{"ts":"2026-09-02T06:01:45+09:00","run_id":"run_20260902_060013","kind":"diff","new":12,"changed":7,"withdrawn":0,"cosmetic":3,"unchanged":9822,"churn_ratio":0.0019} +{"ts":"2026-09-02T06:02:31+09:00","run_id":"run_20260902_060013","kind":"alert","level":"WARN","code":"BACKUP_SKIPPED_LOW_DISK","dedup_key":"backup:lowdisk"} +``` + +**규칙 4가지.** +1. 한 줄 = 하나의 JSON 객체. 개행 없음. `ensure_ascii=False` (한글을 그대로 읽을 수 있어야 한다). +2. 모든 줄에 `ts`(ISO 8601 `+09:00`) · `run_id` · `kind` 세 필드가 반드시 있다. +3. `kind` 는 `stage` / `fetch` / `gate` / `event` / `diff` / `agy` / `alert` / `error` 8종. +4. **비밀 값은 절대 쓰지 않는다.** `logging.mask_patterns`(`serviceKey`, `access_token`)에 걸리는 키는 기록 직전에 `***` 로 치환한다. + +--- + +## 7. 데이터 품질 검증 + +### 7.1 검증의 두 층 + +| 층 | 이름 | 시점 | 실패 시 | +|---|---|---|---| +| **차단** | 게이트 (§5.4) | `integrity` 스테이지 / `diff` 직후 | diff 미수행 · 스냅샷 미저장 · `PARTIAL` · `CRITICAL` 알림 | +| **비차단** | 품질 지표 | `integrity` 스테이지에서 함께 계산 | 저장·diff·리포트 전부 정상 진행. `quality_checks` 기록 + 등급별 알림 + 리포트 메타 시트 표시 | + +**모든 결과는 통과·실패 무관하게 `quality_checks` 에 기록한다.** 통과 기록이 있어야 "임계값이 적절한가" 를 나중에 실측으로 조정할 수 있다. + +### 7.2 비차단 품질 지표 13종 + +| # | `check_name` | 관측 대상 | 기본 임계 | 위반 등급 | 위반 시 동작 | 근거 | +|---|---|---|---|---|---|---| +| 1 | `row_count_range` | 정규화 레코드 수 | 6,000 ≤ N ≤ 30,000 | `WARN` | 리포트 배너 + 알림 | 관측 9,840건 기준 상하 여유. 부트스트랩 후 실측으로 조정 | +| 2 | `permit_parse_rate` | `fmt != 'unknown'` 비율 | ≥ 0.97 | `WARN` | 미파싱 목록을 로그에 덤프 | 급락 = 원본 번호 체계 변경 신호 | +| 3 | `new_substance_ratio` | `fmt == 'new_substance'` 비율 | ≤ 0.05 | `INFO` | 메타 시트 표기만 | 실측 표본이 1건뿐이라 관측 목적 | +| 4 | `unknown_group_count` | `GROUP_TABLE` 에 없는 군 건수 | = 0 | `INFO` | 새 군 값을 알림 본문에 명시 | 제도 확대로 `L` 군이 생길 수 있다 | +| 5 | `synthetic_key_count` | `SYN-` 합성키 건수 | = 0 | `WARN` | 메타 시트 경고 + 해당 목록 로그 | 합성키는 오탐에 취약하다(§2.3) | +| 6 | `permit_date_valid_rate` | `permit_date != ''` 비율 | ≥ 0.99 | `WARN` | 실패 샘플 20건 로그 | 날짜 표기 변경 탐지 | +| 7 | `permit_date_future_count` | 오늘보다 미래인 발급일자 | = 0 | `WARN` | 해당 목록 로그 | 원본 입력 오류 | +| 8 | `permit_date_ancient_count` | 1990-01-01 이전 발급일자 | = 0 | `INFO` | 로그만 | DMF 제도 도입이 2002년이다 | +| 9 | `date_order_violation_rate` | `permit_date < accept_date` 비율 | ≤ 0.05 | `INFO` | 메타 시트 수치 | 재공고 건은 정상적으로 역전될 수 있다 | +| 10 | `country_unresolved_rate` | `COUNTRY_ALIASES` 에 없는 국가명 비율 | ≤ 0.05 | `INFO` | 미등록 국가명 상위 10개 로그 | 별칭 사전 보강 근거 수집 | +| 11 | `field_oversize_count` | 명세 크기 초과 필드 건수 | = 0 | `WARN` | 필드별 건수 알림 | **스키마 드리프트 1순위 신호** | +| 12 | `encoding_replacement_count` | `U+FFFD` 포함 레코드 수 | = 0 | `WARN` | 해당 원문 아카이브 경로 안내 | 인코딩 사고. 재파싱 필요 | +| 13 | `payload_unchanged_days` | `payload_sha256` 연속 동일 일수 | ≤ 7 | `INFO` | 메타 시트 "소스 최종 변동: N일 전" | 요구 SSOT 7.4 갱신 주기 실측 | + +> `rejected`(정규화 자체가 실패한 원본)는 지표가 아니라 **무조건 로그와 `runs.notes` 에 남긴다.** 1건이라도 있으면 `WARN` 이다. 정상 데이터에서는 절대 발생하지 않아야 한다. + +### 7.3 검증 코드 + +```python +# src/dmf_crawler/integrity.py (계속 — 비차단 품질 지표) +from datetime import date + +ANCIENT_CUTOFF = "1990-01-01" + + +def quality_metrics(records: list[DmfRecord], stats: NormalizeStats, + fetch, prev: Optional[PrevSnapshotStats], + payload_unchanged_days: int, cfg) -> tuple[Gate, ...]: + """비차단 지표 13종. 실패해도 파이프라인을 멈추지 않는다.""" + n = max(len(records), 1) + today = date.today().isoformat() + out: list[Gate] = [] + + def add(name: str, passed: bool, observed, threshold, detail: str) -> None: + out.append(Gate(name=name, passed=passed, blocking=False, + observed=None if observed is None else float(observed), + threshold=None if threshold is None else float(threshold), + detail=detail)) + + # 1. 건수 범위 + add("row_count_range", + cfg.min_expected_rows <= len(records) <= cfg.max_expected_rows, + len(records), cfg.max_expected_rows, + f"{len(records)}건 (기대 {cfg.min_expected_rows}~{cfg.max_expected_rows})") + + # 2. 등록번호 파싱률 + parse_rate = 1.0 - (stats.unparsed_permit_no / n) + add("permit_parse_rate", parse_rate >= 0.97, parse_rate, 0.97, + f"파싱 실패 {stats.unparsed_permit_no}건 ({1 - parse_rate:.3%})") + + # 3. 신물질 포맷 비율 + ns_ratio = stats.new_substance_count / n + add("new_substance_ratio", ns_ratio <= 0.05, ns_ratio, 0.05, + f"신물질 포맷 {stats.new_substance_count}건") + + # 4. 미지 알파벳군 + unknown_groups = sorted({r.permit.group for r in records + if r.permit.group and r.permit.group_table is None}) + add("unknown_group_count", not unknown_groups, len(unknown_groups), 0, + f"미등록 군: {unknown_groups or '없음'}") + + # 5. 합성키 + add("synthetic_key_count", stats.synthetic_key_count == 0, + stats.synthetic_key_count, 0, + f"등록번호 부재로 합성키를 쓴 레코드 {stats.synthetic_key_count}건") + + # 6. 발급일자 유효율 + valid_rate = 1.0 - (stats.invalid_permit_date / n) + add("permit_date_valid_rate", valid_rate >= 0.99, valid_rate, 0.99, + f"발급일자 파싱 실패 {stats.invalid_permit_date}건") + + # 7. 미래 일자 + future = [r.dmf_key for r in records if r.permit_date and r.permit_date > today] + add("permit_date_future_count", not future, len(future), 0, + f"미래 발급일자 {len(future)}건 (예: {future[:3]})") + + # 8. 과거 일자 + ancient = [r.dmf_key for r in records + if r.permit_date and r.permit_date < ANCIENT_CUTOFF] + add("permit_date_ancient_count", not ancient, len(ancient), 0, + f"{ANCIENT_CUTOFF} 이전 발급일자 {len(ancient)}건") + + # 9. 날짜 역전 (발급일자 < 등록수리일자) + inverted = [r for r in records + if r.permit_date and r.accepted_date and r.permit_date < r.accepted_date] + inv_rate = len(inverted) / n + add("date_order_violation_rate", inv_rate <= 0.05, inv_rate, 0.05, + f"발급일자가 등록수리일자보다 이른 건 {len(inverted)}건 ({inv_rate:.2%})") + + # 10. 미등록 국가명 + from dmf_crawler.normalize import COUNTRY_ALIASES, matching_key + unresolved: dict[str, int] = {} + country_rows = 0 + for rec in records: + for country in rec.countries: + country_rows += 1 + if matching_key(country) not in COUNTRY_ALIASES: + unresolved[country] = unresolved.get(country, 0) + 1 + unresolved_rate = (sum(unresolved.values()) / country_rows) if country_rows else 0.0 + top = sorted(unresolved.items(), key=lambda kv: -kv[1])[:10] + add("country_unresolved_rate", unresolved_rate <= 0.05, unresolved_rate, 0.05, + f"별칭 미등록 국가 {len(unresolved)}종 상위: {top}") + + # 11. 필드 길이 초과 (스키마 드리프트) + oversize_total = sum(stats.oversize_fields.values()) + add("field_oversize_count", oversize_total == 0, oversize_total, 0, + f"명세 크기 초과: {dict(stats.oversize_fields)}") + + # 12. 인코딩 사고 + add("encoding_replacement_count", stats.replacement_char_count == 0, + stats.replacement_char_count, 0, + f"U+FFFD 포함 레코드 {stats.replacement_char_count}건. " + f"원문: {fetch.archive_dir}") + + # 13. 소스 미갱신 연속 일수 + add("payload_unchanged_days", payload_unchanged_days <= 7, + payload_unchanged_days, 7, + f"소스 데이터가 {payload_unchanged_days}일 연속 동일하다") + + return tuple(out) + + +QUALITY_SEVERITY: Mapping[str, str] = { + "row_count_range": "WARN", + "permit_parse_rate": "WARN", + "new_substance_ratio": "INFO", + "unknown_group_count": "INFO", + "synthetic_key_count": "WARN", + "permit_date_valid_rate": "WARN", + "permit_date_future_count": "WARN", + "permit_date_ancient_count": "INFO", + "date_order_violation_rate": "INFO", + "country_unresolved_rate": "INFO", + "field_oversize_count": "WARN", + "encoding_replacement_count": "WARN", + "payload_unchanged_days": "INFO", +} +``` + +### 7.4 위반 시 동작 매트릭스 + +| 위반 조합 | 실행 상태 | 스냅샷 저장 | diff | 리포트 | 알림 | 다음 실행에 미치는 영향 | +|---|---|---|---|---|---|---| +| 없음 | `SUCCESS` | ○ | ○ | 정상 | 없음 | 없음 | +| 비차단 `INFO` 만 | `SUCCESS` | ○ | ○ | 메타 시트에 표기 | 없음 | 없음 | +| 비차단 `WARN` 1개 이상 | `SUCCESS` | ○ | ○ | 대시보드 주의 배너 + 메타 시트 상세 | `WARN` 1건(쿨다운 4시간) | 없음 | +| 차단 게이트 1~5 실패 | `PARTIAL` | **✕** | **✕** | 마지막 성공 스냅샷 + 실패 배너 | `CRITICAL` | **기준선 불변** — 다음 실행은 여전히 마지막 성공과 비교 | +| 차단 게이트 6 실패 | `PARTIAL` | **✕** | 계산 후 폐기 | 위와 동일 + 변동률 명시 | `CRITICAL` | 동일 | +| 정규화 `rejected` ≥ 1 | 다른 조건 따름 | 다른 조건 따름 | 다른 조건 따름 | 메타 시트에 건수·원문 경로 | `WARN` | 없음 | +| 3회 연속 차단 | `PARTIAL` | ✕ | ✕ | 스테일 배너 강조 | `CRITICAL` **강제 모달** | 소스 서킷 `OPEN` 전이 | + +**핵심 불변식**: 차단이 걸린 날은 **기준선이 움직이지 않는다.** 그래서 소스가 사흘 뒤 정상으로 돌아오면 그날의 diff 는 "마지막 성공일 대비 사흘치 변화" 를 정확히 보여준다. 변화를 놓치는 것이 아니라 **미루는 것**이다. 리포트는 그 사실을 "비교 기준: 2026-08-30 (3일 전)" 로 명시해야 한다. + +--- + +## 8. 보존 정책 + +### 8.1 자산별 보존 기간 + +| 자산 | 경로 / 테이블 | 기본 보존 | 설정 키 | 정리 방법 | 정리 주체 | +|---|---|---|---|---|---| +| 도메인 이벤트 | `events`, `event_changes` | **영구** | 없음(고정) | 삭제하지 않는다 | — | +| 레코드 버전 | `record_versions` | **영구** | 없음(고정) | 삭제하지 않는다 | — | +| 현재 상태 | `records` | **영구** | 없음(고정) | 삭제하지 않는다 | — | +| 스냅샷 멤버십 | `snapshots` | 영구(=`0`) | `storage.snapshot_retain_days` | `run_seq` 기준 배치 DELETE + `VACUUM` | `repo.prune_snapshots()` | +| 실행 기록 | `runs`, `stage_status`, `fetch_stats`, `quality_checks` | 영구 | 없음 | 삭제하지 않는다(행이 작다) | — | +| AI 산출물 | `agy_calls`, `enrichment*` | 영구 | 없음 | 삭제하지 않는다 | — | +| 알림 | `alerts` | 영구 | 없음 | 삭제하지 않는다 | — | +| **API 원문** | `data/raw/YYYY-MM-DD/` | **180일** | `source.archive_retain_days` | 날짜 디렉터리 통째 삭제 | `backup.prune_raw_archive()` | +| **실행 로그** | `logs/run_*/` | **90일** | `logging.retain_days` | 디렉터리 통째 삭제 | `backup.prune_logs()` | +| **리포트** | `reports/DMF_리포트_*.xlsx` | **365일** | `report.retain_days` | 파일 삭제. `*_최신.xlsx` 는 제외 | `backup.prune_reports()` | +| **DB 백업** | `backup/dmf_*.sqlite3` | **최근 30개** | `backup.keep_count` | 오래된 것부터 삭제 | `backup.prune_backups()` | +| 마이그레이션 전 백업 | `backup/premigrate_*.sqlite3` | **최근 5개** | 고정 | 오래된 것부터 삭제 | `backup.prune_backups()` | +| AI 제안 | `state/proposals/` | 사람이 처리할 때까지 | 없음 | **자동 삭제하지 않는다** | — | + +**보존 기간의 근거.** +- `data/raw/` 180일 — 재파싱·회귀 픽스처의 원자료다. 반년이면 정규화 규칙을 고쳤을 때 과거 데이터를 다시 만들어볼 수 있는 충분한 창이다. 하루 ~99페이지 × ~40KB ≈ 4MB/일 → 180일 = **720MB**. 이게 이 프로젝트에서 두 번째로 큰 디스크 소비처다. +- `logs/` 90일 — 분기 단위 장애 회고에 필요한 최소. 하루 ~2MB → 180MB. +- `reports/` 365일 — 전년 동기 리포트를 열어볼 수 있어야 한다. 파일당 ~2MB → 730MB. +- `backup/` 30개 — 한 달치 일일 백업. `VACUUM INTO` 산출물은 원본과 비슷한 크기(~150MB 가정) → **4.5GB**. **가장 큰 소비처이며 다른 드라이브를 권장하는 이유다.** + +**총 디스크 예산 (1년 운영 시)**: DB 150MB + raw 720MB + logs 180MB + reports 730MB + backup 4.5GB ≈ **6.3GB**. 온보딩 GUI 가 이 숫자를 보여주고 백업 드라이브 선택을 유도한다. + +### 8.2 정리 코드 + +```python +# src/dmf_crawler/backup.py (보존 정리 함수군) +"""VACUUM INTO 백업과 자산별 보존 정리. + +정리는 finalize 스테이지에서만 수행한다. required=False 취급이라 +실패해도 실행 상태를 FAILED 로 끌어내리지 않는다. +""" +from __future__ import annotations + +import re +import shutil +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from pathlib import Path + +RUN_DIR_RE = re.compile(r"^run_(?P\d{8}_\d{6})$") +DATE_DIR_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})$") + + +@dataclass(frozen=True, slots=True) +class PruneResult: + asset: str + removed: int + freed_bytes: int + errors: tuple[str, ...] + + +def _dir_size(path: Path) -> int: + total = 0 + for item in path.rglob("*"): + if item.is_file(): + try: + total += item.stat().st_size + except OSError: + pass + return total + + +def prune_raw_archive(raw_root: Path, retain_days: int, + today: date | None = None) -> PruneResult: + """data/raw/YYYY-MM-DD/ 를 날짜 디렉터리 단위로 지운다.""" + if retain_days <= 0 or not raw_root.exists(): + return PruneResult("raw", 0, 0, ()) + cutoff = (today or date.today()) - timedelta(days=retain_days) + removed = freed = 0 + errors: list[str] = [] + for child in sorted(raw_root.iterdir()): + if not child.is_dir(): + continue + m = DATE_DIR_RE.match(child.name) + if not m or m.group("d") >= cutoff.isoformat(): + continue + size = _dir_size(child) + try: + shutil.rmtree(child) + except OSError as exc: + errors.append(f"{child}: {exc}") + continue + removed += 1 + freed += size + return PruneResult("raw", removed, freed, tuple(errors)) + + +def prune_logs(logs_root: Path, retain_days: int, + today: date | None = None) -> PruneResult: + """logs/run_YYYYMMDD_HHMMSS/ 를 디렉터리 단위로 지운다.""" + if retain_days <= 0 or not logs_root.exists(): + return PruneResult("logs", 0, 0, ()) + cutoff = (today or date.today()) - timedelta(days=retain_days) + removed = freed = 0 + errors: list[str] = [] + for child in sorted(logs_root.iterdir()): + if not child.is_dir(): + continue + m = RUN_DIR_RE.match(child.name) + if not m: + continue + try: + stamp = datetime.strptime(m.group("stamp"), "%Y%m%d_%H%M%S").date() + except ValueError: + continue + if stamp >= cutoff: + continue + size = _dir_size(child) + try: + shutil.rmtree(child) + except OSError as exc: + errors.append(f"{child}: {exc}") + continue + removed += 1 + freed += size + return PruneResult("logs", removed, freed, tuple(errors)) + + +def prune_reports(reports_dir: Path, retain_days: int, latest_name: str, + today: date | None = None) -> PruneResult: + """일자별 리포트를 지운다. 최신본 고정 링크는 절대 지우지 않는다.""" + if retain_days <= 0 or not reports_dir.exists(): + return PruneResult("reports", 0, 0, ()) + cutoff = (today or date.today()) - timedelta(days=retain_days) + removed = freed = 0 + errors: list[str] = [] + for item in sorted(reports_dir.glob("*.xlsx")): + if latest_name and item.name == latest_name: + continue + m = re.search(r"(\d{4}-\d{2}-\d{2})", item.name) + if not m or m.group(1) >= cutoff.isoformat(): + continue + try: + size = item.stat().st_size + item.unlink() + except OSError as exc: # 사용자가 열어둔 파일은 잠겨 있다. 다음에 지운다. + errors.append(f"{item.name}: {exc}") + continue + removed += 1 + freed += size + return PruneResult("reports", removed, freed, tuple(errors)) + + +def prune_backups(backup_dir: Path, keep_daily: int, + keep_premigrate: int = 5) -> PruneResult: + """일일 백업과 마이그레이션 전 백업을 각각 개수 기준으로 정리한다.""" + if not backup_dir.exists(): + return PruneResult("backup", 0, 0, ()) + removed = freed = 0 + errors: list[str] = [] + for pattern, keep in (("dmf_*.sqlite3", keep_daily), + ("premigrate_*.sqlite3", keep_premigrate)): + files = sorted(backup_dir.glob(pattern), key=lambda p: p.name, reverse=True) + for item in files[max(keep, 0):]: + try: + size = item.stat().st_size + item.unlink() + except OSError as exc: + errors.append(f"{item.name}: {exc}") + continue + removed += 1 + freed += size + return PruneResult("backup", removed, freed, tuple(errors)) +``` + +```python +# src/dmf_crawler/storage/repo.py (보존 정리 — 스냅샷 멤버십) +import sqlite3 +from datetime import date + + +def prune_snapshots(conn: sqlite3.Connection, retain_days: int, + today: str | None = None) -> int: + """오래된 스냅샷 멤버십 행을 지운다. retain_days <= 0 이면 영구 보존. + + events + records + record_versions 가 남아 있으므로 이력은 손실되지 않는다. + 사라지는 것은 '그날 어떤 레코드가 있었는가' 의 빠른 조회 인덱스뿐이다. + """ + if retain_days <= 0: + return 0 + cutoff = today or date.today().isoformat() + rows = conn.execute( + "SELECT run_seq FROM runs" + " WHERE run_date < date(?, '-' || ? || ' days')" + " AND run_seq IN (SELECT DISTINCT run_seq FROM snapshots)" + " ORDER BY run_seq", + (cutoff, int(retain_days)), + ).fetchall() + if not rows: + return 0 + # 항상 가장 최근 성공 실행 하나는 남긴다(diff 기준선 보호). + keep = conn.execute( + "SELECT run_seq FROM runs WHERE status='SUCCESS' ORDER BY run_seq DESC LIMIT 1" + ).fetchone() + keep_seq = int(keep["run_seq"]) if keep else -1 + + deleted = 0 + for row in rows: + run_seq = int(row["run_seq"]) + if run_seq == keep_seq: + continue + cur = conn.execute("DELETE FROM snapshots WHERE run_seq = ?", (run_seq,)) + deleted += cur.rowcount + return deleted +``` + +### 8.3 정리 실행 규칙 5가지 + +1. **`finalize` 스테이지에서만** 실행한다. 수집·diff·리포트가 전부 끝난 뒤다. +2. **트랜잭션 밖에서** 실행한다. 파일 삭제는 롤백되지 않으므로 DB 트랜잭션과 섞으면 안 된다. +3. **실패해도 무시한다.** 사용자가 xlsx 를 열어두면 그 파일은 잠겨 있다. `errors` 에 담아 로그에만 남기고 다음 실행에 다시 시도한다. +4. **`snapshots` 정리 뒤에만 `VACUUM` 을 고려한다.** `VACUUM` 은 DB 크기만큼의 임시 공간을 쓰므로 `backup.min_free_gb` 확인 후에만 실행하고, 실패해도 무시한다. +5. **가장 최근 성공 실행의 스냅샷은 절대 지우지 않는다.** 그것이 내일 diff 의 기준선이다. 코드에 `keep_seq` 가드로 박아 뒀다. + +--- + +## 부록. 미해결 / 실측 필요 + +serviceKey 발급 직후 또는 첫 정상 실행 직후에 확인하고, 이 문서를 갱신한다. + +**A. 원본 데이터 실측 (serviceKey 발급 즉시)** +- [ ] **전체 등록 건수(`totalCount`) 실측값** — 이 문서는 9,840건을 가정했다. §7 지표 1의 범위(6,000~30,000)를 실측 기준으로 재설정할 것 +- [ ] **`numOfRows` 최대값** — 명세 크기가 3자리다. 999 가 실제로 통하는지 확인. 통하면 `source.page_size` 를 999로 올려 호출 수를 1/10로 줄인다 +- [ ] **중복 `DMF_PERMIT_NO` 가 실재하는가** — §4.5 그룹 매칭 로직의 존재 이유다. 중복이 0건이면 무해한 no-op 으로 남는다 +- [ ] **`DMF_PERMIT_NO` 가 빈 값인 레코드가 있는가** — 있으면 `SYN-` 합성키 경로가 실제로 동작한다. 건수를 기록할 것 +- [ ] **7필드 각각의 실제 널 비율** — 게이트 4 임계 0.01 이 현실적인지 검증 +- [ ] **`MANUF_COUNTRY_CODE_NM` 의 실제 구분자** — 콤마만인지, 슬래시·중점도 쓰이는지. `_COUNTRY_SPLIT_RE` 보강 근거 +- [ ] **`MNFCTR_NAME` 다중값의 실제 구분자** — ` ,`(공백+콤마) 가정이 맞는지. 아니면 `_split_multi()` 를 고쳐야 한다 +- [ ] **`DMF_PERMIT_DATE` 의 실제 표기** — `2015-02-26` 외 `20150226`·`2015.02.26` 이 섞이는지 + +**B. 등록번호 체계** +- [ ] **신물질 포맷 전수 조사** — `수6580-16-ND(20)` 외에 `제`·`허` 등 다른 접두어가 있는가. `RE_PERMIT_NEW_SUBSTANCE` 커버리지 확인 +- [ ] **관측되는 알파벳군 집합** — A~K 전부 등장하는가. `GROUP_TABLE` 에 없는 군(`L` 등)이 있는가 +- [ ] **`sub` 토큰이 J군에서 정말 없는가** — FAQ 는 "J는 제외" 라고 했다. 반례가 있으면 파서 주석을 고칠 것 +- [ ] **허여서 괄호가 3자리(`(10)` 이상)로 가는가** — 현재 정규식은 `\d{1,3}` 로 여유를 뒀다 +- [ ] **[별표1] 성분 목록 전문(1~211호) 확보** — `ingr_no` 를 성분명으로 역매핑하려면 필수. 현행 고시 별표1 파싱 + +**C. 변경 탐지 임계값 (2~4주 관측 후 확정)** +- [ ] **일일 실제 변동 규모** — 신규/변경/취하 각각의 중앙값·최댓값. `integrity.max_churn_ratio = 0.10` 이 너무 빡빡하거나 너무 느슨한지 판정 +- [ ] **`COSMETIC` 이벤트의 실제 발생 빈도** — 원본 표기 정정이 얼마나 잦은가. 잦으면 리포트 기본 제외가 정당하고, 0에 가까우면 `identity_hash` 계층의 비용 대비 효용을 재검토 +- [ ] **소스 갱신 주기** — `payload_sha256` 변화 간격의 실측 분포. 06:00 스케줄이 적절한지, 갱신이 주 1회면 매일 호출이 낭비인지 +- [ ] **`WITHDRAWN` 이 실제로 발생하는가** — API 가 취하 건을 목록에서 빼는지, 아니면 계속 유지하는지. **후자라면 취하 탐지가 원리적으로 불가능**하고 이 문서 §5.4 전체의 전제가 바뀐다. **가장 중요한 미검증 항목** + +**D. 스키마·저장** +- [ ] **`snapshots` 를 `WITHOUT ROWID` 로 둔 것의 실제 크기 이득** — 1만 행 × 30일 적재 후 `dbstat` 로 측정 +- [ ] **1년 운영 후 DB 실제 크기** — §3.2 산정(연 128MB)의 검증. 2GB 임계 경고가 언제 걸리는지 +- [ ] **`VACUUM INTO` 백업 1회 소요 시간** — 30분 실행 제한(`schedule.execution_time_limit_minutes`) 안에 드는지 +- [ ] **부분 유니크 인덱스 `ux_runs_success_per_day` 가 backfill 과 충돌하는가** — 과거 날짜를 채우는 `backfill` 서브커맨드가 같은 날짜에 두 번 SUCCESS 를 넣으려 하면 막힌다. 의도된 동작인지 확인 + +**E. 도메인 규칙** +- [ ] **염·수화물 접미 사전의 커버리지** — `_STRIPPABLE_SUFFIXES` 로 실제 성분명 전량을 처리해 `ingredient_base` 가 얼마나 잘 묶이는지 측정. 과다 제거(예: `벤조산`이 성분 본체인 경우) 사례 확인 +- [ ] **`COUNTRY_ALIASES` 미등록 국가명 목록** — §7 지표 10 이 수집한다. 1주 관측 후 사전 보강 +- [ ] **워치리스트 초기 목록** — 요구 00-REQUIREMENTS.md 의 미해결 항목. 사용자에게 관심 성분·업체를 받아야 `watchlist` 테이블이 의미를 갖는다 diff --git a/docs/design/03-xlsx-report-spec.md b/docs/design/03-xlsx-report-spec.md new file mode 100644 index 0000000..49c323b --- /dev/null +++ b/docs/design/03-xlsx-report-spec.md @@ -0,0 +1,3554 @@ +# xlsx 리포트 완전 스펙 (시트·연동·서식·시각화) + +> **이 문서의 역할**: DMF Crawler 가 매일 06:00 에 산출하는 **최종 결과물** `DMF_리포트_YYYY-MM-DD.xlsx` 를 셀 단위로 확정한다. 시트 8종의 컬럼·너비·표시형식·조건부서식·수식, 대시보드의 셀 주소별 레이아웃, 시트 간 하이퍼링크·정의된 이름, 디자인 토큰(HEX·폰트·행높이), 그리고 `src/dmf_crawler/report/` 전 모듈의 구현 골격을 담는다. 이 문서를 읽고 구현하면 리포트가 그대로 나와야 한다. 상위 SSOT 는 `docs/design/01-architecture.md`(구조·모듈명·설정키)와 `docs/design/00-DATA-SOURCE-DECISION.md`(원천 7필드)이며, 근거는 `docs/research/06-xlsx-linking-and-formatting.md`·`07-pharma-excel-dashboard-design.md` 에 있다. + +--- + +## 0. 한눈에 보기 + +이 문서가 확정하는 것: + +- **쓰기 엔진은 `XlsxWriter` 단독.** openpyxl 은 런타임 의존성에서 **완전히 제외**한다. 스파크라인 API 가 openpyxl 에 없고, diff 원본은 xlsx 가 아니라 SQLite 이므로 "어제 파일 읽기" 요구 자체가 소멸했다. 산출물 검증은 stdlib `zipfile` 로 한다. +- **시트 8종, 이 순서로 고정**: `00_대시보드` → `01_오늘변경분` → `02_전체현황` → `03_성분별` → `04_업체별` → `05_워치리스트`(빈 경우 생략) → `06_추이` → `99_메타`. 아키텍처 SSOT §3.13 과 1:1 일치한다. +- **대시보드는 스크롤 없는 1화면.** 총 폭 ≈1,257px / 총 높이 ≈794px, `set_zoom(90)` 적용 시 1,131 × 715px → 1366×768 노트북에서 가로·세로 스크롤 모두 없음. +- **모든 숫자는 파이썬이 미리 계산해 넣는다.** 수식을 쓰는 곳은 전부 `write_formula(..., value=캐시값)` 로 **캐시값을 함께 기록**한다. 파일을 Excel 로 한 번도 열지 않아도 값이 보인다. +- **색은 Okabe-Ito 고정, 상태는 4중 코딩**(배경색 + 폰트색 + 텍스트 라벨 + 기호). 신호등 아이콘셋은 CVD 위험으로 금지, `3_arrows_gray` 만 허용. 모든 상태 배경/폰트 조합은 WCAG 대비비 7.2 이상(AAA). +- **상태 도메인은 3종뿐**: `신규` / `변경` / `취하`. 원천 API 에 등록구분 필드가 없으므로 리서치 07 의 `연차보고`·`사전등록` 상태는 채택하지 않는다(부록에 기록). +- **원자적 저장 + 폴백**: 같은 볼륨 임시파일 → `zipfile` 검증 → `os.replace`. 잠김 시 3회 지수 백오프 후 `DMF_리포트_YYYY-MM-DD_HHMMSS.xlsx` 로 폴백하고 WARN 알림을 남긴다. 최신본 고정 링크는 심볼릭 링크가 아니라 **원자적 복사**다(Windows 심볼릭 링크는 권한이 필요). +- **AI 산출물은 리포트를 막지 못한다.** `enrichment` 조인이 비면 헤드라인은 `(AI 요약 없음)`, 메모 열은 `-` 로 렌더링하고 리포트는 항상 완주한다. +- **`src/report/workbook.py` 는 존재하지 않는다.** 아키텍처 SSOT 가 확정한 분할 — `report/{build,data,theme,widgets,atomic}.py` + `report/sheets/s00~s99` — 을 따른다(§8.0 에서 매핑을 명시). +- **인쇄는 A4 가로 1페이지 너비 맞춤**, 데이터 시트는 헤더 3행을 매 페이지 반복(`repeat_rows(0, 2)`). + +--- + +## 1. 라이브러리 확정 + +### 1.1 결론 + +> **쓰기 = `XlsxWriter` 단독. openpyxl·pandas 는 이 프로젝트의 런타임 의존성이 아니다.** + +아키텍처 SSOT §8.1 의 런타임 의존성 3개(`httpx`, `XlsxWriter`, `jsonschema`)와 정확히 일치한다. + +### 1.2 요구 대조표 + +| 리포트 요구 | XlsxWriter | openpyxl | 판정 | +|---|---|---|---| +| **스파크라인**(KPI 타일 6개 하단) | ✅ `add_sparkline()` — line/column/win_loss | ❌ **API 자체가 없음** | **XlsxWriter 필수** | +| 누적 세로 막대 차트(3계열) | ✅ `add_chart({'type':'column','subtype':'stacked'})` | ✅ | 둘 다 가능 | +| 가로 막대 차트 + 데이터 라벨 | ✅ `data_labels`, `set_size` | ✅(라벨 옵션 빈약) | XlsxWriter 우위 | +| Excel 표(ListObject) + 합계행 | ✅ `add_table(total_row=True, total_function, total_value)` | 수동 구현 | XlsxWriter 우위 | +| **수식 캐시값 지정** | ✅ `write_formula(cell, f, fmt, value)` | ❌ 불가 | **XlsxWriter 필수** | +| 조건부서식 18종 | ✅ `conditional_format()` | ✅ | 둘 다 가능 | +| 내부 하이퍼링크 | ✅ `write_url("internal:'시트'!A1")` | ✅ | 둘 다 가능 | +| 정의된 이름 | ✅ `define_name()` | ✅ | 둘 다 가능 | +| 인쇄·탭색·틀고정 | ✅ | ✅ | 둘 다 가능 | +| 기존 파일 수정 | ❌ 신규 생성 전용 | ✅(단 차트·스파크라인 **손실**) | 해당 없음 — §1.3 | + +### 1.3 openpyxl 을 뺀 근거 3가지 + +1. **스파크라인이 요구다.** 요구 R3.3(시각화) 과 대시보드 KPI 타일 설계가 스파크라인을 전제한다. openpyxl 에는 API 가 없다. 이 한 줄로 엔진은 결정된다. +2. **"어제 파일을 읽어 diff" 요구가 소멸했다.** 아키텍처 ADR 이 diff 원본을 **SQLite 이벤트 소싱**으로 확정했다(`repo.load_snapshot`). 리포트 xlsx 는 사람이 열어 편집·오염시킬 수 있는 **파생물**이지 정본이 아니다. 따라서 xlsx 읽기 기능이 필요 없다. +3. **차트·스파크라인 손실 함정을 원천 차단한다.** openpyxl 공식 문서: *"openpyxl does currently not read all possible items in an Excel file so shapes will be lost from existing files if they are opened and saved with the same name."* 매일 새 파일을 만드는 우리 정책에서는 애초에 열고 저장할 일이 없다. 의존성을 두면 언젠가 누군가 `load_workbook → save` 를 쓴다. **없애는 것이 가장 확실한 방어다.** + +### 1.4 openpyxl 이 담당했을 두 역할의 대체안 + +| 원래 역할 | 대체 | +|---|---| +| 산출물 xlsx 무결성 검증 | stdlib `zipfile` 로 `testzip()` + `xl/workbook.xml` 존재 확인 (`report/atomic.py::verify_xlsx`) | +| 워치리스트 사용자 편집 왕복(이월) | **SSOT 를 SQLite `watchlist` 테이블로 확정**. `05_워치리스트` 시트는 **읽기 전용 미러**이며 시트 보호를 건다. 편집은 설정 GUI(`gui/app.py`)에서 한다 | + +### 1.5 금지 사항 (구현 시 위반하면 리뷰에서 반려) + +- `constant_memory: True` **금지**. XlsxWriter 문서 원문: *"tables aren't available in XlsxWriter when Workbook 'constant_memory' mode is enabled."* `02_전체현황` 이 Excel 표를 쓰므로 켤 수 없다. 현재 규모(수천~수만 행)에서 메모리 문제는 없다. +- `worksheet.autofit()` **금지**. Calibri 11 메트릭 추정이라 한글에서 좁게 나온다. §8.3 의 `display_width()` 로 직접 계산한다. +- 수식을 캐시값 없이 쓰는 것 **금지**. 예외는 `add_table` 의 `total_function` 인데, 이때는 반드시 `total_value` 를 함께 준다. +- LibreOffice headless 재계산 **금지**(아키텍처 부록에서 기각). 외부 프로그램 의존을 늘리지 않는다. + +--- + +## 2. 워크북 전체 구성 + +### 2.1 시트 목록·순서·탭 색 + +| # | 시트명 | 탭 색 | 목적 1줄 | 생략 조건 | +|---|---|---|---|---| +| 1 | `00_대시보드` | `#0072B2` | 5초 안에 오늘 상황을 파악하는 1화면 요약. 활성 시트 | 없음 | +| 2 | `01_오늘변경분` | `#D55E00` | 전일 스냅샷 대비 신규/변경/취하만 모은 리포트 본체 | 없음(0건이면 안내행 1줄) | +| 3 | `02_전체현황` | `#5B6770` | 유효 등록 전체 원장. 사용자가 필터로 파고드는 시트 | 없음 | +| 4 | `03_성분별` | `#009E73` | 성분(원료명) 기준 Top N 집계 + 30일 추이 | 없음 | +| 5 | `04_업체별` | `#009E73` | 업체(신청인)·제조국 기준 Top N 집계 | 없음 | +| 6 | `05_워치리스트` | `#CC79A7` | 관심 키워드 히트 현황(읽기 전용 미러) | `watchlist` 활성 항목 0건이면 **시트 자체를 만들지 않음** | +| 7 | `06_추이` | `#0072B2` | 일자별 건수 시계열. **모든 스파크라인·차트의 원본 데이터** | 없음 | +| 8 | `99_메타` | `#888888` | 수집 시각·소스·건수 검증·해시·로그 경로·AI 상태 | 없음 | + +- 탭 색 `#0072B2` 가 `00_대시보드` 와 `06_추이` 에 중복되는 것은 의도다. **파란색 = 워크북의 구조/강조 축**(대시보드와 그 데이터 원본), 나머지는 의미색이다. +- 리서치 07 의 `스냅샷·로그` 시트는 **채택하지 않는다.** 그 내용(append-only 감사 로그)은 SQLite `events`/`fetch_stats` 테이블이 정본이고, xlsx 에는 `99_메타` 의 요약·경로 포인터만 둔다. 5만 행 롤오프 규칙도 함께 소멸. + +### 2.2 워크북 전역 설정 + +```python +wb = xlsxwriter.Workbook(str(tmp_path), { + "constant_memory": False, # 표(ListObject) 사용을 위해 반드시 False + "strings_to_numbers": False, # 등록번호 '20121228-168-I-169-04' 훼손 방지 + "strings_to_urls": False, # 제조소 소재지에 든 문자열이 링크로 오인되지 않게 + "nan_inf_to_errors": True, + "use_future_functions": True, # _xlfn 접두 자동 처리 + "default_date_format": "yyyy-mm-dd", + "remove_timezone": True, +}) +wb.set_size(1500, 900) # 최초 열림 창 크기 +wb.set_properties({ + "title": f"DMF 일일 모니터링 리포트 {report_date}", + "subject": "원료의약품 등록(DMF) 신규·변경·취하 현황", + "author": "DMF Crawler", + "manager": "", + "company": "", + "category": "규제 인텔리전스", + "keywords": "DMF, 원료의약품, 식약처, 등록현황", + "comments": f"run_id={run_id} / 자동 생성 / 원본: 공공데이터포털 15057075", + "created": datetime_of_run, # datetime 객체 +}) +``` + +| 항목 | 값 | 근거 | +|---|---|---| +| 기본 폰트 | 맑은 고딕 10pt `#1F2933` | `report.font` 설정키. Windows Vista+ 기본 탑재 | +| 최초 활성 시트 | `00_대시보드` (`activate()` + `set_first_sheet()`) | 5초 규칙 | +| 눈금선 | `00_대시보드` 만 `hide_gridlines(2)`, 나머지 유지 | 대시보드는 카드 UI, 데이터 시트는 표 | +| 확대 | `00_대시보드` 90%, 나머지 100% | 1화면 수납 | +| 값 없음 표기 | 빈 문자열이 아니라 `-` | 정렬·필터 일관성 | +| 표 스타일 | `Table Style Light 11` | 줄무늬 약함 = 데이터 잉크 비율 우선 | +| 표 이름 | `T_LEDGER` / `T_INGREDIENT` / `T_COMPANY` / `T_WATCH` / `T_TREND` | Excel 표 이름 규칙(공백·숫자 시작 불가) | + +### 2.3 시트 공통 레이아웃 규약 (`01`~`06`, `99`) + +| 행 | 내용 | +|---|---| +| 1행 | `A1` = `← 대시보드` 역링크, `B1` = 시트 제목(14pt bold), `우측 끝` = `기준일 YYYY-MM-DD · 생성 HH:MM` | +| 2행 | 공백(높이 6) | +| **3행** | **컬럼 헤더** | +| 4행~ | 데이터 | + +- `freeze_panes(3, k)` — 0-index 로 "3행까지 고정". `k` 는 시트별로 다르다(§3). +- 헤더 셀에는 `write_comment()` 로 컬럼 정의 툴팁을 단다. +- 데이터가 0건이면 4행에 `(해당 없음)` 안내 1줄을 회색으로 쓰고 표/조건부서식은 걸지 않는다. + +--- + +## 3. 시트별 완전 명세 + +표기 규약: **정렬** = 셀 가로 정렬, **형식** = `num_format` 문자열, **폭** = `set_column` 문자 단위 폭. + +### 3.1 `00_대시보드` + +셀 단위 레이아웃은 §4 에서 별도로 다룬다. 여기서는 시트 속성만 확정한다. + +| 항목 | 값 | +|---|---| +| 탭 색 | `#0072B2` | +| 눈금선 | `hide_gridlines(2)` (화면·인쇄 모두 숨김) | +| 확대 | `set_zoom(90)` | +| 틀 고정 | **없음** (1화면이라 불필요) | +| 자동 필터 | 없음 | +| 활성화 | `activate()`, `set_first_sheet()` | +| 인쇄 | A4 가로, `fit_to_pages(1, 1)`, 인쇄 영역 `A1:S47` | + +### 3.2 `01_오늘변경분` — 리포트 본체 + +| 항목 | 값 | +|---|---| +| 헤더 행 | 3행 (0-index 2) | +| 데이터 시작 | 4행 (0-index 3) | +| `freeze_panes` | `(3, 5)` — 3행 + A~E열 고정 (정렬키·기호·상태·등록번호·성분명까지 따라다님) | +| `autofilter` | `(2, 0, last_row, 18)` = `A3:S{last}` — Excel 표를 쓰지 않고 명시 지정(행 전체 조건부서식이 표 줄무늬와 충돌하지 않게) | +| 정렬 기준 | `A(정렬키)` 오름차순 → `N(워치히트)` 내림차순 → `E(성분명)` 오름차순. **파이썬에서 미리 정렬해 기록**한다 | +| 필터 기본값 | 없음(전량 표시) | +| 행 높이 | 데이터 행 30(줄바꿈 2줄 수용), 헤더 32 | + +정렬키 값 도메인: `취하=1`, `신규=2`, `변경=3`. 사용자가 임의 정렬한 뒤에도 A열 오름차순으로 원복할 수 있다. + +| 열 | 컬럼명 | 타입 | 폭 | 정렬 | 형식 | 조건부서식 / 수식 | 비고 | +|---|---|---|---|---|---|---|---| +| A | `정렬키` | int | 4 | 가운데 | `0` | — | **히든** `{'hidden': 1}` | +| B | `기호` | str | 4 | 가운데 | `@` | 행 규칙에 포함 | `+` `◆` `✕` | +| C | `상태` | str | 8 | 가운데 | `@` | **행 전체 규칙의 판정 열** | `신규`/`변경`/`취하` | +| D | `등록번호` | str | 24 | 왼쪽 | `@` | 내부 링크 → `02_전체현황` 해당 행 | `write_url` | +| E | `성분명` | str | 30 | 왼쪽 | `@` | 워치 매칭 시 bold(`CF-01-06`) | 줄바꿈 | +| F | `업체명` | str | 26 | 왼쪽 | `@` | 워치 매칭 시 bold | `ENTP_NAME` | +| G | `제조소명` | str | 26 | 왼쪽 | `@` | — | `MNFCTR_NAME` | +| H | `제조소 소재지` | str | 40 | 왼쪽 | `@` | — | `MNFCTR_PLACE`, 줄바꿈 | +| I | `제조국가` | str | 16 | 가운데 | `@` | `대한민국` 포함 → `#E3F3EA`(`CF-01-05`) | 다중값은 `, ` 결합 | +| J | `발급일자` | date | 12 | 가운데 | `yyyy-mm-dd` | — | `DMF_PERMIT_DATE` | +| K | `수리일자(파생)` | date | 12 | 가운데 | `yyyy-mm-dd` | — | 등록번호 앞 8자리 | +| L | `변경필드` | str | 24 | 왼쪽 | `@` | 줄바꿈 | `성분명, 제조국가` | +| M | `변경내용(전→후)` | str | 52 | 왼쪽 | `@` | 줄바꿈, 9pt | `제조국가: 이탈리아 → 이탈리아, 스위스` | +| N | `워치히트` | int | 8 | 가운데 | `0;;-` | `>0` → `#F7E9F0` + bold(`CF-01-04`) | 매칭 개수 | +| O | `워치키워드` | str | 20 | 왼쪽 | `@` | — | 쉼표 구분 | +| P | `AI 메모` | str | 40 | 왼쪽 | `@` | 줄바꿈, 9pt `#5B6770` | 없으면 `-` | +| Q | `원문검색` | url | 10 | 가운데 | `@` | `write_url(외부, string='조회')` | nedrug 성분명 검색 URL | +| R | `dmf_key` | str | 26 | 왼쪽 | `@` | — | **히든**. 재현·추적용 | +| S | `content_hash` | str | 18 | 왼쪽 | `@` | — | **히든** | + +**Q열 외부 링크 URL 템플릿** (성분명 검색): + +``` +https://nedrug.mfds.go.kr/pbp/CCBAC03/getItem?totalPages=1&limit=10&page=1&searchYn=true&itemName={성분명 URL 인코딩} +``` + +> ⚠️ **미검증**: 위 쿼리스트링이 성분명 검색으로 정확히 동작하는지는 실측하지 않았다. 실패 시 파라미터 없는 목록 URL `https://nedrug.mfds.go.kr/pbp/CCBAC03` 로 폴백하도록 구현한다. + +**행 전체 조건부서식은 `B4:S{last}` 에 건다**(A열 히든 제외). 판정 열은 `$C`. + +### 3.3 `02_전체현황` — 누적 원장 + +| 항목 | 값 | +|---|---| +| 헤더 행 | 3행 | +| Excel 표 | `add_table(2, 0, last_row, 16, {'name':'T_LEDGER','style':'Table Style Light 11','banded_rows':True,'autofilter':True})` | +| `freeze_panes` | `(3, 3)` — 3행 + A~C열(등록번호·성분명·업체명) | +| 정렬 기준 | `I(수리일자)` 내림차순 → `A(등록번호)` 오름차순 | +| 행 높이 | 15 고정, 줄바꿈 없음(수천~수만 행 성능) | +| 열 그룹화 | `P:Q` 를 `{'level': 1, 'hidden': True}` 로 묶어 접기 가능하게 | + +| 열 | 컬럼명 | 타입 | 폭 | 정렬 | 형식 | 조건부서식 / 수식 | +|---|---|---|---|---|---|---| +| A | `등록번호` | str | 24 | 왼쪽 | `@` | `duplicate` 강조(`CF-02-01`) | +| B | `성분명` | str | 30 | 왼쪽 | `@` | 워치 매칭 → `#F7E9F0`(`CF-02-05`) | +| C | `업체명` | str | 26 | 왼쪽 | `@` | 워치 매칭 → `#F7E9F0` | +| D | `제조소명` | str | 26 | 왼쪽 | `@` | — | +| E | `제조소 소재지` | str | 44 | 왼쪽 | `@` | 헤더 주석(툴팁) | +| F | `제조국가` | str | 18 | 가운데 | `@` | `대한민국` 포함 → `#E3F3EA`(`CF-02-04`) | +| G | `제조국 수` | int | 8 | 가운데 | `0` | `>=2` → bold (`CF-02-06`) | +| H | `발급일자` | date | 12 | 가운데 | `yyyy-mm-dd` | — | +| I | `수리일자` | date | 12 | 가운데 | `yyyy-mm-dd` | `data_bar` `#0072B2`(`CF-02-07`) | +| J | `최초관측일` | date | 12 | 가운데 | `yyyy-mm-dd` | — | +| K | `최종관측일` | date | 12 | 가운데 | `yyyy-mm-dd` | — | +| L | `최종변경일` | date | 12 | 가운데 | `yyyy-mm-dd` | **오늘이면** `#FDF0DC`(`CF-02-02`) | +| M | `상태` | str | 8 | 가운데 | `@` | `취하` → 행 전체 `#FBE5DC`+취소선(`CF-02-03`) | +| N | `경과일` | int | 8 | 가운데 | `#,##0;;-` | **수식**(아래) + `3_arrows_gray` 아이콘셋(`CF-02-08`) | +| O | `원문검색` | url | 10 | 가운데 | `@` | 외부 링크 `조회` | +| P | `dmf_key` | str | 26 | 왼쪽 | `@` | **히든**, 그룹 level 1 | +| Q | `content_hash` | str | 18 | 왼쪽 | `@` | **히든**, 그룹 level 1 | + +**N열 수식 전문** (행 4 기준, 아래로 상대 복사): + +```excel +=IF($I4="","-",INT(REPORT_DATE-$I4)) +``` + +`REPORT_DATE` 는 §5.4 의 정의된 이름(`='99_메타'!$B$3`). 캐시값은 파이썬이 `(기준일 - 수리일자).days` 로 계산해 `write_formula(..., value=n)` 로 함께 기록한다. + +> **주의**: Excel 표(ListObject) 안에서는 컬럼 수식이 구조적 참조로 자동 확장된다. 우리는 캐시값을 넣어야 하므로 `add_table` 의 `formula` 옵션을 쓰지 않고 **셀마다 `write_formula` 로 직접 기록**한 뒤 표를 씌운다. 표는 서식·필터만 담당한다. + +### 3.4 `03_성분별` + +| 항목 | 값 | +|---|---| +| 헤더 행 | 3행 | +| Excel 표 | `add_table(2, 0, last_row+1, 15, {'name':'T_INGREDIENT','style':'Table Style Light 11','total_row':True})` — 마지막 1행은 합계행 | +| `freeze_panes` | `(3, 2)` — 3행 + A~B열(순위·성분명) | +| 정렬 기준 | `F(오늘 합계)` 내림차순 → `I(누적 유효등록)` 내림차순 → `B(성분명)` 오름차순 | +| 행 수 | `report.top_n`(기본 10). 단 **오늘 변경분이 있는 성분은 top_n 을 넘어도 전부 포함**한다(합집합) | +| 행 높이 | 18(스파크라인 수용) | + +| 열 | 컬럼명 | 타입 | 폭 | 정렬 | 형식 | 조건부서식 / 합계행 | +|---|---|---|---|---|---|---| +| A | `순위` | int | 6 | 가운데 | `0` | `total_string: '합계'` | +| B | `성분명` | str | 34 | 왼쪽 | `@` | 워치 매칭 → `#F7E9F0` + bold(`CF-03-05`) | +| C | `오늘 신규` | int | 9 | 가운데 | `#,##0;;-` | `>0` → `#E3F3EA`(`CF-03-01`), `total_function:'sum'` | +| D | `오늘 변경` | int | 9 | 가운데 | `#,##0;;-` | `>0` → `#FDF0DC`(`CF-03-02`), `total_function:'sum'` | +| E | `오늘 취하` | int | 9 | 가운데 | `#,##0;;-` | `>0` → `#FBE5DC`(`CF-03-03`), `total_function:'sum'` | +| F | `오늘 합계` | int | 9 | 가운데 | `#,##0;;-` | **수식** `=SUM($C4:$E4)` + 캐시값, `total_function:'sum'` | +| G | `최근 7일` | int | 9 | 가운데 | `#,##0;;-` | `data_bar` `#0072B2`(`CF-03-06`), `total_function:'sum'` | +| H | `최근 30일` | int | 9 | 가운데 | `#,##0;;-` | `data_bar` `#0072B2`, `total_function:'sum'` | +| I | `누적 유효등록` | int | 11 | 가운데 | `#,##0` | `data_bar` `#0072B2`, `total_function:'sum'` | +| J | `등록업체 수` | int | 9 | 가운데 | `#,##0` | `>=5` → bold(`CF-03-07`, 공급처 다변화 신호) | +| K | `제조국 수` | int | 9 | 가운데 | `#,##0` | — | +| L | `주요 제조국` | str | 14 | 가운데 | `@` | — | +| M | `국산 보유` | str | 8 | 가운데 | `@` | `Y` → `#E3F3EA`(`CF-03-08`) | +| N | `전일 대비` | int | 9 | 가운데 | `▲ #,##0;▼ -#,##0;– 0` | `icon_set` `3_arrows_gray`(`CF-03-04`) | +| O | `30일 추이` | — | 14 | 가운데 | — | **스파크라인**(column). 원본 `'06_추이'` 히든 블록 | +| P | `상세` | url | 8 | 가운데 | `@` | 내부 링크 → `02_전체현황!A3` | + +**O열 스파크라인**: 성분별 30일 시계열은 `06_추이` 시트의 히든 블록 `AD:BG`(행 = 성분, 열 = 일자 30개)에 가로로 적재한다. 성분 i 의 범위는 `'06_추이'!$AE${4+i}:$BH${4+i}`. 설정은 `{'type':'column','style':None,'series_color':'#0072B2','high_point':True,'empty_cells':'zero'}`. + +### 3.5 `04_업체별` + +`03_성분별` 과 대칭 구조. + +| 항목 | 값 | +|---|---| +| Excel 표 | `add_table(2, 0, last_row+1, 16, {'name':'T_COMPANY','style':'Table Style Light 11','total_row':True})` | +| `freeze_panes` | `(3, 2)` | +| 정렬 기준 | `G(오늘 합계)` 내림차순 → `I(누적 유효등록)` 내림차순 → `B(업체명)` 오름차순 | +| 행 수 | `report.top_n` ∪ (오늘 변경분이 있는 업체 전체) | + +| 열 | 컬럼명 | 타입 | 폭 | 정렬 | 형식 | 조건부서식 / 합계행 | +|---|---|---|---|---|---|---| +| A | `순위` | int | 6 | 가운데 | `0` | `total_string: '합계'` | +| B | `업체명` | str | 30 | 왼쪽 | `@` | 워치 매칭 → `#F7E9F0` + bold | +| C | `업체키` | str | 26 | 왼쪽 | `@` | **히든**(정규화 키) | +| D | `오늘 신규` | int | 9 | 가운데 | `#,##0;;-` | `>0` → `#E3F3EA`, `sum` | +| E | `오늘 변경` | int | 9 | 가운데 | `#,##0;;-` | `>0` → `#FDF0DC`, `sum` | +| F | `오늘 취하` | int | 9 | 가운데 | `#,##0;;-` | `>0` → `#FBE5DC`, `sum` | +| G | `오늘 합계` | int | 9 | 가운데 | `#,##0;;-` | **수식** `=SUM($D4:$F4)` + 캐시값, `sum` | +| H | `최근 30일` | int | 9 | 가운데 | `#,##0;;-` | `data_bar`, `sum` | +| I | `누적 유효등록` | int | 11 | 가운데 | `#,##0` | `data_bar`, `sum` | +| J | `보유 성분 수` | int | 9 | 가운데 | `#,##0` | — | +| K | `제조소 수` | int | 9 | 가운데 | `#,##0` | — | +| L | `주요 제조국` | str | 14 | 가운데 | `@` | — | +| M | `국산여부` | str | 8 | 가운데 | `@` | `국산` → `#E3F3EA` | +| N | `최근 등록일` | date | 12 | 가운데 | `yyyy-mm-dd` | 90일 이상 경과 → `#EFEFEF`(`CF-04-05`) | +| O | `워치리스트` | str | 8 | 가운데 | `@` | `★` → `#F7E9F0` | +| P | `30일 추이` | — | 14 | 가운데 | — | 스파크라인(column) | +| Q | `상세` | url | 8 | 가운데 | `@` | 내부 링크 → `02_전체현황!A3` | + +### 3.6 `05_워치리스트` (조건부 생성) + +- **생성 조건**: `watchlist` 테이블에 `active='Y'` 인 행이 1건 이상. 0건이면 시트를 만들지 않고, 대시보드 내비게이션의 해당 슬롯은 회색 비활성 텍스트 `(워치리스트 없음)` 로 대체한다. +- **시트 보호**: `protect("", {"objects": True, "scenarios": True, "select_locked_cells": True, "select_unlocked_cells": True, "sort": True, "autofilter": True})`. 읽기 전용 미러임을 물리적으로 표시한다. 비밀번호 없음 — 해제 자체를 막는 것이 목적이 아니라 **실수로 편집하고 저장했다가 다음 날 덮어써지는 착각을 막는 것**이 목적이다. +- `freeze_panes(3, 3)` + +**블록 A — 키워드 정의(3행 헤더, 4행~)** + +| 열 | 컬럼명 | 타입 | 폭 | 정렬 | 형식 | 유효성 / 규칙 | +|---|---|---|---|---|---|---| +| A | `번호` | int | 6 | 가운데 | `0` | — | +| B | `대상유형` | str | 12 | 가운데 | `@` | `data_validation` list: `성분명`,`업체명`,`제조소명`,`제조국가`,`등록번호` | +| C | `키워드` | str | 30 | 왼쪽 | `@` | 빈 값 → `#FBE5DC`(`CF-05-03`) | +| D | `매칭방식` | str | 12 | 가운데 | `@` | list: `부분일치`,`정확일치`,`정규식` | +| E | `우선순위` | int | 8 | 가운데 | `0` | list: `1`,`2`,`3` | +| F | `활성` | str | 6 | 가운데 | `@` | list: `Y`,`N` | +| G | `메모` | str | 26 | 왼쪽 | `@` | 자유 | + +**블록 B — 히트 결과(같은 행, I~N)** + +| 열 | 컬럼명 | 타입 | 폭 | 정렬 | 형식 | 조건부서식 / 수식 | +|---|---|---|---|---|---|---| +| I | `오늘 매칭` | int | 9 | 가운데 | `#,##0;;-` | **수식**(아래) + `>0` → `#F7E9F0`·bold(`CF-05-01`) | +| J | `최근 7일` | int | 9 | 가운데 | `#,##0;;-` | `data_bar` `#CC79A7` | +| K | `최근 30일` | int | 9 | 가운데 | `#,##0;;-` | `data_bar` `#CC79A7` | +| L | `누적 매칭` | int | 9 | 가운데 | `#,##0` | — | +| M | `마지막 매칭일` | date | 12 | 가운데 | `yyyy-mm-dd` | 오늘 → `#F7E9F0`(`CF-05-02`) | +| N | `상세` | url | 8 | 가운데 | `@` | 내부 링크 → `01_오늘변경분!A3` | + +**I열 수식 전문** (행 4, 아래로 상대 복사): + +```excel +=IF($C4="",0,COUNTIF('01_오늘변경분'!$O$4:$O$100000,"*"&$C4&"*")) +``` + +캐시값은 파이썬이 실제 매칭 개수로 채운다. + +**블록 C — 매칭 상세표** (`A{blockC}` 이하, 블록 A 마지막 행 + 3행부터) + +헤더 7컬럼: `키워드` / `상태` / `등록번호` / `성분명` / `업체명` / `제조국가` / `발급일자`. +행 전체에 `01_오늘변경분` 과 동일한 상태 조건부서식(`CF-01-01~03`)을 재사용한다. + +### 3.7 `06_추이` — 시각화 원본 + +| 항목 | 값 | +|---|---| +| 헤더 행 | 3행 | +| 데이터 | 4행 ~ `3 + report.trend_days`(기본 90). **오래된 날짜가 위**, 최신이 아래 → 스파크라인이 왼→오 시간순으로 읽힘 | +| Excel 표 | `add_table(2, 0, last_row, 8, {'name':'T_TREND','style':'Table Style Light 11'})` | +| `freeze_panes` | `(3, 1)` | +| 정렬 기준 | `A(일자)` 오름차순 | + +| 열 | 컬럼명 | 타입 | 폭 | 정렬 | 형식 | 조건부서식 | +|---|---|---|---|---|---|---| +| A | `일자` | date | 12 | 가운데 | `yyyy-mm-dd` | 오늘 → bold + `#E5F1F8`(`CF-06-01`) | +| B | `신규` | int | 9 | 가운데 | `#,##0;;-` | `data_bar` `#009E73`(`CF-06-02`) | +| C | `변경` | int | 9 | 가운데 | `#,##0;;-` | `data_bar` `#E69F00` | +| D | `취하` | int | 9 | 가운데 | `#,##0;;-` | `data_bar` `#D55E00` | +| E | `합계` | int | 9 | 가운데 | `#,##0;;-` | **수식** `=SUM($B4:$D4)` + 캐시값 | +| F | `워치 히트` | int | 9 | 가운데 | `#,##0;;-` | `>0` → `#F7E9F0` | +| G | `누적 유효등록` | int | 12 | 가운데 | `#,##0` | — | +| H | `수집 건수` | int | 10 | 가운데 | `#,##0` | 전일 대비 5% 이상 급감 → `#FBE5DC`(`CF-06-03`) | +| I | `실행 상태` | str | 10 | 가운데 | `@` | `SUCCESS`→`#E3F3EA` / `PARTIAL`→`#FDF0DC` / `FAILED`→`#FBE5DC`(`CF-06-04`) | + +**히든 블록 (차트·스파크라인 원본)** — 열 폭 0, `{'hidden': 1}` + +| 범위 | 내용 | +|---|---| +| `AA3:AB3` | 헤더 `제조국` / `건수` | +| `AA4:AB11` | 제조국 Top 8 (최근 30일 기준, 건수 내림차순) — **차트2 원본** | +| `AD3` | 헤더 `성분명` | +| `AE3:BH3` | 최근 30일 일자 헤더 | +| `AD4:BH{3+n_ing}` | 성분별 30일 시계열 (행 = 성분, 열 = 일자) — `03_성분별` O열 스파크라인 원본 | +| `BJ3` | 헤더 `업체명` | +| `BK3:CN3` | 최근 30일 일자 헤더 | +| `BJ4:CN{3+n_co}` | 업체별 30일 시계열 — `04_업체별` P열 스파크라인 원본 | + +### 3.8 `99_메타` + +| 항목 | 값 | +|---|---| +| 헤더 | 블록마다 별도 | +| `freeze_panes` | `(3, 0)` | +| 자동 필터 | 없음 | +| 열 폭 | `A`=28, `B`=46, `C`=16, `D`=16, `E`=30 | + +**블록 1 — 실행 메타 (`A3:B18`)** — A열 항목명(bold, `#5B6770`), B열 값 + +| 행 | 항목 | 값 예 | 형식 | +|---|---|---|---| +| 3 | `리포트 기준일` | `2026-09-02` | `yyyy-mm-dd` | +| 4 | `수집 시작 시각` | `2026-09-02 06:02:14` | `yyyy-mm-dd hh:mm:ss` | +| 5 | `수집 종료 시각` | `2026-09-02 06:06:41` | `yyyy-mm-dd hh:mm:ss` | +| 6 | `실행 ID` | `20260902_060214` | `@` | +| 7 | `실행 상태` | `SUCCESS` | `@` (`CF-99-02`) | +| 8 | `실행 트리거` | `scheduled` | `@` | +| 9 | `실행 호스트` | `DESKTOP-XXXX` | `@` | +| 10 | `파이썬 버전` | `3.12.7` | `@` | +| 11 | `XlsxWriter 버전` | `3.2.9` | `@` | +| 12 | `직전 성공 실행` | `20260901_060119` | `@` | +| 13 | `실행 로그 경로` | `logs\run_20260902_060214\pipeline.log` | `@` (외부 링크) | +| 14 | `이벤트 로그 경로` | `logs\run_20260902_060214\events.jsonl` | `@` (외부 링크) | +| 15 | `원문 아카이브` | `data\raw\2026-09-02\` | `@` (외부 링크) | +| 16 | `DB 백업` | `backup\dmf_2026-09-02.sqlite3` | `@` | +| 17 | `스냅샷 해시` | `9f2c1a7be40d5c11` | `@` | +| 18 | `직전 리포트` | `reports\DMF_리포트_2026-09-01.xlsx` | `@` (외부 링크) | + +**블록 2 — 소스 (`A21:E23`)**: 헤더 `소스명 / URL / HTTP / 수집행수 / 비고`. 최소 1행(공식 OpenAPI `getMdcDmfList01`). URL 은 외부 하이퍼링크. + +**블록 3 — 무결성 게이트 5종 (`A26:E32`)**: 헤더 `게이트 / 기대 / 실제 / 판정 / 상세`. 아키텍처 §3.6 의 5게이트와 1:1. + +| 행 | 게이트 | 기대 | +|---|---|---| +| 27 | `1. 응답 정상성` | 전 페이지 200 + `resultCode=00` + 본문 시그니처 | +| 28 | `2. 건수 일치` | `수집 = totalCount` | +| 29 | `3. 급감 방지` | 전일 대비 감소율 ≤ `max_drop_ratio` | +| 30 | `4. 널 비율` | 필수 3필드 널 비율 ≤ `max_null_ratio` | +| 31 | `5. 중복 비율` | 중복 등록번호 비율 ≤ `max_duplicate_ratio` | +| 32 | `종합` | 전부 통과 | + +**D열(판정) 수식 전문** (행 27, 아래로 복사): + +```excel +=IF($C27=$B27,"OK","불일치") +``` + +수치 비교가 불가능한 게이트 1은 파이썬이 `OK`/`불일치` 문자열을 직접 쓴다. 조건부서식 `CF-99-01`. + +**블록 4 — AI 계층 상태 (`A35:B41`)** + +| 항목 | 값 예 | +|---|---| +| `agy 사용 여부` | `사용` / `건너뜀(변화 없음)` / `건너뜀(서킷 OPEN)` / `건너뜀(토큰 상한)` / `실패` | +| `모델` | `gemini-3.7-flash-medium` | +| `노력 수준` | `medium` | +| `입력 토큰` / `출력 토큰` | `28,412` / `1,180` | +| `소요 시간(초)` | `33.4` | +| `오늘 누적 토큰` | `29,592 / 300,000` | +| `봉투 원문 경로` | `logs\run_...\agy.stdout.json` (외부 링크) | + +**블록 5 — 코드북 (`A44:C60`)**: `구분 / 코드 / 설명`. 상태 3종, 등록번호 5구획 해부(`발급일자8-성분일련-시행군-접수일련-성분내일련`), 제조국 표기 정규화 매핑. + +**블록 6 — 출처·면책 (`A63:E66`)**: 9pt `#5B6770`. 출처 URL, 이용약관 준수 문구, "본 리포트는 공공데이터를 자동 수집·가공한 참고 자료이며 법적 효력이 없다" 면책. + +--- + +## 4. `00_대시보드` 셀 단위 레이아웃 + +### 4.1 열 폭 그리드 + +| 열 | 폭 | ≈px | 역할 | +|---|---|---|---| +| `A` | 1.5 | 15 | 좌 여백 | +| `B` `C` | 13 / 13 | 96 / 96 | KPI 타일 1 · 차트1 · Top 성분표 | +| `D` | 1.5 | 15 | 거터 | +| `E` `F` | 13 / 13 | 96 / 96 | KPI 타일 2 | +| `G` | 1.5 | 15 | 거터 | +| `H` `I` | 13 / 13 | 96 / 96 | KPI 타일 3 · Top 업체표 | +| `J` | 1.5 | 15 | 거터 | +| `K` `L` | 13 / 13 | 96 / 96 | KPI 타일 4 · 차트2 | +| `M` | 1.5 | 15 | 거터 | +| `N` `O` | 13 / 13 | 96 / 96 | KPI 타일 5 · 워치 표 | +| `P` | 1.5 | 15 | 거터 | +| `Q` `R` | 13 / 13 | 96 / 96 | KPI 타일 6 | +| `S` | 1.5 | 15 | 우 여백 | + +총 폭 = 15 + 96×12 + 15×6 + 15 = **1,257px**. `set_zoom(90)` → **1,131px** → 1366px 화면에서 가로 스크롤 없음. + +### 4.2 행 높이 그리드 + +| 행 | 높이 | 역할 | +|---|---|---| +| 1 | 6 | 상단 여백 | +| 2 | 30 | 제목 | +| 3 | 18 | 헤드라인 문장(AI 또는 규칙 기반) | +| 4 | 8 (배너 시 **22**) | 여백 / 상태 배너 | +| 5 | 18 | KPI 라벨 | +| 6 | 34 | KPI 값 (28pt) | +| 7 | 16 | KPI 델타 | +| 8 | 14 | KPI 스파크라인 | +| 9 | 10 | 여백 | +| 10–24 | 20 ×15 = 300 | 차트 밴드 | +| 25 | 10 | 여백 | +| 26 | 18 | 표 헤더 | +| 27–36 | 16 ×10 = 160 | 표 본문(10행) | +| 37–40 | 16 ×4 = 64 | 표 예비행 | +| 41 | 10 | 여백 | +| 42 | 20 | 내비게이션 1행 | +| 43 | 20 | 내비게이션 2행 | +| 44–46 | 8 ×3 = 24 | 여백 | +| 47 | 14 | 출처·면책 | + +총 높이 = **794px**. `set_zoom(90)` → **715px** → 768px 화면에 수납. + +### 4.3 텍스트 와이어프레임 (실제 셀 주소 표기) + +``` + A B C D E F G H I J K L M N O P Q R S + ┌────┬─────────────────┬───┬─────────────────┬───┬─────────────────┬───┬─────────────────┬───┬─────────────────┬───┬─────────────────┬────┐ + 1 │ (상단 여백 h=6) │ + 2 │ │ B2:R2 (merge) DMF 일일 모니터링 리포트 — 2026-09-02(수) 18pt bold #1F2933 좌측정렬 │ │ + 3 │ │ B3:R3 (merge) 오늘 신규 12건 · 변경 3건 · 취하 1건 — 워치리스트 2건 적중(피타바스타틴칼슘, 다파글리플로진) 11pt #5B6770 │ │ + 4 │ │ B4:R4 (merge) [배너] ⚠ 오늘 수집 실패 — 마지막 성공: 2026-09-01. 아래 수치는 그날 기준이다. 11pt bold #8A1D00 / #FBE5DC│ │ + 5 │ │ B5:C5 │ │ E5:F5 │ │ H5:I5 │ │ K5:L5 │ │ N5:O5 │ │ Q5:R5 │ │ + │ │ 오늘 신규 │ │ 오늘 변경 │ │ 오늘 취하 │ │ 오늘 총 변경분 │ │ 워치리스트 히트 │ │ 전체 누적 등록 │ │ + │ │ top=5 #009E73 │ │ top=5 #E69F00 │ │ top=5 #D55E00 │ │ top=5 #0072B2 │ │ top=5 #CC79A7 │ │ top=5 #1F2933 │ │ + 6 │ │ B6:C6 │ │ E6:F6 │ │ H6:I6 │ │ K6:L6 │ │ N6:O6 │ │ Q6:R6 │ │ + │ │ 12 │ │ 3 │ │ 1 │ │ 16 │ │ 2 │ │ 13,842 │ │ + │ │ 28pt bold 상태색│ │ 28pt bold │ │ 28pt bold │ │ 28pt bold │ │ 28pt bold │ │ 28pt bold │ │ + 7 │ │ B7:C7 ▲ 5 │ │ E7:F7 ▼ -2 │ │ H7:I7 – 0 │ │ K7:L7 ▲ 3 │ │ N7:O7 ▲ 2 │ │ Q7:R7 ▲ 11 │ │ + 8 │ │ B8 ▁▂▅▁▃█▂▁ │ │ E8 ▁▁▂▁▁▃▁▁ │ │ H8 ▁▁▁▁█▁▁▁ │ │ K8 ▂▃▅▂▄█▃▂ │ │ N8 ▁▁█▁▁█▁▁ │ │ Q8 ╱╱╱╱╱╱╱╱ │ │ + │ │ sparkline column│ │ sparkline column│ │ sparkline column│ │ sparkline column│ │ sparkline winloss│ │ sparkline line │ │ + 9 │ (여백 h=10) │ +10 │ ┌──────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────┐ │ + │ │ 앵커 B10 · 600×300px │ │ 앵커 K10 · 600×300px │ │ + │ │ [차트1] 최근 30일 일별 등록 동향 (누적 세로 막대) │ │ [차트2] 제조국 Top 8 (가로 막대, 단색 #0072B2) │ │ + │ │ x=일자(30) y=건수 │ │ x=건수 y=국가명 (직접 라벨링, 색 구분 없음) │ │ + │ │ 계열: 신규#009E73 / 변경#E69F00 / 취하#D55E00 │ │ 인도 ████████████████ 120 │ │ +24 │ │ 범례: top │ │ 범례: none (단색이라 불필요) │ │ + │ └──────────────────────────────────────────────────┘ └──────────────────────────────────────────────────┘ │ +25 │ (여백 h=10) │ +26 │ │ B26:F26 Top 10 성분 │ │ H26:L26 Top 10 업체 │ │ N26:R26 워치리스트 히트 │ │ + │ │ 순위│성분명│오늘│30일│누적 │ │ 순위│업체명│오늘│30일│누적 │ │ 키워드│매칭│성분명│업체명│상태 │ │ +27 │ │ 1 │아토르바…│ 3 │ 11 │ 128│ │ 1 │㈜○○제약│ 4 │15 │ 342 │ │ 다파글…│ 1 │… │… │ 신규 │ │ +36 │ │ … 10행, I열 data_bar #0072B2│ │ … 10행 │ │ … 최대 10행 │ │ +37 │ │ (예비 4행 — 데이터가 적으면 공백) │ +41 │ (여백 h=10) │ +42 │ │ B42:C42 [오늘 변경분] │ E42:F42 [전체 현황] │ H42:I42 [성분별] │ K42:L42 [업체별] │ N42:O42 [워치리스트] │ Q42:R42 [추이] │ +43 │ │ B43:C43 [메타] │ +44 │ (여백 h=8 ×3) │ +47 │ │ B47:R47 출처: 공공데이터포털 의약품 원료의약품 등록 정보(15057075) · 식품의약품안전처 │ 수집 2026-09-02 06:02 KST │ 참고용 │ + └────┴─────────────────┴───┴─────────────────┴───┴─────────────────┴───┴─────────────────┴───┴─────────────────┴───┴─────────────────┴────┘ +``` + +### 4.4 KPI 타일 6종 — 셀 주소·값·델타·스파크라인 + +| # | 라벨 | 라벨셀 | 값셀 | 델타셀 | 스파크라인셀 | 액센트 | 스파크 타입 | 스파크 원본 | +|---|---|---|---|---|---|---|---|---| +| 1 | 오늘 신규 | `B5:C5` | `B6:C6` | `B7:C7` | `B8:C8` | `#009E73` | `column` | `'06_추이'!$B${s}:$B${e}` | +| 2 | 오늘 변경 | `E5:F5` | `E6:F6` | `E7:F7` | `E8:F8` | `#E69F00` | `column` | `'06_추이'!$C${s}:$C${e}` | +| 3 | 오늘 취하 | `H5:I5` | `H6:I6` | `H7:I7` | `H8:I8` | `#D55E00` | `column` | `'06_추이'!$D${s}:$D${e}` | +| 4 | 오늘 총 변경분 | `K5:L5` | `K6:L6` | `K7:L7` | `K8:L8` | `#0072B2` | `column` | `'06_추이'!$E${s}:$E${e}` | +| 5 | 워치리스트 히트 | `N5:O5` | `N6:O6` | `N7:O7` | `N8:O8` | `#CC79A7` | `win_loss` | `'06_추이'!$F${s}:$F${e}` | +| 6 | 전체 누적 등록 | `Q5:R5` | `Q6:R6` | `Q7:R7` | `Q8:R8` | `#1F2933` | `line` | `'06_추이'!$G${s}:$G${e}` | + +`e` = `06_추이` 마지막 데이터 행, `s` = `max(4, e - 29)` (최근 30일). + +- 스파크라인은 **병합 범위의 좌상단 셀**(`B8`, `E8`, …)에 `add_sparkline` 한다. 병합해도 좌상단 기준으로 그려진다. +- 공통 옵션: `{"series_color": accent, "high_point": True, "last_point": True, "empty_cells": "zero"}`. `win_loss` 만 `{"negative_points": True}` 추가. +- 스파크라인 데이터 포인트는 30개 — 실무 권고 상한(12~24)을 넘으므로 `column` 타입으로 개별 값을 구분 가능하게 한다. `line` 은 단조 증가하는 누적 KPI(#6)에만 쓴다. + +**KPI 값 셀 수식 전문** (예: 타일 1) — 캐시값 동봉: + +```excel +='06_추이'!$B$93 +``` + +**KPI 델타 셀 수식 전문** (예: 타일 1): + +```excel +=IF(ROW('06_추이'!$B$93)<=4,0,'06_추이'!$B$93-'06_추이'!$B$92) +``` + +실제로는 `93`/`92` 가 파이썬이 계산한 절대 행 번호로 치환된다. 데이터가 1일치뿐이면 델타는 `0` 을 직접 쓰고 수식을 걸지 않는다. + +### 4.5 차트 2종 사양 + +| 항목 | 차트1 | 차트2 | +|---|---|---| +| 앵커 셀 | `B10` | `K10` | +| 크기 | `set_size({'width': 600, 'height': 300})` | 동일 | +| 타입 | `column` / `stacked` | `bar` | +| 제목 | `최근 30일 일별 등록 동향` | `제조국 Top 8 (최근 30일)` | +| 카테고리 | `['06_추이', s-1, 0, e-1, 0]` (A열 일자) | `['06_추이', 3, 26, 10, 26]` (AA4:AA11) | +| 계열 | 3개: 신규(B, `#009E73`) / 변경(C, `#E69F00`) / 취하(D, `#D55E00`) | 1개: 건수(AB, `#0072B2` 단색) | +| 범례 | `{'position': 'top', 'font': {'name':'맑은 고딕','size':9}}` | `{'none': True}` | +| x축 | 라벨 8pt `#5B6770`, `major_tick_mark: 'none'`, 축선 `#D0D7DE` | `{'visible': False}` — 데이터 라벨로 대체 | +| y축 | 8pt, `major_gridlines` `#EFEFEF` 0.75pt, 축선 없음 | 9pt `#1F2933`, `reverse: True`(1위가 위) | +| 데이터 라벨 | 없음(누적 막대라 혼잡) | `{'value': True, 'position': 'outside_end', 'font': 8pt}` | +| gap | 40 | 50 | +| 차트영역 | 테두리 `#D0D7DE`, 배경 `#FFFFFF` | 동일 | +| 플롯영역 | 테두리 없음, 배경 없음 | 동일 | + +**색 구분 금지 근거**: 차트2 는 카테고리 8개다. Claus Wilke — *"Use direct labeling instead of colors when you need to distinguish between more than about eight categorical items."* → 단색 + 축 라벨 직접 표기. + +### 4.6 Top 표 3종 + +| 표 | 헤더 범위 | 데이터 범위 | 컬럼 | 데이터 원본 | +|---|---|---|---|---| +| Top 10 성분 | `B26:F26` | `B27:F36` | `순위`(6) / `성분명`(20) / `오늘`(6) / `30일`(6) / `누적`(8) | `03_성분별` 상위 10행 | +| Top 10 업체 | `H26:L26` | `H27:L36` | `순위` / `업체명` / `오늘` / `30일` / `누적` | `04_업체별` 상위 10행 | +| 워치 히트 | `N26:R26` | `N27:R36` | `키워드`(10) / `매칭`(5) / `성분명`(14) / `업체명`(12) / `상태`(6) | `05_워치리스트` 블록 C 상위 10행 | + +- 표 헤더: 10pt bold `#FFFFFF` on `#1F2933`, 가운데. +- 표 본문: 10pt `#1F2933`, 아래선 `#D0D7DE` hair(스타일 7). +- `누적` 열에 `data_bar` `#0072B2`(`CF-00-01`). +- 워치 히트 표의 `상태` 열에 상태 조건부서식(`CF-00-02~04`). +- **`누적` 열은 시트 간 수식**으로 검증 가능하게 한다 — §5.3 참조. + +### 4.7 상태 배너 (`B4:R4`) + +| 조건 | 표시 | 서식 | +|---|---|---| +| 정상(`SUCCESS`) | 배너 없음. 행 4 높이 8, 내용 없음 | — | +| 무결성 차단(`PARTIAL`) | `⚠ 오늘 수집 실패 — 마지막 성공: {날짜}. 아래 수치는 그날 기준이다. 사유: {blocking_reason}` | bold `#8A1D00` on `#FBE5DC`, 높이 22 | +| 기준선 수립일(첫 실행) | `ℹ 기준선 수립일입니다. 변경 탐지는 내일 실행부터 시작됩니다.` | bold `#00456B` on `#E5F1F8`, 높이 22 | +| AI 실패/스킵 | `ℹ AI 요약을 생성하지 못했습니다({사유}). 수치는 모두 정상입니다.` | `#5B6770` on `#EFEFEF`, 높이 22 | + +배너가 2개 이상 해당하면 **심각도 순(PARTIAL > 기준선 > AI)으로 1개만** 표시하고 나머지는 `99_메타` 에 기록한다. 대시보드에 경고를 쌓지 않는다. + +### 4.8 내비게이션 (`42~43행`) + +| 셀(병합) | 표시 문자열 | 링크 대상 | +|---|---|---| +| `B42:C42` | `오늘 변경분 →` | `internal:'01_오늘변경분'!A1` | +| `E42:F42` | `전체 현황 →` | `internal:'02_전체현황'!A1` | +| `H42:I42` | `성분별 →` | `internal:'03_성분별'!A1` | +| `K42:L42` | `업체별 →` | `internal:'04_업체별'!A1` | +| `N42:O42` | `워치리스트 →` | `internal:'05_워치리스트'!A1` (없으면 회색 `(워치리스트 없음)`) | +| `Q42:R42` | `추이 →` | `internal:'06_추이'!A1` | +| `B43:C43` | `메타·로그 →` | `internal:'99_메타'!A1` | + +서식: 10pt bold `#0072B2`, 밑줄 없음, 배경 `#F7F7F7`, 테두리 1px `#D0D7DE`, 가운데 정렬. `tip=` 로 툴팁을 단다. + +--- + +## 5. 시트 간 연동 명세 + +### 5.1 하이퍼링크 전집 + +**시트명 인용 규칙**: 이 워크북의 모든 시트명은 숫자로 시작한다(`00_대시보드` …). Excel 은 숫자로 시작하는 시트명을 참조할 때 **작은따옴표 인용을 요구**한다. 따라서 **예외 없이 `'시트명'!셀`** 로 쓴다. 내부 링크는 `internal:'00_대시보드'!A1` 형태다. + +| # | 출발 시트 | 출발 셀 | 링크 종류 | 대상 | 표시 문자열 | 툴팁 | +|---|---|---|---|---|---|---| +| L01 | `00_대시보드` | `B42:C42` | internal | `'01_오늘변경분'!A1` | `오늘 변경분 →` | 오늘 신규·변경·취하 전체 | +| L02 | `00_대시보드` | `E42:F42` | internal | `'02_전체현황'!A1` | `전체 현황 →` | 유효 등록 전체 원장 | +| L03 | `00_대시보드` | `H42:I42` | internal | `'03_성분별'!A1` | `성분별 →` | 성분 기준 집계 | +| L04 | `00_대시보드` | `K42:L42` | internal | `'04_업체별'!A1` | `업체별 →` | 업체 기준 집계 | +| L05 | `00_대시보드` | `N42:O42` | internal | `'05_워치리스트'!A1` | `워치리스트 →` | 관심 키워드 히트 | +| L06 | `00_대시보드` | `Q42:R42` | internal | `'06_추이'!A1` | `추이 →` | 일자별 시계열 | +| L07 | `00_대시보드` | `B43:C43` | internal | `'99_메타'!A1` | `메타·로그 →` | 수집 메타·무결성·AI 상태 | +| L08 | `00_대시보드` | `B26` | internal | `'03_성분별'!A1` | `Top 10 성분` (헤더가 링크) | 성분별 시트로 | +| L09 | `00_대시보드` | `H26` | internal | `'04_업체별'!A1` | `Top 10 업체` | 업체별 시트로 | +| L10 | `00_대시보드` | `N26` | internal | `'05_워치리스트'!A1` | `워치리스트 히트` | 워치리스트 시트로 | +| L11 | `01`~`06`,`99` | `A1` | internal | `'00_대시보드'!A1` | `← 대시보드` | 대시보드로 돌아가기 | +| L12 | `01_오늘변경분` | `D4:D{last}` | internal | `'02_전체현황'!A{행}` | 등록번호 원문 | 전체 현황의 해당 행으로 | +| L13 | `01_오늘변경분` | `Q4:Q{last}` | external | nedrug 검색 URL | `조회` | 의약품안전나라에서 검색 | +| L14 | `02_전체현황` | `O4:O{last}` | external | nedrug 검색 URL | `조회` | 의약품안전나라에서 검색 | +| L15 | `03_성분별` | `P4:P{last}` | internal | `'02_전체현황'!A3` | `보기` | 전체 현황에서 필터 | +| L16 | `04_업체별` | `Q4:Q{last}` | internal | `'02_전체현황'!A3` | `보기` | 전체 현황에서 필터 | +| L17 | `05_워치리스트` | `N4:N{last}` | internal | `'01_오늘변경분'!A3` | `보기` | 오늘 변경분에서 확인 | +| L18 | `99_메타` | `B13`,`B14`,`B15`,`B18` | external | `file:///…` 로컬 경로 | 경로 문자열 | 파일 열기 | +| L19 | `99_메타` | `B22` | external | OpenAPI 엔드포인트 URL | URL | 원본 API 문서 | + +**L12 의 대상 행 계산**: `02_전체현황` 은 `dmf_key → 행번호` 사전을 빌드 순서대로 만들어 두고, `01_오늘변경분` 을 쓸 때 그 사전을 조회한다. `취하` 이벤트는 `02_전체현황` 에 행이 없을 수 있으므로 그때는 링크 없이 일반 텍스트로 쓴다. + +**링크 시각 스타일** — Excel 기본 파랑 밑줄을 쓰지 않는다. + +| 링크 종류 | 서식 | +|---|---| +| 내비게이션 버튼(L01~L07) | 10pt bold `#0072B2`, 밑줄 없음, 배경 `#F7F7F7`, 테두리 `#D0D7DE` | +| 역링크(L11) | 10pt `#0072B2`, 밑줄 없음, 배경 없음 | +| 표 안 내부 링크(L12, L15~L17) | 10pt `#0072B2`, 밑줄 없음 | +| 외부 링크(L13, L14, L18, L19) | 10pt `#0072B2`, **밑줄 있음** — 워크북 밖으로 나간다는 신호를 시각적으로 구분 | + +### 5.2 목차 기능 + +**별도 목차 시트를 만들지 않는다.** `00_대시보드` 가 목차를 겸한다(§4.8 내비게이션 + §4.6 Top 표 헤더 링크). 근거: + +- 시트가 8개뿐이고 탭 자체가 목차 역할을 한다. 목차 전용 시트는 클릭 1회를 더 요구한다. +- 5초 규칙 — 첫 화면은 "지금 무슨 일이 일어났는가"여야 하며 "어디로 갈 수 있는가"는 그 아래여야 한다. +- 모든 데이터 시트 `A1` 의 역링크(L11)가 항상 대시보드로 되돌린다 → 왕복 구조가 완결된다. + +### 5.3 시트 간 수식 전집 + +모두 `write_formula(row, col, formula, fmt, value=캐시값)` 로 기록한다. + +| # | 위치 | 수식 전문 | 캐시값 산출 | +|---|---|---|---| +| F01 | `00_대시보드!B6` | `='06_추이'!$B${e}` | 오늘 NEW 건수 | +| F02 | `00_대시보드!E6` | `='06_추이'!$C${e}` | 오늘 CHANGED 건수 | +| F03 | `00_대시보드!H6` | `='06_추이'!$D${e}` | 오늘 WITHDRAWN 건수 | +| F04 | `00_대시보드!K6` | `='06_추이'!$E${e}` | 세 값의 합 | +| F05 | `00_대시보드!N6` | `='06_추이'!$F${e}` | 오늘 워치 히트 | +| F06 | `00_대시보드!Q6` | `='06_추이'!$G${e}` | 누적 유효 등록 | +| F07 | `00_대시보드!B7` | `='06_추이'!$B${e}-'06_추이'!$B${e-1}` | 전일 대비 델타 | +| F08 | `00_대시보드!E7` | `='06_추이'!$C${e}-'06_추이'!$C${e-1}` | 〃 | +| F09 | `00_대시보드!H7` | `='06_추이'!$D${e}-'06_추이'!$D${e-1}` | 〃 | +| F10 | `00_대시보드!K7` | `='06_추이'!$E${e}-'06_추이'!$E${e-1}` | 〃 | +| F11 | `00_대시보드!N7` | `='06_추이'!$F${e}-'06_추이'!$F${e-1}` | 〃 | +| F12 | `00_대시보드!Q7` | `='06_추이'!$G${e}-'06_추이'!$G${e-1}` | 〃 | +| F13 | `00_대시보드!F27:F36` (Top 성분 누적) | `=COUNTIFS('02_전체현황'!$B:$B,$C27,'02_전체현황'!$M:$M,"유효")` | SQL `COUNT(*)` | +| F14 | `00_대시보드!L27:L36` (Top 업체 누적) | `=COUNTIFS('02_전체현황'!$C:$C,$I27,'02_전체현황'!$M:$M,"유효")` | SQL `COUNT(*)` | +| F15 | `02_전체현황!N4:N{last}` | `=IF($I4="","-",INT(REPORT_DATE-$I4))` | `(기준일-수리일자).days` | +| F16 | `03_성분별!F4:F{last}` | `=SUM($C4:$E4)` | 세 값의 합 | +| F17 | `04_업체별!G4:G{last}` | `=SUM($D4:$F4)` | 세 값의 합 | +| F18 | `05_워치리스트!I4:I{last}` | `=IF($C4="",0,COUNTIF('01_오늘변경분'!$O$4:$O$100000,"*"&$C4&"*"))` | 실제 매칭 개수 | +| F19 | `06_추이!E4:E{last}` | `=SUM($B4:$D4)` | 세 값의 합 | +| F20 | `99_메타!D27:D31` | `=IF($C27=$B27,"OK","불일치")` | 게이트 판정 | +| F21 | `99_메타!D32` (종합) | `=IF(COUNTIF($D$27:$D$31,"불일치")=0,"OK","불일치")` | `verdict.ok` | + +> **XlsxWriter 수식 작성 규칙 2가지** (문서 명시): +> 1. 인자 구분자는 **쉼표**다(세미콜론 아님). 한국어 Excel 에서 화면에는 쉼표로 보이지만 파일 포맷은 항상 US 스타일이다. +> 2. `_xlfn` 접두가 필요한 미래 함수는 워크북 옵션 `use_future_functions: True` 로 자동 처리된다. 위 수식들은 모두 레거시 함수라 해당 없음. + +### 5.4 정의된 이름 (Defined Names) + +**ASCII 이름을 정본으로 한다.** Excel 은 유니코드 정의 이름을 허용하지만 XlsxWriter 의 유효성 검사 통과 여부를 실측하지 않았다(부록 기록). ASCII 는 100% 안전하고, 이름 상자에 뜨는 문자열이 짧아 오히려 쓰기 편하다. + +| 이름 | 스코프 | 수식 | 용도 | +|---|---|---|---| +| `REPORT_DATE` | 통합문서 | `='99_메타'!$B$3` | F15 경과일 계산의 기준일 | +| `RUN_ID` | 통합문서 | `='99_메타'!$B$6` | 추적 | +| `KPI_NEW` | 통합문서 | `='00_대시보드'!$B$6` | 외부 참조용 | +| `KPI_CHANGED` | 통합문서 | `='00_대시보드'!$E$6` | 〃 | +| `KPI_WITHDRAWN` | 통합문서 | `='00_대시보드'!$H$6` | 〃 | +| `KPI_TOTAL` | 통합문서 | `='00_대시보드'!$K$6` | 〃 | +| `KPI_WATCH` | 통합문서 | `='00_대시보드'!$N$6` | 〃 | +| `KPI_ACTIVE` | 통합문서 | `='00_대시보드'!$Q$6` | 〃 | +| `TREND_DATE` | 통합문서 | `='06_추이'!$A$4:$A${last}` | 차트1 카테고리 | +| `TREND_NEW` | 통합문서 | `='06_추이'!$B$4:$B${last}` | 차트1 계열 | +| `TREND_CHANGED` | 통합문서 | `='06_추이'!$C$4:$C${last}` | 〃 | +| `TREND_WITHDRAWN` | 통합문서 | `='06_추이'!$D$4:$D${last}` | 〃 | +| `LEDGER` | 통합문서 | `='02_전체현황'!$A$3:$Q${last}` | 사용자 임의 수식용 | +| `TODAY_CHANGES` | 통합문서 | `='01_오늘변경분'!$A$3:$S${last}` | 〃 | +| `_xlnm.Print_Titles` | 시트별 | `repeat_rows(0, 2)` 가 자동 생성 | 인쇄 제목 행 | +| `_xlnm.Print_Area` | 시트별 | `print_area(...)` 가 자동 생성 | 인쇄 영역 | + +```python +# report/build.py 에서 시트를 모두 만든 뒤 마지막에 호출 +wb.define_name("REPORT_DATE", "='99_메타'!$B$3") +wb.define_name("TREND_NEW", f"='06_추이'!$B$4:$B${trend_last}") +# … 표의 나머지도 같은 형태 +``` + +> **주의**: `define_name` 은 워크북 스코프에서 **시트를 모두 생성한 뒤** 호출해야 한다. 존재하지 않는 시트를 참조하면 Excel 이 열 때 `#REF!` 가 된다. + +### 5.5 연동 구조 요약 다이어그램 + +``` + ┌───────────────────────────────┐ + │ 00_대시보드 │ + │ KPI 6 · 차트 2 · Top 표 3 │ + └───┬───────────────────────┬───┘ + L01~L07 (내비 링크) │ │ F01~F14 (수식 참조) + ┌──────┬──────┬────┴─┬──────┬──────┬───────┴──┐ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ + 01_오늘 02_전체 03_성분 04_업체 05_워치 06_추이 99_메타 + 변경분 현황 별 별 리스트 (원본) (기준일) + │ ▲ │ │ │ ▲ ▲ + │ │ │ │ │ │ │ + └──────┘ └──────┴──────┘ │ │ + L12 등록번호 L15/L16 상세 │ │ + └──────────────────────────── L17 ───┘ │ + │ + 모든 데이터 시트 A1 ──── L11 (← 대시보드) ─────────┘ + 스파크라인 원본: 06_추이 B~G열 + 히든 AD:CN + 차트 원본: 06_추이 A~D열(차트1), AA:AB(차트2) +``` + +--- + +## 6. 디자인 토큰 + +### 6.1 색 팔레트 — Okabe-Ito 기반 + +**채택 근거**: Masataka Okabe · Kei Ito 의 Color Universal Design 팔레트. Nature Methods 권장, Claus Wilke *Fundamentals of Data Visualization* 의 기본 범주형 스케일. **순수 red 와 순수 green 을 아예 쓰지 않는다** — Orange(`#E69F00`)는 1형 색각(protanopia)에서도 황등색으로 지각되고, Bluish Green(`#009E73`)은 청색 채널이 충분해 적록 병합에서 살아남는다. Tableau 10 은 인접한 빨강·초록이 2형 색각에서 충돌하므로 **기각**. + +| 토큰 | HEX | 원 팔레트 | 흰 배경 대비비 | 용도 | +|---|---|---|---|---| +| `ACCENT` | `#0072B2` | Okabe-Ito Blue | **5.19** (AA) | 유일한 강조색. 링크·데이터바·단색 차트·합계 | +| `NEW` | `#009E73` | Bluish Green | 3.42 | 신규 액센트(KPI 값·차트 계열) | +| `CHANGED` | `#E69F00` | Orange | 2.25 | 변경 액센트 | +| `WITHDRAWN` | `#D55E00` | Vermillion | 3.87 | 취하 액센트 | +| `WATCH` | `#CC79A7` | Reddish Purple | 3.06 | 워치리스트 액센트 | +| `SKY` | `#56B4E9` | Sky Blue | 2.31 | 보조 계열(예비) | +| `INK` | `#1F2933` | (파생) | **14.76** (AAA) | 본문 텍스트·표 헤더 배경·누적 KPI | +| `MUTED` | `#5B6770` | (파생) | 5.80 (AA) | 보조 텍스트·라벨·각주 | +| `NEUTRAL` | `#888888` | Paul Tol bad-data grey | 3.54 | 결측·무효·비활성 | +| `RULE` | `#D0D7DE` | (파생) | 1.45 | 테두리·구분선(장식 전용, 텍스트 아님) | +| `GRID` | `#EFEFEF` | (파생) | — | 차트 격자선 | +| `TILE_BG` | `#F7F7F7` | (파생) | — | KPI 타일·내비 버튼 배경 | +| `PAPER` | `#FFFFFF` | — | — | 시트 배경 | + +**사용 금지**: Okabe-Ito Yellow(`#F0E442`) — 흰 배경 대비 1.32 로 텍스트·선에 부적합. 배경으로도 쓰지 않는다(우리 연한 톤이 이미 충분). + +### 6.2 상태색 매핑 — 4중 코딩 (배경 + 폰트 + 라벨 + 기호) + +WCAG 1.4.1 은 색 단독 전달을 금지한다. 남성 약 8%, 여성 약 0.5% 가 색각 이상이다. 따라서 **모든 상태는 배경색·폰트색·텍스트 라벨·기호 4개 채널로 동시에** 표현한다. + +| 상태 | 라벨 | 기호 | 배경 HEX | 폰트 HEX | 대비비 | 등급 | 액센트 HEX | +|---|---|---|---|---|---|---|---| +| 신규 | `신규` | `+` | `#E3F3EA` | `#005A32` | **7.28** | AAA | `#009E73` | +| 변경 | `변경` | `◆` | `#FDF0DC` | `#7A3E00` | **7.42** | AAA | `#E69F00` | +| 취하 | `취하` | `✕` | `#FBE5DC` | `#8A1D00` | **7.69** | AAA | `#D55E00` | +| 워치 히트 | `★` | `★` | `#F7E9F0` | `#7A2E55` | **7.61** | AAA | `#CC79A7` | +| 정보/정상 | `OK` | `✓` | `#E5F1F8` | `#00456B` | **8.85** | AAA | `#0072B2` | +| 변동 없음 | `-` | `·` | `#FFFFFF` | `#5B6770` | 5.80 | AA | `#888888` | +| 오류/결측 | `?` | `⚠` | `#EFEFEF` | `#3A3A3A` | **9.89** | AAA | `#888888` | + +- 취하 행에는 배경·폰트·라벨·기호에 더해 **취소선**(`font_strikeout: True`)까지 5번째 채널을 얹는다. +- Excel 조건부서식 기본색(`#FFC7CE`/`#9C0006`, `#C6EFCE`/`#006100`)은 **red/green 쌍이라 CUD 금지 조합**이고 대비도 낮다 → 사용하지 않는다. + +### 6.3 타이포그래피 계층 + +폰트는 `report.font` 설정키(기본 `맑은 고딕`). 폰트명에 공백이 있어 반드시 문자열로 전달한다. Excel 은 해당 PC 에 설치된 폰트만 렌더링하며, 배포 대상이 Windows 로 한정된다는 전제에서 안전하다. + +| 요소 | 크기 | 굵기 | 색 | 비고 | +|---|---|---|---|---| +| 대시보드 제목 | 18pt | bold | `#1F2933` | `B2:R2` | +| 대시보드 헤드라인 | 11pt | regular | `#5B6770` | `B3:R3` | +| 상태 배너 | 11pt | bold | 상태 폰트색 | `B4:R4` | +| **KPI 값** | **28pt** | bold | 상태 액센트색 | 카드 내 단독 최대 요소. 44pt 는 5자리에서 넘침 | +| KPI 라벨 | 10pt | bold | `#5B6770` | | +| KPI 델타 | 10pt | bold | `#5B6770` | 색이 아니라 ▲▼ 기호로 방향 전달 | +| 시트 제목 (`B1`) | 14pt | bold | `#1F2933` | | +| 표 헤더 | 10pt | bold | `#FFFFFF` on `#1F2933` | 줄바꿈 허용 | +| 표 본문 | 10pt | regular | `#1F2933` | | +| 표 본문(긴 텍스트: 변경내용·AI 메모) | 9pt | regular | `#1F2933` / `#5B6770` | 줄바꿈 | +| 하이퍼링크 | 10pt | bold(내비)/regular | `#0072B2` | 외부만 밑줄 | +| 각주·출처·면책 | 9pt | regular | `#5B6770` | | +| 차트 제목 | 11pt | bold | `#1F2933` | | +| 차트 축 라벨 | 8~9pt | regular | `#5B6770` / `#1F2933` | | + +### 6.4 행 높이 · 열 너비 규칙 + +| 대상 | 값 | 근거 | +|---|---|---| +| 대시보드 행 높이 | §4.2 표 그대로 | 1화면 수납 계산 | +| 데이터 시트 1행(제목) | 24 | | +| 데이터 시트 2행(여백) | 6 | | +| 데이터 시트 3행(헤더) | 32 | 2줄 줄바꿈 헤더 수용 | +| `01_오늘변경분` 데이터 행 | 30 | 변경내용 2줄 수용 | +| `02_전체현황` 데이터 행 | 15 | 수천~수만 행 성능. 줄바꿈 없음 | +| `03`/`04` 데이터 행 | 18 | 스파크라인 수용 | +| `05`/`06`/`99` 데이터 행 | 16 | | +| 열 너비 최소 / 최대 / 여백 | 8.0 / 48.0 / +2.0 | `compute_col_widths` 파라미터 | +| 한글 폭 계수 | W·F = **1.8**, A = 1.2, 그 외 = 1.0 | `east_asian_width` 의 2.0 은 맑은 고딕에서 과대 추정. 실무 경험치 1.8 채택 | + +### 6.5 테두리 규칙 (데이터 잉크 비율 우선) + +| 대상 | 테두리 | +|---|---| +| 표 본문 셀 | **세로선 없음.** 아래선만 `hair`(스타일 7) `#D0D7DE` | +| 표 헤더 | 아래선 `medium`(스타일 2) `#1F2933` | +| 표 합계행 | 위선 `double`(스타일 6) `#1F2933` | +| KPI 타일 | 좌·우 `thin`(1) `#D0D7DE`, **상단 `thick`(5) 상태 액센트색**, 하단 `thin` `#D0D7DE` | +| 내비 버튼 | 사방 `thin`(1) `#D0D7DE` | +| 상태 배너 | 좌측 `thick`(5) 상태 폰트색 | +| 차트 영역 | `#D0D7DE` 1px | +| 플롯 영역 | 없음 | + +XlsxWriter 테두리 스타일 번호: `1=thin`, `2=medium`, `3=dashed`, `4=dotted`, `5=thick`, `6=double`, `7=hair`. + +### 6.6 여백·정렬 + +| 항목 | 값 | +|---|---| +| 인쇄 여백 | `set_margins(left=0.4, right=0.4, top=0.6, bottom=0.6)` (인치) | +| 머리글/바닥글 여백 | 0.3 / 0.3 | +| 문자열 열 정렬 | 왼쪽 + `valign: vcenter` | +| 숫자·날짜 열 정렬 | 가운데 (자릿수 비교가 아니라 스캔이 목적) | +| 합계·금액성 큰 수 | 오른쪽 (`02_전체현황` 에는 해당 없음) | +| 헤더 정렬 | 가운데 + `text_wrap: True` | +| 긴 텍스트 셀 | 왼쪽 + `text_wrap: True` + `valign: top` | + +### 6.7 표시 형식(number_format) 사전 + +| 토큰 | 문자열 | 적용 | +|---|---|---| +| `INT` | `#,##0` | 누적 건수 | +| `INT_DASH` | `#,##0;;-` | 0을 `-` 로 (일일 건수) | +| `INT_UNIT` | `#,##0"건"` | 대시보드 표 예비 | +| `DELTA` | `▲ #,##0;▼ -#,##0;– 0` | KPI 델타 | +| `DELTA_PCT` | `▲ 0.0%;▼ -0.0%;– 0.0%` | 비율 델타(예비) | +| `DATE` | `yyyy-mm-dd` | 모든 날짜 | +| `DATETIME` | `yyyy-mm-dd hh:mm:ss` | 메타 시각 | +| `TEXT` | `@` | 등록번호·해시 등 **숫자 변환 금지 문자열** | +| `RATIO` | `0.0%` | 널 비율·감소율 | +| `SEC` | `0.0"초"` | agy 소요 시간 | + +> `▲ #,##0;▼ -#,##0;– 0` 의 3구획은 각각 양수/음수/0 이다. 음수 구획에 `-` 를 남겨 두는 이유는 `▼ -2` 처럼 부호를 함께 보여 스크린 리더와 흑백 인쇄에서도 방향이 전달되게 하기 위함이다. + +--- + +## 7. 조건부 서식 규칙 전집 + +모든 규칙은 `worksheet.conditional_format(first_row, first_col, last_row, last_col, options)` 로 건다. `first_row` 등은 **0-index**지만 `criteria` 수식 안의 셀 참조는 **A1 표기(1-index)** 다. 아래 표의 "수식" 열은 데이터 첫 행이 워크시트 4행(0-index 3)임을 전제한다 — `$C4` 의 `4` 가 그것이다. **이 한 칸 어긋남이 "한 행씩 밀린 색칠"의 대부분 원인이다.** + +| ID | 시트 | 대상 범위 | 유형 | 수식 / 조건 | 서식 결과 | +|---|---|---|---|---|---| +| `CF-01-01` | `01_오늘변경분` | `B4:S{last}` | `formula` | `=$C4="신규"` | bg `#E3F3EA`, font `#005A32` | +| `CF-01-02` | `01_오늘변경분` | `B4:S{last}` | `formula` | `=$C4="변경"` | bg `#FDF0DC`, font `#7A3E00` | +| `CF-01-03` | `01_오늘변경분` | `B4:S{last}` | `formula` | `=$C4="취하"` | bg `#FBE5DC`, font `#8A1D00`, **취소선** | +| `CF-01-04` | `01_오늘변경분` | `N4:N{last}` | `cell` | `> 0` | bg `#F7E9F0`, font `#7A2E55`, bold | +| `CF-01-05` | `01_오늘변경분` | `I4:I{last}` | `text` | `containing` `대한민국` | bg `#E3F3EA`, font `#005A32` | +| `CF-01-06` | `01_오늘변경분` | `E4:F{last}` | `formula` | `=$N4>0` | bold (성분명·업체명만) | +| `CF-02-01` | `02_전체현황` | `A4:A{last}` | `duplicate` | — | bg `#FFF2CC`, font `#7F6000` | +| `CF-02-02` | `02_전체현황` | `L4:L{last}` | `formula` | `=$L4=REPORT_DATE` | bg `#FDF0DC`, font `#7A3E00`, bold | +| `CF-02-03` | `02_전체현황` | `A4:Q{last}` | `formula` | `=$M4="취하"` | bg `#FBE5DC`, font `#8A1D00`, **취소선** | +| `CF-02-04` | `02_전체현황` | `F4:F{last}` | `text` | `containing` `대한민국` | bg `#E3F3EA`, font `#005A32` | +| `CF-02-05` | `02_전체현황` | `B4:C{last}` | `formula` | `=COUNTIF(WATCH_KEYS,"*"&B4&"*")>0` → **미사용**. 파이썬이 워치 매칭 여부를 계산해 히든 열에 쓰고 `=$R4="Y"` 로 판정 | bg `#F7E9F0`, font `#7A2E55` | +| `CF-02-06` | `02_전체현황` | `G4:G{last}` | `cell` | `>= 2` | bold | +| `CF-02-07` | `02_전체현황` | `I4:I{last}` | `data_bar` | `bar_color #0072B2`, `bar_solid True`, `bar_only False` | 수리일자 최신일수록 긴 막대 | +| `CF-02-08` | `02_전체현황` | `N4:N{last}` | `icon_set` | `3_arrows_gray`, `icons=[{'criteria':'>=','type':'number','value':1095},{'criteria':'>=','type':'number','value':365}]`, `icons_only False` | 3년↑ / 1년↑ / 그 외 방향 표시 | +| `CF-03-01` | `03_성분별` | `C4:C{last}` | `cell` | `> 0` | bg `#E3F3EA`, font `#005A32` | +| `CF-03-02` | `03_성분별` | `D4:D{last}` | `cell` | `> 0` | bg `#FDF0DC`, font `#7A3E00` | +| `CF-03-03` | `03_성분별` | `E4:E{last}` | `cell` | `> 0` | bg `#FBE5DC`, font `#8A1D00` | +| `CF-03-04` | `03_성분별` | `N4:N{last}` | `icon_set` | `3_arrows_gray`, `icons=[{'criteria':'>=','type':'number','value':1},{'criteria':'>=','type':'number','value':0}]` | ▲ / ▬ / ▼ | +| `CF-03-05` | `03_성분별` | `B4:B{last}` | `formula` | `=$Q4="Y"` (히든 워치플래그 열) | bg `#F7E9F0`, font `#7A2E55`, bold | +| `CF-03-06` | `03_성분별` | `G4:I{last}` | `data_bar` | `bar_color #0072B2`, `bar_solid True` | 3열 각각 별도 규칙(`multi_range` 사용 금지 — 열별 스케일이 달라야 함) | +| `CF-03-07` | `03_성분별` | `J4:J{last}` | `cell` | `>= 5` | bold | +| `CF-03-08` | `03_성분별` | `M4:M{last}` | `text` | `containing` `Y` | bg `#E3F3EA`, font `#005A32` | +| `CF-04-01` | `04_업체별` | `D4:D{last}` | `cell` | `> 0` | bg `#E3F3EA`, font `#005A32` | +| `CF-04-02` | `04_업체별` | `E4:E{last}` | `cell` | `> 0` | bg `#FDF0DC`, font `#7A3E00` | +| `CF-04-03` | `04_업체별` | `F4:F{last}` | `cell` | `> 0` | bg `#FBE5DC`, font `#8A1D00` | +| `CF-04-04` | `04_업체별` | `H4:I{last}` | `data_bar` | `bar_color #0072B2` | 열별 개별 규칙 | +| `CF-04-05` | `04_업체별` | `N4:N{last}` | `formula` | `=AND($N4<>"",REPORT_DATE-$N4>90)` | bg `#EFEFEF`, font `#5B6770` | +| `CF-04-06` | `04_업체별` | `O4:O{last}` | `text` | `containing` `★` | bg `#F7E9F0`, font `#7A2E55` | +| `CF-05-01` | `05_워치리스트` | `I4:I{last}` | `cell` | `> 0` | bg `#F7E9F0`, font `#7A2E55`, bold | +| `CF-05-02` | `05_워치리스트` | `M4:M{last}` | `formula` | `=$M4=REPORT_DATE` | bg `#F7E9F0`, font `#7A2E55` | +| `CF-05-03` | `05_워치리스트` | `C4:C{last}` | `blanks` | — | bg `#FBE5DC`, font `#8A1D00` | +| `CF-05-04` | `05_워치리스트` | `J4:K{last}` | `data_bar` | `bar_color #CC79A7` | 열별 개별 규칙 | +| `CF-05-05` | `05_워치리스트` (블록C) | `A{c}:G{cend}` | `formula` ×3 | `=$B{c}="신규"` / `="변경"` / `="취하"` | `CF-01-01~03` 과 동일 서식 | +| `CF-06-01` | `06_추이` | `A4:A{last}` | `formula` | `=$A4=REPORT_DATE` | bg `#E5F1F8`, font `#00456B`, bold | +| `CF-06-02` | `06_추이` | `B4:B{last}` / `C…` / `D…` | `data_bar` ×3 | `bar_color` 각각 `#009E73` / `#E69F00` / `#D55E00` | 열별 | +| `CF-06-03` | `06_추이` | `H4:H{last}` | `formula` | `=AND(ROW()>4,$H4<$H3*0.95)` | bg `#FBE5DC`, font `#8A1D00` | +| `CF-06-04` | `06_추이` | `I4:I{last}` | `text` ×3 | `containing` `SUCCESS` / `PARTIAL` / `FAILED` | `#E3F3EA` / `#FDF0DC` / `#FBE5DC` | +| `CF-99-01` | `99_메타` | `D27:D32` | `text` ×2 | `containing` `OK` / `불일치` | `#E3F3EA`+`#005A32` / `#FBE5DC`+`#8A1D00` | +| `CF-99-02` | `99_메타` | `B7` | `text` ×3 | `containing` `SUCCESS` / `PARTIAL` / `FAILED` | `#E3F3EA` / `#FDF0DC` / `#FBE5DC` | +| `CF-00-01` | `00_대시보드` | `F27:F36`, `L27:L36` | `data_bar` ×2 | `bar_color #0072B2`, `bar_solid True` | Top 표 누적 열 | +| `CF-00-02` | `00_대시보드` | `R27:R36` | `text` | `containing` `신규` | `#E3F3EA` + `#005A32` | +| `CF-00-03` | `00_대시보드` | `R27:R36` | `text` | `containing` `변경` | `#FDF0DC` + `#7A3E00` | +| `CF-00-04` | `00_대시보드` | `R27:R36` | `text` | `containing` `취하` | `#FBE5DC` + `#8A1D00` | + +### 7.1 규칙 적용 순서와 `stop_if_true` + +- 행 전체 규칙 3종(`CF-01-01~03`, `CF-02-03`)은 상태 값이 상호 배타적이므로 `stop_if_true` 를 **주지 않는다**(기본 False). 서로 겹치지 않는다. +- 열 단위 강조(`CF-01-04`, `CF-01-06`)는 행 규칙 **뒤에** 등록해야 위에 얹힌다. XlsxWriter 는 등록 순서를 그대로 규칙 우선순위로 쓰므로, `report/sheets/*` 안에서 **행 규칙 → 열 규칙 → 데이터바/아이콘셋** 순서로 호출한다. +- `data_bar` 와 `icon_set` 은 배경색을 덮지 않으므로 언제 등록해도 무방하지만, 관례를 위해 항상 마지막에 둔다. + +### 7.2 아이콘셋 금지 목록 + +`3_traffic_lights`, `3_traffic_lights_rimmed`, `4_traffic_lights` 는 **사용 금지**. red/green 조합에 형태까지 동일한 원이라 색각 이상에서 정보가 완전히 소실된다. 허용은 `3_arrows_gray`(무채색, 방향만) 하나뿐이며, `3_symbols_circled` 는 예비다. + +`icons` 파라미터 규칙: `criteria` 는 `>=` 또는 `<` 만 가능(기본 `>=`), `type` 은 `number`/`percentile`/`percent`/`formula`(기본 `percent`), **값은 높은 것부터 내림차순**으로 나열한다. + +--- + +## 8. 생성 코드 + +### 8.0 모듈 매핑 — `src/report/workbook.py` 는 없다 + +요구 항목에 적힌 `src/report/workbook.py` 는 아키텍처 SSOT §2 의 확정 트리에 존재하지 않는다. SSOT 가 이긴다. 아래가 확정 매핑이다. + +| 요구가 말한 것 | 실제 파일 | 책임 | +|---|---|---| +| `workbook.py` 의 워크북 조립 | `src/dmf_crawler/report/build.py` | 워크북 생성·시트 순서·정의된 이름·저장 호출 | +| `workbook.py` 의 스타일 헬퍼 | `src/dmf_crawler/report/theme.py` | 팔레트 상수·`Format` 캐시 | +| `workbook.py` 의 한글 열너비 계산 | `src/dmf_crawler/report/widgets.py` | `display_width`·`compute_col_widths`·KPI 타일·역링크 | +| `workbook.py` 의 원자적 저장 | `src/dmf_crawler/report/atomic.py` | 임시파일 → 검증 → `os.replace` → 폴백 | +| `workbook.py` 의 시트 빌더 | `src/dmf_crawler/report/sheets/s00~s99` | 시트 1개당 파일 1개 | +| (신규) DB 조회 | `src/dmf_crawler/report/data.py` | `ReportData` 조립. 시트별 SQL 상수 | + +공개 진입점은 아키텍처 §3.13 대로 `report/__init__.py` 의 `build_report(conn, run_id, cfg) -> ReportOutcome` 하나다. + +### 8.1 `src/dmf_crawler/report/theme.py` + +```python +"""리포트 디자인 토큰과 Format 캐시. + +XlsxWriter 의 Format 객체는 워크북에 종속되고, 같은 속성이면 재사용해야 +파일 크기와 생성 속도가 유지된다. FormatCache 가 그 재사용을 강제한다. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import xlsxwriter + +# ─────────────────────────────────────────────────────────── 팔레트 (§6.1) +PALETTE: dict[str, str] = { + "ACCENT": "#0072B2", + "NEW": "#009E73", + "CHANGED": "#E69F00", + "WITHDRAWN": "#D55E00", + "WATCH": "#CC79A7", + "SKY": "#56B4E9", + "INK": "#1F2933", + "MUTED": "#5B6770", + "NEUTRAL": "#888888", + "RULE": "#D0D7DE", + "GRID": "#EFEFEF", + "TILE_BG": "#F7F7F7", + "PAPER": "#FFFFFF", +} + +# ─────────────────────────────────────────────── 상태 4중 코딩 매핑 (§6.2) +@dataclass(frozen=True, slots=True) +class StatusStyle: + label: str + symbol: str + bg: str + fg: str + accent: str + strikeout: bool = False + sort_key: int = 9 + + +STATUS: dict[str, StatusStyle] = { + "취하": StatusStyle("취하", "✕", "#FBE5DC", "#8A1D00", "#D55E00", True, 1), + "신규": StatusStyle("신규", "+", "#E3F3EA", "#005A32", "#009E73", False, 2), + "변경": StatusStyle("변경", "◆", "#FDF0DC", "#7A3E00", "#E69F00", False, 3), + "워치": StatusStyle("★", "★", "#F7E9F0", "#7A2E55", "#CC79A7", False, 4), + "정보": StatusStyle("OK", "✓", "#E5F1F8", "#00456B", "#0072B2", False, 5), + "없음": StatusStyle("-", "·", "#FFFFFF", "#5B6770", "#888888", False, 8), + "오류": StatusStyle("?", "⚠", "#EFEFEF", "#3A3A3A", "#888888", False, 9), +} + +EVENT_TO_STATUS = {"NEW": "신규", "CHANGED": "변경", "WITHDRAWN": "취하"} + +# ─────────────────────────────────────────────────────── 표시 형식 (§6.7) +NUMFMT: dict[str, str] = { + "INT": "#,##0", + "INT_DASH": "#,##0;;-", + "INT_UNIT": '#,##0"건"', + "DELTA": "▲ #,##0;▼ -#,##0;– 0", + "DELTA_PCT": "▲ 0.0%;▼ -0.0%;– 0.0%", + "DATE": "yyyy-mm-dd", + "DATETIME": "yyyy-mm-dd hh:mm:ss", + "TEXT": "@", + "RATIO": "0.0%", + "SEC": '0.0"초"', +} + +# ─────────────────────────────────────────────────── 탭 색 · 시트명 (§2.1) +SHEET_NAMES = { + "dashboard": "00_대시보드", + "changes": "01_오늘변경분", + "ledger": "02_전체현황", + "ingredient": "03_성분별", + "company": "04_업체별", + "watchlist": "05_워치리스트", + "trend": "06_추이", + "meta": "99_메타", +} + +TAB_COLORS = { + "00_대시보드": "#0072B2", + "01_오늘변경분": "#D55E00", + "02_전체현황": "#5B6770", + "03_성분별": "#009E73", + "04_업체별": "#009E73", + "05_워치리스트": "#CC79A7", + "06_추이": "#0072B2", + "99_메타": "#888888", +} + +TABLE_STYLE = "Table Style Light 11" + + +def qsheet(name: str) -> str: + """시트명을 수식·링크에 쓸 수 있게 작은따옴표로 감싼다. + + 이 워크북의 시트명은 전부 숫자로 시작하므로 인용이 필수다. + 시트명 안의 작은따옴표는 Excel 규칙대로 두 번 반복해 이스케이프한다. + """ + return "'" + name.replace("'", "''") + "'" + + +class FormatCache: + """같은 속성 조합에 대해 Format 객체를 단 한 번만 만든다.""" + + def __init__(self, wb: xlsxwriter.Workbook, font_name: str = "맑은 고딕") -> None: + self._wb = wb + self._font = font_name + self._cache: dict[tuple[tuple[str, Any], ...], Any] = {} + + def get(self, **props: Any): + props.setdefault("font_name", self._font) + props.setdefault("font_size", 10) + props.setdefault("font_color", PALETTE["INK"]) + key = tuple(sorted(props.items(), key=lambda kv: kv[0])) + fmt = self._cache.get(key) + if fmt is None: + fmt = self._wb.add_format(props) + self._cache[key] = fmt + return fmt + + # ── 자주 쓰는 조합 (이름으로 접근) ──────────────────────────────── + def title(self, size: int = 18): + return self.get(font_size=size, bold=True, valign="vcenter") + + def subtitle(self): + return self.get(font_size=11, font_color=PALETTE["MUTED"], valign="vcenter") + + def header(self, wrap: bool = True): + return self.get(bold=True, font_color="#FFFFFF", bg_color=PALETTE["INK"], + align="center", valign="vcenter", text_wrap=wrap, + bottom=2, bottom_color=PALETTE["INK"]) + + def cell_text(self, wrap: bool = False, size: int = 10, color: str | None = None): + return self.get(font_size=size, font_color=color or PALETTE["INK"], + align="left", valign="vcenter" if not wrap else "top", + text_wrap=wrap, num_format=NUMFMT["TEXT"], + bottom=7, bottom_color=PALETTE["RULE"]) + + def cell_num(self, fmt_key: str = "INT_DASH"): + return self.get(align="center", valign="vcenter", + num_format=NUMFMT[fmt_key], + bottom=7, bottom_color=PALETTE["RULE"]) + + def cell_date(self): + return self.get(align="center", valign="vcenter", + num_format=NUMFMT["DATE"], + bottom=7, bottom_color=PALETTE["RULE"]) + + def link_internal(self, bold: bool = False): + return self.get(font_color=PALETTE["ACCENT"], bold=bold, underline=0, + align="center", valign="vcenter", + bottom=7, bottom_color=PALETTE["RULE"]) + + def link_external(self): + return self.get(font_color=PALETTE["ACCENT"], underline=1, + align="center", valign="vcenter", + bottom=7, bottom_color=PALETTE["RULE"]) + + def nav_button(self): + return self.get(bold=True, font_color=PALETTE["ACCENT"], + bg_color=PALETTE["TILE_BG"], align="center", + valign="vcenter", border=1, border_color=PALETTE["RULE"]) + + def footnote(self): + return self.get(font_size=9, font_color=PALETTE["MUTED"], valign="vcenter") + + def status_cell(self, status: str, *, align: str = "center"): + s = STATUS[status] + return self.get(bg_color=s.bg, font_color=s.fg, bold=True, + align=align, valign="vcenter", + font_strikeout=s.strikeout, + bottom=7, bottom_color=PALETTE["RULE"]) + + def cf(self, status: str): + """조건부서식 전용 — 배경·폰트·취소선만 지정(테두리는 건드리지 않는다).""" + s = STATUS[status] + props = {"bg_color": s.bg, "font_color": s.fg} + if s.strikeout: + props["font_strikeout"] = True + return self._wb.add_format(props) # CF 서식은 캐시하지 않는다(부분 서식이라 재사용 위험) +``` + +> **`cf()` 를 캐시하지 않는 이유**: 조건부서식용 Format 은 "지정한 속성만 덮어쓰는" 부분 서식이다. 일반 셀 서식 캐시와 섞이면 폰트 크기·테두리까지 딸려 들어가 예상 밖의 결과를 만든다. 규칙마다 새로 만드는 비용은 규칙 수(40개 미만)를 고려하면 무시할 만하다. + +### 8.2 `src/dmf_crawler/report/widgets.py` + +```python +"""리포트 공용 위젯: 한글 열너비, 시트 머리글, KPI 타일, Top 표.""" +from __future__ import annotations + +import unicodedata +from typing import Any, Iterable, Sequence + +from .theme import NUMFMT, PALETTE, STATUS, FormatCache, qsheet + +# ───────────────────────────────────────── 한글 폭 계산 (§6.4) +# east_asian_width 의 W/F 를 2.0 으로 잡으면 맑은 고딕에서 과대 추정된다. +# 실무 경험치 1.8 을 쓴다. A(Ambiguous)는 1.2. +_EAW_WIDTH: dict[str, float] = { + "F": 1.8, # Fullwidth + "H": 1.0, # Halfwidth + "W": 1.8, # Wide (한글·한자·가나) + "Na": 1.0, # Narrow + "A": 1.2, # Ambiguous + "N": 1.0, # Neutral +} + + +def display_width(value: Any) -> float: + """맑은 고딕 10pt 기준 셀 표시 폭(문자 단위)을 근사한다. + + 줄바꿈이 든 문자열은 가장 긴 줄을 기준으로 한다. + """ + if value is None: + return 0.0 + text = str(value) + if not text: + return 0.0 + best = 0.0 + for line in text.split("\n"): + w = 0.0 + for ch in line: + w += _EAW_WIDTH.get(unicodedata.east_asian_width(ch), 1.0) + if w > best: + best = w + return best + + +def compute_col_widths( + header: Sequence[Any], + rows: Iterable[Sequence[Any]], + *, + min_w: float = 8.0, + max_w: float = 48.0, + margin: float = 2.0, + sample: int = 2000, + overrides: dict[int, float] | None = None, +) -> list[float]: + """헤더 + 데이터 표본으로 열별 폭 리스트를 만든다. + + overrides 로 특정 열의 폭을 강제 고정한다(스파크라인 열 등). + """ + widths = [display_width(h) for h in header] + for i, row in enumerate(rows): + if i >= sample: + break + for c, v in enumerate(row): + if c >= len(widths): + widths.append(0.0) + w = display_width(v) + if w > widths[c]: + widths[c] = w + result = [max(min_w, min(max_w, w + margin)) for w in widths] + for c, w in (overrides or {}).items(): + if 0 <= c < len(result): + result[c] = w + return result + + +def apply_col_widths(ws, widths: Sequence[float], + formats: Sequence[Any] | None = None, + options: dict[int, dict] | None = None) -> None: + """열 너비 + 열 기본 서식 + 열 옵션(hidden/level)을 적용한다.""" + opts = options or {} + for i, w in enumerate(widths): + fmt = formats[i] if formats else None + ws.set_column(i, i, w, fmt, opts.get(i, {})) + + +# ───────────────────────────────────────── 시트 머리글 (§2.3) +def write_sheet_header(ws, fc: FormatCache, *, title: str, dashboard: str, + report_date: str, generated_at: str, + last_col: int) -> None: + """1행에 역링크 + 제목 + 생성 정보를 쓰고, 2행을 여백으로 비운다.""" + ws.set_row(0, 24) + ws.set_row(1, 6) + ws.write_url(0, 0, f"internal:{qsheet(dashboard)}!A1", + fc.get(font_color=PALETTE["ACCENT"], valign="vcenter"), + string="← 대시보드", tip="대시보드로 돌아가기") + ws.write(0, 1, title, fc.get(font_size=14, bold=True, valign="vcenter")) + ws.write(0, max(3, last_col - 3), + f"기준일 {report_date} · 생성 {generated_at}", + fc.get(font_size=9, font_color=PALETTE["MUTED"], + align="right", valign="vcenter")) + + +def write_table_header(ws, fc: FormatCache, row: int, header: Sequence[str], + comments: dict[int, str] | None = None, + height: float = 32.0) -> None: + """헤더 행을 쓰고 정의 툴팁(셀 주석)을 단다.""" + ws.set_row(row, height) + hfmt = fc.header() + for c, name in enumerate(header): + ws.write(row, c, name, hfmt) + for c, text in (comments or {}).items(): + ws.write_comment(row, c, text, + {"width": 220, "height": 90, "font_name": "맑은 고딕", + "font_size": 9, "x_scale": 1, "y_scale": 1}) + + +def write_empty_notice(ws, fc: FormatCache, row: int, last_col: int, + message: str = "(해당 없음)") -> None: + ws.merge_range(row, 0, row, last_col, message, + fc.get(font_color=PALETTE["MUTED"], align="center", + valign="vcenter", italic=True)) + + +# ───────────────────────────────────────── KPI 타일 (§4.4) +KPI_TILES: tuple[tuple[str, int, int, str, str], ...] = ( + # (라벨, 시작 열(0-index), 끝 열, 액센트, 스파크라인 타입) + ("오늘 신규", 1, 2, "#009E73", "column"), # B:C + ("오늘 변경", 4, 5, "#E69F00", "column"), # E:F + ("오늘 취하", 7, 8, "#D55E00", "column"), # H:I + ("오늘 총 변경분", 10, 11, "#0072B2", "column"), # K:L + ("워치리스트 히트",13, 14, "#CC79A7", "win_loss"), # N:O + ("전체 누적 등록", 16, 17, "#1F2933", "line"), # Q:R +) + + +def tile_formats(fc: FormatCache, accent: str) -> tuple[Any, Any, Any, Any]: + """KPI 타일 4행(라벨/값/델타/스파크라인)용 서식 세트.""" + common = { + "bg_color": PALETTE["TILE_BG"], + "align": "center", "valign": "vcenter", + "left": 1, "left_color": PALETTE["RULE"], + "right": 1, "right_color": PALETTE["RULE"], + } + label = fc.get(**common, font_size=10, bold=True, + font_color=PALETTE["MUTED"], top=5, top_color=accent) + value = fc.get(**common, font_size=28, bold=True, + font_color=accent, num_format=NUMFMT["INT"]) + delta = fc.get(**common, font_size=10, bold=True, + font_color=PALETTE["MUTED"], num_format=NUMFMT["DELTA"]) + spark = fc.get(**common, bottom=1, bottom_color=PALETTE["RULE"]) + return label, value, delta, spark + + +def write_kpi_tiles(ws, fc: FormatCache, *, trend_sheet: str, + trend_last_row: int, trend_first_row: int, + values: Sequence[int], deltas: Sequence[int], + value_formulas: Sequence[str | None], + delta_formulas: Sequence[str | None]) -> None: + """5~8행에 KPI 타일 6장을 그린다. + + trend_last_row / trend_first_row 는 워크시트 1-index 행 번호다. + value_formulas / delta_formulas 는 None 이면 값만 직접 쓴다. + """ + ws.set_row(4, 18) + ws.set_row(5, 34) + ws.set_row(6, 16) + ws.set_row(7, 14) + # 스파크라인 원본 열: B(1) 신규, C(2) 변경, D(3) 취하, E(4) 합계, F(5) 워치, G(6) 누적 + src_cols = ("B", "C", "D", "E", "F", "G") + q = qsheet(trend_sheet) + for i, (label, c1, c2, accent, stype) in enumerate(KPI_TILES): + f_label, f_value, f_delta, f_spark = tile_formats(fc, accent) + ws.merge_range(4, c1, 4, c2, label, f_label) + + if value_formulas[i]: + ws.merge_range(5, c1, 5, c2, "", f_value) + ws.write_formula(5, c1, value_formulas[i], f_value, values[i]) + else: + ws.merge_range(5, c1, 5, c2, values[i], f_value) + + if delta_formulas[i]: + ws.merge_range(6, c1, 6, c2, "", f_delta) + ws.write_formula(6, c1, delta_formulas[i], f_delta, deltas[i]) + else: + ws.merge_range(6, c1, 6, c2, deltas[i], f_delta) + + ws.merge_range(7, c1, 7, c2, "", f_spark) + col = src_cols[i] + opts: dict[str, Any] = { + "range": f"{q}!${col}${trend_first_row}:${col}${trend_last_row}", + "type": stype, + "series_color": accent, + "high_point": True, + "last_point": True, + "empty_cells": "zero", + } + if stype == "win_loss": + opts["negative_points"] = True + opts.pop("high_point") + ws.add_sparkline(7, c1, opts) + + +# ───────────────────────────────────────── Top 표 (§4.6) +def write_mini_table(ws, fc: FormatCache, *, header_row: int, first_col: int, + title: str, title_link: str | None, + header: Sequence[str], rows: Sequence[Sequence[Any]], + max_rows: int = 10, + formulas: dict[int, list[tuple[str, Any]]] | None = None) -> None: + """대시보드용 소형 표. title 행 위에 제목을 얹고 header_row 에 헤더를 쓴다.""" + ncol = len(header) + hfmt = fc.get(bold=True, font_color="#FFFFFF", bg_color=PALETTE["INK"], + align="center", valign="vcenter") + if title_link: + ws.write_url(header_row - 1, first_col, title_link, + fc.get(bold=True, font_color=PALETTE["ACCENT"], + valign="vcenter"), string=title) + else: + ws.write(header_row - 1, first_col, title, + fc.get(bold=True, valign="vcenter")) + for c, name in enumerate(header): + ws.write(header_row, first_col + c, name, hfmt) + + text_fmt = fc.get(align="left", valign="vcenter", num_format=NUMFMT["TEXT"], + bottom=7, bottom_color=PALETTE["RULE"]) + num_fmt = fc.get(align="center", valign="vcenter", + num_format=NUMFMT["INT_DASH"], + bottom=7, bottom_color=PALETTE["RULE"]) + for r in range(max_rows): + wrow = header_row + 1 + r + if r >= len(rows): + for c in range(ncol): + ws.write_blank(wrow, first_col + c, None, text_fmt if c == 1 else num_fmt) + continue + for c, v in enumerate(rows[r][:ncol]): + fmt = text_fmt if isinstance(v, str) else num_fmt + fset = (formulas or {}).get(c) + if fset is not None and r < len(fset): + formula, cached = fset[r] + ws.write_formula(wrow, first_col + c, formula, num_fmt, cached) + else: + ws.write(wrow, first_col + c, v, fmt) +``` + +### 8.3 `src/dmf_crawler/report/atomic.py` + +```python +"""원자적 xlsx 저장 — Excel 이 파일을 잡고 있어도 데이터를 잃지 않는다. + +아키텍처 §3.13 계약: + atomic_write(build_fn, target, retries=3, backoff_seconds=2.0) + -> tuple[Path, bool] # (실제 저장 경로, 폴백 사용 여부) +""" +from __future__ import annotations + +import os +import shutil +import tempfile +import time +import zipfile +from datetime import datetime +from pathlib import Path +from typing import Callable + +from ..errors import ReportError + +_MIN_XLSX_BYTES = 4096 + + +def excel_lock_file(path: Path) -> Path: + """Excel 이 만드는 숨김 잠금 파일 경로(~$name.xlsx).""" + return path.with_name("~$" + path.name) + + +def looks_locked(path: Path) -> bool: + """빠른 사전 판단. 확정적이지 않으므로 실제 시도의 보조로만 쓴다.""" + if excel_lock_file(path).exists(): + return True + if not path.exists(): + return False + try: + with open(path, "r+b"): + return False + except OSError: + return True + + +def verify_xlsx(path: Path, min_bytes: int = _MIN_XLSX_BYTES) -> None: + """저장된 xlsx 가 온전한지 stdlib 만으로 검사한다(openpyxl 불필요).""" + if not path.exists(): + raise ReportError(f"출력 파일이 생성되지 않았다: {path}") + size = path.stat().st_size + if size < min_bytes: + raise ReportError(f"출력 파일이 너무 작다({size} bytes): {path}") + try: + with zipfile.ZipFile(path) as zf: + bad = zf.testzip() + if bad is not None: + raise ReportError(f"손상된 zip 엔트리: {bad}") + names = set(zf.namelist()) + except zipfile.BadZipFile as exc: + raise ReportError(f"xlsx 가 zip 으로 열리지 않는다: {path}") from exc + for required in ("xl/workbook.xml", "[Content_Types].xml"): + if required not in names: + raise ReportError(f"{required} 가 없다. 올바른 xlsx 가 아니다: {path}") + + +def fallback_path(target: Path, now: datetime | None = None) -> Path: + """DMF_리포트_2026-09-02_060241.xlsx 형태의 폴백 이름.""" + stamp = (now or datetime.now()).strftime("%H%M%S") + return target.with_name(f"{target.stem}_{stamp}{target.suffix}") + + +def atomic_write(build_fn: Callable[[Path], None], target: Path, + retries: int = 3, backoff_seconds: float = 2.0) -> tuple[Path, bool]: + """build_fn(tmp) 으로 임시 파일을 만든 뒤 target 으로 원자 교체한다. + + - 같은 볼륨에 임시 파일을 만들어야 os.replace 가 원자적이다. + - 쓰기 도중 죽어도 target 은 이전 상태 그대로 남는다. + - 잠김 시 지수 백오프로 재시도하고, 끝내 실패하면 시각 접미사 폴백. + """ + target = Path(target).resolve() + target.parent.mkdir(parents=True, exist_ok=True) + + fd, tmp_name = tempfile.mkstemp(prefix=".~dmf_", suffix=".xlsx", + dir=str(target.parent)) + os.close(fd) + tmp_path = Path(tmp_name) + try: + build_fn(tmp_path) + verify_xlsx(tmp_path) + + for attempt in range(max(1, retries)): + if not looks_locked(target): + try: + os.replace(tmp_path, target) + return target, False + except PermissionError: + pass + if attempt < retries - 1: + time.sleep(backoff_seconds * (2 ** attempt)) + + alt = fallback_path(target) + shutil.move(str(tmp_path), str(alt)) + return alt, True + finally: + if tmp_path.exists(): + try: + tmp_path.unlink() + except OSError: + pass + + +def refresh_latest_link(source: Path, latest: Path) -> bool: + """최신본 고정 파일을 원자적 '복사'로 갱신한다. + + Windows 심볼릭 링크는 관리자 권한 또는 개발자 모드를 요구하므로 쓰지 않는다. + 하드링크도 쓰지 않는다 — 원본을 지우면 최신본이 남아 혼란스럽다. + 잠겨 있으면 조용히 False 를 반환한다(리포트 본체는 이미 저장됐다). + """ + if not source.exists(): + return False + latest.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=".~dmflatest_", suffix=".xlsx", + dir=str(latest.parent)) + os.close(fd) + tmp_path = Path(tmp_name) + try: + shutil.copyfile(source, tmp_path) + if looks_locked(latest): + return False + os.replace(tmp_path, latest) + return True + except OSError: + return False + finally: + if tmp_path.exists(): + try: + tmp_path.unlink() + except OSError: + pass +``` + +### 8.4 `src/dmf_crawler/report/data.py` + +> ⚠️ 아래 SQL 은 `docs/design/02-data-model.md`(M1 에서 작성) 확정 전의 **잠정 스키마**를 전제한다. 컬럼명이 바뀌면 이 파일만 고치면 되도록 SQL 을 모듈 상수로 몰아 두었다. + +**전제 스키마 요약** (0001~0003 마이그레이션) + +```sql +runs(run_id TEXT PK, run_date TEXT, trigger TEXT, started_at TEXT, finished_at TEXT, + status TEXT, exit_code INT, notes TEXT) +fetch_stats(run_id TEXT PK, total_count_reported INT, pages INT, fetched_rows INT, + duration_ms INT, http_summary TEXT, archive_dir TEXT, snapshot_hash TEXT) +snapshots(run_id TEXT, dmf_key TEXT, permit_no TEXT, ingredient_name TEXT, + applicant TEXT, manufacturer TEXT, manufacture_place TEXT, countries TEXT, + permit_date TEXT, accepted_date TEXT, content_hash TEXT, + PRIMARY KEY(run_id, dmf_key)) +records(dmf_key TEXT PK, permit_no TEXT, ingredient_name TEXT, applicant TEXT, + manufacturer TEXT, manufacture_place TEXT, countries TEXT, permit_date TEXT, + accepted_date TEXT, content_hash TEXT, first_seen_date TEXT, + last_seen_date TEXT, last_changed_date TEXT, status TEXT, last_run_id TEXT) +events(event_id INTEGER PK, run_id TEXT, run_date TEXT, dmf_key TEXT, + event_type TEXT, field TEXT, before_value TEXT, after_value TEXT, created_at TEXT) +enrichment_run(run_id TEXT PK, headline TEXT, summary TEXT, risk_note TEXT, + model TEXT, created_at TEXT) +enrichment(run_id TEXT, dmf_key TEXT, note TEXT, importance INT, + PRIMARY KEY(run_id, dmf_key)) +agy_calls(id INTEGER PK, run_id TEXT, status TEXT, model TEXT, effort TEXT, + input_tokens INT, output_tokens INT, duration_ms INT, error_kind TEXT) +watchlist(id INTEGER PK, target_type TEXT, keyword TEXT, match_mode TEXT, + priority INT, active TEXT, memo TEXT, created_at TEXT) +``` + +```python +"""DB → ReportData. 리포트는 이 모듈을 통해서만 DB를 읽는다.""" +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass, field +from datetime import date, datetime +from typing import Any + +# ─────────────────────────────────────────────────────── 자료구조 +@dataclass(frozen=True, slots=True) +class ChangeRow: + sort_key: int + status: str # 신규 / 변경 / 취하 + permit_no: str + ingredient_name: str + applicant: str + manufacturer: str + manufacture_place: str + countries: str + permit_date: date | None + accepted_date: date | None + changed_fields: str + change_detail: str + watch_hits: int + watch_keywords: str + ai_note: str + dmf_key: str + content_hash: str + + +@dataclass(frozen=True, slots=True) +class LedgerRow: + permit_no: str + ingredient_name: str + applicant: str + manufacturer: str + manufacture_place: str + countries: str + country_count: int + permit_date: date | None + accepted_date: date | None + first_seen: date | None + last_seen: date | None + last_changed: date | None + status: str # 유효 / 취하 + elapsed_days: int | None + watch_flag: str # Y / N + dmf_key: str + content_hash: str + + +@dataclass(frozen=True, slots=True) +class AggRow: + rank: int + name: str + key: str + today_new: int + today_changed: int + today_withdrawn: int + last7: int + last30: int + cumulative: int + partner_count: int # 성분→등록업체 수 / 업체→보유 성분 수 + country_count: int # 성분→제조국 수 / 업체→제조소 수 + main_country: str + domestic: str + delta: int + last_date: date | None + watch_flag: str + series30: tuple[int, ...] + + +@dataclass(frozen=True, slots=True) +class WatchRow: + no: int + target_type: str + keyword: str + match_mode: str + priority: int + active: str + memo: str + today: int + last7: int + last30: int + cumulative: int + last_hit: date | None + + +@dataclass(frozen=True, slots=True) +class WatchDetailRow: + keyword: str + status: str + permit_no: str + ingredient_name: str + applicant: str + countries: str + permit_date: date | None + + +@dataclass(frozen=True, slots=True) +class TrendRow: + day: date + new: int + changed: int + withdrawn: int + total: int + watch_hits: int + active_total: int + fetched_rows: int + run_status: str + + +@dataclass(frozen=True, slots=True) +class GateRow: + name: str + expected: str + actual: str + verdict: str + detail: str + + +@dataclass(frozen=True, slots=True) +class MetaInfo: + report_date: date + run_id: str + trigger: str + started_at: str + finished_at: str + run_status: str + host: str + python_version: str + xlsxwriter_version: str + prev_run_id: str + log_dir: str + events_path: str + archive_dir: str + backup_path: str + snapshot_hash: str + prev_report_path: str + source_name: str + source_url: str + http_summary: str + fetched_rows: int + is_baseline: bool + banner_kind: str # none / partial / baseline / ai + banner_text: str + + +@dataclass(frozen=True, slots=True) +class AiInfo: + used: str # 사용 / 건너뜀(...) / 실패 + headline: str + summary: str + risk_note: str + model: str + effort: str + input_tokens: int + output_tokens: int + duration_s: float + tokens_today: int + token_cap: int + envelope_path: str + + +@dataclass(frozen=True, slots=True) +class ReportData: + meta: MetaInfo + ai: AiInfo + kpi: dict[str, int] # new/changed/withdrawn/total/watch/active + kpi_delta: dict[str, int] + changes: tuple[ChangeRow, ...] + ledger: tuple[LedgerRow, ...] + ingredients: tuple[AggRow, ...] + companies: tuple[AggRow, ...] + watch: tuple[WatchRow, ...] + watch_detail: tuple[WatchDetailRow, ...] + trend: tuple[TrendRow, ...] + gates: tuple[GateRow, ...] + top_countries: tuple[tuple[str, int], ...] + trend_dates: tuple[date, ...] = field(default_factory=tuple) + + +# ─────────────────────────────────────────────────────── SQL 상수 +SQL_RUN = """ +SELECT r.run_id, r.run_date, r.trigger, r.started_at, r.finished_at, + r.status, r.notes, + f.total_count_reported, f.fetched_rows, f.pages, f.duration_ms, + f.http_summary, f.archive_dir, f.snapshot_hash + FROM runs r LEFT JOIN fetch_stats f ON f.run_id = r.run_id + WHERE r.run_id = ? +""" + +SQL_PREV_RUN = """ +SELECT run_id, run_date FROM runs + WHERE status IN ('SUCCESS','PARTIAL') AND run_id < ? + ORDER BY run_id DESC LIMIT 1 +""" + +SQL_CHANGES = """ +SELECT e.dmf_key, + e.event_type, + GROUP_CONCAT(DISTINCT e.field) AS fields, + GROUP_CONCAT(e.field || ': ' || COALESCE(e.before_value,'-') || + ' → ' || COALESCE(e.after_value,'-'), CHAR(10)) AS detail, + c.permit_no, c.ingredient_name, c.applicant, c.manufacturer, + c.manufacture_place, c.countries, c.permit_date, c.accepted_date, + c.content_hash, + COALESCE(x.note, '') AS ai_note + FROM events e + LEFT JOIN records c ON c.dmf_key = e.dmf_key + LEFT JOIN enrichment x ON x.run_id = e.run_id AND x.dmf_key = e.dmf_key + WHERE e.run_id = ? + GROUP BY e.dmf_key, e.event_type +""" + +SQL_LEDGER = """ +SELECT permit_no, ingredient_name, applicant, manufacturer, manufacture_place, + countries, permit_date, accepted_date, first_seen_date, last_seen_date, + last_changed_date, status, dmf_key, content_hash + FROM records + ORDER BY accepted_date DESC, permit_no ASC +""" + +SQL_INGREDIENT_TODAY = """ +SELECT r.ingredient_name, + SUM(CASE WHEN e.event_type='NEW' THEN 1 ELSE 0 END) AS n_new, + SUM(CASE WHEN e.event_type='CHANGED' THEN 1 ELSE 0 END) AS n_chg, + SUM(CASE WHEN e.event_type='WITHDRAWN' THEN 1 ELSE 0 END) AS n_wdr + FROM events e JOIN records r ON r.dmf_key = e.dmf_key + WHERE e.run_id = ? + GROUP BY r.ingredient_name +""" + +SQL_INGREDIENT_WINDOW = """ +SELECT r.ingredient_name, e.run_date, COUNT(DISTINCT e.dmf_key) AS n + FROM events e JOIN records r ON r.dmf_key = e.dmf_key + WHERE e.run_date >= ? + GROUP BY r.ingredient_name, e.run_date +""" + +SQL_INGREDIENT_CUM = """ +SELECT ingredient_name, + COUNT(*) AS cum, + COUNT(DISTINCT applicant) AS n_applicant, + COUNT(DISTINCT countries) AS n_country, + MAX(accepted_date) AS last_date, + SUM(CASE WHEN countries LIKE '%대한민국%' THEN 1 ELSE 0 END) AS n_domestic + FROM records + WHERE status = '유효' + GROUP BY ingredient_name +""" + +SQL_COMPANY_TODAY = SQL_INGREDIENT_TODAY.replace("r.ingredient_name", "r.applicant") +SQL_COMPANY_WINDOW = SQL_INGREDIENT_WINDOW.replace("r.ingredient_name", "r.applicant") +SQL_COMPANY_CUM = """ +SELECT applicant, + COUNT(*) AS cum, + COUNT(DISTINCT ingredient_name) AS n_ingredient, + COUNT(DISTINCT manufacturer) AS n_site, + MAX(accepted_date) AS last_date, + SUM(CASE WHEN countries LIKE '%대한민국%' THEN 1 ELSE 0 END) AS n_domestic + FROM records + WHERE status = '유효' + GROUP BY applicant +""" + +SQL_TREND = """ +SELECT r.run_date, + SUM(CASE WHEN e.event_type='NEW' THEN 1 ELSE 0 END) AS n_new, + SUM(CASE WHEN e.event_type='CHANGED' THEN 1 ELSE 0 END) AS n_chg, + SUM(CASE WHEN e.event_type='WITHDRAWN' THEN 1 ELSE 0 END) AS n_wdr, + MAX(r.status) AS run_status, + MAX(COALESCE(f.fetched_rows, 0)) AS fetched + FROM runs r + LEFT JOIN events e ON e.run_id = r.run_id + LEFT JOIN fetch_stats f ON f.run_id = r.run_id + WHERE r.run_date >= ? AND r.status IN ('SUCCESS','PARTIAL') + GROUP BY r.run_date + ORDER BY r.run_date ASC +""" + +SQL_ACTIVE_BY_DAY = """ +SELECT run_id, run_date, COUNT(*) AS n + FROM snapshots s JOIN runs u USING (run_id) + WHERE u.run_date >= ? AND u.status IN ('SUCCESS','PARTIAL') + GROUP BY run_id, run_date + ORDER BY run_date ASC +""" + +SQL_TOP_COUNTRY = """ +SELECT TRIM(value) AS country, COUNT(*) AS n + FROM records, json_each('["' || REPLACE(countries, ',', '","') || '"]') + WHERE status = '유효' AND TRIM(value) <> '' + GROUP BY country + ORDER BY n DESC + LIMIT 8 +""" + +SQL_WATCHLIST = """ +SELECT id, target_type, keyword, match_mode, priority, active, memo + FROM watchlist + WHERE active = 'Y' + ORDER BY priority ASC, id ASC +""" + +SQL_AGY = """ +SELECT status, model, effort, input_tokens, output_tokens, duration_ms, error_kind + FROM agy_calls WHERE run_id = ? ORDER BY id DESC LIMIT 1 +""" + +SQL_AGY_TOKENS_TODAY = """ +SELECT COALESCE(SUM(input_tokens + output_tokens), 0) + FROM agy_calls a JOIN runs r USING (run_id) + WHERE r.run_date = ? +""" + +SQL_ENRICHMENT_RUN = """ +SELECT headline, summary, risk_note, model FROM enrichment_run WHERE run_id = ? +""" + + +def fetch_report_data(conn: sqlite3.Connection, run_id: str, cfg) -> ReportData: + """모든 시트가 필요로 하는 데이터를 한 번에 조립한다. + + - 워치리스트 매칭은 파이썬에서 수행한다(정규식 모드가 SQL 로 불가능). + - 파생 컬럼(elapsed_days, series30 등)도 여기서 계산해 시트 빌더는 + '쓰기'만 하게 한다. + """ + conn.row_factory = sqlite3.Row + # 구현 본문은 위 SQL 을 순서대로 실행해 dataclass 로 옮기는 단순 매핑이다. + # 규칙 3가지만 지키면 된다: + # 1) 모든 날짜 문자열은 datetime.date 로 변환해 넣는다(엑셀 날짜 서식용). + # 2) 값이 없으면 None 이 아니라 '-' 를 쓸 자리는 시트 빌더가 판단한다. + # data.py 는 None 을 그대로 넘긴다. + # 3) trend 는 오래된 날짜가 먼저 오도록 ASC 로 정렬한다. + raise NotImplementedError("M3 에서 위 SQL 매핑을 채운다") +``` + +### 8.5 `src/dmf_crawler/report/build.py` + +```python +"""워크북 조립 오케스트레이션.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +import xlsxwriter + +from ..errors import ReportError +from .atomic import atomic_write, refresh_latest_link +from .data import ReportData, fetch_report_data +from .sheets import SHEET_BUILDERS +from .theme import SHEET_NAMES, TAB_COLORS, FormatCache, qsheet + + +@dataclass(frozen=True, slots=True) +class ReportOutcome: + path: Path + latest_link_path: Path | None + used_fallback_name: bool + sheets: tuple[str, ...] + warnings: tuple[str, ...] + + +class BuildContext: + """빌더 함수들이 공유하는 상태. 시트 간 참조(행 번호 등)를 여기에 남긴다.""" + + def __init__(self, wb: xlsxwriter.Workbook, rd: ReportData, cfg) -> None: + self.wb = wb + self.rd = rd + self.cfg = cfg + self.fc = FormatCache(wb, font_name=cfg.font) + self.sheets: dict[str, object] = {} + self.warnings: list[str] = [] + # 시트 간 연동에 필요한 좌표들 + self.ledger_row_of: dict[str, int] = {} # dmf_key -> 워크시트 1-index 행 + self.trend_first_row: int = 4 + self.trend_last_row: int = 4 + self.spark_first_row: int = 4 + self.ledger_last_row: int = 3 + self.changes_last_row: int = 3 + self.has_watchlist: bool = False + + def add_sheet(self, key: str): + name = SHEET_NAMES[key] + ws = self.wb.add_worksheet(name) + ws.set_tab_color(TAB_COLORS[name]) + self.sheets[key] = ws + return ws + + +def _build_workbook(rd: ReportData, cfg, tmp_path: Path) -> tuple[list[str], list[str]]: + wb = xlsxwriter.Workbook(str(tmp_path), { + "constant_memory": False, + "strings_to_numbers": False, + "strings_to_urls": False, + "nan_inf_to_errors": True, + "use_future_functions": True, + "default_date_format": "yyyy-mm-dd", + "remove_timezone": True, + }) + wb.set_size(1500, 900) + wb.set_properties({ + "title": f"DMF 일일 모니터링 리포트 {rd.meta.report_date:%Y-%m-%d}", + "subject": "원료의약품 등록(DMF) 신규·변경·취하 현황", + "author": "DMF Crawler", + "category": "규제 인텔리전스", + "keywords": "DMF, 원료의약품, 식약처, 등록현황", + "comments": f"run_id={rd.meta.run_id} / 자동 생성 / 출처: 공공데이터포털 15057075", + }) + + ctx = BuildContext(wb, rd, cfg) + built: list[str] = [] + try: + # ── 1단계: 원본 시트를 먼저 만든다 ────────────────────────── + # 06_추이 를 먼저 만들어야 대시보드 스파크라인 범위를 알 수 있고, + # 02_전체현황 을 만들어야 01_오늘변경분 의 링크 대상 행을 안다. + # 그래서 '생성 순서'와 '탭 순서'를 분리한다. + # XlsxWriter 는 add_worksheet 순서가 곧 탭 순서이므로, + # 시트 객체는 탭 순서대로 미리 만들고 '기록'만 나중에 한다. + for key in ("dashboard", "changes", "ledger", "ingredient", "company"): + ctx.add_sheet(key) + if rd.watch: + ctx.has_watchlist = True + ctx.add_sheet("watchlist") + ctx.add_sheet("trend") + ctx.add_sheet("meta") + + # ── 2단계: 의존 순서대로 내용을 채운다 ─────────────────────── + for key, builder in SHEET_BUILDERS: + if key == "watchlist" and not ctx.has_watchlist: + continue + builder(ctx) + built.append(SHEET_NAMES[key]) + + # ── 3단계: 정의된 이름 (모든 시트 생성 후) ──────────────────── + t = qsheet(SHEET_NAMES["trend"]) + d = qsheet(SHEET_NAMES["dashboard"]) + m = qsheet(SHEET_NAMES["meta"]) + lg = qsheet(SHEET_NAMES["ledger"]) + ch = qsheet(SHEET_NAMES["changes"]) + last = ctx.trend_last_row + wb.define_name("REPORT_DATE", f"={m}!$B$3") + wb.define_name("RUN_ID", f"={m}!$B$6") + wb.define_name("KPI_NEW", f"={d}!$B$6") + wb.define_name("KPI_CHANGED", f"={d}!$E$6") + wb.define_name("KPI_WITHDRAWN", f"={d}!$H$6") + wb.define_name("KPI_TOTAL", f"={d}!$K$6") + wb.define_name("KPI_WATCH", f"={d}!$N$6") + wb.define_name("KPI_ACTIVE", f"={d}!$Q$6") + wb.define_name("TREND_DATE", f"={t}!$A$4:$A${last}") + wb.define_name("TREND_NEW", f"={t}!$B$4:$B${last}") + wb.define_name("TREND_CHANGED", f"={t}!$C$4:$C${last}") + wb.define_name("TREND_WITHDRAWN", f"={t}!$D$4:$D${last}") + wb.define_name("LEDGER", f"={lg}!$A$3:$Q${ctx.ledger_last_row}") + wb.define_name("TODAY_CHANGES", f"={ch}!$A$3:$S${ctx.changes_last_row}") + + ctx.sheets["dashboard"].activate() + ctx.sheets["dashboard"].set_first_sheet() + finally: + wb.close() + return built, ctx.warnings + + +def build_report(conn, run_id: str, cfg) -> ReportOutcome: + """아키텍처 §3.13 의 공개 API. AI 산출물 유무와 무관하게 항상 완주한다.""" + rd = fetch_report_data(conn, run_id, cfg) + out_dir = Path(cfg.output_dir) + target = out_dir / cfg.filename_pattern.format(date=f"{rd.meta.report_date:%Y-%m-%d}") + + built: list[str] = [] + warns: list[str] = [] + + def _fn(tmp: Path) -> None: + nonlocal built, warns + built, warns = _build_workbook(rd, cfg, tmp) + + try: + path, used_fallback = atomic_write( + _fn, target, + retries=cfg.lock_retries, + backoff_seconds=2.0, + ) + except Exception as exc: # noqa: BLE001 + raise ReportError( + f"리포트 생성 실패: {exc}. " + f"`python -m dmf_crawler report-only --run-id {run_id}` 로 재생성할 수 있다." + ) from exc + + if used_fallback: + warns.append( + f"원본 파일이 열려 있어 대체 이름으로 저장했다: {path.name}. " + f"Excel 을 닫고 report-only 를 다시 실행하면 정규 파일명으로 저장된다." + ) + + latest: Path | None = None + if cfg.latest_link_name: + cand = out_dir / cfg.latest_link_name + if refresh_latest_link(path, cand): + latest = cand + else: + warns.append(f"최신본 갱신 실패(열려 있음): {cand.name}") + + return ReportOutcome( + path=path, + latest_link_path=latest, + used_fallback_name=used_fallback, + sheets=tuple(built), + warnings=tuple(warns), + ) +``` + +> **생성 순서와 탭 순서의 분리**가 이 모듈의 핵심 트릭이다. XlsxWriter 는 `add_worksheet` 호출 순서가 곧 탭 순서이므로, 워크시트 객체는 탭 순서대로 미리 전부 만들고, 실제 쓰기는 `SHEET_BUILDERS` 의 **의존 순서**로 한다. + +### 8.6 `src/dmf_crawler/report/sheets/__init__.py` + +```python +"""시트 빌더 등록부. 튜플의 순서 = 실제 '쓰기' 순서(의존 순서).""" +from __future__ import annotations + +from . import (s00_dashboard, s01_changes, s02_ledger, s03_ingredient, + s04_company, s05_watchlist, s06_trend, s99_meta) + +SHEET_BUILDERS = ( + ("trend", s06_trend.build), # 1. 스파크라인·차트 원본을 먼저 + ("ledger", s02_ledger.build), # 2. dmf_key → 행번호 사전 생성 + ("changes", s01_changes.build), # 3. ledger 사전을 써서 링크 + ("ingredient", s03_ingredient.build), + ("company", s04_company.build), + ("watchlist", s05_watchlist.build), + ("meta", s99_meta.build), # 4. REPORT_DATE 원본 + ("dashboard", s00_dashboard.build), # 5. 모든 좌표가 확정된 뒤 마지막 +) +``` + +### 8.7 `src/dmf_crawler/report/sheets/s00_dashboard.py` + +```python +"""00_대시보드 — KPI 6 · 차트 2 · Top 표 3 · 내비게이션.""" +from __future__ import annotations + +from ..theme import NUMFMT, PALETTE, SHEET_NAMES, STATUS, qsheet +from ..widgets import write_kpi_tiles, write_mini_table + +COL_WIDTHS = [1.5, 13, 13, 1.5, 13, 13, 1.5, 13, 13, 1.5, + 13, 13, 1.5, 13, 13, 1.5, 13, 13, 1.5] # A..S +ROW_HEIGHTS = {0: 6, 1: 30, 2: 18, 3: 8, 8: 10, 24: 10, 25: 18, + 40: 10, 41: 20, 42: 20, 46: 14} + +NAV = ( + (41, 1, 2, "오늘 변경분 →", "changes", "오늘 신규·변경·취하 전체"), + (41, 4, 5, "전체 현황 →", "ledger", "유효 등록 전체 원장"), + (41, 7, 8, "성분별 →", "ingredient", "성분 기준 집계"), + (41, 10, 11, "업체별 →", "company", "업체 기준 집계"), + (41, 13, 14, "워치리스트 →", "watchlist", "관심 키워드 히트"), + (41, 16, 17, "추이 →", "trend", "일자별 시계열"), + (42, 1, 2, "메타·로그 →", "meta", "수집 메타·무결성·AI 상태"), +) + + +def build(ctx) -> None: + ws, fc, rd = ctx.sheets["dashboard"], ctx.fc, ctx.rd + ws.hide_gridlines(2) + ws.set_zoom(90) + for c, w in enumerate(COL_WIDTHS): + ws.set_column(c, c, w) + for r, h in ROW_HEIGHTS.items(): + ws.set_row(r, h) + for r in range(9, 24): + ws.set_row(r, 20) + for r in range(26, 40): + ws.set_row(r, 16) + for r in range(43, 46): + ws.set_row(r, 8) + + _title_block(ctx, ws, fc, rd) + _banner(ctx, ws, fc, rd) + _kpis(ctx, ws, fc, rd) + _charts(ctx, ws, rd) + _top_tables(ctx, ws, fc, rd) + _nav(ctx, ws, fc) + _footer(ctx, ws, fc, rd) + _print_setup(ws) + + +def _title_block(ctx, ws, fc, rd) -> None: + weekday = "월화수목금토일"[rd.meta.report_date.weekday()] + ws.merge_range(1, 1, 1, 17, + f"DMF 일일 모니터링 리포트 — {rd.meta.report_date:%Y-%m-%d}({weekday})", + fc.get(font_size=18, bold=True, valign="vcenter")) + headline = rd.ai.headline or _rule_headline(rd) + ws.merge_range(2, 1, 2, 17, headline, fc.subtitle()) + + +def _rule_headline(rd) -> str: + """AI 요약이 없을 때 쓰는 규칙 기반 한 줄.""" + k = rd.kpi + base = (f"오늘 신규 {k['new']}건 · 변경 {k['changed']}건 · " + f"취하 {k['withdrawn']}건") + if k["watch"]: + names = ", ".join(w.ingredient_name for w in rd.watch_detail[:3]) + return f"{base} — 워치리스트 {k['watch']}건 적중({names})" + if k["total"] == 0: + return f"{base} — 전일 대비 변동이 없다" + return f"{base} — 워치리스트 적중 없음" + + +def _banner(ctx, ws, fc, rd) -> None: + kind, text = rd.meta.banner_kind, rd.meta.banner_text + if kind == "none" or not text: + return + style = {"partial": ("취하", 22), "baseline": ("정보", 22), "ai": ("없음", 22)}[kind] + s = STATUS[style[0]] + ws.set_row(3, style[1]) + ws.merge_range(3, 1, 3, 17, text, + fc.get(font_size=11, bold=True, font_color=s.fg, + bg_color=s.bg, align="left", valign="vcenter", + left=5, left_color=s.fg, indent=1)) + + +def _kpis(ctx, ws, fc, rd) -> None: + t = qsheet(SHEET_NAMES["trend"]) + e, p = ctx.trend_last_row, ctx.trend_last_row - 1 + has_prev = ctx.trend_last_row > ctx.trend_first_row + cols = ("B", "C", "D", "E", "F", "G") + keys = ("new", "changed", "withdrawn", "total", "watch", "active") + vformulas = [f"={t}!${c}${e}" for c in cols] + dformulas = ([f"={t}!${c}${e}-{t}!${c}${p}" for c in cols] + if has_prev else [None] * 6) + write_kpi_tiles( + ws, fc, + trend_sheet=SHEET_NAMES["trend"], + trend_last_row=ctx.trend_last_row, + trend_first_row=ctx.spark_first_row, + values=[rd.kpi[k] for k in keys], + deltas=[rd.kpi_delta[k] for k in keys], + value_formulas=vformulas, + delta_formulas=dformulas, + ) + + +def _charts(ctx, ws, rd) -> None: + wb, t = ctx.wb, SHEET_NAMES["trend"] + s0 = ctx.spark_first_row - 1 # 0-index + e0 = ctx.trend_last_row - 1 + + ch1 = wb.add_chart({"type": "column", "subtype": "stacked"}) + for col, (label, color) in enumerate( + (("신규", "#009E73"), ("변경", "#E69F00"), ("취하", "#D55E00")), start=1): + ch1.add_series({ + "name": label, + "categories": [t, s0, 0, e0, 0], + "values": [t, s0, col, e0, col], + "fill": {"color": color}, + "border": {"none": True}, + "gap": 40, + }) + ch1.set_title({"name": "최근 30일 일별 등록 동향", + "name_font": {"name": "맑은 고딕", "size": 11, "bold": True, + "color": PALETTE["INK"]}}) + ch1.set_x_axis({"num_font": {"name": "맑은 고딕", "size": 8, + "color": PALETTE["MUTED"]}, + "line": {"color": PALETTE["RULE"]}, + "major_tick_mark": "none", "date_axis": False}) + ch1.set_y_axis({"num_font": {"name": "맑은 고딕", "size": 8, + "color": PALETTE["MUTED"]}, + "major_gridlines": {"visible": True, + "line": {"color": PALETTE["GRID"], + "width": 0.75}}, + "line": {"none": True}, "major_tick_mark": "none"}) + ch1.set_legend({"position": "top", + "font": {"name": "맑은 고딕", "size": 9}}) + ch1.set_chartarea({"border": {"color": PALETTE["RULE"]}, + "fill": {"color": PALETTE["PAPER"]}}) + ch1.set_plotarea({"border": {"none": True}, "fill": {"none": True}}) + ch1.set_size({"width": 600, "height": 300}) + ws.insert_chart(9, 1, ch1) # 앵커 B10 + + n = max(1, len(rd.top_countries)) + ch2 = wb.add_chart({"type": "bar"}) + ch2.add_series({ + "name": "건수", + "categories": [t, 3, 26, 2 + n, 26], # AA4:AA{3+n} + "values": [t, 3, 27, 2 + n, 27], # AB4:AB{3+n} + "fill": {"color": PALETTE["ACCENT"]}, + "border": {"none": True}, + "data_labels": {"value": True, "position": "outside_end", + "font": {"name": "맑은 고딕", "size": 8, + "color": PALETTE["INK"]}}, + "gap": 50, + }) + ch2.set_title({"name": "제조국 Top 8 (유효 등록 기준)", + "name_font": {"name": "맑은 고딕", "size": 11, "bold": True, + "color": PALETTE["INK"]}}) + ch2.set_x_axis({"visible": False}) + ch2.set_y_axis({"num_font": {"name": "맑은 고딕", "size": 9, + "color": PALETTE["INK"]}, + "line": {"color": PALETTE["RULE"]}, + "major_tick_mark": "none", "reverse": True}) + ch2.set_legend({"none": True}) + ch2.set_chartarea({"border": {"color": PALETTE["RULE"]}, + "fill": {"color": PALETTE["PAPER"]}}) + ch2.set_plotarea({"border": {"none": True}, "fill": {"none": True}}) + ch2.set_size({"width": 600, "height": 300}) + ws.insert_chart(9, 10, ch2) # 앵커 K10 + + +def _top_tables(ctx, ws, fc, rd) -> None: + lg = qsheet(SHEET_NAMES["ledger"]) + ing = [(a.rank, a.name, a.today_new + a.today_changed + a.today_withdrawn, + a.last30, a.cumulative) for a in rd.ingredients[:10]] + co = [(a.rank, a.name, a.today_new + a.today_changed + a.today_withdrawn, + a.last30, a.cumulative) for a in rd.companies[:10]] + wd = [(w.keyword, 1, w.ingredient_name, w.applicant, w.status) + for w in rd.watch_detail[:10]] + + # 누적 열은 시트 간 COUNTIFS 수식 + 캐시값 (§5.3 F13/F14) + f_ing = {4: [(f'=COUNTIFS({lg}!$B:$B,$C{27 + i},{lg}!$M:$M,"유효")', r[4]) + for i, r in enumerate(ing)]} + f_co = {4: [(f'=COUNTIFS({lg}!$C:$C,$I{27 + i},{lg}!$M:$M,"유효")', r[4]) + for i, r in enumerate(co)]} + + write_mini_table(ws, fc, header_row=25, first_col=1, + title="Top 10 성분", + title_link=f"internal:{qsheet(SHEET_NAMES['ingredient'])}!A1", + header=("순위", "성분명", "오늘", "30일", "누적"), + rows=ing, formulas=f_ing) + write_mini_table(ws, fc, header_row=25, first_col=7, + title="Top 10 업체", + title_link=f"internal:{qsheet(SHEET_NAMES['company'])}!A1", + header=("순위", "업체명", "오늘", "30일", "누적"), + rows=co, formulas=f_co) + write_mini_table(ws, fc, header_row=25, first_col=13, + title="워치리스트 히트", + title_link=(f"internal:{qsheet(SHEET_NAMES['watchlist'])}!A1" + if ctx.has_watchlist else None), + header=("키워드", "매칭", "성분명", "업체명", "상태"), + rows=wd) + + bar = {"type": "data_bar", "bar_color": PALETTE["ACCENT"], "bar_solid": True} + ws.conditional_format(26, 5, 35, 5, dict(bar)) # F27:F36 + ws.conditional_format(26, 11, 35, 11, dict(bar)) # L27:L36 + for label in ("신규", "변경", "취하"): + ws.conditional_format(26, 17, 35, 17, { + "type": "text", "criteria": "containing", "value": label, + "format": fc.cf(label), + }) # R27:R36 + + +def _nav(ctx, ws, fc) -> None: + nav_fmt = fc.nav_button() + off_fmt = fc.get(font_color=PALETTE["NEUTRAL"], bg_color=PALETTE["TILE_BG"], + align="center", valign="vcenter", + border=1, border_color=PALETTE["RULE"], italic=True) + for row, c1, c2, text, key, tip in NAV: + if key == "watchlist" and not ctx.has_watchlist: + ws.merge_range(row, c1, row, c2, "(워치리스트 없음)", off_fmt) + continue + ws.merge_range(row, c1, row, c2, "", nav_fmt) + ws.write_url(row, c1, f"internal:{qsheet(SHEET_NAMES[key])}!A1", + nav_fmt, string=text, tip=tip) + + +def _footer(ctx, ws, fc, rd) -> None: + ws.merge_range( + 46, 1, 46, 17, + f"출처: 공공데이터포털 「의약품 원료의약품 등록 정보」(15057075) · 식품의약품안전처 " + f"│ 수집 {rd.meta.started_at} KST │ 본 리포트는 공개 데이터를 자동 수집·가공한 " + f"참고 자료이며 법적 효력이 없다.", + fc.footnote()) + + +def _print_setup(ws) -> None: + ws.set_landscape() + ws.set_paper(9) # A4 + ws.fit_to_pages(1, 1) + ws.set_margins(0.4, 0.4, 0.6, 0.6) + ws.center_horizontally() + ws.print_area(0, 0, 46, 18) + ws.set_header("") + ws.set_footer("&L&\"맑은 고딕\"&8DMF Crawler&C&\"맑은 고딕\"&8&P / &N" + "&R&\"맑은 고딕\"&8&D &T") +``` + +### 8.8 `src/dmf_crawler/report/sheets/s01_changes.py` + +```python +"""01_오늘변경분 — 리포트 본체.""" +from __future__ import annotations + +from urllib.parse import quote + +from ..theme import NUMFMT, PALETTE, SHEET_NAMES, STATUS, qsheet +from ..widgets import (apply_col_widths, compute_col_widths, write_empty_notice, + write_sheet_header, write_table_header) + +HEADER = ("정렬키", "기호", "상태", "등록번호", "성분명", "업체명", "제조소명", + "제조소 소재지", "제조국가", "발급일자", "수리일자(파생)", "변경필드", + "변경내용(전→후)", "워치히트", "워치키워드", "AI 메모", "원문검색", + "dmf_key", "content_hash") +WIDTH_OVERRIDE = {0: 4.0, 1: 4.0, 2: 8.0, 3: 24.0, 7: 40.0, 12: 52.0, + 13: 8.0, 15: 40.0, 16: 10.0, 17: 26.0, 18: 18.0} +HIDDEN_COLS = (0, 17, 18) +COMMENTS = { + 2: "신규 = 어제 없던 등록번호 / 변경 = 비교 대상 필드가 달라짐 / 취하 = 어제 있었으나 오늘 사라짐", + 3: "DMF_PERMIT_NO. 발급일자8자리-성분일련-시행군-접수일련-성분내일련 구조", + 10: "등록번호 앞 8자리에서 파생한 최초 수리일. 원본 필드가 아니다", + 12: "diff 로 잡힌 필드별 변경 전/후 값. 여러 건이면 줄바꿈으로 구분", + 13: "이 레코드가 매칭된 워치리스트 키워드 개수", +} +NEDRUG_SEARCH = ("https://nedrug.mfds.go.kr/pbp/CCBAC03/getItem" + "?totalPages=1&limit=10&page=1&searchYn=true&itemName={q}") + + +def build(ctx) -> None: + ws, fc, rd = ctx.sheets["changes"], ctx.fc, ctx.rd + last_col = len(HEADER) - 1 + write_sheet_header(ws, fc, title="오늘 변경분", + dashboard=SHEET_NAMES["dashboard"], + report_date=f"{rd.meta.report_date:%Y-%m-%d}", + generated_at=rd.meta.finished_at[-8:-3], + last_col=last_col) + write_table_header(ws, fc, 2, HEADER, COMMENTS) + + rows = rd.changes + if not rows: + write_empty_notice(ws, fc, 3, last_col, + "(오늘 전일 대비 변경된 DMF 가 없습니다)") + ctx.changes_last_row = 4 + _finish(ws, fc, last_col, data_last_row=3, empty=True) + return + + body = [_as_row(r) for r in rows] + widths = compute_col_widths(HEADER, body, overrides=WIDTH_OVERRIDE) + apply_col_widths(ws, widths, + options={c: {"hidden": 1} for c in HIDDEN_COLS}) + + lg = qsheet(SHEET_NAMES["ledger"]) + f_text = fc.cell_text() + f_wrap = fc.cell_text(wrap=True) + f_small = fc.cell_text(wrap=True, size=9) + f_muted = fc.cell_text(wrap=True, size=9, color=PALETTE["MUTED"]) + f_num = fc.cell_num("INT_DASH") + f_date = fc.cell_date() + f_ctr = fc.get(align="center", valign="vcenter", num_format=NUMFMT["TEXT"], + bottom=7, bottom_color=PALETTE["RULE"]) + f_link_i = fc.link_internal() + f_link_e = fc.link_external() + + for i, r in enumerate(rows): + wr = 3 + i + ws.set_row(wr, 30) + ws.write_number(wr, 0, r.sort_key, f_num) + ws.write_string(wr, 1, STATUS[r.status].symbol, f_ctr) + ws.write_string(wr, 2, r.status, f_ctr) + + target_row = ctx.ledger_row_of.get(r.dmf_key) + if target_row: + ws.write_url(wr, 3, f"internal:{lg}!A{target_row}", f_link_i, + string=r.permit_no, tip="전체 현황에서 이 등록번호 보기") + else: + ws.write_string(wr, 3, r.permit_no, f_text) + + ws.write_string(wr, 4, r.ingredient_name or "-", f_wrap) + ws.write_string(wr, 5, r.applicant or "-", f_wrap) + ws.write_string(wr, 6, r.manufacturer or "-", f_wrap) + ws.write_string(wr, 7, r.manufacture_place or "-", f_wrap) + ws.write_string(wr, 8, r.countries or "-", f_ctr) + _write_date(ws, wr, 9, r.permit_date, f_date, f_ctr) + _write_date(ws, wr, 10, r.accepted_date, f_date, f_ctr) + ws.write_string(wr, 11, r.changed_fields or "-", f_wrap) + ws.write_string(wr, 12, r.change_detail or "-", f_small) + ws.write_number(wr, 13, r.watch_hits, f_num) + ws.write_string(wr, 14, r.watch_keywords or "-", f_text) + ws.write_string(wr, 15, r.ai_note or "-", f_muted) + url = NEDRUG_SEARCH.format(q=quote(r.ingredient_name or "")) + ws.write_url(wr, 16, url, f_link_e, string="조회", + tip="의약품안전나라에서 이 성분 검색") + ws.write_string(wr, 17, r.dmf_key, f_text) + ws.write_string(wr, 18, r.content_hash, f_text) + + data_last = 3 + len(rows) - 1 + ctx.changes_last_row = data_last + 1 + _conditional(ws, fc, data_last) + _finish(ws, fc, last_col, data_last_row=data_last, empty=False) + + +def _as_row(r): + return (r.sort_key, "", r.status, r.permit_no, r.ingredient_name, r.applicant, + r.manufacturer, r.manufacture_place, r.countries, r.permit_date, + r.accepted_date, r.changed_fields, r.change_detail, r.watch_hits, + r.watch_keywords, r.ai_note, "조회", r.dmf_key, r.content_hash) + + +def _write_date(ws, row, col, value, f_date, f_dash) -> None: + if value is None: + ws.write_string(row, col, "-", f_dash) + else: + ws.write_datetime(row, col, value, f_date) + + +def _conditional(ws, fc, last: int) -> None: + # 순서가 곧 우선순위다: 행 규칙 → 열 규칙 → 데이터바/아이콘 + for label in ("신규", "변경", "취하"): # CF-01-01~03 + ws.conditional_format(3, 1, last, 18, { + "type": "formula", "criteria": f'=$C4="{label}"', + "format": fc.cf(label), + }) + ws.conditional_format(3, 13, last, 13, { # CF-01-04 + "type": "cell", "criteria": ">", "value": 0, + "format": fc.cf("워치"), + }) + ws.conditional_format(3, 8, last, 8, { # CF-01-05 + "type": "text", "criteria": "containing", "value": "대한민국", + "format": fc.cf("신규"), + }) + ws.conditional_format(3, 4, last, 5, { # CF-01-06 + "type": "formula", "criteria": "=$N4>0", + "format": fc._wb.add_format({"bold": True}), + }) + + +def _finish(ws, fc, last_col: int, *, data_last_row: int, empty: bool) -> None: + ws.freeze_panes(3, 5) + if not empty: + ws.autofilter(2, 0, data_last_row, last_col) + ws.set_landscape() + ws.set_paper(9) + ws.fit_to_pages(1, 0) + ws.set_margins(0.4, 0.4, 0.6, 0.6) + ws.repeat_rows(0, 2) + ws.print_area(0, 0, data_last_row, last_col) + ws.set_footer("&L&\"맑은 고딕\"&8DMF 오늘 변경분&C&\"맑은 고딕\"&8&P / &N" + "&R&\"맑은 고딕\"&8&D") +``` + +### 8.9 `src/dmf_crawler/report/sheets/s02_ledger.py` + +```python +"""02_전체현황 — 누적 원장. dmf_key → 행번호 사전을 ctx 에 남긴다.""" +from __future__ import annotations + +from urllib.parse import quote + +from ..theme import NUMFMT, PALETTE, SHEET_NAMES, TABLE_STYLE +from ..widgets import (apply_col_widths, compute_col_widths, write_sheet_header, + write_table_header) + +HEADER = ("등록번호", "성분명", "업체명", "제조소명", "제조소 소재지", "제조국가", + "제조국 수", "발급일자", "수리일자", "최초관측일", "최종관측일", + "최종변경일", "상태", "경과일", "원문검색", "dmf_key", "content_hash") +WIDTH_OVERRIDE = {0: 24.0, 4: 44.0, 6: 8.0, 12: 8.0, 13: 8.0, 14: 10.0, + 15: 26.0, 16: 18.0} +HIDDEN_COLS = (15, 16) +NEDRUG_SEARCH = ("https://nedrug.mfds.go.kr/pbp/CCBAC03/getItem" + "?totalPages=1&limit=10&page=1&searchYn=true&itemName={q}") + + +def build(ctx) -> None: + ws, fc, rd = ctx.sheets["ledger"], ctx.fc, ctx.rd + last_col = len(HEADER) - 1 + write_sheet_header(ws, fc, title="전체 현황 (누적 원장)", + dashboard=SHEET_NAMES["dashboard"], + report_date=f"{rd.meta.report_date:%Y-%m-%d}", + generated_at=rd.meta.finished_at[-8:-3], + last_col=last_col) + write_table_header(ws, fc, 2, HEADER, { + 4: "MNFCTR_PLACE 원문. 원본 데이터에 공백 오류가 섞여 있을 수 있다", + 13: f"기준일({rd.meta.report_date:%Y-%m-%d}) − 수리일자. 3년 이상이면 아이콘이 바뀐다", + }) + + rows = rd.ledger + body = [_as_row(r) for r in rows] + widths = compute_col_widths(HEADER, body, overrides=WIDTH_OVERRIDE) + apply_col_widths(ws, widths, options={ + **{c: {"hidden": 1, "level": 1} for c in HIDDEN_COLS}, + }) + + f_text = fc.cell_text() + f_ctr = fc.get(align="center", valign="vcenter", num_format=NUMFMT["TEXT"], + bottom=7, bottom_color=PALETTE["RULE"]) + f_num = fc.cell_num("INT") + f_date = fc.cell_date() + f_link_e = fc.link_external() + + for i, r in enumerate(rows): + wr = 3 + i + ws.set_row(wr, 15) + ctx.ledger_row_of[r.dmf_key] = wr + 1 # 1-index 행 번호 + ws.write_string(wr, 0, r.permit_no, f_text) + ws.write_string(wr, 1, r.ingredient_name or "-", f_text) + ws.write_string(wr, 2, r.applicant or "-", f_text) + ws.write_string(wr, 3, r.manufacturer or "-", f_text) + ws.write_string(wr, 4, r.manufacture_place or "-", f_text) + ws.write_string(wr, 5, r.countries or "-", f_ctr) + ws.write_number(wr, 6, r.country_count, f_num) + _wd(ws, wr, 7, r.permit_date, f_date, f_ctr) + _wd(ws, wr, 8, r.accepted_date, f_date, f_ctr) + _wd(ws, wr, 9, r.first_seen, f_date, f_ctr) + _wd(ws, wr, 10, r.last_seen, f_date, f_ctr) + _wd(ws, wr, 11, r.last_changed, f_date, f_ctr) + ws.write_string(wr, 12, r.status, f_ctr) + if r.elapsed_days is None: + ws.write_string(wr, 13, "-", f_ctr) + else: + ws.write_formula(wr, 13, f'=IF($I{wr + 1}="","-",INT(REPORT_DATE-$I{wr + 1}))', + fc.cell_num("INT_DASH"), r.elapsed_days) + ws.write_url(wr, 14, NEDRUG_SEARCH.format(q=quote(r.ingredient_name or "")), + f_link_e, string="조회") + ws.write_string(wr, 15, r.dmf_key, f_text) + ws.write_string(wr, 16, r.watch_flag, f_text) # 히든: CF-02-05 판정용 + + last = 3 + len(rows) - 1 + ctx.ledger_last_row = last + 1 + + ws.add_table(2, 0, last, last_col, { + "name": "T_LEDGER", "style": TABLE_STYLE, + "banded_rows": True, "autofilter": True, "header_row": True, + "columns": [{"header": h} for h in HEADER], + }) + _conditional(ws, fc, last) + ws.freeze_panes(3, 3) + ws.set_landscape() + ws.set_paper(9) + ws.fit_to_pages(1, 0) + ws.set_margins(0.4, 0.4, 0.6, 0.6) + ws.repeat_rows(0, 2) + ws.print_area(0, 0, last, 14) # 히든 열은 인쇄 영역에서 제외 + ws.set_footer("&L&\"맑은 고딕\"&8DMF 전체 현황&C&\"맑은 고딕\"&8&P / &N" + "&R&\"맑은 고딕\"&8&D") + + +def _as_row(r): + return (r.permit_no, r.ingredient_name, r.applicant, r.manufacturer, + r.manufacture_place, r.countries, r.country_count, r.permit_date, + r.accepted_date, r.first_seen, r.last_seen, r.last_changed, + r.status, r.elapsed_days, "조회", r.dmf_key, r.watch_flag) + + +def _wd(ws, row, col, value, f_date, f_dash) -> None: + if value is None: + ws.write_string(row, col, "-", f_dash) + else: + ws.write_datetime(row, col, value, f_date) + + +def _conditional(ws, fc, last: int) -> None: + ws.conditional_format(3, 0, last, 16, { # CF-02-03 + "type": "formula", "criteria": '=$M4="취하"', + "format": fc.cf("취하"), + }) + ws.conditional_format(3, 0, last, 0, { # CF-02-01 + "type": "duplicate", + "format": fc._wb.add_format({"bg_color": "#FFF2CC", + "font_color": "#7F6000"}), + }) + ws.conditional_format(3, 11, last, 11, { # CF-02-02 + "type": "formula", "criteria": "=$L4=REPORT_DATE", + "format": fc.cf("변경"), + }) + ws.conditional_format(3, 5, last, 5, { # CF-02-04 + "type": "text", "criteria": "containing", "value": "대한민국", + "format": fc.cf("신규"), + }) + ws.conditional_format(3, 1, last, 2, { # CF-02-05 + "type": "formula", "criteria": '=$Q4="Y"', + "format": fc.cf("워치"), + }) + ws.conditional_format(3, 6, last, 6, { # CF-02-06 + "type": "cell", "criteria": ">=", "value": 2, + "format": fc._wb.add_format({"bold": True}), + }) + ws.conditional_format(3, 8, last, 8, { # CF-02-07 + "type": "data_bar", "bar_color": PALETTE["ACCENT"], "bar_solid": True, + }) + ws.conditional_format(3, 13, last, 13, { # CF-02-08 + "type": "icon_set", "icon_style": "3_arrows_gray", + "icons_only": False, "reverse_icons": True, + "icons": [{"criteria": ">=", "type": "number", "value": 1095}, + {"criteria": ">=", "type": "number", "value": 365}], + }) +``` + +### 8.10 `src/dmf_crawler/report/sheets/s03_ingredient.py` · `s04_company.py` + +두 시트는 컬럼 구성만 다르고 로직이 같다. 공용 빌더를 두고 스펙 테이블로 분기한다. + +```python +# s03_ingredient.py +"""03_성분별 — 성분 기준 집계.""" +from __future__ import annotations + +from ._agg import AggSpec, build_agg +from ..theme import SHEET_NAMES + +SPEC = AggSpec( + sheet_key="ingredient", + title="성분별 집계", + name_header="성분명", + name_width=34.0, + partner_header="등록업체 수", + country_header="제조국 수", + domestic_header="국산 보유", + domestic_values=("Y", "N"), + has_company_key=False, + spark_block_col=29, # AD (0-index) + table_name="T_INGREDIENT", +) + + +def build(ctx) -> None: + build_agg(ctx, SPEC) +``` + +```python +# s04_company.py +"""04_업체별 — 업체 기준 집계.""" +from __future__ import annotations + +from ._agg import AggSpec, build_agg + +SPEC = AggSpec( + sheet_key="company", + title="업체별 집계", + name_header="업체명", + name_width=30.0, + partner_header="보유 성분 수", + country_header="제조소 수", + domestic_header="국산여부", + domestic_values=("국산", "해외"), + has_company_key=True, # C열에 히든 업체키를 넣는다 + spark_block_col=61, # BJ (0-index) + table_name="T_COMPANY", +) + + +def build(ctx) -> None: + build_agg(ctx, SPEC) +``` + +```python +# _agg.py — 03/04 공용 빌더 +"""성분별·업체별 집계 시트 공용 구현.""" +from __future__ import annotations + +from dataclasses import dataclass + +from ..theme import NUMFMT, PALETTE, SHEET_NAMES, TABLE_STYLE, qsheet +from ..widgets import (apply_col_widths, compute_col_widths, write_empty_notice, + write_sheet_header, write_table_header) + + +@dataclass(frozen=True, slots=True) +class AggSpec: + sheet_key: str + title: str + name_header: str + name_width: float + partner_header: str + country_header: str + domestic_header: str + domestic_values: tuple[str, str] + has_company_key: bool + spark_block_col: int + table_name: str + + +def _header(spec: AggSpec) -> tuple[str, ...]: + base = ["순위", spec.name_header] + if spec.has_company_key: + base.append("업체키") + base += ["오늘 신규", "오늘 변경", "오늘 취하", "오늘 합계"] + if not spec.has_company_key: + base.append("최근 7일") + base += ["최근 30일", "누적 유효등록", spec.partner_header, spec.country_header, + "주요 제조국", spec.domestic_header] + if spec.has_company_key: + base += ["최근 등록일", "워치리스트"] + else: + base.append("전일 대비") + base += ["30일 추이", "상세"] + return tuple(base) + + +def build_agg(ctx, spec: AggSpec) -> None: + ws, fc, rd = ctx.sheets[spec.sheet_key], ctx.fc, ctx.rd + rows = rd.ingredients if spec.sheet_key == "ingredient" else rd.companies + header = _header(spec) + last_col = len(header) - 1 + idx = {name: i for i, name in enumerate(header)} + + write_sheet_header(ws, fc, title=spec.title, + dashboard=SHEET_NAMES["dashboard"], + report_date=f"{rd.meta.report_date:%Y-%m-%d}", + generated_at=rd.meta.finished_at[-8:-3], + last_col=last_col) + write_table_header(ws, fc, 2, header, { + idx["누적 유효등록"]: "취하되지 않은 유효 등록 건수", + idx["주요 제조국"]: "이 항목에서 건수가 가장 많은 제조국", + }) + + if not rows: + write_empty_notice(ws, fc, 3, last_col, "(집계할 데이터가 없습니다)") + ws.freeze_panes(3, 2) + return + + body = [_as_row(r, spec, idx) for r in rows] + overrides = {idx["순위"]: 6.0, idx[spec.name_header]: spec.name_width, + idx["30일 추이"]: 14.0, idx["상세"]: 8.0} + if spec.has_company_key: + overrides[idx["업체키"]] = 26.0 + widths = compute_col_widths(header, body, overrides=overrides) + opts = {idx["업체키"]: {"hidden": 1}} if spec.has_company_key else {} + opts[last_col + 1] = {"hidden": 1} # 워치플래그 히든 열 (CF-03-05) + apply_col_widths(ws, widths, options=opts) + + f_text = fc.cell_text() + f_ctr = fc.get(align="center", valign="vcenter", num_format=NUMFMT["TEXT"], + bottom=7, bottom_color=PALETTE["RULE"]) + f_num = fc.cell_num("INT_DASH") + f_cum = fc.cell_num("INT") + f_date = fc.cell_date() + f_delta = fc.cell_num("DELTA") + f_link = fc.link_internal() + lg = qsheet(SHEET_NAMES["ledger"]) + trend = qsheet(SHEET_NAMES["trend"]) + + for i, r in enumerate(rows): + wr = 3 + i + ws.set_row(wr, 18) + ws.write_number(wr, idx["순위"], r.rank, f_num) + ws.write_string(wr, idx[spec.name_header], r.name or "-", f_text) + if spec.has_company_key: + ws.write_string(wr, idx["업체키"], r.key, f_text) + ws.write_number(wr, idx["오늘 신규"], r.today_new, f_num) + ws.write_number(wr, idx["오늘 변경"], r.today_changed, f_num) + ws.write_number(wr, idx["오늘 취하"], r.today_withdrawn, f_num) + c1 = _colletter(idx["오늘 신규"]) + c3 = _colletter(idx["오늘 취하"]) + ws.write_formula(wr, idx["오늘 합계"], f"=SUM(${c1}{wr + 1}:${c3}{wr + 1})", + f_num, r.today_new + r.today_changed + r.today_withdrawn) + if "최근 7일" in idx: + ws.write_number(wr, idx["최근 7일"], r.last7, f_num) + ws.write_number(wr, idx["최근 30일"], r.last30, f_num) + ws.write_number(wr, idx["누적 유효등록"], r.cumulative, f_cum) + ws.write_number(wr, idx[spec.partner_header], r.partner_count, f_cum) + ws.write_number(wr, idx[spec.country_header], r.country_count, f_cum) + ws.write_string(wr, idx["주요 제조국"], r.main_country or "-", f_ctr) + ws.write_string(wr, idx[spec.domestic_header], r.domestic, f_ctr) + if "최근 등록일" in idx: + if r.last_date is None: + ws.write_string(wr, idx["최근 등록일"], "-", f_ctr) + else: + ws.write_datetime(wr, idx["최근 등록일"], r.last_date, f_date) + if "워치리스트" in idx: + ws.write_string(wr, idx["워치리스트"], + "★" if r.watch_flag == "Y" else "-", f_ctr) + if "전일 대비" in idx: + ws.write_number(wr, idx["전일 대비"], r.delta, f_delta) + ws.write_blank(wr, idx["30일 추이"], None, f_ctr) + ws.add_sparkline(wr, idx["30일 추이"], { + "range": (f"{trend}!${_colletter(spec.spark_block_col + 1)}${wr + 1}" + f":${_colletter(spec.spark_block_col + 30)}${wr + 1}"), + "type": "column", "series_color": PALETTE["ACCENT"], + "high_point": True, "empty_cells": "zero", + }) + ws.write_url(wr, idx["상세"], f"internal:{lg}!A3", f_link, string="보기", + tip="전체 현황에서 필터로 확인") + ws.write_string(wr, last_col + 1, r.watch_flag, f_text) # 히든 판정 열 + + last = 3 + len(rows) - 1 + total_row = last + 1 + ws.add_table(2, 0, total_row, last_col, { + "name": spec.table_name, "style": TABLE_STYLE, + "banded_rows": True, "autofilter": True, "total_row": True, + "columns": _table_columns(header, idx, rows), + }) + _conditional(ws, fc, spec, idx, last, last_col) + ws.freeze_panes(3, 2) + ws.set_landscape() + ws.set_paper(9) + ws.fit_to_pages(1, 0) + ws.set_margins(0.4, 0.4, 0.6, 0.6) + ws.repeat_rows(0, 2) + ws.print_area(0, 0, total_row, last_col) + + +def _table_columns(header, idx, rows): + """add_table 컬럼 정의. 합계행은 total_value 로 캐시값을 함께 준다.""" + sum_cols = {"오늘 신규", "오늘 변경", "오늘 취하", "오늘 합계", + "최근 7일", "최근 30일", "누적 유효등록"} + attr = {"오늘 신규": "today_new", "오늘 변경": "today_changed", + "오늘 취하": "today_withdrawn", "최근 7일": "last7", + "최근 30일": "last30", "누적 유효등록": "cumulative"} + cols = [] + for name in header: + col: dict = {"header": name} + if name == "순위": + col["total_string"] = "합계" + elif name in sum_cols: + col["total_function"] = "sum" + if name == "오늘 합계": + col["total_value"] = sum(r.today_new + r.today_changed + + r.today_withdrawn for r in rows) + else: + col["total_value"] = sum(getattr(r, attr[name]) for r in rows) + cols.append(col) + return cols + + +def _conditional(ws, fc, spec, idx, last, last_col) -> None: + for name, status in (("오늘 신규", "신규"), ("오늘 변경", "변경"), + ("오늘 취하", "취하")): + c = idx[name] + ws.conditional_format(3, c, last, c, { + "type": "cell", "criteria": ">", "value": 0, + "format": fc.cf(status), + }) + c = idx[spec.name_header] + ws.conditional_format(3, c, last, c, { + "type": "formula", + "criteria": f"=${_colletter(last_col + 1)}4=\"Y\"", + "format": fc.cf("워치"), + }) + for name in ("최근 7일", "최근 30일", "누적 유효등록"): + if name not in idx: + continue + c = idx[name] + ws.conditional_format(3, c, last, c, { + "type": "data_bar", "bar_color": PALETTE["ACCENT"], "bar_solid": True, + }) + c = idx[spec.partner_header] + ws.conditional_format(3, c, last, c, { + "type": "cell", "criteria": ">=", "value": 5, + "format": fc._wb.add_format({"bold": True}), + }) + c = idx[spec.domestic_header] + ws.conditional_format(3, c, last, c, { + "type": "text", "criteria": "containing", + "value": spec.domestic_values[0], "format": fc.cf("신규"), + }) + if "전일 대비" in idx: + c = idx["전일 대비"] + ws.conditional_format(3, c, last, c, { + "type": "icon_set", "icon_style": "3_arrows_gray", "icons_only": False, + "icons": [{"criteria": ">=", "type": "number", "value": 1}, + {"criteria": ">=", "type": "number", "value": 0}], + }) + if "최근 등록일" in idx: + c = idx["최근 등록일"] + ws.conditional_format(3, c, last, c, { + "type": "formula", + "criteria": f'=AND(${_colletter(c)}4<>"",REPORT_DATE-${_colletter(c)}4>90)', + "format": fc.cf("없음"), + }) + if "워치리스트" in idx: + c = idx["워치리스트"] + ws.conditional_format(3, c, last, c, { + "type": "text", "criteria": "containing", "value": "★", + "format": fc.cf("워치"), + }) + + +def _colletter(col0: int) -> str: + """0-index 열 번호 → 엑셀 열 문자.""" + s = "" + n = col0 + 1 + while n: + n, rem = divmod(n - 1, 26) + s = chr(65 + rem) + s + return s + + +def _as_row(r, spec, idx): + row = [None] * len(idx) + row[idx["순위"]] = r.rank + row[idx[spec.name_header]] = r.name + if spec.has_company_key: + row[idx["업체키"]] = r.key + row[idx["오늘 신규"]] = r.today_new + row[idx["오늘 변경"]] = r.today_changed + row[idx["오늘 취하"]] = r.today_withdrawn + row[idx["오늘 합계"]] = r.today_new + r.today_changed + r.today_withdrawn + if "최근 7일" in idx: + row[idx["최근 7일"]] = r.last7 + row[idx["최근 30일"]] = r.last30 + row[idx["누적 유효등록"]] = r.cumulative + row[idx[spec.partner_header]] = r.partner_count + row[idx[spec.country_header]] = r.country_count + row[idx["주요 제조국"]] = r.main_country + row[idx[spec.domestic_header]] = r.domestic + if "최근 등록일" in idx: + row[idx["최근 등록일"]] = r.last_date + if "워치리스트" in idx: + row[idx["워치리스트"]] = "★" if r.watch_flag == "Y" else "-" + if "전일 대비" in idx: + row[idx["전일 대비"]] = r.delta + row[idx["30일 추이"]] = "" + row[idx["상세"]] = "보기" + return row +``` + +### 8.11 `src/dmf_crawler/report/sheets/s05_watchlist.py` + +```python +"""05_워치리스트 — 읽기 전용 미러. active 항목이 0건이면 호출되지 않는다.""" +from __future__ import annotations + +from ..theme import NUMFMT, PALETTE, SHEET_NAMES, STATUS, qsheet +from ..widgets import apply_col_widths, write_sheet_header, write_table_header + +HEADER_A = ("번호", "대상유형", "키워드", "매칭방식", "우선순위", "활성", "메모") +HEADER_B = ("오늘 매칭", "최근 7일", "최근 30일", "누적 매칭", "마지막 매칭일", "상세") +HEADER_C = ("키워드", "상태", "등록번호", "성분명", "업체명", "제조국가", "발급일자") +WIDTHS = [6, 12, 30, 12, 8, 6, 26, 1.5, 9, 9, 9, 9, 12, 8] # A..N +VALID = { + 1: ["성분명", "업체명", "제조소명", "제조국가", "등록번호"], + 3: ["부분일치", "정확일치", "정규식"], + 4: [1, 2, 3], + 5: ["Y", "N"], +} + + +def build(ctx) -> None: + ws, fc, rd = ctx.sheets["watchlist"], ctx.fc, ctx.rd + write_sheet_header(ws, fc, title="워치리스트 (읽기 전용)", + dashboard=SHEET_NAMES["dashboard"], + report_date=f"{rd.meta.report_date:%Y-%m-%d}", + generated_at=rd.meta.finished_at[-8:-3], last_col=13) + apply_col_widths(ws, WIDTHS) + write_table_header(ws, fc, 2, HEADER_A + ("",) + HEADER_B, { + 2: "이 값이 성분명·업체명 등에 포함되면 히트로 센다", + 3: "정규식 모드는 파이썬 re 문법을 따른다", + }) + + f_text = fc.cell_text() + f_ctr = fc.get(align="center", valign="vcenter", num_format=NUMFMT["TEXT"], + bottom=7, bottom_color=PALETTE["RULE"]) + f_num = fc.cell_num("INT_DASH") + f_cum = fc.cell_num("INT") + f_date = fc.cell_date() + f_link = fc.link_internal() + ch = qsheet(SHEET_NAMES["changes"]) + + for i, w in enumerate(rd.watch): + wr = 3 + i + ws.set_row(wr, 16) + ws.write_number(wr, 0, w.no, f_num) + ws.write_string(wr, 1, w.target_type, f_ctr) + ws.write_string(wr, 2, w.keyword, f_text) + ws.write_string(wr, 3, w.match_mode, f_ctr) + ws.write_number(wr, 4, w.priority, f_num) + ws.write_string(wr, 5, w.active, f_ctr) + ws.write_string(wr, 6, w.memo or "-", f_text) + ws.write_formula( + wr, 8, + f'=IF($C{wr + 1}="",0,COUNTIF({ch}!$O$4:$O$100000,"*"&$C{wr + 1}&"*"))', + f_num, w.today) + ws.write_number(wr, 9, w.last7, f_num) + ws.write_number(wr, 10, w.last30, f_num) + ws.write_number(wr, 11, w.cumulative, f_cum) + if w.last_hit is None: + ws.write_string(wr, 12, "-", f_ctr) + else: + ws.write_datetime(wr, 12, w.last_hit, f_date) + ws.write_url(wr, 13, f"internal:{ch}!A3", f_link, string="보기") + + last = 3 + len(rd.watch) - 1 + for col, values in VALID.items(): + ws.data_validation(3, col, last, col, + {"validate": "list", "source": values}) + _conditional_ab(ws, fc, last) + + # ── 블록 C: 매칭 상세표 ────────────────────────────────────── + cstart = last + 3 + ws.write(cstart - 1, 0, "오늘 매칭 상세", + fc.get(font_size=12, bold=True, valign="vcenter")) + write_table_header(ws, fc, cstart, HEADER_C, height=24) + for i, d in enumerate(rd.watch_detail): + wr = cstart + 1 + i + ws.set_row(wr, 16) + ws.write_string(wr, 0, d.keyword, f_text) + ws.write_string(wr, 1, d.status, f_ctr) + ws.write_string(wr, 2, d.permit_no, f_text) + ws.write_string(wr, 3, d.ingredient_name or "-", f_text) + ws.write_string(wr, 4, d.applicant or "-", f_text) + ws.write_string(wr, 5, d.countries or "-", f_ctr) + if d.permit_date is None: + ws.write_string(wr, 6, "-", f_ctr) + else: + ws.write_datetime(wr, 6, d.permit_date, f_date) + cend = cstart + max(1, len(rd.watch_detail)) + for label in ("신규", "변경", "취하"): + ws.conditional_format(cstart + 1, 0, cend, 6, { + "type": "formula", + "criteria": f'=$B{cstart + 2}="{label}"', + "format": fc.cf(label), + }) + + ws.freeze_panes(3, 3) + ws.protect("", {"objects": True, "scenarios": True, + "select_locked_cells": True, "select_unlocked_cells": True, + "sort": True, "autofilter": True}) + ws.set_landscape() + ws.set_paper(9) + ws.fit_to_pages(1, 0) + ws.set_margins(0.4, 0.4, 0.6, 0.6) + ws.repeat_rows(0, 2) + ws.print_area(0, 0, cend, 13) + + +def _conditional_ab(ws, fc, last: int) -> None: + ws.conditional_format(3, 8, last, 8, { # CF-05-01 + "type": "cell", "criteria": ">", "value": 0, "format": fc.cf("워치"), + }) + ws.conditional_format(3, 12, last, 12, { # CF-05-02 + "type": "formula", "criteria": "=$M4=REPORT_DATE", "format": fc.cf("워치"), + }) + ws.conditional_format(3, 2, last, 2, { # CF-05-03 + "type": "blanks", "format": fc.cf("취하"), + }) + for c in (9, 10): # CF-05-04 + ws.conditional_format(3, c, last, c, { + "type": "data_bar", "bar_color": PALETTE["WATCH"], "bar_solid": True, + }) +``` + +### 8.12 `src/dmf_crawler/report/sheets/s06_trend.py` + +```python +"""06_추이 — 시각화 원본. 가장 먼저 만들어진다.""" +from __future__ import annotations + +from ..theme import NUMFMT, PALETTE, SHEET_NAMES, TABLE_STYLE +from ..widgets import apply_col_widths, write_sheet_header, write_table_header + +HEADER = ("일자", "신규", "변경", "취하", "합계", "워치 히트", + "누적 유효등록", "수집 건수", "실행 상태") +WIDTHS = [12, 9, 9, 9, 9, 9, 12, 10, 10] +BAR_COLORS = {1: "#009E73", 2: "#E69F00", 3: "#D55E00"} + + +def build(ctx) -> None: + ws, fc, rd = ctx.sheets["trend"], ctx.fc, ctx.rd + write_sheet_header(ws, fc, title="일자별 추이", + dashboard=SHEET_NAMES["dashboard"], + report_date=f"{rd.meta.report_date:%Y-%m-%d}", + generated_at=rd.meta.finished_at[-8:-3], last_col=8) + apply_col_widths(ws, WIDTHS) + write_table_header(ws, fc, 2, HEADER, { + 6: "그날 스냅샷의 유효 등록 총건수", + 7: "그날 API 에서 실제로 수집한 행 수. 급감하면 붉게 표시된다", + }) + + f_num = fc.cell_num("INT_DASH") + f_cum = fc.cell_num("INT") + f_date = fc.cell_date() + f_ctr = fc.get(align="center", valign="vcenter", num_format=NUMFMT["TEXT"], + bottom=7, bottom_color=PALETTE["RULE"]) + + for i, t in enumerate(rd.trend): + wr = 3 + i + ws.set_row(wr, 16) + ws.write_datetime(wr, 0, t.day, f_date) + ws.write_number(wr, 1, t.new, f_num) + ws.write_number(wr, 2, t.changed, f_num) + ws.write_number(wr, 3, t.withdrawn, f_num) + ws.write_formula(wr, 4, f"=SUM($B{wr + 1}:$D{wr + 1})", f_num, t.total) + ws.write_number(wr, 5, t.watch_hits, f_num) + ws.write_number(wr, 6, t.active_total, f_cum) + ws.write_number(wr, 7, t.fetched_rows, f_cum) + ws.write_string(wr, 8, t.run_status, f_ctr) + + last = 3 + len(rd.trend) - 1 + ctx.trend_first_row = 4 + ctx.trend_last_row = last + 1 + ctx.spark_first_row = max(4, ctx.trend_last_row - 29) + + ws.add_table(2, 0, last, 8, { + "name": "T_TREND", "style": TABLE_STYLE, + "banded_rows": True, "autofilter": True, + "columns": [{"header": h} for h in HEADER], + }) + _conditional(ws, fc, last) + _hidden_blocks(ctx, ws, fc, rd) + + ws.freeze_panes(3, 1) + ws.set_landscape() + ws.set_paper(9) + ws.fit_to_pages(1, 0) + ws.set_margins(0.4, 0.4, 0.6, 0.6) + ws.repeat_rows(0, 2) + ws.print_area(0, 0, last, 8) + + +def _conditional(ws, fc, last: int) -> None: + ws.conditional_format(3, 0, last, 0, { # CF-06-01 + "type": "formula", "criteria": "=$A4=REPORT_DATE", "format": fc.cf("정보"), + }) + for c, color in BAR_COLORS.items(): # CF-06-02 + ws.conditional_format(3, c, last, c, { + "type": "data_bar", "bar_color": color, "bar_solid": True, + }) + ws.conditional_format(3, 5, last, 5, { + "type": "cell", "criteria": ">", "value": 0, "format": fc.cf("워치"), + }) + ws.conditional_format(4, 7, last, 7, { # CF-06-03 + "type": "formula", "criteria": "=AND($H4<>\"\",$H4<$H3*0.95)", + "format": fc.cf("취하"), + }) + for value, status in (("SUCCESS", "신규"), ("PARTIAL", "변경"), + ("FAILED", "취하")): # CF-06-04 + ws.conditional_format(3, 8, last, 8, { + "type": "text", "criteria": "containing", "value": value, + "format": fc.cf(status), + }) + + +def _hidden_blocks(ctx, ws, fc, rd) -> None: + """차트2·스파크라인 원본. 열 폭 0 + hidden.""" + plain = fc.get() + num = fc.get(num_format=NUMFMT["INT"]) + # AA(26)/AB(27): 제조국 Top 8 — 차트2 원본 + ws.set_column(26, 27, 12, None, {"hidden": 1}) + ws.write(2, 26, "제조국", plain) + ws.write(2, 27, "건수", plain) + for i, (country, n) in enumerate(rd.top_countries): + ws.write_string(3 + i, 26, country, plain) + ws.write_number(3 + i, 27, n, num) + + # AD(29)~BH(59): 성분별 30일 시계열 + ws.set_column(29, 59, 6, None, {"hidden": 1}) + ws.write(2, 29, "성분명", plain) + for i, d in enumerate(rd.trend_dates[-30:]): + ws.write_datetime(2, 30 + i, d, fc.cell_date()) + for r, agg in enumerate(rd.ingredients): + ws.write_string(3 + r, 29, agg.name, plain) + for c, v in enumerate(agg.series30[:30]): + ws.write_number(3 + r, 30 + c, v, num) + + # BJ(61)~CN(91): 업체별 30일 시계열 + ws.set_column(61, 91, 6, None, {"hidden": 1}) + ws.write(2, 61, "업체명", plain) + for i, d in enumerate(rd.trend_dates[-30:]): + ws.write_datetime(2, 62 + i, d, fc.cell_date()) + for r, agg in enumerate(rd.companies): + ws.write_string(3 + r, 61, agg.name, plain) + for c, v in enumerate(agg.series30[:30]): + ws.write_number(3 + r, 62 + c, v, num) +``` + +### 8.13 `src/dmf_crawler/report/sheets/s99_meta.py` + +```python +"""99_메타 — 수집 메타·소스·무결성 게이트·AI 상태·코드북·면책.""" +from __future__ import annotations + +from ..theme import NUMFMT, PALETTE, SHEET_NAMES +from ..widgets import apply_col_widths, write_sheet_header + +WIDTHS = [28, 46, 16, 16, 30] +CODEBOOK = ( + ("상태", "신규", "어제 스냅샷에 없던 등록번호가 오늘 나타남"), + ("상태", "변경", "양쪽에 있으나 비교 대상 6필드 중 하나 이상이 달라짐"), + ("상태", "취하", "어제 있었으나 오늘 스냅샷에서 사라짐"), + ("등록번호", "20121228-168-I-169-04", "발급일자8-성분일련-시행군-접수일련-성분내일련"), + ("실행 상태", "SUCCESS", "전 단계 성공"), + ("실행 상태", "PARTIAL", "무결성 게이트 차단 또는 선택 단계 실패. 수치는 직전 성공 기준"), + ("실행 상태", "FAILED", "필수 단계 실패. 리포트가 갱신되지 않았을 수 있다"), +) + + +def build(ctx) -> None: + ws, fc, rd = ctx.sheets["meta"], ctx.fc, ctx.rd + m, ai = rd.meta, rd.ai + write_sheet_header(ws, fc, title="메타·로그", + dashboard=SHEET_NAMES["dashboard"], + report_date=f"{m.report_date:%Y-%m-%d}", + generated_at=m.finished_at[-8:-3], last_col=4) + apply_col_widths(ws, WIDTHS) + + k = fc.get(bold=True, font_color=PALETTE["MUTED"], valign="vcenter") + v = fc.get(valign="vcenter", num_format=NUMFMT["TEXT"]) + vd = fc.get(valign="vcenter", num_format=NUMFMT["DATE"]) + vn = fc.get(valign="vcenter", num_format=NUMFMT["INT"]) + link = fc.link_external() + hdr = fc.header(wrap=False) + + # ── 블록 1: 실행 메타 (A3:B18) ────────────────────────────── + ws.write_string(2, 0, "리포트 기준일", k) + ws.write_datetime(2, 1, m.report_date, vd) # ← REPORT_DATE 원본 + pairs = ( + ("수집 시작 시각", m.started_at), ("수집 종료 시각", m.finished_at), + ("실행 ID", m.run_id), ("실행 상태", m.run_status), + ("실행 트리거", m.trigger), ("실행 호스트", m.host), + ("파이썬 버전", m.python_version), ("XlsxWriter 버전", m.xlsxwriter_version), + ("직전 성공 실행", m.prev_run_id or "-"), + ) + for i, (key, val) in enumerate(pairs): + ws.write_string(3 + i, 0, key, k) + ws.write_string(3 + i, 1, str(val), v) + files = (("실행 로그 경로", m.log_dir), ("이벤트 로그 경로", m.events_path), + ("원문 아카이브", m.archive_dir), ("DB 백업", m.backup_path), + ("스냅샷 해시", m.snapshot_hash), ("직전 리포트", m.prev_report_path)) + for i, (key, val) in enumerate(files): + r = 12 + i + ws.write_string(r, 0, key, k) + if val and key != "스냅샷 해시": + ws.write_url(r, 1, f"external:{val}", link, string=val) + else: + ws.write_string(r, 1, val or "-", v) + + # ── 블록 2: 소스 (A21:E22) ─────────────────────────────────── + ws.write_row(20, 0, ("소스명", "URL", "HTTP", "수집행수", "비고"), hdr) + ws.write_string(21, 0, m.source_name, v) + ws.write_url(21, 1, m.source_url, link, string=m.source_url) + ws.write_string(21, 2, m.http_summary, v) + ws.write_number(21, 3, m.fetched_rows, vn) + ws.write_string(21, 4, "공공데이터포털 15057075", v) + + # ── 블록 3: 무결성 게이트 (A26:E32) ────────────────────────── + ws.write_row(25, 0, ("게이트", "기대", "실제", "판정", "상세"), hdr) + for i, g in enumerate(rd.gates): + r = 26 + i + ws.write_string(r, 0, g.name, k) + ws.write_string(r, 1, g.expected, v) + ws.write_string(r, 2, g.actual, v) + ws.write_formula(r, 3, f"=IF($C{r + 1}=$B{r + 1},\"OK\",\"불일치\")", + v, g.verdict) + ws.write_string(r, 4, g.detail, v) + tail = 26 + len(rd.gates) + ws.write_string(tail, 0, "종합", k) + ws.write_formula(tail, 3, + f'=IF(COUNTIF($D$27:$D${tail},"불일치")=0,"OK","불일치")', + v, "OK" if all(g.verdict == "OK" for g in rd.gates) else "불일치") + for value, status in (("OK", "신규"), ("불일치", "취하")): # CF-99-01 + ws.conditional_format(26, 3, tail, 3, { + "type": "text", "criteria": "containing", "value": value, + "format": fc.cf(status), + }) + + # ── 블록 4: AI 계층 상태 ───────────────────────────────────── + base = tail + 3 + ws.write_string(base - 1, 0, "AI 계층 (agy)", + fc.get(font_size=12, bold=True, valign="vcenter")) + ai_pairs = ( + ("agy 사용 여부", ai.used), ("모델", ai.model or "-"), + ("노력 수준", ai.effort or "-"), + ("입력 토큰", f"{ai.input_tokens:,}"), + ("출력 토큰", f"{ai.output_tokens:,}"), + ("소요 시간(초)", f"{ai.duration_s:.1f}"), + ("오늘 누적 토큰", f"{ai.tokens_today:,} / {ai.token_cap:,}"), + ("헤드라인", ai.headline or "(AI 요약 없음)"), + ("요약", ai.summary or "(AI 요약 없음)"), + ("리스크 메모", ai.risk_note or "-"), + ) + for i, (key, val) in enumerate(ai_pairs): + ws.write_string(base + i, 0, key, k) + ws.write_string(base + i, 1, str(val), v) + envr = base + len(ai_pairs) + ws.write_string(envr, 0, "봉투 원문 경로", k) + if ai.envelope_path: + ws.write_url(envr, 1, f"external:{ai.envelope_path}", link, + string=ai.envelope_path) + else: + ws.write_string(envr, 1, "-", v) + ws.conditional_format(base, 1, base, 1, { # CF-99-02 + "type": "text", "criteria": "containing", "value": "실패", + "format": fc.cf("취하"), + }) + + # ── 블록 5: 코드북 ─────────────────────────────────────────── + cb = envr + 3 + ws.write_row(cb, 0, ("구분", "코드", "설명"), hdr) + for i, (g, code, desc) in enumerate(CODEBOOK): + ws.write_string(cb + 1 + i, 0, g, v) + ws.write_string(cb + 1 + i, 1, code, v) + ws.write_string(cb + 1 + i, 2, desc, v) + + # ── 블록 6: 출처·면책 ──────────────────────────────────────── + foot = cb + len(CODEBOOK) + 3 + ws.merge_range( + foot, 0, foot, 4, + "출처: 공공데이터포털 「의약품 원료의약품 등록 정보」(서비스 15057075), " + "식품의약품안전처. 이용약관에 따라 출처를 표시하고 정중한 호출 간격을 지킨다.", + fc.footnote()) + ws.merge_range( + foot + 1, 0, foot + 1, 4, + "면책: 본 리포트는 공개 데이터를 자동 수집·가공한 참고 자료이며, " + "원본과 차이가 있을 수 있고 법적 효력이 없다. " + "규제 판단은 반드시 원문 공고를 확인할 것.", + fc.footnote()) + + ws.freeze_panes(3, 0) + ws.set_portrait() + ws.set_paper(9) + ws.fit_to_pages(1, 0) + ws.set_margins(0.5, 0.5, 0.6, 0.6) + ws.print_area(0, 0, foot + 1, 4) +``` + +--- + +## 9. 인쇄·공유 설정 + +### 9.1 시트별 인쇄 설정표 + +| 시트 | 방향 | 용지 | 맞춤 | 제목 반복 | 인쇄 영역 | 눈금선 인쇄 | 가운데 | +|---|---|---|---|---|---|---|---| +| `00_대시보드` | 가로 | A4(`set_paper(9)`) | `fit_to_pages(1, 1)` | 없음 | `A1:S47` | 숨김(`hide_gridlines(2)`) | 가로 가운데 | +| `01_오늘변경분` | 가로 | A4 | `fit_to_pages(1, 0)` | `repeat_rows(0, 2)` | `A1:S{last}` | 기본 | 아니오 | +| `02_전체현황` | 가로 | A4 | `fit_to_pages(1, 0)` | `repeat_rows(0, 2)` | `A1:O{last}` (히든 열 제외) | 기본 | 아니오 | +| `03_성분별` | 가로 | A4 | `fit_to_pages(1, 0)` | `repeat_rows(0, 2)` | `A1:P{total_row}` | 기본 | 아니오 | +| `04_업체별` | 가로 | A4 | `fit_to_pages(1, 0)` | `repeat_rows(0, 2)` | `A1:Q{total_row}` | 기본 | 아니오 | +| `05_워치리스트` | 가로 | A4 | `fit_to_pages(1, 0)` | `repeat_rows(0, 2)` | `A1:N{cend}` | 기본 | 아니오 | +| `06_추이` | 가로 | A4 | `fit_to_pages(1, 0)` | `repeat_rows(0, 2)` | `A1:I{last}` | 기본 | 아니오 | +| `99_메타` | **세로** | A4 | `fit_to_pages(1, 0)` | 없음 | `A1:E{foot+1}` | 기본 | 아니오 | + +- `fit_to_pages(1, 0)` = **너비 1페이지, 높이 무제한**. 원장이 수백 페이지가 되어도 열이 잘리지 않는다. +- `99_메타` 만 세로다. 5열짜리 목록이라 가로로 하면 여백만 커진다. +- **인쇄 영역에서 히든 열은 제외**한다. 숨겨진 열은 인쇄되지 않지만, 인쇄 영역 끝을 히든 열에 두면 마지막 페이지에 빈 페이지가 생기는 경우가 있다. + +### 9.2 여백·머리글·바닥글 + +```python +ws.set_margins(left=0.4, right=0.4, top=0.6, bottom=0.6) # 인치 +ws.set_header("") # 머리글 없음 +ws.set_footer('&L&"맑은 고딕"&8{시트 이름}' + '&C&"맑은 고딕"&8&P / &N' + '&R&"맑은 고딕"&8&D') +``` + +| 코드 | 의미 | +|---|---| +| `&L` / `&C` / `&R` | 좌 / 가운데 / 우 구역 | +| `&"맑은 고딕"` | 폰트 지정 | +| `&8` | 8pt | +| `&P` / `&N` | 현재 페이지 / 전체 페이지 | +| `&D` / `&T` | 인쇄 날짜 / 시각 | + +머리글을 비우는 이유: 1행에 이미 제목과 기준일이 있고, `repeat_rows(0, 2)` 로 매 페이지 반복되므로 머리글이 중복된다. + +### 9.3 화면 공유용 설정 + +| 항목 | 값 | 이유 | +|---|---|---| +| 최초 열림 창 크기 | `wb.set_size(1500, 900)` | 노트북 화면에서 대시보드가 잘리지 않게 | +| 최초 활성 시트 | `00_대시보드` | 파일을 여는 사람이 먼저 볼 것 | +| 최초 활성 셀 | 각 시트 `A1` (기본) | 스크롤 위치 초기화 | +| 확대 | 대시보드 90%, 나머지 100% | | +| 문서 속성 | `set_properties(...)` 로 제목·주제·작성자·키워드 채움 | 파일 탐색기 미리보기와 검색에 잡힘 | +| 시트 보호 | `05_워치리스트` 만 | 읽기 전용 미러임을 물리적으로 표시 | +| 통합문서 보호 | **걸지 않는다** | 사용자가 시트를 복사·추가해 자기 분석을 할 수 있어야 한다 | +| 비밀번호 | **없음** | 자동 배치가 만드는 파일이라 비밀번호 관리 주체가 없다 | + +### 9.4 PDF 변환 + +내장 변환은 제공하지 않는다. LibreOffice headless 는 아키텍처 부록에서 기각됐고, Excel COM 자동화는 Excel 설치와 대화형 세션을 요구해 배치(S4U)에서 동작하지 않는다. 사용자가 Excel 에서 `파일 → 내보내기 → PDF` 를 누르면 위 인쇄 설정이 그대로 적용된다. **인쇄 설정을 정확히 잡아 두는 것이 곧 PDF 품질 보증이다.** + +--- + +## 10. 파일명·보관 규약 + +### 10.1 파일명 + +| 종류 | 패턴 | 예 | 설정키 | +|---|---|---|---| +| 일자별 정본 | `DMF_리포트_{date}.xlsx` (`date` = `YYYY-MM-DD`) | `DMF_리포트_2026-09-02.xlsx` | `report.filename_pattern` | +| 최신본 고정 링크 | `DMF_리포트_최신.xlsx` | 좌동 | `report.latest_link_name` (빈 문자열이면 생성 안 함) | +| 잠김 폴백 | `DMF_리포트_{date}_{HHMMSS}.xlsx` | `DMF_리포트_2026-09-02_060241.xlsx` | 자동 | +| 생성 중 임시 | `.~dmf_XXXXXX.xlsx` (`tempfile.mkstemp`) | — | 자동, 반드시 같은 디렉터리 | +| 최신본 임시 | `.~dmflatest_XXXXXX.xlsx` | — | 자동 | + +**날짜는 `report_date` 이지 실행 시각이 아니다.** 06:00 실행이 자정을 넘겨 07:00 에 끝나도 파일명은 그날 날짜다. `general.timezone`(`Asia/Seoul`) 기준으로 판정한다. + +**파일명에 한글을 쓰는 이유**: 사용자가 탐색기에서 바로 찾는 것이 이 프로젝트의 배포 방식이다. Windows 로 배포처가 한정되므로 인코딩 문제가 없다. 다만 이메일 첨부나 웹 업로드 시 깨질 수 있음을 README 에 적는다. + +### 10.2 "최신본" 을 심볼릭 링크로 만들지 않는 이유 + +| 방식 | 문제 | +|---|---| +| 심볼릭 링크(`os.symlink`) | Windows 에서 관리자 권한 또는 개발자 모드 필요. 배치는 일반 사용자 계정(S4U)으로 돈다 | +| 하드링크(`os.link`) | 원본을 보존기간 정리로 지우면 최신본만 남아, 어느 날짜인지 알 수 없게 된다 | +| 바로가기(`.lnk`) | xlsx 가 아니라 파일 형식이 달라진다. 더블클릭 동작이 환경마다 다르다 | +| **원자적 복사(채택)** | 디스크를 2배 쓰지만 파일 하나가 수 MB 수준이라 무시할 수 있다. 완전히 독립적이고 안전하다 | + +`refresh_latest_link()` 는 최신본이 열려 있으면 **조용히 실패하고 False 를 반환**한다. 리포트 본체는 이미 저장됐으므로 실패시켜서는 안 된다. 경고만 `ReportOutcome.warnings` 에 남긴다. + +### 10.3 보관 폴더 구조 + +``` +reports\ +├── DMF_리포트_최신.xlsx ← 항상 최신 성공본의 복사 +├── DMF_리포트_2026-09-02.xlsx +├── DMF_리포트_2026-09-01.xlsx +├── DMF_리포트_2026-08-31.xlsx +└── … ← report.retain_days(기본 365) 초과분 삭제 +``` + +**하위 폴더를 만들지 않는다.** 아키텍처 §2 의 확정 트리가 `reports\` 를 평면으로 정의했고, 1년치 365개는 탐색기가 문제없이 다룬다. 날짜가 파일명에 있어 이름순 정렬이 곧 시간순이다. + +### 10.4 보존 정리 규칙 + +`finalize` 스테이지에서 로그 정리와 함께 수행한다. + +| 대상 | 보존 | 설정키 | +|---|---|---| +| `reports\DMF_리포트_YYYY-MM-DD.xlsx` | `report.retain_days` = 365일 | `report.retain_days` | +| `reports\DMF_리포트_최신.xlsx` | 삭제하지 않음 | — | +| 폴백 파일(`_HHMMSS.xlsx`) | **7일**. 정규 파일이 같은 날짜로 존재하면 즉시 삭제 후보 | 하드코딩 | +| 잔여 임시 파일(`.~dmf_*.xlsx`) | 24시간 초과분 무조건 삭제 | 하드코딩 | +| `data\raw\YYYY-MM-DD\` | `source.archive_retain_days` = 180일 | 별도 | +| `logs\run_*\` | `logging.retain_days` = 90일 | 별도 | + +```python +def prune_reports(out_dir: Path, retain_days: int, today: date) -> list[Path]: + """보존 기간 초과 리포트와 잔여 임시 파일을 지운다. 지운 경로 목록 반환.""" + removed: list[Path] = [] + cutoff = today - timedelta(days=retain_days) + for p in out_dir.glob("DMF_리포트_*.xlsx"): + if p.name == "DMF_리포트_최신.xlsx": + continue + m = re.match(r"DMF_리포트_(\d{4}-\d{2}-\d{2})", p.stem) + if not m: + continue + d = date.fromisoformat(m.group(1)) + # 폴백 파일: 정규 파일이 있으면 7일, 없으면 정규와 동일 취급 + is_fallback = re.search(r"_\d{6}$", p.stem) is not None + limit = today - timedelta(days=7) if is_fallback else cutoff + if d < limit: + p.unlink(missing_ok=True) + removed.append(p) + now = time.time() + for p in out_dir.glob(".~dmf*.xlsx"): + if now - p.stat().st_mtime > 86400: + p.unlink(missing_ok=True) + removed.append(p) + return removed +``` + +### 10.5 백업과의 관계 + +리포트는 **재생성 가능한 파생물**이다. SQLite 정본(`data\dmf.sqlite3`)과 원문 아카이브(`data\raw\`)가 살아 있으면 `python -m dmf_crawler report-only --run-id ` 로 언제든 다시 만들 수 있다. 따라서 `backup\` 에는 리포트를 넣지 않는다. 백업 대상은 DB 하나다. + +--- + +## 부록. 미해결 / 실측 필요 + +### A. 실측이 필요한 것 + +- [ ] nedrug 성분명 검색 URL(`CCBAC03/getItem?...&itemName=`)이 실제로 동작하는지. 실패 시 목록 URL 폴백으로 전환. +- [ ] XlsxWriter `define_name()` 이 한글 정의 이름을 통과시키는지. 통과하면 `REPORT_DATE` 옆에 `기준일` 같은 한글 별칭을 추가할 수 있다(현재는 ASCII 만 사용). +- [ ] `add_table` 의 `total_value` 옵션이 설치 버전에서 실제로 캐시값을 기록하는지. 기록하지 않으면 합계행을 표 밖의 일반 행으로 옮긴다. +- [ ] `write_url` 로 만든 `external:` 로컬 경로 링크가 한글 경로(`DMF_리포트_최신.xlsx`)에서 정상 동작하는지. Excel 보안 설정에 따라 차단될 수 있다. +- [ ] 조건부서식에서 정의된 이름(`REPORT_DATE`)을 참조하는 수식(`CF-02-02`, `CF-05-02`, `CF-06-01`)이 Excel 에서 정상 평가되는지. 실패하면 기준일 문자열을 리터럴로 굽는다. +- [ ] 스파크라인 30 포인트가 `column` 타입에서 96px 폭(2열 병합)에 시각적으로 읽히는지. 안 읽히면 14 포인트로 줄인다. +- [ ] `records` 테이블 행수가 수만 건일 때 `02_전체현황` 생성 시간과 파일 크기. 30초를 넘으면 Excel 표를 포기하고 `autofilter` 로 대체(`constant_memory` 를 켤 수 있게). +- [ ] 맑은 고딕 10pt 기준 열 폭 계수 1.8 의 실제 적합도. 첫 산출물을 열어 눈으로 확인하고 1.7~1.9 사이에서 보정. +- [ ] `SQL_TOP_COUNTRY` 의 `json_each` 트릭이 설치된 SQLite 버전에서 동작하는지(JSON1 확장 필요). 안 되면 파이썬에서 콤마 분해로 집계. +- [ ] `06_추이` 히든 블록의 열 인덱스(AD=29, BJ=61)가 성분·업체 30일 시계열 폭과 충돌하지 않는지. Top N 이 커지면 블록 시작 열을 재배치. + +### B. 상위 문서 확정 후 갱신할 것 + +- [ ] `docs/design/02-data-model.md` 확정 시 §8.4 의 잠정 스키마·SQL 을 실제 DDL 에 맞춰 교체. +- [ ] `records.status` 의 값 도메인(`유효` / `취하`)이 데이터 모델에서 확정되면 `CF-02-03`·`SQL_*_CUM` 의 리터럴을 맞춘다. +- [ ] `watchlist` 테이블의 편집 UI 를 `docs/design/04-onboarding-wizard.md` 에서 확정. 현재는 "설정 GUI 에서 편집" 이라고만 정해 두었고 화면이 없다. +- [ ] `enrichment` 스키마(`headline`/`summary`/`risk_note`/`note`/`importance`)가 `prompts/daily_briefing.schema.json` 과 일치하는지 M3 에서 교차 확인. + +### C. 의도적으로 채택하지 않은 것 (기록) + +- [ ] **`스냅샷·로그` 시트** — 리서치 07 제안. SQLite `events` 가 정본이라 xlsx 중복. `99_메타` 의 경로 포인터로 대체. +- [ ] **상태 `연차보고` / `사전등록`** — 원천 API 에 등록구분 필드가 없다. nedrug 화면 크롤링을 추가하면 그때 재검토. +- [ ] **피벗테이블** — 파이썬에서 생성 불가. `03`/`04` 의 정적 집계표 + 차트로 대체(리서치 07 §10 의 방식 A). +- [ ] **워치리스트 사용자 편집 왕복** — openpyxl 의존을 만들어야 해서 기각. SSOT 를 SQLite 로 옮겨 해결. +- [ ] **도형(shape) 기반 KPI 카드** — XlsxWriter 로 도형 그룹을 만들 수 없다. 셀 병합 + 테두리 + 배경으로 동일 효과. +- [ ] **신호등 아이콘셋** — CVD 위험(red/green + 동일 형태). `3_arrows_gray` 만 사용. +- [ ] **`constant_memory` 모드** — Excel 표와 배타적. 현재 규모에서 불필요. +- [ ] **PDF 자동 변환** — LibreOffice·Excel COM 둘 다 기각. 인쇄 설정으로 대체. +- [ ] **Okabe-Ito Yellow(`#F0E442`)** — 흰 배경 대비 1.32. 팔레트에는 있으나 사용 금지. diff --git a/docs/design/04-onboarding-wizard.md b/docs/design/04-onboarding-wizard.md new file mode 100644 index 0000000..1c53dcf --- /dev/null +++ b/docs/design/04-onboarding-wizard.md @@ -0,0 +1,4862 @@ +# 첫 실행 온보딩 마법사 설계 — 더블클릭으로 끝내는 설정 + +> **이 문서의 역할**: 이 시스템을 쓰는 사람은 **개발자가 아니다.** 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 ] [--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 ` 로 강제 기동한다 (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 ` 를 워커 스레드에서 실행하고 출력을 [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 ` (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("", save)` / `bind("", 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"([^<]*)", body) + e = re.search(r"([^<]*)", 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 | 이 자료에 대한 사용 신청이 아직 완료되지 않았습니다.
마이페이지 > 데이터 활용 > Open API > 활용신청 현황 에서 상태를 확인해 주세요. | ❌ | [마이페이지 열기] | +| `21` | TEMPORARILY_DISABLE_THE_SERVICEKEY_ERROR | 인증키가 일시적으로 중지된 상태입니다. | ❌ | [마이페이지 열기] | +| **`22`** | LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS | ▲ 오늘 조회 횟수를 다 썼습니다. **인증키 자체는 정상입니다.**
자정이 지나면 자동으로 초기화됩니다. | **✅ 저장** | 저장하고 닫음 | +| **`23`** | ..._PER_SECOND_EXCEEDS_ERROR | ▲ 잠시 뒤 다시 시도해 주세요. **인증키 자체는 정상입니다.** | **✅ 저장** | 저장하고 닫음 | +| `29` | BLACKLIST_IP_ACCESS_ERROR | 이 컴퓨터의 인터넷 주소가 차단되어 있습니다.
공공데이터포털 활용지원센터(1566-0025)로 문의해 주세요. | ❌ | [전화번호 복사] | +| **`30`** | SERVICE_KEY_IS_NOT_REGISTERED_ERROR | 등록되지 않은 인증키입니다.
**포털에서 인증키를 새로 발급하면 예전 키는 자동으로 사라집니다.**
마이페이지에서 현재 인증키를 다시 복사해 주세요. | ❌ | [마이페이지 열기] | +| **`31`** | DEADLINE_HAS_EXPIRED_ERROR | 인증키 사용 기간이 끝났습니다.
마이페이지 > 활용신청 현황 에서 '활용연장신청' 을 해주세요. | ❌ | [마이페이지 열기] | +| `32` | UNREGISTERED_IP_ERROR | 신청할 때 등록한 인터넷 주소와 지금 주소가 다릅니다. | ❌ | [마이페이지 열기] | +| `10` `11` `12` | INVALID_REQUEST / NO_OPENAPI_SERVICE | 자료 제공 방식이 바뀐 것 같습니다.
프로그램 업데이트가 필요할 수 있습니다. | ❌ | [자료 안내 페이지 열기] | +| `01` `04` `05` `99` | 일시 장애 | 잠시 문제가 있었습니다. 다시 시도해 주세요. | ❌ | [다시 시도] | +| — | network | 인터넷 연결을 확인해 주세요.
회사 PC 라면 IT 담당자에게 apis.data.go.kr (443) 허용을 요청해 주세요. | ❌ | [다시 시도] / [주소 복사] | + +```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.","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("", lambda _e: self._on_save()) + self.bind("", 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("", lambda _e: self.canvas.configure( + scrollregion=self.canvas.bbox("all"))) + self.canvas.bind("", lambda e: self.canvas.itemconfigure( + self._win, width=e.width)) + self.canvas.bind_all("", 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 ` | 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"), + ('SERVICE_KEY_IS_NULL' + '20', + 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 ` 출력 형식을 `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` 이며, 충돌 시 그쪽이 이긴다.* diff --git a/docs/design/07-tdd-red-system.md b/docs/design/07-tdd-red-system.md new file mode 100644 index 0000000..8c6453e --- /dev/null +++ b/docs/design/07-tdd-red-system.md @@ -0,0 +1,433 @@ +# DMF Crawler TDD · E2E RED · 디자인 감사 운영 체계 + +> 이 문서는 DMF Crawler 의 **TDD/RED 운영 SSOT** 다. +> 이론 근거는 `docs/research/11-tdd-red-and-design-audit-theory.md` 를 따른다. + +**상태**: v1 확정 +**적용 시점**: 이 문서가 생성된 뒤의 모든 코드·GUI·리포트·에이전트 작업 +**핵심 명령**: **테스트 없이 새 코드를 쓰지 않는다. RED 실패를 먼저 본다.** + +--- + +## 1. 법칙 + +### 1.1 신성한 루프 + +모든 구현 작업은 다음 순서를 따른다. + +1. **RED** — 실패하는 자동화 테스트를 먼저 만든다. +2. **RED 확인** — 그 테스트가 실제로 실패하며, 실패 이유가 기대한 요구 위반인지 확인한다. +3. **GREEN** — 통과에 필요한 최소 구현만 한다. +4. **REFACTOR** — 전체 관련 테스트가 Green 인 상태에서만 구조·중복·이름·디자인을 정리한다. +5. **REGRESSION** — 영향 테스트 + 전체 smoke 를 돌리고 결과를 기록한다. + +### 1.2 금지 + +- 테스트가 없는 `src/` 기능 구현 금지. +- 실패를 보지 않은 테스트를 “RED” 라고 부르기 금지. +- 테스트를 약하게 바꿔 Green 만들기 금지. +- `pytest.skip`, `xfail`, 느슨한 `except Exception: pass`, `assert True` 류로 실패 숨기기 금지. +- 실패 중에 리팩터링 금지. +- 의미 없는 테스트를 무한히 추가하기 금지. +- 디자인 감사를 “눈으로 대충 봄”으로 대체 금지. + +### 1.3 예외 + +다음은 테스트 선행이 어려울 수 있다. 그래도 문서화가 필요하다. + +| 예외 | 허용 조건 | 대체 증거 | +|---|---|---| +| 문서만 수정 | 코드 동작이 변하지 않음 | 링크/목차/SSOT 일관성 검사 또는 변경 요약 | +| 설치 스크립트/작업 스케줄러 | 실제 Windows 권한이 필요 | dry-run/PowerShell parser/ASCII/BOM 검사 + 수동 프로브 로그 | +| 실제 공공 사이트 접근 | 사용자 승인 필요 | fixture/mock 서버 E2E + 수동 실행 체크리스트 | +| 순수 스타일 문구 | 자동화 어려움 | 디자인 RED 체크리스트와 스크린샷/수기 판정 기록 | + +예외를 남길 때는 “왜 자동화 못 했는가 / 어떤 수동 증거로 대체했는가 / 나중에 자동화할 조건”을 적는다. + +--- + +## 2. RED 계층 구조 + +### R0 — 계약/부팅 RED + +목표: 프로젝트가 import/CLI/패키지 수준에서 깨지지 않음을 보장한다. + +필수 검사: + +```powershell +.\.venv\Scripts\python.exe -m compileall -q src tests +.\.venv\Scripts\python.exe - <<'PY' +import importlib, pathlib +for p in pathlib.Path('src/dmf_crawler').rglob('*.py'): + if p.name == '__init__.py': + continue + importlib.import_module('.'.join(p.with_suffix('').relative_to('src').parts)) +PY +.\.venv\Scripts\python.exe -m dmf_crawler --help +.\.venv\Scripts\python.exe -m dmf_crawler doctor --json +``` + +RED 예시: + +- `cli.py` 가 없으면 `python -m dmf_crawler --help` 실패. +- `storage/repo.py` 가 없으면 `pipeline.run_once()` 런타임 실패. +- `.ps1` 에 BOM 이 없으면 PowerShell 5.1 한글 깨짐 위험. + +### R1 — 도메인 단위 RED + +대상: + +- `normalize.py` +- `diff.py` +- `integrity.py` +- `source_mfds.py` 파서 +- `agy/extract.py` JSON 추출 + +원칙: + +- 네트워크 없음. +- DB 없음. +- clock/random 고정. +- fixture 로 실패 재현. + +대표 RED: + +| 위험 | 테스트 | +|---|---| +| 등록번호 파서 회귀 | 표준/신물질/허여서/깨진 입력 10종 | +| openpyxl 함정 재발 | nedrug xlsx 는 sheet XML 직접 파싱 요구 문서/테스트 | +| API 오류 봉투 2종 | JSON 정상 봉투 + XML/JSON Gateway 오류 봉투 | +| 조용한 빈 파일/빈 응답 | 200 OK + 헤더만/0건은 무결성 게이트 차단 | +| 발급일자 변경 신호 | permit_date 변경 시 CHANGED | +| 취하 오탐 | totalCount 급감/전건 취하율 임계 초과 시 diff 폐기 | + +### R2 — 저장소/통합 RED + +대상: + +- `storage/db.py` +- `storage/repo.py` +- migrations +- `pipeline.py` + +필수 RED: + +- 새 DB 에 migrations 0001~0003 적용. +- 중복 적용 시 checksum 검증 후 무변화. +- `start_run` → checkpoint → snapshot → events → report data 조회. +- 같은 날짜 SUCCESS 중복 방지. +- 기준선 첫 실행은 이벤트 0건. +- 스냅샷 저장 전 무결성 실패면 DB 오염 없음. + +### R3 — 파이프라인 E2E RED + +목표: 사람이 실제로 쓰는 흐름을 fixture 로 끝까지 검증한다. + +시나리오: + +1. **첫 설치/키 없음** + - `doctor` 가 API 키 없음 WARN 을 낸다. 인증키는 선택 기능이다. + - `source.mode=auto` 는 API 키가 없으면 AGY headful Chrome CCBAC03 xlsx 수집을 시도한다. + - `source.mode=api` 에서 키가 없거나 headful 수집이 실패하면 BLOCKED 가 아니라 마지막 성공 자료 PARTIAL 리포트로 폴백한다. +2. **첫 수집 기준선** + - fixture API 3건 → DB snapshot 3건. + - diff 이벤트 0건. + - 대시보드 배너 “기준선 수립일”. +3. **둘째 날 변경** + - 신규 1 / 변경 1 / 취하 1 fixture. + - events 3건, report changes 3행. +4. **무결성 차단** + - 200 OK 이지만 0건. + - diff/persist 스킵. + - 마지막 성공 자료로 PARTIAL 리포트. +5. **리포트 파일 잠김** + - 대상 xlsx 잠김 또는 `~$` 파일 존재. + - `_HHMMSS` 폴백 이름 사용. + - 최신본 갱신 실패는 WARN 으로 기록. +6. **AI 실패 독립성** + - agy timeout/schema fail. + - 리포트는 생성되고 AI 없음 배너만 표시. + +### R4 — xlsx 디자인 감사 RED + +DMF Crawler 의 가장 중요한 사용자 화면은 Excel 리포트다. xlsx 는 zip/XML 이므로 stdlib 로 검사한다. + +필수 감사: + +| 항목 | RED 조건 | +|---|---| +| 파일 구조 | `[Content_Types].xml`, `xl/workbook.xml`, worksheets, styles 존재 | +| 시트 순서 | 대시보드가 첫 시트, 메타가 마지막, 워치리스트는 조건부 | +| 시트명 | `00_대시보드` 등 SSOT 명칭과 1:1 | +| 탭 색 | `report/theme.py` 의 `TAB_COLORS` 반영 | +| 대시보드 viewport | zoom 90, 행/열 폭 합이 1366×768 무스크롤 목표 초과 금지 | +| 틀 고정 | 변경분/전체현황/집계 시트에 freeze panes | +| 자동필터/표 | 데이터 표가 ListObject 또는 autofilter 를 가짐 | +| 내부 링크 | 대시보드 내비, 변경분→전체현황 링크 존재 | +| 조건부서식 | 신규/변경/취하/워치/게이트 실패 서식 존재 | +| 대비 | 팔레트 상태 배경/글자 contrast 최소 AA, 핵심 상태 AAA 목표 | +| 색 단독 금지 | 신규/변경/취하가 라벨+기호+색으로 표현 | +| 긴 텍스트 | 한글 긴 성분명/제조소/소재지 fixture 가 열폭 255 같은 폭주를 만들지 않음 | +| 빈 상태 | 변경 0건, 워치리스트 없음, AI 없음이 깨진 표/빈 차트로 보이지 않음 | +| 출처 | 식약처/공공데이터포털 출처 문구가 메타/푸터에 존재 | + +### R5 — GUI 디자인·UX 감사 RED + +대상: `tkinter` 온보딩/복구 GUI. + +자동화 전략: + +- 실제 창을 띄우되 가능하면 withdraw/offscreen 으로 둔다. +- 위젯 트리(`winfo_children`)를 검사한다. +- `Entry` 에 실제 텍스트를 넣고 버튼 command 를 호출한다. +- 텍스트 길이를 늘려 레이아웃 clipping 을 검사한다. +- geometry propagation 이후 `winfo_reqwidth/height` 와 screen/컨테이너 크기를 비교한다. + +필수 감사 항목: + +| 범주 | 실패 조건 | +|---|---| +| 끔찍한 중앙 정렬 | 폼 라벨/입력/버튼이 의미 없이 전체 중앙에 뭉쳐 스캔 불가 | +| 시각적 치우침 | 주요 column/버튼 그룹의 x/y 정렬선이 크게 어긋남 | +| 폰트 | 본문 10pt 미만, 대비 낮은 muted text, 제목/본문 hierarchy 없음 | +| overflow | 긴 한글/영문/API 키/경로가 컨테이너 밖으로 나가거나 잘림 | +| input label | 모든 입력에 visible label 또는 설명 텍스트 없음 | +| input padding | 입력 내부 텍스트와 경계/버튼 간격이 너무 좁음 | +| 실제 입력 | API 키/웹훅/워치리스트 텍스트를 넣어도 깨지지 않음 | +| 버튼 클릭 | [키 입력], [다시 검사], [지금 실행], [로그 열기] command 가 연결됨 | +| 복합 시나리오 | 키 없음→입력→검증 실패→오류 표시→재입력 흐름이 끊기지 않음 | +| 아이콘/텍스트 | 아이콘만 있는 버튼에 텍스트/툴팁 없음, vertical align 어긋남 | +| focus | Tab 순서가 논리적이고 기본 포커스가 위험 버튼에 가지 않음 | +| 모달 | 닫기/나중에/복구 행동이 명확하고 ESC/창닫기 처리됨 | +| 자연스러운 한국어 안내 | 온보딩 상단 안내가 `무엇:/왜:/어떻게:/다음:` 같은 AI식 라벨을 노출하지 않고, 현재 막힌 항목과 누를 행동을 1~3개의 자연스러운 문장으로 설명 | + +### R6 — 브라우저 수집 E2E RED + +브라우저 수집은 수집용 AGY가 visible/headful Chrome 을 조작하는 경로다. 요약용 AGY와 혼동하지 않는다. + +- [ ] 전용 Chrome 프로필 Preferences 에 다운로드 경로가 고정된다. +- [ ] CDP 포트 소유권 sentinel 을 확인하지 못하면 중단한다. +- [x] `.tmp`/`.crdownload` 가 사라진 뒤 최종 xlsx 만 집는다. (`test_headful_browser_collect_rejects_incomplete_downloads`) +- [x] 다운로드 파일이 최소 크기/zip 구조/행 수 임계 검증을 통과한다. (`test_headful_browser_collect_validates_download_and_returns_raw_records`) +- [x] CCBAC03 웹 xlsx 헤더 alias(`신청인`, `제조국가`, `최초등록일자`, `최종변경일자`)를 RawRecord 표준 필드로 매핑한다. (`test_headful_browser_accepts_public_ccbac03_xlsx_headers`) +- [x] 로그인 상태가 아니면 “세션 만료”로 감지하고 비밀번호를 agy prompt 로 보내지 않는다. (`test_headful_browser_prompt_contract_forbids_headless_http_and_secrets`) +- [x] `mcp`/`execute_url` permission auto-deny 를 빈 JSON 오류로 뭉개지 않고 원인별로 보고한다. (`test_headful_browser_collect_reports_mcp_permission_denial_from_stderr`, `test_headful_browser_collect_reports_execute_url_permission_denial_from_stderr`) +- [x] AGY 권한은 `mcp(chrome-devtools/*)`, `execute_url(nedrug.mfds.go.kr)`만 좁게 추가한다. `mcp(*)`, `execute_url(*)`, `--dangerously-skip-permissions` 금지. (`test_headful_mcp_allow_rule_is_scoped_and_does_not_use_dangerous_skip`) +- [x] 프롬프트는 ambiguous 버튼 클릭 대신 visible Chrome 에서 `/pbp/CCBAC03/getExcel` 을 열고 wrong export 를 guard 한다. (`test_headful_browser_prompt_blocks_wrong_review_result_export`) +- [x] `source.mode=auto` + API 키 없음은 AGY headful browser 로 라우팅한다. (`test_source_auto_without_api_key_routes_to_headful_browser`) +- [x] `source.mode=browser` 는 API 키가 있어도 AGY headful browser 를 강제한다. (`test_source_browser_mode_routes_to_headful_even_when_api_key_exists`) +- [x] 실제 nedrug 접근은 기본 테스트가 아니라 사용 승인 수동 smoke 로 분리한다. 2026-09-03 smoke `run_20260903_130332`: fetch/normalize/persist/report SUCCESS, 9,816건. + +### R7 — 에이전트 지침/정책 RED + +`AGENTS.md`, `CLAUDE.md`, 이 문서가 서로 어긋나면 agent 가 나쁜 습관으로 돌아간다. + +필수 검사 후보: + +- `AGENTS.md` 가 이 문서를 링크한다. +- “No code without RED” 규칙이 존재한다. +- “테스트 약화 금지”가 존재한다. +- “디자인 감사 RED” 체크리스트가 존재한다. +- “실패를 숨기지 말 것”이 존재한다. +- `CLAUDE.md` 는 중복 SSOT 가 아니라 `AGENTS.md`/이 문서 포인터다. + +--- + +## 3. RED 작성 품질 기준 + +새 RED 는 아래 8문항을 통과해야 한다. + +1. 어떤 사용자/운영 위험을 막는가? +2. 어느 요구사항/설계 문서를 근거로 하는가? +3. 실패했을 때 메시지가 원인을 좁혀 주는가? +4. 현재 코드에서 실제로 실패하는가? +5. 실패 이유가 기대한 이유인가? +6. network/clock/random/shared DB 에 의존하지 않는가? +7. 같은 위험을 이미 더 강한 테스트가 덮고 있지 않은가? +8. Green 후에도 리팩터링을 방해하지 않는 행동 중심 테스트인가? + +통과하지 못하면 RED 가 아니라 메모다. + +--- + +## 4. RED 통폐합·삭제 기준 + +TDD 는 계속 RED 를 늘리는 행위가 아니다. 테스트 스위트도 제품이다. + +### 4.1 통합해야 하는 경우 + +- 같은 fixture 로 같은 실패를 여러 테스트가 반복한다. +- 단위 테스트와 E2E 가 같은 단순 분기만 검사한다. +- 실패하면 원인이 항상 같은 내부 구현 세부다. + +처리: + +- 가장 사용자 의미가 강한 테스트 하나를 남긴다. +- 나머지는 helper/fixture 로 합친다. +- 커밋/작업 로그에 “어떤 위험이 어디로 이동했는지” 기록한다. + +### 4.2 삭제해야 하는 경우 + +- 구현 세부를 강제해 좋은 리팩터링을 막는다. +- flaky 해서 신뢰를 깎는다. +- 요구사항이 사라졌다. +- 더 강한 상위 테스트가 같은 결함을 잡는다. + +삭제 금지: + +- 단지 Green 을 만들기 어렵다는 이유. +- 실행 시간이 길다는 이유만으로 삭제. 먼저 계층 이동/fixture 축소/impact run 을 시도한다. + +### 4.3 완화해야 하는 경우 + +디자인 임계값(예: 폭/대비/간격)은 완화 가능하지만, 다음이 있어야 한다. + +- 실패 스크린샷 또는 xlsx XML 증거. +- 사용자 유즈케이스에서 문제가 되지 않는 이유. +- 완화 후에도 잡히는 결함 목록. + +--- + +## 5. 작업 절차 — 에이전트 필수 프로토콜 + +### 5.1 시작 전 + +1. `docs/HANDOFF.md` 를 읽는다. +2. 이 문서와 관련 설계 문서를 읽는다. +3. 변경할 파일 목록을 정한다. +4. 영향 테스트 목록을 쓴다. +5. 아직 테스트가 없으면 먼저 RED 를 작성한다. + +### 5.2 구현 중 + +1. RED 를 실행해 실패 로그를 확인한다. +2. 최소 구현으로 Green 을 만든다. +3. 관련 테스트를 다시 실행한다. +4. Green 상태에서만 리팩터링한다. +5. 전체 smoke 를 돌린다. + +### 5.3 보고 + +보고에는 최소 다음을 포함한다. + +- 추가/수정한 RED. +- RED 실패가 기대한 이유였는지. +- Green 을 위해 바꾼 파일. +- 실행한 명령과 결과. +- 남은 실패/미검증 항목. +- 디자인 감사 결과(해당 시). + +--- + +## 6. 영향 테스트 지도 v1 + +| 변경 파일 | 먼저 돌릴 테스트 | 그 다음 | +|---|---|---| +| `models.py` | 전체 import, dataclass contract tests | 전체 pytest | +| `normalize.py` | `tests/test_normalize.py` | diff/integrity/report data | +| `diff.py` | diff unit tests | pipeline scenario | +| `integrity.py` | integrity gate tests | pipeline stale fallback | +| `source_mfds.py` | API fixture parser tests | fetch E2E mock | +| `storage/db.py` | migration tests | repo/pipeline/report data | +| `storage/repo.py` | repo roundtrip tests | pipeline/report-only | +| `pipeline.py` | pipeline fixture E2E | alerts/watchdog/report | +| `agy/*` | polluted JSON/schema/budget tests | pipeline AI failure independence | +| `report/data.py` | SQL fixture report data tests | xlsx design audit | +| `report/*sheets*` | xlsx XML/visual audit tests | report-only smoke | +| `gui/*` | tkinter structure/input/button tests | onboard smoke | +| `notify/*`, `alerts.py` | template 4요소/DB state tests | notify-pump once | +| `scripts/*.ps1` | BOM/parser tests | 수동 Windows registration probe | +| `AGENTS.md`, `CLAUDE.md` | agent policy contract tests | docs link check | + +--- + +## 7. 현재 프로젝트에 즉시 필요한 RED backlog + +이전 구현 워크플로우가 중간에 끊겨 있으므로, 아래 RED 부터 만든다. + +### 7.1 R0 계약 RED + +- [x] `test_all_modules_import` — 현재 이름: `test_all_expected_runtime_entry_modules_import` (`tests/test_project_contracts.py`) +- [x] `test_cli_help_exits_zero` (`tests/test_project_contracts.py`) +- [x] `test_doctor_json_never_crashes_without_key` (`tests/test_project_contracts.py`) +- [x] `test_powershell_scripts_have_bom` — 현재 이름: `test_powershell_scripts_have_utf8_bom` (`tests/test_project_contracts.py`) +- [x] `test_cmd_files_are_ascii_only` (`tests/test_project_contracts.py`) + +### 7.2 R2 저장소 RED + +- [x] `test_migrations_apply_to_empty_db` — 현재 이름: `test_migrations_apply_to_empty_db_and_are_checksum_idempotent` (`tests/test_storage_repo.py`) +- [x] `test_repo_baseline_roundtrip` — 현재 이름: `test_repo_baseline_roundtrip_creates_snapshot_without_events` (`tests/test_storage_repo.py`) +- [x] `test_repo_changed_withdrawn_events_roundtrip` — 현재 이름: `test_repo_changed_new_withdrawn_events_roundtrip` (`tests/test_storage_repo.py`) +- [x] `test_same_day_success_is_idempotent` — 현재 이름: `test_same_day_success_is_enforced_by_database_guard` (`tests/test_storage_repo.py`) + +### 7.3 R3 파이프라인 RED + +- [x] `test_run_without_service_key_uses_existing_baseline_instead_of_blocking` +- [ ] `test_first_success_run_creates_baseline_report` +- [ ] `test_empty_success_body_blocks_diff_and_preserves_previous_snapshot` +- [ ] `test_agy_failure_does_not_block_report` + +### 7.4 R4 xlsx 디자인 RED + +- [x] `test_report_sheet_order_and_names` — `tests/test_report_e2e_design.py` 의 seeded DB → 실제 xlsx XML 감사가 검사한다. +- [ ] `test_dashboard_has_no_scroll_viewport_budget` +- [x] `test_report_has_freeze_panes_autofilter_internal_links` — `tests/test_report_e2e_design.py` +- [x] `test_report_status_styles_are_not_color_only` — `tests/test_report_e2e_design.py` 의 상태 라벨/기호/조건부서식 감사가 검사한다. +- [ ] `test_report_palette_contrast_ratios` +- [x] `test_long_korean_values_do_not_create_extreme_column_widths` — `tests/test_report_e2e_design.py` + +### 7.5 R5 GUI 디자인 RED + +- [ ] `test_onboard_all_inputs_have_visible_labels` +- [ ] `test_onboard_can_type_api_key_and_trigger_save_action` +- [ ] `test_recover_message_explains_problem_and_action_in_natural_korean` +- [ ] `test_main_actions_have_specific_button_labels` +- [ ] `test_long_korean_error_text_does_not_exceed_window_budget` +- [ ] `test_tab_order_reaches_primary_actions_before_secondary_actions` +- [x] `test_onboarding_footer_buttons_render_visible_text_after_refresh` — 사용자 스크린샷 `C:\Users\encep\AppData\Local\Temp\pi-clipboard-5df73a57-a813-41be-b025-819e48bd73c2.png` 회귀. 하단 버튼이 빈 버튼처럼 보이면 실패해야 한다. (`tests/test_ux_regressions.py`) +- [x] `test_onboarding_footer_buttons_keep_text_when_required_items_exist` — 필수/권장 조치가 있는 상태에서도 footer 버튼 텍스트가 짜부라지거나 사라지면 실패해야 한다. (`tests/test_ux_regressions.py`) +- [x] `test_onboarding_row_action_buttons_are_not_clipped_inside_scroll_view` — 행별 액션 버튼이 스크롤 영역/창 하단에 잘리면 실패해야 한다. (`tests/test_ux_regressions.py`) +- [x] `test_onboarding_new_grouped_layout_keeps_primary_actions_visible` — 새 그룹 레이아웃에서 주요 버튼이 아예 안 보이면 실패해야 한다. (`tests/test_ux_regressions.py`) +- [x] `test_scroll_frame_mousewheel_scrolls_when_pointer_is_over_content` — scroll canvas 안의 실제 텍스트/카드 위에서 휠을 굴려도 스크롤되어야 한다. (`tests/test_ux_regressions.py`) +- [x] `test_onboarding_status_panel_names_blockers_instead_of_generic_yellow_info_box` — 상단 안내문은 `무엇:/왜:/어떻게:/다음:` 같은 AI식 라벨이 아니라 자연스러운 한국어 문장이어야 한다. (`tests/test_ux_regressions.py`) +- [x] `test_agy_google_login_is_optional_when_ai_disabled_for_api_mode` — 공식 API만 쓰고 AI 요약이 꺼져 있으면 AGY/Google 로그인을 요구하지 않는다. (`tests/test_ux_regressions.py`) +- [x] `test_agy_is_prompted_for_headful_collection_when_auto_has_no_api_key` — auto+API 키 없음이면 AI 요약이 꺼져 있어도 최신 수집용 headful AGY 준비를 안내한다. (`tests/test_ux_regressions.py`) +- [x] `test_agy_is_prompted_for_forced_browser_mode_even_with_api_key` — browser 모드는 API 키가 있어도 수집용 headful AGY 준비를 안내한다. (`tests/test_ux_regressions.py`) + +### 7.6 R6 브라우저 수집 RED + +- [x] `test_headful_browser_prompt_contract_forbids_headless_http_and_secrets` +- [x] `test_headful_browser_collect_validates_download_and_returns_raw_records` +- [x] `test_headful_browser_collect_rejects_incomplete_downloads` +- [x] `test_headful_browser_collect_reports_mcp_permission_denial_from_stderr` +- [x] `test_headful_browser_collect_reports_execute_url_permission_denial_from_stderr` +- [x] `test_headful_mcp_allow_rule_is_scoped_and_does_not_use_dangerous_skip` +- [x] `test_headful_browser_prompt_blocks_wrong_review_result_export` +- [x] `test_headful_browser_accepts_public_ccbac03_xlsx_headers` +- [x] `test_source_auto_without_api_key_routes_to_headful_browser` +- [x] `test_source_browser_mode_routes_to_headful_even_when_api_key_exists` +- [ ] 전용 Chrome profile Preferences / CDP 포트 sentinel / 스케줄러 headful smoke RED + +--- + +## 8. 이 문서와 다른 문서의 관계 + +| 문서 | 관계 | +|---|---| +| `docs/00-REQUIREMENTS.md` | 사용자 요구 SSOT. 이 문서는 테스트 운영 방법 SSOT. | +| `docs/design/01-architecture.md` | 아키텍처 SSOT. 이 문서는 검증 게이트를 추가한다. | +| `docs/design/03-xlsx-report-spec.md` | xlsx 디자인 명세 SSOT. R4 RED 는 이 문서를 실행 가능하게 만든다. | +| `docs/design/04-onboarding-wizard.md` | GUI 명세 SSOT. R5 RED 는 이 문서를 실행 가능하게 만든다. | +| `docs/research/11-tdd-red-and-design-audit-theory.md` | 이론/근거 아카이브. | +| `AGENTS.md` | AI agent 실무 지침. 이 문서를 압축해 적용한다. | +| `CLAUDE.md` | Claude Code 진입 포인터. 중복 정본이 아니다. | + +--- + +## 9. 미해결 항목 + +- [x] `tests/test_project_contracts.py` 를 만들어 R0 계약 RED 를 실제 코드화한다. +- [x] xlsx XML 감사 RED 를 실제 코드화한다. 현재 파일: `tests/test_report_e2e_design.py`. +- [x] tkinter 구조/입력/버튼 감사 RED 를 실제 코드화한다. 현재 파일: `tests/test_gui_design_contracts.py`, `tests/test_ux_regressions.py`. +- [x] 브라우저 수집 경로 확정 후 R6 RED 를 코드화한다. 현재 파일: `tests/test_agy_headful_collection.py`. +- [ ] 영향 테스트 지도를 자동 생성/검증할지 결정한다(TDAD 방식의 단순 텍스트 맵부터 시작). diff --git a/docs/ops/01-scheduling-and-resilience.md b/docs/ops/01-scheduling-and-resilience.md new file mode 100644 index 0000000..06b7e70 --- /dev/null +++ b/docs/ops/01-scheduling-and-resilience.md @@ -0,0 +1,2379 @@ +# 운영 런북 — 06:00 스케줄링 · 재부팅 내성 · 워치독 + +> **이 문서의 역할**: DMF Crawler 를 Windows 11 PC 에 올려 **매일 06:00 에 사람 없이 실행**시키고, 재부팅·절전·Windows Update·프로세스 강제 종료를 견디게 만들고, 죽었을 때 **사람이 15분 안에 알아채고 복구**하게 만드는 **실행 런북**이다. 이 문서의 명령은 복사·붙여넣기하면 그대로 동작해야 한다. 설계 근거는 `docs/design/01-architecture.md`(정본)에, 스케줄러 옵션의 원문 근거는 `docs/research/08-windows-scheduling-and-resilience.md`에 있다. **충돌하면 아키텍처 정본이 이긴다.** + +--- + +## 0. 한눈에 보기 + +이 문서가 확정하는 것: + +- **실행 컨테이너는 Windows 작업 스케줄러다.** Windows 서비스·WSL2 cron·Docker Desktop 은 모두 기각한다. 결정적 이유는 성능이 아니라 **`agy` OAuth 토큰이 `~/.gemini/antigravity-cli/antigravity-oauth-token` 평문 파일로 사용자 프로필 안에 있다**는 사실이다 — 배치는 **반드시 그 사용자 계정 컨텍스트**에서 돌아야 한다. +- **작업은 3종이다**: `DMF_Crawler_Daily`(배치, S4U, 06:00 + 부팅), `DMF_Crawler_Agent`(알림·워치독, Interactive, 로그온 + 15분 반복), `DMF_Crawler_AgyUpdate`(주간 `agy update`, 일요일 14:00). 전부 `\DMF_Crawler\` 폴더에 등록한다. +- **워치독은 4번째 작업이 아니다.** heartbeat 신선도 판정은 `DMF_Crawler_Agent` 안(`watchdog.py`)에서 돈다. 알림을 띄울 수 있는 세션에서 판정해야 "판정은 됐는데 화면에 못 띄운다"는 공백이 사라지기 때문이다(아키텍처 ADR-10·기각 기록). +- **기본값이 우리를 배신하는 4개를 반드시 뒤집는다**: `DisallowStartIfOnBatteries`(기본 true→false), `StopIfGoingOnBatteries`(true→false), `StartWhenAvailable`(false→true), `WakeToRun`(false→true). 손대지 않으면 노트북에서 06:00 에 **안 돈다**. +- **`AtStartup` 트리거는 보조 수단이지 안전망이 아니다.** Fast Startup(빠른 시작) 때문에 "종료 → 켜기"는 실제로는 커널 세션 최대 절전 복귀라서 부팅 트리거가 안 뜬다. 진짜 캐치업 안전망은 **`StartWhenAvailable`** 이다. +- **`-LogonType S4U` 가 기본이지만, DPAPI 복호화가 S4U 세션에서 실패하면 즉시 `Password` 로 전환한다.** 이 분기는 추측하지 말고 §2.8 의 **프로브 절차로 실측**한다. 판정이 갈리는 유일한 설치 시점 결정이다. +- **중복 실행 방어는 3중이다**: 코드 idempotency 가드(오늘 이미 SUCCESS면 종료 0) → `state\run.lock` 파일 락(`msvcrt`) → 스케줄러 `MultipleInstances=IgnoreNew`. 스케줄러 설정만으로는 "06:00 실행 후 재부팅 캐치업" 같은 **순차 재실행**을 못 막는다. +- **한국은 DST 가 없다(KST=UTC+9 고정).** 작업 트리거에 "표준 시간대에 맞춰 동기화(Synchronize across time zones)"를 **켜지 않는다**. DST 전환 버그의 사정권 밖이다. +- **Windows Update 자동 재시작은 활성 시간(Active hours) 05:00–23:00 으로 06:00 을 보호한다.** 최대 범위 18시간 제한 안에 들어간다. +- **로그는 실행 1회당 디렉터리 하나**(`logs\run_\`)로 떨어진다. 조사 시작점이 "가장 최근 디렉터리를 연다" 하나로 고정된다. + +--- + +## 1. 스케줄링 방식 확정 + +### 1.1 워크로드 성질 + +| 항목 | 값 | 스케줄러 선택에 주는 함의 | +|---|---|---| +| 실행 빈도 | 하루 1회 06:00 (KST) | 상주 프로세스 불필요 | +| 실행 시간 | 정상 2~6분, 상한 30분(`ExecutionTimeLimit`) | 타임아웃 여유 필요 | +| 네트워크 | 공개 Open API 로의 **아웃바운드 HTTPS** 만 | 네트워크 드라이브·UNC·도메인 인증 불필요 | +| 자격증명 | ① 공공데이터포털 API 키(DPAPI 사용자 범위 암호화) ② `agy` OAuth 토큰(사용자 프로필 평문 파일) | **사용자 프로필이 살아 있어야 한다** | +| 산출물 | `reports\*.xlsx`, `data\dmf.sqlite3` | 로컬 디스크 쓰기 | +| 알림 | 실패 시 토스트·강제 모달 | **데스크톱이 있는 세션**이 필요 | +| 재부팅 내성 | 필수 | 부팅 트리거 + 놓친 작업 캐치업 | + +### 1.2 후보 4종 평가 + +| 방식 | agy 토큰 접근 | 부팅 내성 | 놓친 실행 캐치업 | 알림 표시 | 설치·형상관리 | 판정 | +|---|---|---|---|---|---|---| +| **작업 스케줄러** | ✅ 사용자 계정으로 실행 가능 | ✅ `BootTrigger` | ✅ `StartWhenAvailable` 내장 | △ 배치는 불가 → 별도 Interactive 작업으로 분리 | ✅ XML export/import | **채택** | +| Windows 서비스(NSSM/WinSW/pywin32) | ⚠️ SYSTEM 이면 **불가**. 사용자 계정으로 돌리면 가능하나 이점 소멸 | ✅ | ❌ 직접 구현 | ❌ **Session 0 격리** — UI 절대 불가 | ❌ 관리자 설치·제거 | 기각 | +| WSL2 cron | ❌ 토큰이 Windows 프로필에 있음(교차 접근 지저분) | ❌ 배포판이 떠 있어야 cron 이 돈다 | ❌ | ❌ | ❌ | 기각 | +| Docker Desktop + cron | ❌ 토큰·DPAPI 모두 컨테이너 밖 | ❌ "Start Docker Desktop when you sign in" — **로그인 없이는 안 뜬다** | ❌ | ❌ | △ | 기각 | + +### 1.3 결정과 그 근거 세 줄 + +1. **`agy` 토큰이 사용자 프로필 평문 파일**이라는 실측 사실(agy SSOT §4.2)이 SYSTEM 계정을 원천 배제한다. 서비스의 유일한 장점(SYSTEM 무인성)이 여기서는 장점이 아니라 **결함**이다. +2. 하루 1회 5분짜리 배치를 위해 24시간 상주 프로세스를 띄우면 "그 프로세스는 누가 감시하나"라는 감시 대상이 하나 더 생긴다. 작업 스케줄러는 OS 가 이미 감시한다. +3. 서비스는 Session 0 격리 때문에 **요구 R7.2(강제 창)를 구조적으로 만족할 수 없다.** 작업 스케줄러는 Interactive 로그온 타입 작업으로 이 문제를 정면 해결한다. + +### 1.4 작업 3종 세트 + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ ① \DMF_Crawler\DMF_Crawler_Daily [배치 · UI 없음] │ +│ 트리거 : 매일 06:00 (RandomDelay PT4M) + 시스템 시작 (Delay PT5M) │ +│ 보안 : <도메인>\<사용자>, LogonType S4U(기본), RunLevel Highest │ +│ 설정 : StartWhenAvailable / WakeToRun / 배터리 조건 해제 / │ +│ RestartCount 3 · RestartInterval PT10M / │ +│ ExecutionTimeLimit PT30M / MultipleInstances IgnoreNew │ +│ 액션 : .venv\Scripts\python.exe -m dmf_crawler run --trigger scheduled│ +│ 결과 : 성공 → state\heartbeat.json 갱신 │ +│ 실패 → alerts 테이블 + state\alerts.json + 이벤트 로그 │ +│ ★ 화면에는 아무것도 띄우지 않는다 (S4U 에는 데스크톱이 없다) │ +└──────────────────────────────────────────────────────────────────────────┘ + │ (파일·DB 를 통한 비동기 전달) + ▼ +┌──────────────────────────────────────────────────────────────────────────┐ +│ ② \DMF_Crawler\DMF_Crawler_Agent [알림 · 워치독 · UI 소유자] │ +│ 트리거 : 로그온 시 + 15분마다 무한 반복 │ +│ 보안 : 동일 사용자, LogonType Interactive, RunLevel Limited │ +│ 설정 : ExecutionTimeLimit PT10M / MultipleInstances IgnoreNew │ +│ 액션 : .venv\Scripts\pythonw.exe -m dmf_crawler notify-pump --once │ +│ 동작 : watchdog 판정 → WARN/INFO 토스트 → CRITICAL 복구 GUI(모달) │ +└──────────────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────────────┐ +│ ③ \DMF_Crawler\DMF_Crawler_AgyUpdate [주간 유지보수] │ +│ 트리거 : 매주 일요일 14:00 │ +│ 보안 : 동일 사용자, LogonType S4U, RunLevel Limited │ +│ 액션 : %LOCALAPPDATA%\agy\bin\agy.exe update │ +│ 이유 : 배치 중 자동 업데이트가 바이너리를 교체하면 실행이 깨진다. │ +│ 배치는 AGY_CLI_DISABLE_AUTO_UPDATE=true 로 자동 업데이트를 │ +│ 끄고(코드에서 주입), 교체는 사람이 깨어 있는 시간에 몰아서 한다.│ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +**핵심 설계 원칙 한 줄**: *알림을 발생시키는 주체(배치)와 알림을 표시하는 주체(에이전트)를 분리한다.* 배치는 "알림 의도"만 남기고, 데스크톱을 가진 에이전트가 그것을 읽어 띄운다. + +### 1.5 워치독을 별도 작업으로 만들지 않는 이유 + +`DMF_Crawler_Agent` 가 15분마다 로그온 세션에서 도는데, 그 안에서 heartbeat 판정을 함께 하면 **작업이 하나 줄고** "판정한 세션이 곧 표시 가능한 세션"이라는 성질이 공짜로 따라온다. 07:00 짜리 별도 워치독 작업을 두면 판정은 S4U 세션에서 되고 표시는 다른 세션에서 되어, 두 세션 사이에 또 큐를 놓아야 한다. 기각한다. + +> **예외**: 사람이 며칠씩 로그오프해 두는 PC 라면 §5.8 의 자동 로그온 대안 또는 부록의 웹훅 확장을 검토하라. 기본 구성은 "다음 로그온 시 밀린 알림을 전부 표시"로 흡수한다(아키텍처 실패 경로 [F]). + +--- + +## 2. 주 작업 등록 스크립트 + +### 2.1 파일 이름과 호출 관계 + +| 파일 | 역할 | +|---|---| +| `scripts\install_tasks.ps1` | **작업 3종 idempotent 등록.** 이 절의 전문 | +| `scripts\uninstall_tasks.ps1` | 작업 3종 제거 | +| `python -m dmf_crawler install-task --time 06:00 --user <계정>` | 위 스크립트를 `config.toml` 값으로 호출하는 얇은 래퍼 | + +> 파일명은 **복수형 `install_tasks.ps1`** 이다(아키텍처 디렉터리 트리 §2). 작업이 3개이므로 단수형은 쓰지 않는다. + +### 2.2 설정 키 → 스크립트 파라미터 매핑 + +PowerShell 은 TOML 을 파싱하지 못한다. 그래서 **`config.toml` 을 읽는 쪽은 Python CLI 이고, PS 스크립트는 파라미터만 받는다.** 스크립트의 기본값은 `config.toml` 기본값과 **정확히 같게** 유지한다. + +| `config.toml` 키 | 스크립트 파라미터 | 기본값 | +|---|---|---| +| `schedule.daily_time` | `-Time` | `06:00` | +| `schedule.jitter_seconds` | `-JitterSeconds` | `240` | +| `schedule.startup_delay_minutes` | `-StartupDelayMinutes` | `5` | +| `schedule.execution_time_limit_minutes` | `-ExecutionTimeLimitMinutes` | `30` | +| `schedule.restart_count` | `-RestartCount` | `3` | +| `schedule.restart_interval_minutes` | `-RestartIntervalMinutes` | `10` | +| `schedule.agent_repeat_minutes` | `-AgentRepeatMinutes` | `15` | +| `schedule.agy_update_weekday` | `-AgyUpdateWeekday` | `Sunday` | +| `schedule.agy_update_time` | `-AgyUpdateTime` | `14:00` | +| — | `-User` | `$env:USERDOMAIN\$env:USERNAME` | +| — | `-LogonType` | `S4U` | + +### 2.3 `scripts\install_tasks.ps1` 전문 + +```powershell +#Requires -Version 5.1 +<# +.SYNOPSIS + DMF Crawler 작업 스케줄러 작업 3종(Daily / Agent / AgyUpdate)을 등록한다. + +.DESCRIPTION + 멱등(idempotent)하다. 같은 이름의 작업이 이미 있으면 지우고 다시 만든다. + 관리자 권한 PowerShell 에서 실행해야 한다(RunLevel Highest 등록에 필요). + +.EXAMPLE + powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 + +.EXAMPLE + # DPAPI 프로브 결과 S4U 가 실패했을 때 + powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -LogonType Password + +.EXAMPLE + # 등록 직후 보안 컨텍스트 프로브까지 수행 + powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify +#> +[CmdletBinding()] +param( + [string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot), + [string]$TaskPath = '\DMF_Crawler\', + [string]$Time = '06:00', + [string]$User = "$env:USERDOMAIN\$env:USERNAME", + [ValidateSet('S4U', 'Password', 'Interactive')] + [string]$LogonType = 'S4U', + [System.Security.SecureString]$Password, + [ValidateRange(0, 300)] + [int]$JitterSeconds = 240, + [ValidateRange(0, 60)] + [int]$StartupDelayMinutes = 5, + [ValidateRange(5, 720)] + [int]$ExecutionTimeLimitMinutes = 30, + [ValidateRange(0, 255)] + [int]$RestartCount = 3, + [ValidateRange(1, 1440)] + [int]$RestartIntervalMinutes = 10, + [ValidateRange(1, 1440)] + [int]$AgentRepeatMinutes = 15, + [ValidateSet('Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday')] + [string]$AgyUpdateWeekday = 'Sunday', + [string]$AgyUpdateTime = '14:00', + [switch]$SkipAgyUpdateTask, + [switch]$Verify +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ---------------------------------------------------------------- 유틸 +function Write-Step { param([string]$Message) Write-Host "[install_tasks] $Message" } +function Write-Warn { param([string]$Message) Write-Host "[install_tasks] ! $Message" -ForegroundColor Yellow } +function Write-Good { param([string]$Message) Write-Host "[install_tasks] + $Message" -ForegroundColor Green } + +function Assert-Administrator { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + $pr = [Security.Principal.WindowsPrincipal]::new($id) + if (-not $pr.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw '관리자 권한 PowerShell 에서 실행하세요. (시작 → PowerShell 우클릭 → 관리자 권한으로 실행)' + } +} + +function ConvertTo-PlainText { + param([System.Security.SecureString]$Secure) + if (-not $Secure) { return $null } + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) + try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } + finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } +} + +function Get-TimeOfDay { + param([string]$Text, [string]$Label) + $parsed = [datetime]::MinValue + $ok = [datetime]::TryParseExact( + $Text, 'HH:mm', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::None, + [ref]$parsed) + if (-not $ok) { throw "$Label 형식이 잘못됐습니다: '$Text' (HH:mm 이어야 합니다)" } + return (Get-Date).Date.AddHours($parsed.Hour).AddMinutes($parsed.Minute) +} + +# ---------------------------------------------------------------- 0. 사전 점검 +Assert-Administrator + +$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot).Path +$PythonExe = Join-Path $ProjectRoot '.venv\Scripts\python.exe' +$PythonwExe = Join-Path $ProjectRoot '.venv\Scripts\pythonw.exe' +$AgyExe = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe' + +Write-Step "프로젝트 루트 : $ProjectRoot" +Write-Step "실행 계정 : $User (LogonType=$LogonType)" + +foreach ($exe in @($PythonExe, $PythonwExe)) { + if (-not (Test-Path -LiteralPath $exe)) { + throw "가상환경 실행 파일이 없습니다: $exe`n → bootstrap.cmd 를 먼저 실행하세요." + } +} +if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'config\config.toml'))) { + Write-Warn 'config\config.toml 이 없습니다. 등록은 진행하지만 첫 실행은 종료 코드 2(BLOCKED)로 끝납니다.' +} + +foreach ($dir in @('state', 'logs', 'reports', 'data')) { + $p = Join-Path $ProjectRoot $dir + if (-not (Test-Path -LiteralPath $p)) { New-Item -ItemType Directory -Path $p | Out-Null } +} + +$plainPassword = $null +if ($LogonType -eq 'Password') { + if (-not $Password) { + $Password = Read-Host -AsSecureString "«$User» 계정의 Windows 로그인 암호" + } + $plainPassword = ConvertTo-PlainText -Secure $Password + if ([string]::IsNullOrEmpty($plainPassword)) { throw '암호가 비어 있습니다.' } +} + +# ---------------------------------------------------------------- 1. 작업 기록(History) 채널 활성화 +# 기본적으로 꺼져 있다. 꺼져 있으면 "기록" 탭이 비고 사후 진단이 불가능하다. +Write-Step '작업 스케줄러 Operational 로그 활성화' +& wevtutil.exe set-log 'Microsoft-Windows-TaskScheduler/Operational' /enabled:true /quiet +& wevtutil.exe set-log 'Microsoft-Windows-TaskScheduler/Operational' /maxsize:67108864 +if ($LASTEXITCODE -ne 0) { Write-Warn "wevtutil 이 $LASTEXITCODE 로 끝났습니다. 기록 없이 진행합니다." } + +# ---------------------------------------------------------------- 2. 공통 등록 함수 +function Register-DmfTask { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)]$Action, + [Parameter(Mandatory)]$Trigger, + [Parameter(Mandatory)]$Settings, + [Parameter(Mandatory)]$Principal, + [Parameter(Mandatory)][string]$Description, + [string]$PlainPassword + ) + $existing = Get-ScheduledTask -TaskName $Name -TaskPath $Path -ErrorAction SilentlyContinue + if ($existing) { + Write-Step "기존 작업 제거: $Path$Name" + Unregister-ScheduledTask -TaskName $Name -TaskPath $Path -Confirm:$false + } + + $definition = New-ScheduledTask ` + -Action $Action ` + -Trigger $Trigger ` + -Settings $Settings ` + -Principal $Principal ` + -Description $Description + + if ($PlainPassword) { + Register-ScheduledTask -TaskName $Name -TaskPath $Path -InputObject $definition ` + -User $Principal.UserId -Password $PlainPassword | Out-Null + } else { + Register-ScheduledTask -TaskName $Name -TaskPath $Path -InputObject $definition | Out-Null + } + Write-Good "등록 완료: $Path$Name" +} + +# ---------------------------------------------------------------- 3. ① DMF_Crawler_Daily +Write-Step '① DMF_Crawler_Daily 구성' + +$dailyAt = Get-TimeOfDay -Text $Time -Label 'schedule.daily_time' + +$actionDaily = New-ScheduledTaskAction ` + -Execute $PythonExe ` + -Argument '-m dmf_crawler run --trigger scheduled' ` + -WorkingDirectory $ProjectRoot + +$trgDaily = New-ScheduledTaskTrigger -Daily -At $dailyAt ` + -RandomDelay (New-TimeSpan -Seconds $JitterSeconds) + +# AtStartup 트리거에는 -Delay 파라미터가 없다. CIM 인스턴스 속성을 직접 채운다. +$trgBoot = New-ScheduledTaskTrigger -AtStartup +$trgBoot.Delay = "PT${StartupDelayMinutes}M" + +$setDaily = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -StartWhenAvailable ` + -WakeToRun ` + -DontStopOnIdleEnd ` + -RunOnlyIfNetworkAvailable ` + -ExecutionTimeLimit (New-TimeSpan -Minutes $ExecutionTimeLimitMinutes) ` + -RestartCount $RestartCount ` + -RestartInterval (New-TimeSpan -Minutes $RestartIntervalMinutes) ` + -MultipleInstances IgnoreNew ` + -Priority 5 ` + -Compatibility Win8 + +$prcDaily = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Highest + +Register-DmfTask ` + -Name 'DMF_Crawler_Daily' ` + -Path $TaskPath ` + -Action $actionDaily ` + -Trigger @($trgDaily, $trgBoot) ` + -Settings $setDaily ` + -Principal $prcDaily ` + -Description "DMF 일일 수집·비교·리포트 배치. 매일 $Time + 부팅 후 ${StartupDelayMinutes}분. UI 를 띄우지 않는다." ` + -PlainPassword $plainPassword + +# ---------------------------------------------------------------- 4. ② DMF_Crawler_Agent +Write-Step '② DMF_Crawler_Agent 구성' + +$actionAgent = New-ScheduledTaskAction ` + -Execute $PythonwExe ` + -Argument '-m dmf_crawler notify-pump --once' ` + -WorkingDirectory $ProjectRoot + +# (a) 로그온 시. (b) 지금부터 15분마다 무한 반복. +$trgLogon = New-ScheduledTaskTrigger -AtLogOn -User $User + +$repeatStart = (Get-Date).AddMinutes(2) +try { + $trgRepeat = New-ScheduledTaskTrigger -Once -At $repeatStart ` + -RepetitionInterval (New-TimeSpan -Minutes $AgentRepeatMinutes) ` + -RepetitionDuration ([TimeSpan]::MaxValue) +} catch { + # 일부 빌드에서 [TimeSpan]::MaxValue 가 거부된다. 10년으로 대체한다. + Write-Warn 'RepetitionDuration=MaxValue 거부됨 → 3650일로 대체' + $trgRepeat = New-ScheduledTaskTrigger -Once -At $repeatStart ` + -RepetitionInterval (New-TimeSpan -Minutes $AgentRepeatMinutes) ` + -RepetitionDuration (New-TimeSpan -Days 3650) +} +# 로그온 트리거에도 같은 반복을 붙여 둔다(로그온 이후에도 계속 돌게). +$trgLogon.Repetition = $trgRepeat.Repetition + +$setAgent = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -DontStopOnIdleEnd ` + -ExecutionTimeLimit (New-TimeSpan -Minutes 10) ` + -MultipleInstances IgnoreNew ` + -Priority 7 ` + -Compatibility Win8 + +# Interactive 는 로그온한 세션에서만 돈다 — 그것이 목적이다(UI 를 띄우는 유일한 작업). +$prcAgent = New-ScheduledTaskPrincipal -UserId $User -LogonType Interactive -RunLevel Limited + +Register-DmfTask ` + -Name 'DMF_Crawler_Agent' ` + -Path $TaskPath ` + -Action $actionAgent ` + -Trigger @($trgLogon, $trgRepeat) ` + -Settings $setAgent ` + -Principal $prcAgent ` + -Description "DMF 알림 에이전트. 로그온 시 + ${AgentRepeatMinutes}분마다 heartbeat 를 점검하고 밀린 알림을 표시한다." + +# ---------------------------------------------------------------- 5. ③ DMF_Crawler_AgyUpdate +if ($SkipAgyUpdateTask) { + Write-Warn '③ DMF_Crawler_AgyUpdate 는 -SkipAgyUpdateTask 로 건너뜁니다.' +} elseif (-not (Test-Path -LiteralPath $AgyExe)) { + Write-Warn "agy.exe 를 찾을 수 없어 ③ 을 건너뜁니다: $AgyExe" + Write-Warn ' → scripts\bootstrap_agy.ps1 실행 후 이 스크립트를 다시 돌리세요.' +} else { + Write-Step '③ DMF_Crawler_AgyUpdate 구성' + + $agyAt = Get-TimeOfDay -Text $AgyUpdateTime -Label 'schedule.agy_update_time' + + $actionAgy = New-ScheduledTaskAction ` + -Execute $AgyExe ` + -Argument 'update' ` + -WorkingDirectory $ProjectRoot + + $trgAgy = New-ScheduledTaskTrigger -Weekly -WeeksInterval 1 ` + -DaysOfWeek $AgyUpdateWeekday -At $agyAt ` + -RandomDelay (New-TimeSpan -Minutes 10) + + $setAgy = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -StartWhenAvailable ` + -RunOnlyIfNetworkAvailable ` + -ExecutionTimeLimit (New-TimeSpan -Minutes 30) ` + -MultipleInstances IgnoreNew ` + -Priority 7 ` + -Compatibility Win8 + + $prcAgy = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Limited + + Register-DmfTask ` + -Name 'DMF_Crawler_AgyUpdate' ` + -Path $TaskPath ` + -Action $actionAgy ` + -Trigger $trgAgy ` + -Settings $setAgy ` + -Principal $prcAgy ` + -Description "agy CLI 주간 업데이트. 배치 시간대를 피해 $AgyUpdateWeekday $AgyUpdateTime 에 돈다." ` + -PlainPassword $plainPassword +} + +# ---------------------------------------------------------------- 6. 등록 결과 요약 +Write-Host '' +Write-Step '등록 결과' +Get-ScheduledTask -TaskPath $TaskPath | + Select-Object TaskName, + State, + @{ n = 'LogonType'; e = { $_.Principal.LogonType } }, + @{ n = 'RunLevel'; e = { $_.Principal.RunLevel } }, + @{ n = 'UserId'; e = { $_.Principal.UserId } } | + Format-Table -AutoSize + +Get-ScheduledTask -TaskPath $TaskPath | ForEach-Object { + $info = $_ | Get-ScheduledTaskInfo + [pscustomobject]@{ + TaskName = $_.TaskName + NextRunTime = $info.NextRunTime + LastRunTime = $info.LastRunTime + LastTaskResult = ('0x{0:X}' -f $info.LastTaskResult) + } +} | Format-Table -AutoSize + +# ---------------------------------------------------------------- 7. 보안 컨텍스트 프로브 (-Verify) +if ($Verify) { + Write-Host '' + Write-Step '보안 컨텍스트 프로브 시작 (DPAPI 복호화가 이 LogonType 에서 되는지 실측)' + + $probeName = 'DMF_Crawler_Probe' + $probeOut = Join-Path $ProjectRoot 'state\probe.json' + if (Test-Path -LiteralPath $probeOut) { Remove-Item -LiteralPath $probeOut -Force } + + # doctor --json 을 파일로 리다이렉트해야 하므로 cmd.exe 를 경유한다. + $probeCmd = '/c ""{0}" -m dmf_crawler doctor --json > "{1}" 2>&1"' -f $PythonExe, $probeOut + + $actionProbe = New-ScheduledTaskAction -Execute $env:ComSpec -Argument $probeCmd -WorkingDirectory $ProjectRoot + $setProbe = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit (New-TimeSpan -Minutes 3) -MultipleInstances IgnoreNew -Compatibility Win8 + $trgProbe = New-ScheduledTaskTrigger -Once -At (Get-Date).AddYears(10) # 자동 실행은 절대 안 함 + $prcProbe = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Highest + + Register-DmfTask -Name $probeName -Path $TaskPath -Action $actionProbe -Trigger $trgProbe ` + -Settings $setProbe -Principal $prcProbe -Description '일회성 보안 컨텍스트 프로브(자동 삭제)' ` + -PlainPassword $plainPassword + + try { + Start-ScheduledTask -TaskName $probeName -TaskPath $TaskPath + $deadline = (Get-Date).AddMinutes(3) + do { + Start-Sleep -Seconds 2 + $state = (Get-ScheduledTask -TaskName $probeName -TaskPath $TaskPath).State + } while ($state -eq 'Running' -and (Get-Date) -lt $deadline) + + if (-not (Test-Path -LiteralPath $probeOut)) { + Write-Warn '프로브가 출력을 남기지 못했습니다. 작업이 아예 시작되지 못했을 수 있습니다.' + Write-Warn ' → Get-WinEvent -LogName "Microsoft-Windows-TaskScheduler/Operational" -MaxEvents 30 으로 확인' + } else { + $raw = Get-Content -LiteralPath $probeOut -Raw + try { + $doc = $raw | ConvertFrom-Json + $bad = @($doc.checks | Where-Object { -not $_.ok }) + if ($bad.Count -eq 0) { + Write-Good "프로브 통과: LogonType=$LogonType 에서 모든 진단이 정상입니다." + } else { + Write-Warn "프로브 실패 항목 $($bad.Count) 개:" + $bad | ForEach-Object { Write-Warn (" - [{0}] {1} : {2}" -f $_.key, $_.title, $_.detail) } + if ($bad.key -contains 'api_key') { + Write-Warn '' + Write-Warn ' ★ api_key 체크가 실패했다면 DPAPI 복호화가 이 로그온 타입에서 막힌 것입니다.' + Write-Warn ' 다음 명령으로 암호 저장 방식으로 다시 등록하세요:' + Write-Warn " .\scripts\install_tasks.ps1 -LogonType Password -Verify" + } + } + } catch { + Write-Warn 'JSON 파싱 실패. 원문을 그대로 출력합니다:' + Write-Host $raw + } + } + } finally { + Unregister-ScheduledTask -TaskName $probeName -TaskPath $TaskPath -Confirm:$false -ErrorAction SilentlyContinue + Write-Step '프로브 작업 제거 완료' + } +} + +Write-Host '' +Write-Good '작업 등록이 끝났습니다. 다음 단계: §2.7 검증 명령을 실행하세요.' +``` + +### 2.4 `scripts\uninstall_tasks.ps1` 전문 + +```powershell +#Requires -Version 5.1 +<# +.SYNOPSIS + DMF Crawler 작업 3종을 제거한다. 데이터·로그·리포트는 건드리지 않는다. +#> +[CmdletBinding()] +param( + [string]$TaskPath = '\DMF_Crawler\', + [switch]$RemoveFolder +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$names = @('DMF_Crawler_Daily', 'DMF_Crawler_Agent', 'DMF_Crawler_AgyUpdate', 'DMF_Crawler_Probe') + +foreach ($n in $names) { + $t = Get-ScheduledTask -TaskName $n -TaskPath $TaskPath -ErrorAction SilentlyContinue + if ($t) { + if ($t.State -eq 'Running') { + Write-Host "[uninstall_tasks] 실행 중 → 중지: $n" + Stop-ScheduledTask -TaskName $n -TaskPath $TaskPath + } + Unregister-ScheduledTask -TaskName $n -TaskPath $TaskPath -Confirm:$false + Write-Host "[uninstall_tasks] 제거: $TaskPath$n" + } else { + Write-Host "[uninstall_tasks] 없음(건너뜀): $TaskPath$n" + } +} + +if ($RemoveFolder) { + try { + $svc = New-Object -ComObject 'Schedule.Service' + $svc.Connect() + $root = $svc.GetFolder('\') + $root.DeleteFolder($TaskPath.Trim('\'), 0) + Write-Host "[uninstall_tasks] 폴더 제거: $TaskPath" + } catch { + Write-Host "[uninstall_tasks] 폴더 제거 실패(무해): $($_.Exception.Message)" + } +} + +Write-Host '[uninstall_tasks] 완료. data\ logs\ reports\ backup\ state\ 는 그대로 남아 있습니다.' +``` + +### 2.5 실행 방법 + +```powershell +# 관리자 권한 PowerShell 에서 +cd D:\workspace\DMF_Crawler +powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify +``` + +`ExecutionPolicy` 를 영구히 바꾸지 않는다. 호출 시점의 `-ExecutionPolicy Bypass` 로 충분하다. + +### 2.6 왜 액션이 `powershell.exe` 가 아니라 `python.exe` 인가 + +| 이유 | 설명 | +|---|---| +| **종료 코드 보존** | 중간에 `powershell.exe` 를 끼우면 스크립트가 `$LASTEXITCODE` 를 명시적으로 `exit` 하지 않는 한 종료 코드가 뭉개진다. `RestartCount` 는 종료 코드에 반응하므로 치명적이다. | +| **콘솔 창** | S4U 세션에는 데스크톱이 없어 `python.exe` 라도 창이 뜨지 않는다. 반대로 Interactive 로 도는 Agent 는 `pythonw.exe` 를 써야 검은 창이 깜빡이지 않는다. | +| **레이어 하나 제거** | 인코딩(코드페이지 949 vs UTF-8), 실행 정책, 프로필 로딩 같은 PowerShell 고유 변수를 제거한다. | + +> **`AGY_CLI_DISABLE_AUTO_UPDATE` 는 작업에 설정하지 않는다.** 작업 스케줄러 액션은 환경변수를 직접 넣을 수 없다. 이 변수는 `agy/client.py` 가 `subprocess` 를 띄울 때 자식 환경에 주입한다 — 배치가 아니라 **코드의 책임**이다. + +### 2.7 등록 후 검증 명령 + +```powershell +# (1) 작업 3종이 Ready 상태로 보이는가 +Get-ScheduledTask -TaskPath '\DMF_Crawler\' | + Format-Table TaskName, State -AutoSize + +# (2) 다음 실행 시각이 내일 06:00 근처인가 +Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | + Get-ScheduledTaskInfo | + Format-List TaskName, NextRunTime, LastRunTime, LastTaskResult, NumberOfMissedRuns + +# (3) 보안 컨텍스트가 의도대로인가 (SYSTEM 이면 즉시 실패로 간주하라) +(Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Principal | + Format-List UserId, LogonType, RunLevel + +# (4) 배터리·캐치업·절전 해제 4종이 제대로 뒤집혔는가 +(Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Settings | + Format-List DisallowStartIfOnBatteries, StopIfGoingOnBatteries, + StartWhenAvailable, WakeToRun, RunOnlyIfNetworkAvailable, + ExecutionTimeLimit, MultipleInstances, Priority + +# (5) 재시작 정책 +(Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Settings | + Format-List RestartCount, RestartInterval + +# (6) 트리거 2개(Daily + Boot)가 다 붙었는가. BootTrigger 의 Delay 가 PT5M 인가 +(Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Triggers | + Format-List CimClass, Enabled, StartBoundary, RandomDelay, Delay + +# (7) 절전 해제 타이머로 등록됐는가 (WakeToRun 의 실제 효과 확인) +powercfg /waketimers + +# (8) XML 전문을 눈으로 확인 +Export-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' + +# (9) 고전 도구로도 교차 확인 +schtasks /Query /TN "\DMF_Crawler\DMF_Crawler_Daily" /V /FO LIST +``` + +**자동 검증 스니펫** — 하나라도 어긋나면 붉게 출력한다. 설치 직후 그대로 붙여넣어라. + +```powershell +$t = Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' +$s = $t.Settings +$p = $t.Principal +$exp = [ordered]@{ + 'DisallowStartIfOnBatteries = False' = ($s.DisallowStartIfOnBatteries -eq $false) + 'StopIfGoingOnBatteries = False' = ($s.StopIfGoingOnBatteries -eq $false) + 'StartWhenAvailable = True' = ($s.StartWhenAvailable -eq $true) + 'WakeToRun = True' = ($s.WakeToRun -eq $true) + 'MultipleInstances = IgnoreNew' = ($s.MultipleInstances -eq 'IgnoreNew') + 'RestartCount = 3' = ($s.RestartCount -eq 3) + 'RestartInterval = PT10M' = ($s.RestartInterval -eq 'PT10M') + 'ExecutionTimeLimit = PT30M' = ($s.ExecutionTimeLimit -eq 'PT30M') + 'RunLevel = Highest' = ($p.RunLevel -eq 'Highest') + 'UserId != SYSTEM' = ($p.UserId -notmatch 'SYSTEM|LOCALSERVICE|NETWORKSERVICE') + '트리거 2개(Daily + Boot)' = ($t.Triggers.Count -eq 2) +} +$fail = 0 +foreach ($k in $exp.Keys) { + if ($exp[$k]) { Write-Host (" OK {0}" -f $k) -ForegroundColor Green } + else { Write-Host (" FAIL {0}" -f $k) -ForegroundColor Red; $fail++ } +} +if ($fail -gt 0) { Write-Host "`n$fail 개 항목이 어긋났습니다. install_tasks.ps1 을 다시 실행하세요." -ForegroundColor Red } +else { Write-Host "`n전 항목 통과." -ForegroundColor Green } +``` + +### 2.8 ★ S4U vs Password — 설치 시점에 반드시 실측할 단 하나의 분기 + +**사실 관계부터 정확히.** Microsoft 문서의 `TASK_LOGON_S4U` 설명 원문: + +> "Use an existing interactive token to run a task. The user must log on using a service for user (S4U) logon. When an S4U logon is used, **no password is stored by the system and there is no access to either the network or encrypted files**." + +여기서 "no access to the network" 는 **네트워크 자원에 사용자 자격증명으로 인증하는 것**(UNC 공유, 매핑 드라이브, Kerberos 위임)을 말한다. **공개 HTTPS 엔드포인트로의 아웃바운드 요청은 막히지 않는다.** 우리 크롤러는 `https://apis.data.go.kr/...` 하나만 호출하므로 이 제약에 걸리지 않는다. + +**진짜 위험은 "encrypted files" 쪽이다.** S4U 로그온에는 사용자 암호가 개입하지 않으므로 **DPAPI 사용자 마스터 키를 풀지 못해 `CryptUnprotectData` 가 실패할 수 있다.** 우리는 공공데이터포털 API 키를 `secrets_dpapi.py` 로 DPAPI 암호화해 저장한다 → **정면 충돌 가능성이 있다.** + +| | `S4U` | `Password` | +|---|---|---| +| 암호 저장 | 안 함 | Task Scheduler 자격증명 저장소에 저장 | +| 로그오프 상태 실행 | ✅ | ✅ | +| 아웃바운드 HTTPS | ✅ | ✅ | +| UNC·매핑 드라이브 | ❌ | ✅ | +| **DPAPI 사용자 범위 복호화** | **⚠️ 실패할 수 있음 — 실측 필요** | ✅ | +| 사용자 암호 변경 시 | 영향 없음 | **작업이 깨짐 → 재등록 필요** | +| 필요 권한 | 해당 계정에 `Logon as Batch` | 동일 | + +**판정 절차(추측 금지):** + +```powershell +# 1) S4U 로 등록하면서 프로브까지 수행 +.\scripts\install_tasks.ps1 -LogonType S4U -Verify + +# 2) 출력의 api_key 체크를 본다. +# "OK" → S4U 그대로 간다. 끝. +# "FAIL: DPAPI ..." → 3) 으로. + +# 3) 암호 저장 방식으로 재등록 +.\scripts\install_tasks.ps1 -LogonType Password -Verify +``` + +**`Password` 로 갔다면 반드시 기록할 것**: 이 PC 는 **Windows 로그인 암호를 바꾸는 순간 06:00 배치가 죽는다.** 암호 변경 후 `install_tasks.ps1 -LogonType Password` 재실행이 필수 절차다. `docs/ops/02-failure-alerting.md` 의 복구 안내와 온보딩 GUI 체크 ⑨ 에 이 문구가 들어가야 한다. + +> **`Logon as Batch` 권한**: "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." 개인 PC 의 관리자 계정이면 기본으로 있다. 없으면 `secpol.msc → 로컬 정책 → 사용자 권한 할당 → 일괄 작업으로 로그온`에 계정을 추가한다. + +--- + +## 3. 동등한 schtasks XML 전문 + +작업 스케줄러 XML 은 **형상관리 가능한 정본**이다. `scripts\tasks\` 아래에 두고 Git 에 올린다. `` 와 경로만 PC 에 맞게 치환한다. + +> **인코딩 주의**: `schtasks /Create /XML` 은 **UTF-16 LE** 파일을 기대한다. PowerShell 에서 저장할 때 `Out-File -Encoding Unicode` 를 쓰거나, 인코딩 문제를 피하려면 `Register-ScheduledTask -Xml (Get-Content -Raw -Encoding UTF8 ...)` 를 쓴다. + +### 3.1 `scripts\tasks\DMF_Crawler_Daily.xml` + +```xml + + + + 2026-09-02T00:00:00 + DMF Crawler + DMF 일일 수집·비교·리포트 배치. 매일 06:00 + 부팅 후 5분. UI 를 띄우지 않는다. + \DMF_Crawler\DMF_Crawler_Daily + + + + 2026-09-02T06:00:00 + true + PT4M + + 1 + + + + true + PT5M + + + + + DESKTOP-XXXXXXX\encep + S4U + HighestAvailable + + + + IgnoreNew + false + false + true + true + true + + false + false + + true + true + false + false + false + true + true + PT30M + 5 + + PT10M + 3 + + + + + D:\workspace\DMF_Crawler\.venv\Scripts\python.exe + -m dmf_crawler run --trigger scheduled + D:\workspace\DMF_Crawler + + + +``` + +**XSD 상 유효성 근거 3가지** (이걸 모르면 "왜 임포트가 거부되는지" 를 못 찾는다): + +- `` 은 `PT1M` 이상 `P31D` 이하로 제한된다. `PT10M` 은 유효. +- `` 는 `unsignedByte` 이고 최소 1. `3` 은 유효. +- `` 에 `PT0S` 를 주면 **무제한**이 된다. 값을 아예 생략하면 기본 3일이다. 우리는 폭주 방지를 위해 `PT30M` 을 명시한다. + +### 3.2 `scripts\tasks\DMF_Crawler_Agent.xml` + +```xml + + + + 2026-09-02T00:00:00 + DMF Crawler + DMF 알림 에이전트. 로그온 시 + 15분마다 heartbeat 를 점검하고 밀린 알림을 표시한다. + \DMF_Crawler\DMF_Crawler_Agent + + + + true + DESKTOP-XXXXXXX\encep + + PT15M + false + + + + 2026-09-02T00:05:00 + true + + PT15M + false + + + + + + DESKTOP-XXXXXXX\encep + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + true + false + false + + false + false + + true + true + false + false + false + true + false + PT10M + 7 + + + + D:\workspace\DMF_Crawler\.venv\Scripts\pythonw.exe + -m dmf_crawler notify-pump --once + D:\workspace\DMF_Crawler + + + +``` + +`` 에서 `` 을 **생략하면 무기한 반복**이다. `false` 와 함께 쓴다. `WakeToRun` 은 **false** — 알리미가 새벽 3시에 PC 를 깨우면 안 된다. + +### 3.3 `scripts\tasks\DMF_Crawler_AgyUpdate.xml` + +```xml + + + + 2026-09-02T00:00:00 + DMF Crawler + agy CLI 주간 업데이트. 배치 시간대를 피해 일요일 14:00 에 돈다. + \DMF_Crawler\DMF_Crawler_AgyUpdate + + + + 2026-09-06T14:00:00 + true + PT10M + + + + + 1 + + + + + + DESKTOP-XXXXXXX\encep + S4U + LeastPrivilege + + + + IgnoreNew + false + false + true + true + true + + false + false + + true + true + false + false + false + true + false + PT30M + 7 + + + + C:\Users\encep\AppData\Local\agy\bin\agy.exe + update + D:\workspace\DMF_Crawler + + + +``` + +> `%LOCALAPPDATA%` 같은 환경변수는 `` 에서 **전개되지 않는다.** 절대 경로를 써야 한다. + +### 3.4 XML 임포트 · 익스포트 명령 + +```powershell +# ── 임포트 (schtasks, UTF-16 파일 전제) ────────────────────────────── +schtasks /Create /TN "\DMF_Crawler\DMF_Crawler_Daily" ` + /XML "D:\workspace\DMF_Crawler\scripts\tasks\DMF_Crawler_Daily.xml" /F + +# S4U(암호 저장 안 함)로 계정을 지정해 임포트 +schtasks /Create /TN "\DMF_Crawler\DMF_Crawler_Daily" ` + /XML "...\DMF_Crawler_Daily.xml" /RU "DESKTOP-XXXXXXX\encep" /F + +# 암호 저장 방식으로 임포트 (실행 시 암호를 물어본다) +schtasks /Create /TN "\DMF_Crawler\DMF_Crawler_Daily" ` + /XML "...\DMF_Crawler_Daily.xml" /RU "DESKTOP-XXXXXXX\encep" /RP * /F + +# ── 임포트 (PowerShell, 인코딩 걱정 없음) ──────────────────────────── +$xml = Get-Content -Raw -Encoding UTF8 ` + 'D:\workspace\DMF_Crawler\scripts\tasks\DMF_Crawler_Daily.xml' +Register-ScheduledTask -TaskName 'DMF_Crawler_Daily' -TaskPath '\DMF_Crawler\' ` + -Xml $xml -User 'DESKTOP-XXXXXXX\encep' -Force + +# ── 익스포트 (현재 PC 의 실제 상태를 정본으로 되돌려 받기) ──────────── +New-Item -ItemType Directory -Force -Path 'D:\workspace\DMF_Crawler\scripts\tasks' | Out-Null +foreach ($n in 'DMF_Crawler_Daily','DMF_Crawler_Agent','DMF_Crawler_AgyUpdate') { + $t = Get-ScheduledTask -TaskName $n -TaskPath '\DMF_Crawler\' -ErrorAction SilentlyContinue + if ($t) { + Export-ScheduledTask -TaskName $n -TaskPath '\DMF_Crawler\' | + Out-File -Encoding Unicode "D:\workspace\DMF_Crawler\scripts\tasks\$n.xml" + Write-Host "exported: $n" + } +} +``` + +--- + +## 4. 워치독과 heartbeat + +### 4.1 heartbeat 파일 규약 + +| 항목 | 값 | +|---|---| +| 경로 | `D:\workspace\DMF_Crawler\state\heartbeat.json` | +| 인코딩 | UTF-8 (BOM 없음), LF | +| 쓰는 주체 | `pipeline.py` 의 `finalize` 스테이지 (**성공 · 부분성공일 때만**) | +| 읽는 주체 | `watchdog.py`(에이전트 안), `checks.py` 체크 ⑫, 운영자의 `check_heartbeat.ps1` | +| 쓰기 방식 | 임시 파일 → `os.replace` 원자 교체. **SQLite 잠금을 요구하지 않는다** | +| 갱신 시점 | 파이프라인 종료 직전. `status ∈ {SUCCESS, PARTIAL}` 일 때만 `updated_at` 을 현재 시각으로 밀어 올린다 | +| **갱신하지 않는 경우** | `FAILED`, `BLOCKED`, `ExecutionTimeLimit` 강제 종료, 프로세스 크래시, 작업이 아예 시작되지 못함 | + +**★ 설계상 가장 중요한 한 줄**: **실패했을 때 heartbeat 를 갱신하면 dead-man switch 가 설계상 무력화된다.** "돌긴 돌았다"를 기록하고 싶은 유혹을 버려라. 실행 시도 자체는 `runs` 테이블과 `events.jsonl` 이 이미 남긴다. + +### 4.2 `state\heartbeat.json` 스키마와 예시 + +```json +{ + "schema": 1, + "updated_at": "2026-09-02T06:04:37+09:00", + "run_id": "20260902_060012", + "run_date": "2026-09-02", + "trigger": "scheduled", + "status": "SUCCESS", + "exit_code": 0, + "started_at": "2026-09-02T06:00:12+09:00", + "duration_seconds": 265, + "records_total": 12874, + "events": { "new": 3, "changed": 1, "withdrawn": 0 }, + "integrity": { "passed": true, "blocked_gate": null }, + "report_path": "D:\\workspace\\DMF_Crawler\\reports\\DMF_리포트_2026-09-02.xlsx", + "log_dir": "D:\\workspace\\DMF_Crawler\\logs\\run_20260902_060012", + "agy": { "used": true, "status": "OK", "tokens": 31245 }, + "consecutive_failures": 0, + "host": "DESKTOP-XXXXXXX", + "user": "encep", + "version": "0.1.0" +} +``` + +| 필드 | 타입 | 의미 | +|---|---|---| +| `schema` | int | 파일 포맷 버전. 읽는 쪽은 모르는 버전이면 WARN 후 무시 | +| `updated_at` | ISO8601 (오프셋 포함) | **워치독 판정의 유일한 기준** | +| `run_id` | str | `YYYYMMDD_HHMMSS` (KST). 로그 디렉터리 이름과 1:1 | +| `run_date` | `YYYY-MM-DD` | 실행 대상 일자. idempotency 가드가 쓰는 키 | +| `trigger` | `scheduled` \| `startup` \| `manual` | 어느 트리거로 돌았나 | +| `status` | `SUCCESS` \| `PARTIAL` | `PARTIAL` = 리포트는 나왔지만 무결성 게이트 차단 또는 AI 실패 | +| `exit_code` | int | 0 / 1 / 2 / 130 | +| `duration_seconds` | int | 사람이 "느려졌다"를 감지하는 지표 | +| `records_total` | int | 오늘 수집 총 건수 | +| `events` | object | 신규/변경/취하 건수 | +| `integrity.blocked_gate` | str \| null | 차단한 게이트 이름(있으면 PARTIAL 사유) | +| `report_path` | str | 알림에서 "리포트 열기" 버튼이 쓴다 | +| `log_dir` | str | 알림에서 "로그 폴더 열기" 버튼이 쓴다 | +| `agy.status` | `OK` \| `SKIPPED` \| `AUTH` \| `QUOTA` \| `ERROR` | AI 계층 결과 | +| `consecutive_failures` | int | 마지막 성공 이후 누적 실패 횟수. `notify.consecutive_failure_critical`(3) 승격 판정에 쓴다 | + +### 4.3 heartbeat 기록기 — `src\dmf_crawler\watchdog.py` 의 쓰기 부분 + +```python +"""heartbeat 기록과 신선도 판정. + +이 모듈은 SQLite 를 열지 않는다. 알림 에이전트가 배치와 락 경쟁을 하지 +않고 상태를 읽을 수 있어야 하기 때문이다(아키텍처 §2 state/ 설명). +""" + +from __future__ import annotations + +import json +import os +import socket +import tempfile +from dataclasses import dataclass +from datetime import datetime, time, timedelta +from pathlib import Path +from typing import Any, Literal +from zoneinfo import ZoneInfo + +HEARTBEAT_SCHEMA = 1 + +Status = Literal["SUCCESS", "PARTIAL"] + + +def write_heartbeat(path: Path, payload: dict[str, Any]) -> None: + """heartbeat.json 을 원자적으로 교체한다. + + 성공·부분성공일 때만 호출한다. 실패 시 호출하면 dead-man switch 가 + 무력화된다. + """ + payload = {"schema": HEARTBEAT_SCHEMA, **payload} + payload.setdefault("host", socket.gethostname()) + payload.setdefault("user", os.environ.get("USERNAME", "")) + + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + dir=str(path.parent), prefix=".heartbeat-", suffix=".tmp" + ) + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh: + json.dump(payload, fh, ensure_ascii=False, indent=2) + fh.write("\n") + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) + except BaseException: + tmp.unlink(missing_ok=True) + raise +``` + +### 4.4 워치독 판정 로직 — 같은 파일의 읽기·판정 부분 + +**나이브한 구현이 반드시 틀리는 지점**: "heartbeat 가 120분보다 오래됐으면 경보" 라고 쓰면, 새벽 3시에는 어제 06:00 heartbeat 가 21시간 묵어 있으므로 **매일 밤 오탐이 뜬다.** 올바른 기준은 **"가장 최근에 지나간 예정 실행 시각(expected_last_run) 이후에 갱신됐는가"** 이다. + +```python +@dataclass(frozen=True, slots=True) +class Heartbeat: + updated_at: datetime + run_id: str + run_date: str + status: str + exit_code: int + report_path: str + log_dir: str + consecutive_failures: int + raw: dict[str, Any] + + @classmethod + def load(cls, path: Path, tz: ZoneInfo) -> "Heartbeat | None": + """읽지 못하면 None. 예외를 밖으로 던지지 않는다 — + 진단기가 죽어서 진단이 안 되는 일이 없어야 한다.""" + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + try: + updated = datetime.fromisoformat(str(doc["updated_at"])) + except (KeyError, ValueError): + return None + if updated.tzinfo is None: + updated = updated.replace(tzinfo=tz) + return cls( + updated_at=updated.astimezone(tz), + run_id=str(doc.get("run_id", "")), + run_date=str(doc.get("run_date", "")), + status=str(doc.get("status", "")), + exit_code=int(doc.get("exit_code", -1)), + report_path=str(doc.get("report_path", "")), + log_dir=str(doc.get("log_dir", "")), + consecutive_failures=int(doc.get("consecutive_failures", 0)), + raw=doc, + ) + + +@dataclass(frozen=True, slots=True) +class Verdict: + stale: bool + code: str # OK | NEVER_RAN | STALE | GRACE | DEGRADED + expected_at: datetime | None + age_minutes: int | None + detail: str + + +def expected_last_run(now: datetime, daily_time: time) -> datetime: + """now 기준으로 '가장 최근에 지나간 예정 실행 시각'.""" + today = now.replace( + hour=daily_time.hour, minute=daily_time.minute, second=0, microsecond=0 + ) + return today if now >= today else today - timedelta(days=1) + + +def judge( + hb: Heartbeat | None, + *, + now: datetime, + daily_time: time, + stale_minutes: int, +) -> Verdict: + """워치독 판정. + + stale_minutes 는 '예정 시각 이후 이만큼 지나도 갱신이 없으면 경보'라는 + 유예 시간이다(config: notify.watchdog_stale_minutes, 기본 120). + 지터 4분 + 재시작 3회 × 10분 = 최악 34분 을 충분히 덮는다. + """ + expected = expected_last_run(now, daily_time) + deadline = expected + timedelta(minutes=stale_minutes) + + if hb is None: + if now < deadline: + return Verdict(False, "GRACE", expected, None, + "아직 한 번도 실행되지 않았지만 유예 시간 안입니다.") + return Verdict(True, "NEVER_RAN", expected, None, + "성공 기록이 한 번도 없습니다. 최초 설치가 끝나지 않았을 수 있습니다.") + + age = int((now - hb.updated_at).total_seconds() // 60) + + if hb.updated_at >= expected: + code = "DEGRADED" if hb.status == "PARTIAL" else "OK" + detail = ( + f"마지막 성공 {hb.updated_at:%Y-%m-%d %H:%M} " + f"(run_id={hb.run_id}, status={hb.status})" + ) + return Verdict(False, code, expected, age, detail) + + if now < deadline: + return Verdict(False, "GRACE", expected, age, + f"{expected:%H:%M} 실행이 아직 진행 중이거나 재시도 중일 수 있습니다.") + + return Verdict( + True, "STALE", expected, age, + f"{expected:%Y-%m-%d %H:%M} 예정 실행의 성공 기록이 없습니다. " + f"마지막 성공은 {hb.updated_at:%Y-%m-%d %H:%M} ({age}분 전)입니다.", + ) +``` + +### 4.5 판정 → 알림 연결 + +`notify/pump.py` 가 `judge()` 결과를 받아 다음 표대로 행동한다. 알림 문구 4요소(무엇/왜/어떻게/다음 행동)는 `alerts.raise_alert()` 가 **계약으로 강제**한다 — 하나라도 비면 `ValueError`. + +| `code` | 등급 | 표시 방식 | 알림 코드 | 다음 행동 버튼 | +|---|---|---|---|---| +| `OK` | — | 표시 없음 | — | — | +| `GRACE` | — | 표시 없음 | — | — | +| `DEGRADED` | WARN | 자동소멸 토스트 | `RUN_PARTIAL` | [리포트 열기] [로그 폴더 열기] | +| `STALE` | CRITICAL | **강제 모달** (`gui.launch(mode="recover", focus_key="last_run")`) | `WATCHDOG_STALE` | [지금 실행] [작업 상태 확인] [로그 폴더 열기] | +| `NEVER_RAN` | CRITICAL | **강제 모달** (`mode="setup"`) | `WATCHDOG_NEVER_RAN` | [설치 마법사 열기] | + +중복 억제: `dedup_key = code + run_date`, 쿨다운 `notify.cooldown_minutes`(기본 240분). 즉 STALE 상태가 지속돼도 하루에 최대 몇 번만 창이 뜬다. + +### 4.6 운영자용 즉석 점검 스크립트 — `scripts\check_heartbeat.ps1` + +Python 환경이 깨졌을 때도 상태를 볼 수 있어야 한다. **읽기 전용**이며 아무것도 등록하지 않는다. + +```powershell +#Requires -Version 5.1 +<# +.SYNOPSIS + state\heartbeat.json 을 읽어 06:00 배치의 최근 상태를 판정한다. 읽기 전용. +.OUTPUTS + 종료 코드 0 = 정상, 1 = 열화(PARTIAL), 2 = 경보(STALE / 파일 없음) +#> +[CmdletBinding()] +param( + [string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot), + [string]$DailyTime = '06:00', + [int]$StaleMinutes = 120 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$hbPath = Join-Path $ProjectRoot 'state\heartbeat.json' +$now = Get-Date + +if (-not (Test-Path -LiteralPath $hbPath)) { + Write-Host "[heartbeat] 파일이 없습니다: $hbPath" -ForegroundColor Red + Write-Host ' → 성공 실행이 한 번도 없습니다. 다음을 실행하세요:' -ForegroundColor Red + Write-Host ' .\.venv\Scripts\python.exe -m dmf_crawler doctor' + exit 2 +} + +$hb = Get-Content -LiteralPath $hbPath -Raw -Encoding UTF8 | ConvertFrom-Json +$upd = [datetime]::Parse($hb.updated_at) + +$parts = $DailyTime.Split(':') +$todayRun = $now.Date.AddHours([int]$parts[0]).AddMinutes([int]$parts[1]) +$expected = if ($now -ge $todayRun) { $todayRun } else { $todayRun.AddDays(-1) } +$deadline = $expected.AddMinutes($StaleMinutes) +$ageMin = [int]($now - $upd).TotalMinutes + +Write-Host '' +Write-Host ' DMF Crawler heartbeat' -ForegroundColor Cyan +Write-Host ' ---------------------------------------------------------------' +Write-Host (" 마지막 성공 : {0:yyyy-MM-dd HH:mm:ss} ({1}분 전)" -f $upd, $ageMin) +Write-Host (" run_id : {0}" -f $hb.run_id) +Write-Host (" 상태 : {0} (exit={1})" -f $hb.status, $hb.exit_code) +Write-Host (" 수집 건수 : {0:N0}" -f $hb.records_total) +Write-Host (" 변경 : 신규 {0} / 변경 {1} / 취하 {2}" -f $hb.events.new, $hb.events.changed, $hb.events.withdrawn) +Write-Host (" 소요 : {0}초" -f $hb.duration_seconds) +Write-Host (" AI : {0} (토큰 {1:N0})" -f $hb.agy.status, $hb.agy.tokens) +Write-Host (" 리포트 : {0}" -f $hb.report_path) +Write-Host (" 로그 : {0}" -f $hb.log_dir) +Write-Host (" 연속 실패 : {0}" -f $hb.consecutive_failures) +Write-Host (" 기준 예정시각 : {0:yyyy-MM-dd HH:mm} (유예 {1}분 → {2:HH:mm})" -f $expected, $StaleMinutes, $deadline) +Write-Host ' ---------------------------------------------------------------' + +if ($upd -ge $expected) { + if ($hb.status -eq 'PARTIAL') { + Write-Host ' 판정: DEGRADED — 리포트는 나왔지만 일부 단계가 실패했습니다.' -ForegroundColor Yellow + Write-Host ' → 로그 폴더의 pipeline.log 에서 WARN 을 확인하세요.' + exit 1 + } + Write-Host ' 판정: OK — 최근 예정 실행이 성공했습니다.' -ForegroundColor Green + exit 0 +} + +if ($now -lt $deadline) { + Write-Host ' 판정: GRACE — 아직 유예 시간 안입니다(실행 중이거나 재시도 중).' -ForegroundColor Yellow + exit 0 +} + +Write-Host ' 판정: STALE — 예정 실행의 성공 기록이 없습니다!' -ForegroundColor Red +Write-Host '' +Write-Host ' 복구 순서:' -ForegroundColor Red +Write-Host ' 1) Get-ScheduledTask -TaskPath "\DMF_Crawler\" | Get-ScheduledTaskInfo' +Write-Host ' 2) .\.venv\Scripts\python.exe -m dmf_crawler doctor' +Write-Host ' 3) .\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual' +exit 2 +``` + +--- + +## 5. 재부팅 · 전원 · 시각 시나리오 전체 + +### 5.1 시나리오 표 — "이 상황에서 06:00 배치는 어떻게 되는가" + +| # | 상황 | 06:00 에 실행되는가 | 무엇이 구해주는가 | 사람이 할 일 | +|---|---|---|---|---| +| 1 | PC 켜져 있고 로그온 | ✅ 06:00±4분 | Daily 트리거 | 없음 | +| 2 | PC 켜져 있고 **로그오프** | ✅ | S4U/Password 로그온 타입 | 없음. 알림만 다음 로그온까지 지연 | +| 3 | PC 켜져 있고 **화면 잠금** | ✅ | 잠금은 세션 종료가 아니다 | 없음 | +| 4 | **절전(S3/모던 대기)** | ✅ 깨워서 실행 | `WakeToRun` + 전원 관리의 절전 해제 타이머 허용 | §5.4 의 powercfg 1회 설정 | +| 5 | **최대 절전(S4)** | △ 하드웨어 의존 | `WakeToRun` 은 S4 에서도 시도하지만 보장 없음 | §5.4 로 최대 절전 자체를 끄는 것이 확실 | +| 6 | **완전히 꺼짐** | ❌ 06:00 에는 못 돎 | 켜지면 `StartWhenAvailable` 이 즉시 캐치업 + 부팅 트리거(+5분) | 없음 | +| 7 | 06:00 직전 재부팅 중 | ❌ 그 순간엔 못 돎 | `StartWhenAvailable` 캐치업 | 없음 | +| 8 | Windows Update 재시작이 06:00 에 걸림 | ❌ | 활성 시간 05:00–23:00 으로 **예방** + `StartWhenAvailable` | §5.6 1회 설정 | +| 9 | BitLocker **PIN** 입력 대기 | ❌ 부팅이 멈춰 있음 | 없음 — OS 가 아직 안 떴다 | §5.9 판단 | +| 10 | 네트워크가 아직 안 올라옴(부팅 직후) | △ | `BootTrigger Delay PT5M` + `RunOnlyIfNetworkAvailable` + 코드의 재시도 4회 | 없음 | +| 11 | 06:00 실행 중 배터리로 전환 | ✅ 계속 실행 | `StopIfGoingOnBatteries=false` | 없음 | +| 12 | 배터리로만 구동 중 | ✅ | `DisallowStartIfOnBatteries=false` | 없음 | +| 13 | 실행이 30분 초과 | ❌ 강제 종료 | `RestartCount 3` × `PT10M` 재시도 + 스테이지 체크포인트 재개 | 반복되면 `source.page_size` 조정 | +| 14 | 06:00 실행 성공 후 07:00 재부팅 | 재실행 안 함 | **idempotency 가드**(오늘 SUCCESS → 종료 0) | 없음 | +| 15 | 여러 날 꺼져 있다가 켜짐 | 당일분 1회만 | `StartWhenAvailable` 은 **놓친 실행을 한 번만** 몰아 실행한다 | 과거분은 `backfill` 로 | + +### 5.2 부팅 후 네트워크 대기 + +부팅 트리거가 뜨는 시점에 네트워크 스택이 준비돼 있다는 보장이 없다. 3중으로 막는다. + +1. **`PT5M`** — 부팅 후 5분 대기. `New-ScheduledTaskTrigger -AtStartup` 에는 `-Delay` 파라미터가 **없어서** CIM 인스턴스의 `Delay` 속성을 직접 채운다(§2.3 코드 참조). +2. **`RunOnlyIfNetworkAvailable=true`** — 네트워크가 없으면 아예 시작하지 않는다. 이때 이벤트 ID **112 (`JobNoStartWithoutNetwork`)** 가 기록된다. +3. **코드의 재시도** — `source.max_attempts=4`, `backoff_base_seconds=5.0`, `Retry-After` 절대 우선. DNS 가 잠깐 안 되는 정도는 여기서 흡수된다. + +`config.toml` 의 `schedule.startup_delay_minutes` 를 늘리면 1번이 함께 늘어난다. 무선 랜만 쓰는 PC 에서 5분이 부족하면 10분으로 올린다. + +### 5.3 놓친 작업 실행(캐치업)의 정확한 의미 + +`StartWhenAvailable=true` 의 공식 정의는 "Specifies that the Task Scheduler can start the task at any time after its scheduled time has passed." 실무적으로 알아야 할 것: + +- **여러 번 놓쳐도 몰아서 여러 번 돌지 않는다.** 3일 꺼져 있다가 켜면 1회 실행된다. 과거 3일치 데이터가 필요하면 `backfill` 을 쓴다. +- 캐치업 실행은 즉시가 아니라 **최대 10분 이내**에 트리거된다(스케줄러 내부 동작). 이벤트 ID **114 (`MissedTaskLaunched`)** 로 확인할 수 있다. +- 캐치업과 부팅 트리거가 겹쳐 **두 번 뜰 수 있다.** `MultipleInstances=IgnoreNew`(동시) + idempotency 가드(순차)가 둘 다 흡수한다. 이벤트 ID **322 (`NewInstanceIgnored`)** 가 보이면 정상 동작이다. + +### 5.4 절전 · 최대 절전 (powercfg) + +**관리자 권한 명령 프롬프트/PowerShell 에서 1회 실행.** + +```powershell +# ── (0) 현재 상태 확인 ───────────────────────────────────────────── +powercfg /a # 이 PC 가 지원하는 절전 상태 (S0 모던 대기 여부 확인) +powercfg /waketimers # 현재 등록된 절전 해제 타이머 +powercfg /devicequery wake_armed # 깨울 수 있는 장치 +powercfg /lastwake # 마지막에 무엇이 깨웠는가 + +# ── (1) 절전 해제 타이머 '사용'으로 (WakeToRun 이 실제로 동작하려면 필수) ── +# SUB_SLEEP = 238c9fa8-0aad-41ed-83f4-97be242c8f20 +# 절전 해제 타이머 허용 = bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d +powercfg /setacvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1 +powercfg /setdcvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1 +powercfg /setactive SCHEME_CURRENT + +# ── (2) 데스크톱 PC 권장: AC 전원에서는 아예 안 잔다 ─────────────── +powercfg /change standby-timeout-ac 0 # 0 = 사용 안 함 +powercfg /change hibernate-timeout-ac 0 +powercfg /change monitor-timeout-ac 15 # 화면만 끈다 (전기·수명) +powercfg /change disk-timeout-ac 0 + +# ── (3) 노트북: 배터리에서도 06:00 을 지키고 싶다면 ──────────────── +powercfg /change standby-timeout-dc 0 +powercfg /change hibernate-timeout-dc 0 +# → 배터리 소모가 커진다. 그래도 실행이 우선이면 이렇게 한다. + +# ── (4) 적용 확인 ───────────────────────────────────────────────── +powercfg /query SCHEME_CURRENT SUB_SLEEP +powercfg /waketimers +``` + +**`powercfg /waketimers` 결과 읽는 법**: 작업을 등록하고 `WakeToRun=true` 라면 다음 06:00 을 가리키는 항목이 하나 보여야 한다. 아무것도 안 보이면 (1) 단계를 안 했거나, 이 PC 가 **모던 대기(S0)** 라서 표기가 다를 수 있다. `powercfg /a` 의 출력에 `대기 (S0 짧은 지연 사용 가능)` 이 있으면 모던 대기 기기다. + +> **모던 대기(S0) 주의**: S0 기기에서는 "절전" 이 사실상 저전력 유지 상태라 예약 작업이 대체로 잘 돈다. 다만 네트워크가 오프로드 상태일 수 있어 첫 요청이 실패할 수 있다 — 코드의 재시도 4회가 흡수한다. + +### 5.5 Fast Startup(빠른 시작)과 `AtStartup` 트리거 + +**핵심 사실**: 종료(Shutdown)를 눌러도 Windows 는 실제로 완전히 끄지 않는다. 커널 세션을 `hiberfil.sys` 에 저장하는 **하이브리드 종료**를 하고, 다음 켜기는 **최대 절전 복귀**다. 그래서 **"시스템 시작 시" 트리거가 뜨지 않는다.** + +- **재시작(Restart)에는 Fast Startup 이 적용되지 않는다.** 재시작은 진짜 부팅이므로 `AtStartup` 이 뜬다. +- Fast Startup 은 **Windows 기본값으로 켜져 있고, Microsoft 는 끄는 것을 권장하지 않는다.** + +**우리의 방침**: + +1. **Fast Startup 을 끄지 않는다.** 부팅 트리거를 안전망으로 삼지 않기 때문이다. 진짜 안전망은 `StartWhenAvailable` 이고, 그것은 Fast Startup 과 무관하게 동작한다. +2. 부팅 트리거는 **"재시작 후 빠른 복귀"** 용 보조 장치로만 취급한다. +3. 그래도 끄고 싶다면(예: 이 PC 가 무인 서버 용도): + +```powershell +# 최대 절전 파일을 없애면 Fast Startup 도 함께 꺼진다 +powercfg /h off + +# 확인 — HiberbootEnabled 가 0 이면 Fast Startup 꺼짐 +Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Power' | + Select-Object HiberbootEnabled + +# 되돌리기 +powercfg /h on +``` + +> `powercfg /h off` 는 **최대 절전 기능 자체를 없앤다.** 노트북에서 최대 절전을 쓰고 있다면 부작용을 감수해야 한다. 그 경우 `HiberbootEnabled` 만 0 으로 두는 방법도 있으나, 최대 절전과 Fast Startup 의 상호작용이 기기마다 달라 ⚠️ 실측을 권한다. + +### 5.6 Windows Update 재시작과 06:00 충돌 회피 + +Windows Update 는 설치 후 **활성 시간(Active hours) 밖에서** 자동 재시작한다. 기본 활성 시간은 08:00–17:00 이므로 **06:00 이 재시작 창에 정통으로 들어간다.** + +- 활성 시간 **최대 범위는 18시간**(Windows 10 1607/Server 2016 은 12시간). +- 05:00 시작 → 23:00 종료 = 18시간. **06:00 을 보호하면서 최대 범위에 딱 맞는다.** + +```powershell +# ── 관리자 PowerShell ────────────────────────────────────────────── +# (A) 정책 경로 (권장 · 사용자가 UI 에서 못 바꾸게 고정) +$policy = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' +New-Item -Path $policy -Force | Out-Null +Set-ItemProperty -Path $policy -Name 'SetActiveHours' -Value 1 -Type DWord +Set-ItemProperty -Path $policy -Name 'ActiveHoursStart' -Value 5 -Type DWord +Set-ItemProperty -Path $policy -Name 'ActiveHoursEnd' -Value 23 -Type DWord + +# (B) 사용자 설정 경로 (UI 의 '활성 시간'과 같은 값) +$ux = 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings' +New-Item -Path $ux -Force | Out-Null +Set-ItemProperty -Path $ux -Name 'ActiveHoursStart' -Value 5 -Type DWord +Set-ItemProperty -Path $ux -Name 'ActiveHoursEnd' -Value 23 -Type DWord + +# ── 확인 ────────────────────────────────────────────────────────── +Get-ItemProperty -Path $policy | Select-Object SetActiveHours, ActiveHoursStart, ActiveHoursEnd +Get-ItemProperty -Path $ux | Select-Object ActiveHoursStart, ActiveHoursEnd +``` + +GUI 경로: **설정 → Windows Update → 고급 옵션 → 활성 시간**. + +> **완벽하지는 않다.** 마감 기한(deadline)을 넘긴 강제 재시작은 활성 시간을 무시할 수 있다. 그래서 `StartWhenAvailable` 이 여전히 필요하다. 재시작으로 06:00 을 놓치면 부팅 후 5분(부팅 트리거) 또는 캐치업으로 자동 회복된다. + +### 5.7 시간대 · DST + +```powershell +tzutil /g # 반드시 "Korea Standard Time" 이어야 한다 +w32tm /query /status +w32tm /resync # 시각이 틀어졌으면 +``` + +- **한국은 1988년 이후 서머타임을 시행하지 않는다.** KST = UTC+9 고정. DST 전환 시 작업이 1시간 일찍/늦게 도는 문제는 이 PC 에서 발생하지 않는다. +- 트리거 속성의 **"표준 시간대에 맞춰 동기화(Synchronize across time zones)" 를 켜지 마라.** 켜면 `StartBoundary` 가 UTC 로 해석되어(`...Z` 접미사) 로컬 06:00 의 의미가 흔들린다. 우리 XML 의 `2026-09-02T06:00:00` 에는 **의도적으로 `Z` 나 오프셋이 없다** — 로컬 시각이라는 뜻이다. +- 코드 쪽 일자 판정은 `general.timezone = "Asia/Seoul"` 로 못박혀 있어 OS 시간대가 바뀌어도 `run_date` 는 흔들리지 않는다. +- 노트북을 해외에 들고 나가 OS 시간대를 바꾸면 배치는 **그 지역의 06:00** 에 돈다. `run_date` 는 KST 기준이므로 하루에 두 번 돌거나 건너뛸 수 있다 — idempotency 가드가 중복은 막지만, 장기 해외 체류라면 시간대를 바꾸지 않는 편이 낫다. + +### 5.8 자동 로그온이 필요한가 — 결론: **배치는 불필요, 알림은 조건부** + +| 대상 | 자동 로그온 필요? | 이유 | +|---|---|---| +| `DMF_Crawler_Daily`(배치) | **불필요** | S4U/Password 로그온 타입은 사용자가 로그오프 상태여도 실행된다 | +| `DMF_Crawler_AgyUpdate` | **불필요** | 동일 | +| `DMF_Crawler_Agent`(알림·워치독) | **필요할 수도** | Interactive 는 로그온한 세션에서만 돈다. 로그오프 상태면 알림이 **지연**된다 | + +**로그오프 상태에서 실패했을 때 무슨 일이 일어나는가** (아키텍처 실패 경로 [F]): + +1. 배치가 `alerts` 테이블 + `state\alerts.json` + Windows 이벤트 로그에 기록한다. +2. 화면에는 아무것도 안 뜬다. +3. 다음 로그온 순간 `DMF_Crawler_Agent` 의 로그온 트리거가 즉시 발화해 **밀린 알림을 전부 표시**한다. + +즉 **알림이 사라지는 게 아니라 늦어질 뿐이다.** 대부분의 개인 PC 운용에서는 이걸로 충분하다. + +**그래도 즉시 알림이 필요하다면** — 세 가지 대안, 위험도 순: + +| 대안 | 방법 | 위험 | +|---|---|---| +| **A. 로그온한 채 화면만 잠금** (권장) | `Win+L`. 세션은 살아 있으므로 Agent 가 계속 돈다 | 없음. 물리 보안은 잠금 화면이 유지 | +| **B. 자동 로그온 + 즉시 잠금** | `netplwiz` 또는 Sysinternals `Autologon.exe` 로 자동 로그온 설정 후, 시작 프로그램에 `rundll32.exe user32.dll,LockWorkStation` 등록 | 자동 로그온은 자격증명을 레지스트리에 남긴다(`DefaultPassword`). **Autologon.exe 는 LSA 비밀에 저장해 그나마 낫다.** BitLocker PIN 과 병용 시 무의미(§5.9) | +| **C. 웹훅 알림 추가** | 배치가 실패 시 디스코드/슬랙 웹훅 호출 | 의존성·비밀 관리 증가. **현재 범위 밖(부록 참조)** | + +**기본 권고는 A 다.** B 는 이 PC 에 다른 민감 데이터가 없고 물리적으로 안전한 위치일 때만. + +### 5.9 BitLocker · PIN 의 영향 + +| 구성 | 무인 재부팅 후 06:00 배치 | 판정 | +|---|---|---| +| BitLocker 미사용 | ✅ 정상 | 문제 없음 | +| **TPM 전용** (PIN 없음) | ✅ 정상 — TPM 이 자동으로 볼륨을 해제하고 OS 가 부팅된다 | **이 프로젝트에 권장** | +| **TPM + PIN**(사전 부팅 PIN) | ❌ **부팅이 PIN 입력 화면에서 멈춘다.** 사람이 PIN 을 넣기 전까지 OS 자체가 뜨지 않으므로 작업 스케줄러도 없다 | 자동화와 정면 충돌 | +| TPM + 시작 키(USB) | ❌ 동일 | 동일 | + +> Microsoft 문서: "Preboot authentication can make it more difficult to update unattended or remotely administered devices because a PIN must be entered when a device reboots or resumes from hibernation." / "The only supported silent configuration for BitLocker involves the TPM only." + +**PIN 을 유지해야 한다면**: 재부팅 후 사람이 PIN 을 넣는 순간 OS 가 뜨고, 그때 `StartWhenAvailable` 이 놓친 06:00 을 즉시 실행한다. 즉 **자동 복구는 되지만 시각은 밀린다.** 이 지연을 허용할 수 없다면 TPM 전용으로 바꾸거나(보안 팀 승인 필요), 06:00 을 사람이 PC 앞에 있는 시각으로 옮겨야 한다. + +현재 PC 의 상태 확인: + +```powershell +manage-bde -status C: +manage-bde -protectors -get C: # TpmPin 이 보이면 PIN 구성이다 +``` + +### 5.10 재부팅 내성 실증 절차 (설치 후 1회 반드시 수행) + +```powershell +# [테스트 1] 캐치업이 실제로 도는가 — 가장 중요한 테스트 +# 1) 작업의 Daily 트리거 시각을 '지금부터 5분 뒤'로 임시 변경 +# 2) PC 를 종료하고 10분 대기 +# 3) PC 를 켜고 로그온하지 않은 채 5분 대기 +# 4) 로그온해서 확인: +Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | Get-ScheduledTaskInfo +Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 50 | + Where-Object { $_.Message -like '*DMF_Crawler_Daily*' } | + Select-Object TimeCreated, Id, LevelDisplayName, Message | Format-List +# → 이벤트 114(MissedTaskLaunched) 또는 118(BootTrigger) 이 보이면 성공 +# 5) 트리거 시각을 06:00 으로 되돌린다 (install_tasks.ps1 재실행) + +# [테스트 2] 재시작(Restart)에서 부팅 트리거가 뜨는가 +Restart-Computer # Fast Startup 이 적용되지 않는 경로 +# → 부팅 후 5분 뒤 이벤트 118 확인 + +# [테스트 3] 절전에서 깨어나 실행하는가 +# 1) 트리거를 '지금부터 10분 뒤'로 변경 +# 2) 즉시 절전 진입 +rundll32.exe powrprof.dll,SetSuspendState 0,1,0 +# 3) 15분 뒤 확인 +powercfg /lastwake +# → "절전 해제 원본: 타이머 - ... DMF_Crawler_Daily" 가 보이면 성공 + +# [테스트 4] 로그오프 상태에서 도는가 (S4U/Password 검증) +# 1) 로그오프 +# 2) 트리거 시각 경과 후 다시 로그온 +# 3) state\heartbeat.json 의 updated_at 이 갱신됐는지 확인 +.\scripts\check_heartbeat.ps1 +``` + +--- + +## 6. 중복 실행 방지 + +### 6.1 3중 방어와 각 층이 막는 것 + +| 층 | 구현 | 막는 것 | 못 막는 것 | +|---|---|---|---| +| ① 스케줄러 | `MultipleInstances=IgnoreNew` | **동시** 실행(06:00 트리거와 부팅 트리거가 겹칠 때) | 순차 재실행 | +| ② 파일 락 | `state\run.lock` + `msvcrt.locking` | 스케줄러 밖에서 시작된 동시 실행(수동 + 자동) | 순차 재실행 | +| ③ 코드 가드 | `repo.last_success_run_on(오늘)` 조회 | **순차** 재실행(06:00 성공 후 07:00 재부팅 캐치업) | 의도적 재실행(`--force` 로 우회) | + +**③ 이 없으면 append-only 스키마가 오염된다.** 같은 날 스냅샷이 두 벌 들어가면 다음 날 diff 의 기준선이 어느 쪽인지 모호해진다. 스케줄러 설정만 믿는 설계는 여기서 무너진다. + +### 6.2 `src\dmf_crawler\runlock.py` 전문 + +```python +"""state\\run.lock 배타 락. Windows 전용(msvcrt). + +파일 끝 멀찍한 오프셋의 1바이트를 잠그고, 사람이 읽을 메타데이터는 파일 +앞쪽(잠그지 않은 영역)에 고정 길이로 쓴다. 잠근 영역을 truncate 하면 +Windows 에서 오류가 나기 때문이다. +""" + +from __future__ import annotations + +import contextlib +import datetime as _dt +import msvcrt +import os +import socket +from collections.abc import Iterator +from pathlib import Path + +from .errors import DmfError + +_LOCK_OFFSET = 1_000_000 # 이 위치의 1바이트를 잠근다 +_META_SIZE = 256 # 파일 앞 256바이트에 메타데이터를 고정 길이로 기록 + + +class LockBusy(DmfError): + """다른 인스턴스가 이미 락을 쥐고 있다.""" + + +def read_holder(lock_path: Path) -> str: + """락 파일 앞부분의 메타데이터를 그대로 읽는다(진단용). 실패하면 빈 문자열.""" + try: + with open(lock_path, "rb") as fh: + return fh.read(_META_SIZE).decode("utf-8", "replace").rstrip("\x00 \n") + except OSError: + return "" + + +@contextlib.contextmanager +def exclusive(lock_path: Path, *, run_id: str = "", trigger: str = "") -> Iterator[None]: + """배타 락을 잡는다. 이미 잡혀 있으면 LockBusy 를 던진다(대기하지 않는다). + + 사용: + try: + with runlock.exclusive(paths.STATE / "run.lock", run_id=rid): + ... + except runlock.LockBusy: + log.info("다른 인스턴스가 실행 중 — 종료 코드 0") + return 0 + """ + lock_path.parent.mkdir(parents=True, exist_ok=True) + + # a+b: 없으면 만들고, 있으면 내용을 지우지 않는다. + fh = open(lock_path, "a+b") + try: + # 파일이 오프셋보다 짧으면 잠글 바이트가 없다. 미리 늘려 둔다. + fh.seek(0, os.SEEK_END) + if fh.tell() <= _LOCK_OFFSET: + fh.write(b"\x00" * (_LOCK_OFFSET + 1 - fh.tell())) + fh.flush() + + fh.seek(_LOCK_OFFSET) + try: + msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1) + except OSError as exc: + holder = read_holder(lock_path) + raise LockBusy( + f"이미 실행 중입니다. lock={lock_path} holder=[{holder}]" + ) from exc + + # 여기부터 락 보유 구간 + meta = ( + f"pid={os.getpid()} host={socket.gethostname()} " + f"user={os.environ.get('USERNAME', '')} " + f"run_id={run_id} trigger={trigger} " + f"acquired={_dt.datetime.now().astimezone().isoformat(timespec='seconds')}" + ).encode("utf-8")[: _META_SIZE - 1] + fh.seek(0) + fh.write(meta.ljust(_META_SIZE, b" ") + b"\n") + fh.flush() + os.fsync(fh.fileno()) + + try: + yield + finally: + fh.seek(_LOCK_OFFSET) + with contextlib.suppress(OSError): + msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1) + finally: + fh.close() +``` + +**왜 뮤텍스(`CreateMutex`)가 아니라 파일 락인가** + +| | 파일 락 (채택) | 네임드 뮤텍스 | +|---|---|---| +| 세션 경계 | 파일이므로 **세션·로그온 타입 무관** | `Global\` 접두사가 없으면 세션마다 별개. S4U 세션과 Interactive 세션이 서로를 못 본다 | +| 잔해 | 프로세스가 죽으면 OS 가 핸들을 닫아 락이 자동 해제 | 동일하나, 소유권 포기(abandoned) 처리를 코드가 다뤄야 함 | +| 진단성 | **누가 잡고 있는지 파일을 열어보면 안다** | 밖에서 볼 방법이 사실상 없다 | +| 의존성 | stdlib `msvcrt` | `ctypes` 로 Win32 직접 호출 | + +세션 경계 문제 하나만으로 결정된다. 배치는 S4U 세션, 수동 실행은 Interactive 세션에서 뜨는데 뮤텍스는 `Global\` 을 빼먹는 순간 조용히 무력화된다. + +### 6.3 idempotency 가드의 정확한 규칙 + +``` +run --trigger scheduled|startup 로 진입 + └─ repo.last_success_run_on(today, tz="Asia/Seoul") 조회 + ├─ status ∈ {SUCCESS, PARTIAL} 인 run 이 있다 → "SKIPPED" 기록 후 종료 코드 0 + └─ 없다 → 정상 진행 + +--force 가 붙으면 이 조회를 건너뛴다. +report-only / backfill / doctor 는 가드 대상이 아니다. +``` + +- **`PARTIAL` 도 "오늘은 이미 돌았다"로 친다.** 리포트가 나왔기 때문이다. 다시 돌리고 싶으면 `--force`. +- `FAILED` / `BLOCKED` 는 가드에 걸리지 않는다 → 재시작(`RestartCount`)이 정상적으로 재시도한다. +- 스테이지 체크포인트(`stage_status`)와는 다른 층이다. 체크포인트는 **같은 `run_id` 안에서** 성공한 스테이지를 건너뛰고, 가드는 **다른 `run_id` 의 재실행 자체**를 막는다. + +### 6.4 락이 걸려 있을 때 사람이 하는 일 + +```powershell +# 1) 누가 잡고 있는지 본다 +Get-Content -LiteralPath 'D:\workspace\DMF_Crawler\state\run.lock' -TotalCount 1 + +# 2) 그 PID 가 정말 살아 있는지 확인 +Get-Process -Id -ErrorAction SilentlyContinue | + Select-Object Id, ProcessName, StartTime, Path + +# 3) 살아 있으면 기다린다(정상). ExecutionTimeLimit 30분 안에 끝난다. +# 4) 죽어 있는데 락이 남아 있다면 → 있을 수 없는 상황이다. +# OS 가 핸들을 닫으면서 락도 풀리기 때문이다. +# 그래도 의심되면 파일을 지운다(실행 중이 아님을 확인한 뒤에만): +Remove-Item -LiteralPath 'D:\workspace\DMF_Crawler\state\run.lock' -Force + +# 5) 작업 자체를 강제 종료해야 한다면 +Stop-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' +``` + +--- + +## 7. 로그 규약 + +### 7.1 실행 ID + +- 형식: **`YYYYMMDD_HHMMSS`** (KST, 프로세스 시작 시각). 예: `20260902_060012` +- 생성: `run` 진입 직후, 로그 디렉터리를 만들기 전에 한 번. +- 전파: `runs.run_id`(PK) · `stage_status` · `snapshots` · `events` · `agy_calls` · `heartbeat.json` · 로그 디렉터리 이름 · 알림 레코드에 **동일한 값**이 들어간다. +- 지터로 06:00:00 이 아니라 06:04:12 에 시작해도 `run_date` 는 `2026-09-02` 다. **`run_id` 와 `run_date` 를 혼동하지 마라.** + +### 7.2 디렉터리와 파일 + +``` +D:\workspace\DMF_Crawler\logs\ +└── run_20260902_060012\ + ├── pipeline.log 사람이 읽는 전체 로그 (텍스트) + ├── events.jsonl 기계가 읽는 구조화 이벤트 (1줄 1 JSON) + ├── agy.stdout.json agy JSON 봉투 원문 (감사용, 손대지 않은 원본) + ├── agy.stderr.log agy 진단 출력 (stdout 과 절대 섞지 않는다) + └── agy_cli.log agy --log-file 로 지정한 agy 내부 로그 +``` + +**실행 1회 = 디렉터리 1개.** 조사의 시작점이 "`logs` 에서 가장 최근 디렉터리를 연다" 하나로 고정된다. 롤링 단일 파일은 여러 실행이 뒤섞여 "어느 줄이 어제 것인지" 를 매번 다시 따져야 한다. + +`agy` 의 stdout 과 stderr 을 분리하는 것은 **agy SSOT §5.3 규약**이다. 섞으면 JSON 파싱이 오염된다. + +### 7.3 `pipeline.log` 포맷 + +``` +2026-09-02 06:00:12.431 +0900 | INFO | run | run_id=20260902_060012 trigger=scheduled 시작 +2026-09-02 06:00:12.502 +0900 | INFO | runlock | 락 획득 state\run.lock +2026-09-02 06:00:12.610 +0900 | INFO | preflight | 스키마 버전 3, integrity_check ok, 여유 공간 214.6GB +2026-09-02 06:00:13.004 +0900 | INFO | fetch | totalCount=12874 pages=129 page_size=100 +2026-09-02 06:01:47.882 +0900 | WARNING | http | 429 Retry-After=30 page=61 attempt=1 대기 30.0s +2026-09-02 06:02:31.115 +0900 | INFO | fetch | 완료 12874건 129페이지 138.1s 아카이브=data\raw\2026-09-02 +2026-09-02 06:02:33.900 +0900 | INFO | normalize | 12874건 정규화, dmf_key 충돌 2건 결정론적 접미사 부여 +2026-09-02 06:02:34.220 +0900 | INFO | integrity | 게이트 5/5 통과 (drop=0.001 null=0.000 dup=0.0002) +2026-09-02 06:02:34.905 +0900 | INFO | diff | new=3 changed=1 withdrawn=0 base_run=20260901_060008 +2026-09-02 06:02:36.470 +0900 | INFO | persist | 스냅샷 12874행 · 이벤트 4행 커밋 +2026-09-02 06:03:41.008 +0900 | INFO | enrich | agy exit=0 status=OK in=28914 out=1204 41.9s +2026-09-02 06:04:29.771 +0900 | INFO | report | 8시트 생성 → reports\DMF_리포트_2026-09-02.xlsx (원자 교체) +2026-09-02 06:04:35.310 +0900 | INFO | backup | VACUUM INTO backup\dmf_2026-09-02.sqlite3 (48.2MB), 보존 30개 유지 +2026-09-02 06:04:37.002 +0900 | INFO | finalize | status=SUCCESS exit=0 265.6s heartbeat 갱신 +``` + +| 컬럼 | 내용 | +|---|---| +| 1 | 로컬 시각 + **UTC 오프셋**(`+0900`). 오프셋을 빼먹으면 로그를 나중에 못 믿는다 | +| 2 | 레벨 (`DEBUG`/`INFO`/`WARNING`/`ERROR`) — `logging.level` 로 하한 조절 | +| 3 | 스테이지 또는 모듈 이름 | +| 4 | 메시지 | + +**마스킹**: `logging.mask_patterns`(기본 `["serviceKey", "access_token"]`)에 걸리는 키의 값은 기록 직전에 `***` 로 치환한다. API 키가 URL 쿼리에 들어가므로 **요청 URL 로깅은 항상 마스킹을 통과해야 한다.** + +### 7.4 `events.jsonl` 스키마 + +한 줄에 JSON 객체 하나. 기계가 읽는다. 필드는 다음을 **항상** 포함한다. + +| 필드 | 타입 | 설명 | +|---|---|---| +| `ts` | ISO8601+오프셋 | 이벤트 시각 | +| `run_id` | str | 실행 ID | +| `stage` | str | `run` \| `preflight` \| `fetch` \| … \| `finalize` | +| `event` | str | 이벤트 이름(아래 표) | +| `level` | str | `INFO` \| `WARN` \| `ERROR` | +| `data` | object | 이벤트별 페이로드 | + +주요 `event` 값과 `data` 필드: + +| `event` | `data` 주요 키 | +|---|---| +| `run_started` | `trigger`, `pid`, `version`, `python`, `run_date` | +| `stage_started` / `stage_finished` | `stage`, `status`, `duration_ms` | +| `fetch_page` | `page`, `http_status`, `bytes`, `elapsed_ms`, `attempt` | +| `http_retry` | `page`, `reason`, `retry_after_s`, `sleep_s`, `attempt` | +| `fetch_summary` | `total_count`, `pages`, `records`, `duration_ms`, `archive_dir` | +| `integrity_gate` | `gate`, `passed`, `observed`, `threshold` | +| `diff_summary` | `new`, `changed`, `withdrawn`, `base_run_id` | +| `agy_call` | `exit_code`, `status`, `model`, `tokens_in`, `tokens_out`, `duration_ms`, `error_class` | +| `report_written` | `path`, `sheets`, `bytes`, `fallback_name` | +| `alert_raised` | `code`, `severity`, `dedup_key`, `suppressed` | +| `run_finished` | `status`, `exit_code`, `duration_ms`, `records_total` | + +예시 3줄: + +```jsonl +{"ts":"2026-09-02T06:00:12.431+09:00","run_id":"20260902_060012","stage":"run","event":"run_started","level":"INFO","data":{"trigger":"scheduled","pid":18244,"version":"0.1.0","python":"3.12.6","run_date":"2026-09-02"}} +{"ts":"2026-09-02T06:01:47.882+09:00","run_id":"20260902_060012","stage":"fetch","event":"http_retry","level":"WARN","data":{"page":61,"reason":"429","retry_after_s":30,"sleep_s":30.0,"attempt":1}} +{"ts":"2026-09-02T06:04:37.002+09:00","run_id":"20260902_060012","stage":"finalize","event":"run_finished","level":"INFO","data":{"status":"SUCCESS","exit_code":0,"duration_ms":265571,"records_total":12874}} +``` + +**필수 기록 필드 체크리스트** (요구 7항): + +- [x] 시작 — `run_started`(트리거·PID·버전) +- [x] 종료 — `run_finished`(상태·종료 코드) +- [x] 건수 — `fetch_summary.records`, `diff_summary.{new,changed,withdrawn}` +- [x] 소요 — 각 `stage_finished.duration_ms` + `run_finished.duration_ms` +- [x] 토큰 사용량 — `agy_call.{tokens_in,tokens_out}` +- [x] 오류 — `level:"ERROR"` 이벤트 + `alert_raised` + +### 7.5 로테이션·보존 + +| 대상 | 설정 키 | 기본 | 정리 시점 | +|---|---|---|---| +| 로그 디렉터리 `logs\run_*` | `logging.retain_days` | 90일 | `finalize` 스테이지 | +| API 원문 아카이브 `data\raw\<날짜>` | `source.archive_retain_days` | 180일 | `finalize` 스테이지 | +| 리포트 `reports\*.xlsx` | `report.retain_days` | 365일 | `finalize` 스테이지 | +| DB 백업 `backup\*.sqlite3` | `backup.keep_count` | 30개 | `backup` 스테이지 | +| Task Scheduler Operational 로그 | `wevtutil /maxsize` | 64MB | OS 가 순환 | + +**파일 단위 롤링(`RotatingFileHandler`)을 쓰지 않는 이유**: 실행 1회당 로그가 수십 KB 로 작고, 디렉터리 단위 보존이 훨씬 단순하다. 롤링은 "이 줄이 어느 실행 것인가" 를 매번 되묻게 만든다. + +수동으로 지금 정리하려면: + +```powershell +$root = 'D:\workspace\DMF_Crawler' +$cut = (Get-Date).AddDays(-90) +Get-ChildItem -LiteralPath "$root\logs" -Directory -Filter 'run_*' | + Where-Object { $_.LastWriteTime -lt $cut } | + Remove-Item -Recurse -Force -WhatIf # 확인 후 -WhatIf 를 떼고 재실행 +``` + +### 7.6 디스크 사용량 감각 + +| 항목 | 1일 | 90일 | 비고 | +|---|---|---|---| +| 로그 디렉터리 | ~60KB | ~5MB | agy 봉투 포함 | +| API 원문 아카이브 | ~4MB | ~360MB(180일) | 페이지 129개 × ~30KB | +| 리포트 xlsx | ~1.5MB | ~550MB(365일) | 시트 8종 | +| DB 증분 | ~3MB | ~270MB | append-only 스냅샷 | +| DB 백업 | ~50MB | ~1.5GB(30개) | `VACUUM INTO` 압축본 | + +`backup.min_free_gb`(기본 2.0) 미만이면 백업을 건너뛰고 WARN 을 남긴다. 여유가 10GB 미만으로 떨어지면 `source.archive_retain_days` 를 줄이는 것이 가장 효과가 크다. + +--- + +## 8. 일상 운영 절차 + +### 8.1 정상 동작 확인 (아침 30초) + +```powershell +cd D:\workspace\DMF_Crawler + +# (1) 가장 빠른 방법 — heartbeat 한 방 +.\scripts\check_heartbeat.ps1 + +# (2) 오늘 리포트가 나왔는가 +Get-ChildItem .\reports -Filter "DMF_리포트_$(Get-Date -Format 'yyyy-MM-dd').xlsx" | + Format-List Name, Length, LastWriteTime + +# (3) 스케줄러가 보는 마지막 결과 (0x0 이면 정상) +Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo | + Select-Object TaskName, + LastRunTime, + @{ n='LastResult'; e={ '0x{0:X}' -f $_.LastTaskResult } }, + NextRunTime | + Format-Table -AutoSize + +# (4) 전체 진단 (느리지만 확실) +.\.venv\Scripts\python.exe -m dmf_crawler doctor + +# (5) 오늘 로그를 눈으로 +$last = Get-ChildItem .\logs -Directory -Filter 'run_*' | Sort-Object Name -Descending | Select-Object -First 1 +Get-Content "$($last.FullName)\pipeline.log" -Tail 40 +``` + +### 8.2 수동 재실행 + +```powershell +cd D:\workspace\DMF_Crawler + +# (A) 스케줄러를 통해 실행 — 실제 배치와 100% 같은 보안 컨텍스트로 돈다. +# "스케줄에서만 실패하는" 문제를 재현하는 유일한 방법이다. +Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' +# 완료 대기 +while ((Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').State -eq 'Running') { + Start-Sleep -Seconds 5; Write-Host '.' -NoNewline +} +Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | Get-ScheduledTaskInfo + +# (B) 콘솔에서 직접 실행 — 로그를 눈으로 보며 디버깅할 때 +.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual + +# (C) 오늘 이미 성공했는데 그래도 다시 돌리고 싶다 +.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual --force + +# (D) 네트워크를 건드리지 않고 흐름만 점검 +.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual --dry-run + +# (E) AI 단계를 건너뛰고 실행 (토큰 절약 / agy 장애 시) +.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual --skip-agy + +# (F) 수집·비교는 그대로 두고 리포트 파일만 다시 만든다 +# (엑셀로 열어둬서 저장이 실패했을 때가 대표적) +.\.venv\Scripts\python.exe -m dmf_crawler report-only +.\.venv\Scripts\python.exe -m dmf_crawler report-only --date 2026-09-01 +``` + +> **(A) 와 (B) 의 차이를 항상 의식하라.** (B) 는 당신의 Interactive 세션에서 돈다. DPAPI·프로필·PATH 가 전부 다르다. "손으로는 되는데 06:00 에는 안 된다" 의 원인은 거의 항상 여기다. + +### 8.3 특정 날짜 백필 + +```powershell +# 원문 아카이브(data\raw\<날짜>)가 남아 있는 구간만 대상이다. +# 파서를 고친 뒤 과거를 다시 해석할 때 쓴다. + +# (1) 아카이브가 어느 날짜까지 있는지 확인 +Get-ChildItem .\data\raw -Directory | Select-Object -ExpandProperty Name + +# (2) 원문 재파싱 + diff 재계산 + 리포트 재생성 +.\.venv\Scripts\python.exe -m dmf_crawler backfill ` + --from 2026-08-25 --to 2026-08-31 --reparse --rediff --rereport + +# (3) 리포트만 다시 만들기(파서·diff 는 그대로) +.\.venv\Scripts\python.exe -m dmf_crawler backfill ` + --from 2026-08-25 --to 2026-08-31 --rereport + +# (4) 백필 전 반드시 백업 +.\.venv\Scripts\python.exe -m dmf_crawler backup --now +``` + +**백필로 할 수 없는 것**: 아카이브가 없는 날짜는 복원할 수 없다. 공식 API 는 "특정 과거 시점의 스냅샷" 을 제공하지 않기 때문이다. 그래서 `data\raw\` 보존이 중요하다(`source.archive_retain_days=180`). + +### 8.4 일시 중지 · 재개 + +```powershell +# 배치만 멈춘다 (알림 에이전트는 계속 돈다 → 곧 STALE 경보가 뜬다) +Disable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' + +# 장기 휴지(휴가 등)라면 알림도 함께 멈춰 오탐을 막는다 +Disable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent' + +# 상태 확인 — State 가 Disabled 로 보인다 +Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Format-Table TaskName, State -AutoSize + +# 재개 +Enable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' +Enable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent' + +# 재개 직후 확인: NextRunTime 이 다음 06:00 을 가리키는가 +Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | + Get-ScheduledTaskInfo | Select-Object NextRunTime +``` + +> **중지 중에 지나간 06:00 은 재개해도 캐치업하지 않는다.** `StartWhenAvailable` 은 "작업이 활성인데 못 돈 경우" 를 다룬다. 비활성 기간의 데이터가 필요하면 `backfill` 을 쓰거나, 그날 데이터는 없는 것으로 확정된다(공식 API 에 과거 스냅샷이 없으므로). + +### 8.5 제거 + +```powershell +# (1) 작업만 제거 — 데이터는 남는다 +powershell -ExecutionPolicy Bypass -File .\scripts\uninstall_tasks.ps1 + +# (2) 작업 + 폴더까지 제거 +powershell -ExecutionPolicy Bypass -File .\scripts\uninstall_tasks.ps1 -RemoveFolder + +# (3) 바로가기 제거 +Remove-Item "$env:USERPROFILE\Desktop\DMF 설정.lnk" -ErrorAction SilentlyContinue +Remove-Item "$env:USERPROFILE\Desktop\지금 실행.lnk" -ErrorAction SilentlyContinue + +# (4) 저장된 API 키(DPAPI) 제거 +.\.venv\Scripts\python.exe -m dmf_crawler doctor --json # 무엇이 남아있는지 먼저 확인 +# GUI: "DMF 설정" → API 키 → 삭제 + +# (5) 완전 삭제 (되돌릴 수 없다. 백업을 먼저 다른 곳으로 옮겨라) +Remove-Item -LiteralPath 'D:\workspace\DMF_Crawler' -Recurse -Force + +# (6) 이 프로젝트가 바꾼 시스템 설정 되돌리기 (선택) +powercfg /change standby-timeout-ac 30 +# 활성 시간 정책 제거 +Remove-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' ` + -Name 'SetActiveHours','ActiveHoursStart','ActiveHoursEnd' -ErrorAction SilentlyContinue +``` + +> `agy` 자체는 이 프로젝트 소유가 아니다. 다른 용도로도 쓴다면 지우지 마라. 지우려면 `%LOCALAPPDATA%\agy` 와 `~\.gemini\antigravity-cli` 를 삭제한다. **토큰 파일이므로 삭제 = 로그아웃이다.** + +--- + +## 9. 점검 체크리스트 + +### 9.1 설치 직후 (1회, 전부 통과해야 운영 개시) + +- [ ] `python --version` 이 3.12 이상 +- [ ] `.venv\Scripts\python.exe -m dmf_crawler version` 이 패키지·의존성 3종·agy 버전을 출력 +- [ ] `.venv\Scripts\python.exe -m dmf_crawler doctor` 가 **12종 전부 통과**(종료 코드 0) +- [ ] `Get-ScheduledTask -TaskPath '\DMF_Crawler\'` 가 **3개**를 `Ready` 로 보여줌 +- [ ] `DMF_Crawler_Daily` 의 `Principal.UserId` 가 **SYSTEM 이 아님**, `RunLevel=Highest` +- [ ] §2.7 자동 검증 스니펫이 **전 항목 통과** +- [ ] §2.8 프로브(`-Verify`)에서 **api_key 체크 통과** — 실패했다면 `-LogonType Password` 로 재등록했고 다시 통과 +- [ ] `powercfg /waketimers` 에 다음 06:00 항목이 보임 +- [ ] 활성 시간이 **05–23** 으로 설정됨(§5.6 확인 명령) +- [ ] `tzutil /g` 가 `Korea Standard Time` +- [ ] `wevtutil get-log "Microsoft-Windows-TaskScheduler/Operational"` 이 `enabled: true` +- [ ] `Start-ScheduledTask` 로 **스케줄러 경유 1회 실행 성공** → `reports\` 에 오늘 xlsx 생성 +- [ ] `state\heartbeat.json` 이 생성되고 `status=SUCCESS` +- [ ] `.\scripts\check_heartbeat.ps1` 이 종료 코드 0 +- [ ] `backup\` 에 `dmf_<날짜>.sqlite3` 생성 (`backup.dir` 이 **다른 드라이브**를 가리키면 더 좋다) +- [ ] `DMF 설정.lnk` / `지금 실행.lnk` 더블클릭 시 **콘솔 창 없이** GUI 가 뜸 +- [ ] §5.10 테스트 1(캐치업)을 실제로 수행하고 이벤트 114 또는 118 확인 +- [ ] BitLocker 구성 확인 — TPM+PIN 이면 §5.9 의 지연을 이해관계자가 수용 + +### 9.2 매주 (5분, 월요일 권장) + +- [ ] `.\scripts\check_heartbeat.ps1` — 판정 OK, `consecutive_failures = 0` +- [ ] 최근 7일 리포트가 7개 있는가: `Get-ChildItem .\reports -Filter '*.xlsx' | Sort-Object LastWriteTime -Descending | Select-Object -First 8` +- [ ] `duration_seconds` 추이 — 지난주 대비 2배 이상 늘었으면 원인 확인 +- [ ] `Get-ScheduledTaskInfo` 의 `LastTaskResult` 가 7일 내내 `0x0` +- [ ] `agy` 상태: 최근 7일 `heartbeat.agy.status` 가 `AUTH` 로 바뀐 적 없는가 (있으면 재로그인 필요) +- [ ] 일일 토큰 사용량이 `agy.daily_token_cap`(300000) 대비 여유 있는가 +- [ ] 디스크 여유: `Get-PSDrive D | Select-Object Used, Free` +- [ ] 백업 개수: `(Get-ChildItem .\backup -Filter '*.sqlite3').Count` 가 30 이하 +- [ ] `DMF_Crawler_AgyUpdate` 가 지난 일요일에 돌았고 `agy --version` 이 갱신됐는가 +- [ ] 로그에 신규 WARN 패턴이 있는가: + ```powershell + Get-ChildItem .\logs -Directory -Filter 'run_*' | + Sort-Object Name -Descending | Select-Object -First 7 | + ForEach-Object { Select-String -Path "$($_.FullName)\pipeline.log" -Pattern 'WARNING|ERROR' } | + Group-Object { ($_.Line -split '\|')[3].Trim() } | + Sort-Object Count -Descending | Format-Table Count, Name -AutoSize + ``` + +### 9.3 장애 시 (STALE 경보가 떴을 때 순서대로) + +- [ ] **1단계 — 무엇이 죽었나** + ```powershell + .\scripts\check_heartbeat.ps1 + Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo | + Select-Object TaskName, LastRunTime, @{n='R';e={'0x{0:X}' -f $_.LastTaskResult}}, NextRunTime + ``` +- [ ] **2단계 — 작업이 시작조차 못 했나, 시작했다가 실패했나** + ```powershell + Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 100 | + Where-Object { $_.Message -like '*DMF_Crawler*' } | + Select-Object TimeCreated, Id, LevelDisplayName, + @{n='Msg';e={ ($_.Message -split "`n")[0] }} | + Format-Table -AutoSize + ``` + → 이벤트 ID 로 §11.2 표를 참조한다. `101`/`104`/`332` 면 **시작 실패**(보안·전원 문제), `102`/`201` 이 있으면 **시작은 했다**(코드 문제). +- [ ] **3단계 — 시작했다면 로그를 본다** + ```powershell + $last = Get-ChildItem .\logs -Directory -Filter 'run_*' | Sort-Object Name -Descending | Select-Object -First 1 + Get-Content "$($last.FullName)\pipeline.log" -Tail 60 + Get-Content "$($last.FullName)\events.jsonl" | Select-Object -Last 20 + ``` +- [ ] **4단계 — 전면 진단** + ```powershell + .\.venv\Scripts\python.exe -m dmf_crawler doctor + ``` +- [ ] **5단계 — 스케줄러 경유로 재현** + ```powershell + Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' + ``` +- [ ] **6단계 — 그래도 안 되면 컨텍스트 차이를 의심**하고 §2.8 프로브를 다시 돌린다 + ```powershell + .\scripts\install_tasks.ps1 -Verify + ``` +- [ ] **7단계 — 작업 정의가 손상됐다면 재등록** + ```powershell + .\scripts\install_tasks.ps1 + # 또는 GUI 의 "작업 다시 등록" 버튼 / .\.venv\Scripts\python.exe -m dmf_crawler doctor --fix-tasks + ``` +- [ ] **8단계 — DB 가 의심되면** + ```powershell + .\.venv\Scripts\python.exe -m dmf_crawler db check + .\.venv\Scripts\python.exe -m dmf_crawler db version + # 손상 확인 시: backup\ 의 최신본을 data\dmf.sqlite3 로 복사 후 report-only + ``` + +**자동 재등록을 하지 않는 이유**: 사용자가 의도적으로 작업을 껐을 수 있다. 진단은 자동, **복구는 사람의 클릭 한 번**이 원칙이다(아키텍처 실패 시나리오 #29). + +--- + +## 10. 완전한 설치 절차 (새 PC, 0부터) + +소요 20~30분. **관리자 권한 PowerShell** 을 기본으로 한다. + +### 단계 0 — 전제 확인 + +```powershell +# Windows 버전 (Windows 10 1809 이상 / Windows 11 권장) +[System.Environment]::OSVersion.Version +winver + +# 아키텍처 (agy 는 windows_amd64) +$env:PROCESSOR_ARCHITECTURE # AMD64 여야 한다 + +# 관리자 권한인가 +([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole('Administrator') + +# 이 계정이 배치를 돌릴 계정인가 (agy 토큰이 이 프로필에 저장된다) +whoami +``` + +**준비물**: ① 공공데이터포털 계정과 **DMF 서비스 활용 신청 승인**(활용 신청 직후 키가 즉시 유효하지 않을 수 있다) ② Google 계정(agy 로그인용) ③ 디스크 여유 10GB 이상. + +### 단계 1 — Python 설치 + +```powershell +# winget 이 있으면 (권장) +winget install --id Python.Python.3.12 --scope machine --silent ` + --override "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0" + +# 새 셸을 열고 확인 +python --version # Python 3.12.x +py -3.12 --version +``` + +수동 설치라면 python.org 에서 **3.12 64-bit** 를 받고 설치 시 **"Add python.exe to PATH"** 를 반드시 체크한다. + +> 3.11 이상이 필요하다(`tomllib`). 3.12 를 권장한다. + +### 단계 2 — 프로젝트 배치 + +```powershell +# Git 으로 받는 경우 +git clone <저장소 URL> D:\workspace\DMF_Crawler + +# 압축본을 푸는 경우 +Expand-Archive .\DMF_Crawler.zip -DestinationPath D:\ + +cd D:\workspace\DMF_Crawler +Get-ChildItem # bootstrap.cmd, pyproject.toml, src\, scripts\, config\ 가 보여야 한다 +``` + +**경로 규칙**: 공백·한글이 없는 짧은 경로를 쓴다. OneDrive 동기화 폴더(`%USERPROFILE%\OneDrive\...`) 아래에 두지 마라 — SQLite WAL 과 충돌하고, 리포트 파일이 동기화 중 잠긴다. + +### 단계 3 — 부트스트랩 (venv + 의존성 + 바로가기) + +```cmd +:: 프로젝트 루트에서 더블클릭하거나 +D:\workspace\DMF_Crawler\bootstrap.cmd +``` + +`bootstrap.cmd` 가 하는 일: + +1. `python -m venv .venv` +2. `.venv\Scripts\python.exe -m pip install --upgrade pip` +3. `.venv\Scripts\python.exe -m pip install -e .` → 런타임 의존성 **3개**(`httpx`, `XlsxWriter`, `jsonschema`) 설치 +4. `scripts\make_shortcuts.ps1` 호출 → 바탕화면에 `DMF 설정.lnk` / `지금 실행.lnk` 생성 +5. 온보딩 GUI 기동 (`pythonw -m dmf_crawler onboard --mode setup`) + +수동으로 같은 일을 하려면: + +```powershell +cd D:\workspace\DMF_Crawler +python -m venv .venv +.\.venv\Scripts\python.exe -m pip install --upgrade pip +.\.venv\Scripts\python.exe -m pip install -e . +.\.venv\Scripts\python.exe -m dmf_crawler version +``` + +### 단계 4 — agy 부트스트랩 + +```powershell +# 자동 +powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_agy.ps1 + +# 스크립트가 하는 일: +# 1) %LOCALAPPDATA%\agy\bin\agy.exe 존재 확인 +# 2) 없으면 공식 설치 스크립트 무인 실행 +# 3) agy --version 출력 +``` + +수동 설치: + +```powershell +irm https://antigravity.google/cli/install.ps1 | iex + +# 확인 +& "$env:LOCALAPPDATA\agy\bin\agy.exe" --version +``` + +**로그인** — 반드시 **배치를 돌릴 그 계정**으로: + +```powershell +& "$env:LOCALAPPDATA\agy\bin\agy.exe" login +# 브라우저가 열린다. Google 계정으로 로그인. + +# 토큰 파일이 생겼는지 확인 (이 파일이 배치 인증의 전부다) +Get-Item "$env:USERPROFILE\.gemini\antigravity-cli\antigravity-oauth-token" | + Format-List FullName, Length, LastWriteTime + +# 헤드리스 왕복 실측 (30초 이상 걸리는 게 정상 — 첫 호출 오버헤드) +& "$env:LOCALAPPDATA\agy\bin\agy.exe" -p "Reply with exactly: PONG" ` + --output-format json --print-timeout 90s +$LASTEXITCODE # 0 이어야 한다 +``` + +> **`antigravity-oauth-token` 은 평문 JSON 이다. 유출 = 계정 탈취다.** 이 파일을 백업·공유·Git 에 올리지 마라. `.gitignore` 에 `*oauth-token*` 이 들어 있는 이유다. + +### 단계 5 — 설정과 API 키 + +```powershell +# (1) 설정 파일 확인 +Get-Content .\config\config.toml | Select-Object -First 20 + +# (2) PC 별 오버라이드가 필요하면 (백업을 다른 드라이브로 등) +Copy-Item .\config\config.local.toml.example .\config\config.local.toml +notepad .\config\config.local.toml +# [backup] +# dir = "E:/DMF_Backup" + +# (3) API 키 등록 — GUI 가 DPAPI 로 암호화 저장한다 +.\.venv\Scripts\pythonw.exe -m dmf_crawler onboard --mode setup +# 또는 바탕화면의 "DMF 설정" 더블클릭 + +# (4) 연락처 이메일도 넣는다 (User-Agent 에 들어간다. 정중한 접근 원칙) +# config.toml 의 general.contact_email +``` + +**API 키를 config.toml 에 직접 쓰지 마라.** 그 파일은 Git 에 올라간다. 온보딩 GUI 를 통하면 DPAPI 사용자 범위로 암호화되어 저장된다. + +### 단계 6 — 첫 실행 (스케줄러 없이) + +```powershell +# (1) 진단부터 +.\.venv\Scripts\python.exe -m dmf_crawler doctor + +# (2) DB 초기화 +.\.venv\Scripts\python.exe -m dmf_crawler db migrate +.\.venv\Scripts\python.exe -m dmf_crawler db version + +# (3) 실제 1회 실행 (첫 실행은 기준선 수립 — diff 는 비어 있는 게 정상) +.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual + +# (4) 결과 확인 +Get-ChildItem .\reports +Get-Content .\state\heartbeat.json +``` + +**첫 실행에서 diff 가 0 인 것은 정상이다.** 비교 대상(전일 스냅샷)이 없기 때문이다. 진짜 검증은 **둘째 날**에 이루어진다. + +### 단계 7 — 시스템 설정 (전원 · 업데이트 · 시간대) + +```powershell +# 절전 해제 타이머 허용 +powercfg /setacvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1 +powercfg /setdcvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1 +powercfg /setactive SCHEME_CURRENT + +# AC 전원에서는 안 자게 +powercfg /change standby-timeout-ac 0 +powercfg /change hibernate-timeout-ac 0 + +# Windows Update 활성 시간 05–23 (06:00 보호) +$ux = 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings' +New-Item -Path $ux -Force | Out-Null +Set-ItemProperty -Path $ux -Name 'ActiveHoursStart' -Value 5 -Type DWord +Set-ItemProperty -Path $ux -Name 'ActiveHoursEnd' -Value 23 -Type DWord + +# 시간대 확인 +tzutil /g # Korea Standard Time +w32tm /resync +``` + +### 단계 8 — 작업 등록 + +```powershell +cd D:\workspace\DMF_Crawler +powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify +``` + +프로브에서 `api_key` 가 실패하면: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -LogonType Password -Verify +``` + +### 단계 9 — 등록 검증 + +§2.7 의 **자동 검증 스니펫**을 그대로 붙여넣어 전 항목 통과를 확인한다. 이어서: + +```powershell +# 스케줄러 경유 실행이 성공하는가 (이게 진짜 검증이다) +Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' +while ((Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').State -eq 'Running') { + Start-Sleep -Seconds 5 +} +Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | Get-ScheduledTaskInfo +.\scripts\check_heartbeat.ps1 +``` + +### 단계 10 — 알림 경로 검증 + +```powershell +# 에이전트를 즉시 한 번 돌린다 +Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent' + +# 인위적으로 STALE 을 만들어 강제 모달이 실제로 뜨는지 본다 +Copy-Item .\state\heartbeat.json .\state\heartbeat.json.bak +# heartbeat.json 의 updated_at 을 이틀 전으로 편집 +notepad .\state\heartbeat.json +Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent' +# → 복구 GUI 가 떠야 한다 + +# 원상복구 +Move-Item .\state\heartbeat.json.bak .\state\heartbeat.json -Force +``` + +**이 테스트를 건너뛰지 마라.** 알림이 안 뜨는 시스템은 감시 없는 시스템과 같다. + +### 단계 11 — 다음 날 아침 확인 (설치 완료 판정) + +```powershell +.\scripts\check_heartbeat.ps1 +# 판정 OK + run_id 가 오늘 06:0x + status=SUCCESS +Get-ChildItem .\reports -Filter "DMF_리포트_$(Get-Date -Format 'yyyy-MM-dd').xlsx" +``` + +**여기까지 통과해야 설치 완료다.** 손으로 돌려본 것만으로는 완료가 아니다. + +### 설치 요약 카드 (복사용) + +```powershell +# 관리자 PowerShell, 순서대로 +winget install --id Python.Python.3.12 --scope machine --silent +git clone D:\workspace\DMF_Crawler ; cd D:\workspace\DMF_Crawler +.\bootstrap.cmd +powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_agy.ps1 +& "$env:LOCALAPPDATA\agy\bin\agy.exe" login +.\.venv\Scripts\pythonw.exe -m dmf_crawler onboard --mode setup # API 키 입력 +.\.venv\Scripts\python.exe -m dmf_crawler db migrate +.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual +powercfg /change standby-timeout-ac 0 +powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify +Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' +.\scripts\check_heartbeat.ps1 +``` + +--- + +## 11. 트러블슈팅 레퍼런스 + +### 11.1 `LastTaskResult` 값 읽는 법 + +`Get-ScheduledTaskInfo` 의 `LastTaskResult` 는 **작업이 실행한 프로그램의 종료 코드** 또는 **스케줄러 자체의 HRESULT** 다. + +| 값 | 의미 | 우리 시스템에서의 해석 | +|---|---|---| +| `0x0` | 성공 | 정상 (SUCCESS / PARTIAL / SKIPPED) | +| `0x1` | 프로그램이 1 로 종료 | **FAILED** — 리포트 미생성. 재시도 가치 있음 | +| `0x2` | 프로그램이 2 로 종료 | **BLOCKED** — 전제조건 미충족(API 키·설정·DB). 재시도해도 같다 | +| `0x82` (130) | 사용자 중단 | 콘솔에서 Ctrl+C | +| `0x41300` | `SCHED_S_TASK_READY` — 다음 예정 시각에 실행 대기 | 정상 | +| `0x41301` | `SCHED_S_TASK_RUNNING` — 현재 실행 중 | 정상 (조회 시점에 돌고 있음) | +| `0x41302` | `SCHED_S_TASK_DISABLED` — 비활성화됨 | 누가 껐다. `Enable-ScheduledTask` | +| `0x41303` | `SCHED_S_TASK_HAS_NOT_RUN` — 한 번도 안 돎 | 등록 직후의 정상 상태 | +| `0x41304` | `SCHED_S_TASK_NO_MORE_RUNS` | 트리거가 만료됐다. 재등록 필요 | +| `0x41305` | `SCHED_S_TASK_NOT_SCHEDULED` — 예약에 필요한 속성 누락 | 트리거가 없거나 깨졌다. 재등록 | +| `0x41306` | `SCHED_S_TASK_TERMINATED` — 마지막 실행이 종료됨 | `ExecutionTimeLimit` 초과 또는 `Stop-ScheduledTask` | +| `0x41307` | `SCHED_S_TASK_NO_VALID_TRIGGERS` | 트리거가 없거나 전부 비활성 | +| `0x41325` | `SCHED_S_TASK_QUEUED` | 큐에 대기 중 | +| `0x8004130F` | `SCHED_E_ACCOUNT_INFORMATION_NOT_SET` | **자격증명 문제.** 암호 변경 후 재등록 안 했거나 S4U 등록 실패 | +| `0x80041310` | `SCHED_E_ACCOUNT_NAME_NOT_FOUND` | `-User` 로 준 계정명이 틀렸다 | +| `0x8004131F` | `SCHED_E_ALREADY_RUNNING` | 이미 실행 중 (IgnoreNew 정상 동작) | +| `0x80041321` | `SCHED_E_INVALID_TASK_HASH` — 작업 이미지 손상 | **작업 정의가 손상됐다. 재등록이 유일한 해법** | +| `0x800710E0` | 요청 거부 | 관리자 권한/RunLevel 문제 | + +> 시스템·네트워크 오류 코드(예: `64`)가 그대로 나올 수도 있다. `net helpmsg 64` 로 의미를 확인한다. + +### 11.2 `Microsoft-Windows-TaskScheduler/Operational` 주요 이벤트 ID + +| ID | 이름 | 의미 | 우리 시스템에서 | +|---|---|---|---| +| 100 | JobStart | 작업 인스턴스 시작 | 정상 | +| **101** | JobStartFailed | **작업 시작 실패** | 보안 컨텍스트·경로 문제 | +| 102 | JobSuccess | 작업 완료 | 정상 | +| **103** | JobFailure | **작업 실행 실패** | 종료 코드 비0 | +| **104** | LogonFailure | **사용자 로그온 실패** | 암호 변경 / S4U 실패 → §2.8 | +| 105 | ImpersonationFailure | 가장(impersonation) 실패 | 동일 계열 | +| 106 | JobRegistered | 작업 등록됨 | `install_tasks.ps1` 실행 흔적 | +| 107 | TimeTrigger | 시간 트리거로 실행 | **06:00 정상 발화** | +| 108 | EventTrigger | 이벤트 트리거로 실행 | 미사용 | +| 110 | Run | 사용자를 위해 실행 시작 | 정상 | +| **111** | JobTermination | **시간 초과로 종료** | `ExecutionTimeLimit` 30분 초과 | +| **112** | JobNoStartWithoutNetwork | **네트워크 없어 미시작** | `RunOnlyIfNetworkAvailable` 동작 | +| **114** | MissedTaskLaunched | **놓친 작업을 실행** | `StartWhenAvailable` 캐치업 성공 | +| 116 | TaskRegisteredWithoutCredentials | 자격증명 저장 실패 | Password 등록 실패 | +| **118** | BootTrigger | **부팅 트리거로 실행** | 재시작 후 정상 발화 | +| 119 | LogonTrigger | 로그온 트리거로 실행 | Agent 정상 | +| 126 | FailedTaskRestart | 실패한 작업 재시작 시도 | `RestartCount` 동작 중 | +| 129 | CreatedTaskProcess | 새 프로세스로 실행 | PID 확인용 | +| 140 / 141 / 142 | TaskUpdated / TaskDeleted / TaskDisabled | 사람이 작업을 바꿨다 | **작업이 사라진 원인 추적** | +| **145** | TaskStartedOnComputerWakeup | **절전 해제 후 실행** | `WakeToRun` 성공 증거 | +| 200 / 201 | ActionStart / ActionSuccess | 액션 시작·완료 | `201` 의 `ResultCode` 가 종료 코드 | +| **202** | ActionFailure | **액션 실패** | 프로그램이 비0 종료 | +| **203** | ActionLaunchFailure | **액션 실행 실패** | 경로가 틀렸거나 실행 권한 없음 | +| **322** | NewInstanceIgnored | **이미 실행 중이라 무시** | `IgnoreNew` 정상 동작 | +| **326** | NoStartOnBatteries | **배터리라서 미시작** | 설정이 안 뒤집혔다! §2.7 재확인 | +| 327 | StoppingOnBatteries | 배터리 전환으로 중단 | 동일 | +| **329** | StoppingOnTimeout | **시간 초과로 중단** | 111 과 함께 본다 | +| **332** | NoStartUserNotLoggedOn | **사용자 미로그온으로 미시작** | Interactive 작업의 정상 동작 | +| 400 / 401 | ScheduleServiceStart / StartFailed | 스케줄러 서비스 상태 | 서비스 자체 문제 | + +조회 명령: + +```powershell +# 우리 작업 관련만, 최근 100건 +Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 200 | + Where-Object { $_.Message -like '*DMF_Crawler*' } | + Select-Object TimeCreated, Id, LevelDisplayName, + @{ n='First'; e={ ($_.Message -split "`n")[0] } } | + Format-Table -AutoSize + +# 특정 ID 만 (시작 실패 계열) +Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-TaskScheduler/Operational' + Id = 101, 103, 104, 111, 112, 202, 203, 326, 332 + StartTime = (Get-Date).AddDays(-7) +} | Format-List TimeCreated, Id, Message +``` + +### 11.3 증상 → 원인 → 조치 빠른 표 + +| 증상 | 가장 흔한 원인 | 조치 | +|---|---|---| +| 06:00 에 아무 일도 안 일어남, `LastRunTime` 이 어제 | PC 가 꺼져 있었다 | 정상. 켜지면 캐치업. `powercfg /waketimers` 확인 | +| 이벤트 326 이 보임 | `DisallowStartIfOnBatteries` 가 안 뒤집혔다 | `install_tasks.ps1` 재실행 | +| 이벤트 104, `LastTaskResult=0x8004130F` | 사용자 암호를 바꿨다 | `install_tasks.ps1 -LogonType Password` 재실행 | +| 손으로는 되는데 스케줄러로는 종료 코드 2 | **DPAPI 가 S4U 에서 실패** | §2.8 프로브 → `-LogonType Password` | +| `agy` 만 실패, 나머지 정상 | OAuth 토큰 만료 | `agy login` 재실행. 리포트는 계속 나온다(설계상 정상) | +| 이벤트 111 + 329 | 30분 초과 | `source.page_size` 하향 또는 `execution_time_limit_minutes` 상향 | +| 리포트가 `_HHMMSS` 붙은 이름으로 저장됨 | Excel 로 파일을 열어뒀다 | Excel 닫고 `report-only` | +| 이벤트 322 가 매일 2건 | 06:00 트리거와 부팅 트리거가 겹침 | **정상.** 방어가 작동 중 | +| 작업이 통째로 사라짐 | 이벤트 141 확인 → 누가 지웠다 | `install_tasks.ps1` 재등록 | +| `NumberOfMissedRuns` 가 계속 증가 | 작업이 실행 자체를 못 하고 있다 | 이벤트 101/104/332 확인 | +| 알림이 전혀 안 뜸 | 로그오프 상태였거나 Agent 가 비활성 | `Get-ScheduledTask ... Agent` 상태 확인, §5.8 | +| `doctor` 는 통과하는데 06:00 만 실패 | 컨텍스트 차이 | 반드시 `Start-ScheduledTask` 로 재현 | + +--- + +## 부록. 미해결 / 실측 필요 + +- [ ] **S4U 로그온에서 DPAPI 사용자 범위 `CryptUnprotectData` 가 성공하는가.** 이 런북 전체에서 가장 중요한 미검증 항목이다. Microsoft 문서는 "no access to ... encrypted files" 만 말할 뿐 DPAPI 마스터 키에 대해 명시하지 않는다. §2.8 프로브로 설치 시점에 **반드시 실측**하고 결과를 이 문서에 기록할 것. +- [ ] `New-ScheduledTaskTrigger -AtStartup` 이 반환하는 CIM 인스턴스에 `Delay` 속성을 대입하는 방식이 Windows 11 26xxx 빌드에서 정상 반영되는지. 등록 후 `Export-ScheduledTask` 로 `PT5M` 가 실제로 들어갔는지 눈으로 확인할 것. +- [ ] `-RepetitionDuration ([TimeSpan]::MaxValue)` 가 이 빌드에서 예외 없이 통과하는지. 통과했다면 XML 에 `` 이 생략되는지(=무기한) 확인. 폴백(3650일) 경로가 실제로 필요한지 판정. +- [ ] **모던 대기(S0) 기기에서 `WakeToRun` 이 실제로 06:00 에 깨우는가.** `powercfg /a` 로 이 PC 의 절전 상태 종류를 먼저 확정하고, §5.10 테스트 3 을 수행할 것. +- [ ] `powercfg /waketimers` 출력에 `DMF_Crawler_Daily` 가 표시되는 정확한 문구. 표시되지 않는데 실제로는 깨우는 사례가 있으므로, 표시 유무만으로 판정하지 말 것. +- [ ] Fast Startup 이 켜진 상태에서 "종료 → 켜기" 시 `StartWhenAvailable` 캐치업이 몇 분 안에 뜨는지. 이벤트 114 의 실제 지연 시간 측정. +- [ ] Windows Update 마감 기한(deadline) 초과 시 활성 시간을 무시하고 05–23 사이에 재시작하는 사례가 이 PC 에서 발생하는지. 발생 빈도에 따라 06:00 을 다른 시각으로 옮길지 판단. +- [ ] `agy update` 의 정확한 종료 코드와, 업데이트 중 다른 `agy` 인스턴스가 있을 때의 동작(agy SSOT 부록 미해결 항목과 동일). `DMF_Crawler_AgyUpdate` 실패 시 알림을 띄울지 여부는 이 결과에 달려 있다. +- [ ] `DMF_Crawler_AgyUpdate` 를 S4U 로 등록했을 때 `agy update` 가 OAuth 토큰 파일을 정상적으로 읽는가. 읽지 못한다면 이 작업만 Interactive 로 내려야 한다. +- [ ] 배치가 실행되는 30분 사이에 사용자가 리포트 xlsx 를 열어두는 빈도. 폴백 파일명 발생률이 높으면 `report.lock_retries` 상향 또는 저장 시각 조정 검토. +- [ ] 로그오프 상태를 며칠 유지하는 운영 형태가 실제로 발생하는가. 발생한다면 §5.8 대안 B(자동 로그온) 또는 웹훅 확장(현재 범위 밖)을 다음 마일스톤 후보로 승격. +- [ ] `state\run.lock` 의 `_LOCK_OFFSET = 1_000_000` 로 파일이 1MB 로 잡히는 것이 스파스 파일로 처리되는지(NTFS). 아니라면 오프셋을 4096 정도로 줄여도 되는지 확인. +- [ ] `msvcrt.locking` 이 네트워크 드라이브·OneDrive 동기화 폴더에서 오동작하는 조건. 프로젝트 경로를 로컬 고정 디스크로 제한하는 것을 온보딩 체크에 추가할지 판단. +- [ ] `wevtutil set-log ... /maxsize:67108864` 가 관리자 권한 없이도 성공하는지, 실패 시 무해한지. `install_tasks.ps1` 이 이미 관리자 전제이므로 실무상 문제는 없으나 문구를 확정할 것. +- [ ] `doctor --json` 의 출력 스키마(`checks[].key/title/ok/detail`)가 `install_tasks.ps1 -Verify` 의 파싱과 일치하는지. **`checks.py` 구현 시 이 계약을 깨지 말 것** — 깨지면 프로브가 조용히 무력화된다. diff --git a/docs/ops/02-failure-alerting.md b/docs/ops/02-failure-alerting.md new file mode 100644 index 0000000..23edfd9 --- /dev/null +++ b/docs/ops/02-failure-alerting.md @@ -0,0 +1,4724 @@ +# 장애 알림과 복구 안내 설계 + +> **이 문서의 역할**: DMF Crawler 가 실패했을 때 **사람이 반드시 알아채고, 그 자리에서 고칠 수 있게** 만드는 알림·복구 계층의 정본(SSOT)이다. 알림 채널 선택과 그 실패 조건, 4등급 체계와 폭주 억제, 상황별 실제 문구 전집, 버튼 액션 구현 코드, `agy` 재로그인 유도 경로, 알림 스크립트 전문, 테스트 절차, 에스컬레이션 규칙까지 이 문서 하나로 구현·검증이 끝나야 한다. 상위 정본은 `docs/design/01-architecture.md`(ADR-10/11/12, §3.14 `alerts.py`, §3.16 `notify/pump.py`, §7 실패 시나리오 대응표)이고, 운영 환경 근거는 `docs/research/08-windows-scheduling-and-resilience.md`, `agy` 사실관계는 `docs/research/05a-agy-cli-ssot.md` 다. + +--- + +## 0. 한눈에 보기 + +이 문서가 확정하는 것: + +- **알림 채널은 4단 사다리다.** ① tkinter 토스트(우하단 자동소멸) → ② tkinter 강제 모달(복구 GUI) → ③ Windows 이벤트 로그 + `state/alerts.json`(항상, 무조건) → ④ 웹훅(선택, CRITICAL 전용). **BurntToast·win11toast·pywin32는 쓰지 않는다**(ADR-12 재확인). 외부 모듈 설치가 전제인 알림기는 "설치가 깨지면 알림도 안 뜨는" 순환 실패를 만든다. +- **토스트가 안 뜨는 조건은 5가지이고, 전부 대응이 있다**: 로그온 전 / Session 0(S4U 배치) / 집중 지원(방해 금지) / 전체 화면 앱 / GUI 스택 자체 손상. 대응의 뼈대는 하나 — **알림을 발생시키는 프로세스와 표시하는 프로세스를 분리하고, 발생 기록은 절대 유실되지 않게 3중으로 남긴다**(ADR-11). +- **등급은 4개다: INFO / WARN / ERROR / CRITICAL.** 아키텍처 §3.14의 `Severity` 3값(INFO/WARN/CRITICAL)에 **ERROR 를 추가한다**(AMD-01). 판정 기준은 단 하나 — **오늘 xlsx 리포트가 나왔는가**. 나왔으면 최대 WARN, 안 나왔으면 ERROR, 여기에 "반복" 또는 "사람이 손대야만 풀린다"가 붙으면 CRITICAL. +- **폭주 억제는 3중이다**: ① `dedup_key`(코드+실행일자) + 코드별 쿨다운, ② 같은 pump 주기 내 다건 병합(N건을 토스트 1장으로), ③ 시간당 토스트 상한·일일 알림 상한. CRITICAL 모달만 상한을 면제받되 스누즈(기본 60분)를 갖는다. +- **문구는 문자열이 아니라 계약이다.** 모든 알림은 **[무엇][왜][어떻게][다음 행동]** 4요소를 반드시 갖고, 하나라도 비면 `raise_alert()`가 `ValueError`를 던진다. 본 문서 §3에 22개 시나리오의 완성 문구 전집을 싣는다. +- **버튼 액션은 액션 키 레지스트리로 관리한다.** `open_log_dir` / `run_now` / `agy_relogin` / `install_agy` / `enter_api_key` / `reregister_tasks` / `restore_backup` / `open_report_dir` / `cleanup_disk` / `open_doctor` / `snooze` / `dismiss` 12종. **액션 없는 실패 알림은 등록 자체가 거부된다**(막다른 골목 금지, R7.7). +- **`dmf://` 프로토콜 핸들러는 기본 경로에서 불필요하다.** tkinter 버튼이 같은 프로세스 안에서 함수를 직접 호출하기 때문이다. 다만 진짜 Windows 토스트(액션 센터 잔류)를 선택 의존성으로 켜는 날을 위해 등록 절차를 §4.4에 완비해 둔다. +- **`agy` 재로그인은 "새 콘솔 창"으로만 가능하다.** S4U 배치 세션에는 데스크톱이 없어 OAuth 브라우저가 뜨지 않는다. 로그온 세션의 pump 가 `cmd.exe /c start` 로 **보이는 콘솔**을 띄우고, 사용자가 로그인을 마치면 pump 가 헬스 프롬프트 1회로 성공을 검증한다. +- **`scripts/notify.ps1` 을 신설한다**(아키텍처 트리 증분, AMD-02). Python/venv 가 통째로 깨졌을 때(실패 시나리오 #32) tkinter 토스트는 원리적으로 뜰 수 없다. PowerShell 단독으로 도는 최후 알림기가 Agent 작업의 **두 번째 액션**으로 등록되어, pump 가 스탬프를 남기지 못했을 때만 MessageBox 를 띄운다. +- **에스컬레이션은 3일/5일/7일 3단이다.** 연속 실패 3일 → CRITICAL 모달 + 진단 자동 표시, 5일 → 웹훅 강제 발사(설정돼 있으면), 7일 → 배치 자동 실행 중단(`state/paused.flag`) 후 사람의 명시적 재개를 요구한다. 고장난 배치가 매일 조용히 실패하며 API 를 두드리는 상태를 방치하지 않는다. + +--- + +## 1. 알림 채널 확정 + +### 1.1 채널 목록과 채택 근거 + +| # | 채널 | 구현 | 프로세스 | 언제 쓰는가 | 채택 | +|---|---|---|---|---|---| +| C1 | **자동소멸 토스트** | `notify/toast.py` (tkinter `overrideredirect` 창) | Agent(Interactive) | INFO·WARN. 12초 후 사라짐. 무시해도 되는 알림 | ✅ 기본 | +| C2 | **강제 모달 복구 창** | `gui/app.py` (tkinter `Toplevel` + `grab_set`) | Agent(Interactive) | ERROR·CRITICAL. 사용자가 액션을 고를 때까지 유지 | ✅ 기본 | +| C3 | **Windows 이벤트 로그** | `notify/eventlog.py` (`eventcreate.exe`) | 배치·Agent 양쪽 | **전 등급 무조건.** 화면이 없어도 남는 유일한 OS 표준 흔적 | ✅ 기본 | +| C4 | **상태 파일 미러** | `alerts.mirror_to_file()` → `state/alerts.json` | 배치 | **전 등급 무조건.** Agent 가 SQLite 잠금 없이 읽는 경로 | ✅ 기본 | +| C5 | **MessageBox 폴백** | `scripts/notify.ps1` (`System.Windows.Forms.MessageBox`) | 별도 PowerShell | Python 이 깨져 C1·C2 가 불가능할 때 | ✅ 폴백 | +| C6 | **`msg.exe` 세션 메시지** | `scripts/notify.ps1` 내부 | 별도 PowerShell | .NET 로드조차 실패할 때의 최후 수단 | ✅ 최후 | +| C7 | **웹훅**(Discord/Slack/Telegram/generic) | `notify/webhook.py` | 배치 또는 Agent | CRITICAL·에스컬레이션. **PC 앞에 사람이 없을 때 닿는 유일한 채널** | ⚪ 선택(기본 off) | +| C8 | **이메일(SMTP)** | — | — | 웹훅으로 대체 | ❌ 기각 | +| C9 | **BurntToast / win11toast** | PowerShell 모듈 / pip 패키지 | — | — | ❌ 기각(ADR-12) | +| C10 | **dead-man switch**(healthchecks.io 등) | `notify/webhook.py` 의 ping 모드 | 배치 | PC 가 통째로 꺼져 있는 경우 감지 | ⚪ 선택(기본 off) | + +**C8(이메일) 기각 근거**: SMTP 는 앱 비밀번호·포트 차단·2FA 정책이라는 실패 표면 3개를 추가하는데, 웹훅은 URL 하나면 되고 실패해도 HTTP 상태 코드로 즉시 진단된다. 요구 N5(의존성 최소)와 R6.3(비개발자가 설정)을 동시에 만족하는 쪽은 웹훅이다. + +**C9(BurntToast/win11toast) 기각 근거 재확인**: ADR-12 가 이미 기각했다. 추가로, 알림 채널이 외부 모듈에 의존하면 **실패 시나리오 #32(Python/venv 손상)에서 알림 자체가 침묵한다.** 알림기는 시스템에서 가장 의존성이 적어야 하는 컴포넌트다. tkinter 는 CPython 표준 배포에 포함되고, `eventcreate.exe`·`msg.exe`·`powershell.exe` 는 Windows 에 내장된다. + +### 1.2 토스트가 안 뜨는 조건과 대응 + +| # | 조건 | 왜 안 뜨는가 | 감지 방법 | 대응 | +|---|---|---|---|---| +| B1 | **로그온 전 / 로그오프 상태** | 표시할 대화형 세션이 존재하지 않는다 | Agent 작업이 아예 실행되지 않음 | 배치는 `alerts` + `state/alerts.json` + 이벤트 로그에 **축적**. Agent 작업에 **`AtLogOn` 트리거**를 걸어 다음 로그온 즉시 밀린 알림을 병합 표시(시나리오 #31) | +| B2 | **Session 0 격리(S4U 배치 세션)** | Windows Vista 이후 서비스·비대화형 세션은 사용자 데스크톱과 분리된다. tkinter 창을 만들어도 아무도 못 본다 | `pipeline` 은 애초에 UI 를 호출하지 않는다(ADR-11) | **구조로 해결.** 배치는 알림 **의도**만 기록하고, Interactive 로 도는 Agent 가 표시한다. 최대 지연 15분(`schedule.agent_repeat_minutes`) | +| B3 | **집중 지원 / 방해 금지 모드** | OS 알림 센터가 억제한다 | 우리 토스트는 OS 알림 API 를 쓰지 않는 **자체 tkinter 창**이므로 **영향받지 않는다** | 해당 없음. 이것이 tkinter 를 택한 부수 이득이다. 단, 전체 화면 앱 위에서는 B4 로 넘어간다 | +| B4 | **전체 화면 앱(게임·프레젠테이션)** | 우리 창이 `-topmost` 여도 배타적 전체 화면 앞에는 못 온다 | `ctypes` 로 `SHQueryUserNotificationState` 조회 | `QUNS_BUSY`/`QUNS_RUNNING_D3D_FULL_SCREEN`/`QUNS_PRESENTATION_MODE` 이면 **표시를 미루고** 다음 주기에 재시도. CRITICAL 은 미루지 않고 표시하되 이벤트 로그·웹훅을 함께 발사 | +| B5 | **AppId(AUMID) 미등록** | 진짜 Windows 토스트는 등록된 AUMID 없이는 XML 토스트를 띄울 수 없다 | 해당 없음 | **우리 경로에는 해당하지 않는다.** tkinter 창은 AUMID 를 요구하지 않는다. 선택 의존성으로 win11toast 를 켜는 날에만 §4.4 의 프로토콜/AUMID 등록이 필요해진다 | +| B6 | **tkinter 자체 손상 / Python 실행 불가** | `import tkinter` 실패, venv 파괴 | Agent 액션이 비0으로 죽고 `state/pump.stamp` 가 갱신되지 않음 | **C5 폴백.** Agent 작업의 2번째 액션 `scripts/notify.ps1 -Mode Guard` 가 스탬프 나이를 보고 MessageBox 를 띄운다(§6.1) | +| B7 | **Agent 작업 자체가 미등록/비활성** | 사용자가 지웠거나 정책이 껐다 | `checks` ⑨ 가 `Get-ScheduledTask` 로 확인 | 배치가 `TASK_MISSING` CRITICAL 을 기록. 다만 **표시할 주체가 없으므로** 웹훅과 이벤트 로그가 유일한 통로 → 웹훅을 켜라고 온보딩에서 유도 | + +> **핵심**: B1~B7 중 어느 하나가 걸려도 **알림이 사라지지는 않는다.** `alerts` 테이블은 append-only 이고 `shown_at IS NULL` 인 행은 표시될 때까지 계속 대기한다. "표시 실패"는 지연일 뿐 유실이 아니다. + +### 1.3 폴백 사다리 (표시 경로 결정) + +``` +알림 발생 (배치 또는 Agent 내부 워치독) + │ + ├─[항상] alerts 테이블 INSERT ────────────────┐ + ├─[항상] state/alerts.json 미러 갱신 ─────────┤ 유실 방지 3중 기록 + └─[항상] Windows 이벤트 로그 기록 ────────────┘ + │ + ▼ + Agent(Interactive, 15분 주기 + AtLogOn) 가 pending() 조회 + │ + ┌──────────┴───────────────────────────────┐ + │ 등급 판정 │ + ├─ INFO / WARN → C1 토스트(12초 자동소멸) │ + │ └ 실패 시 → 다음 주기 재시도(최대 3회) │ + │ └ 3회 실패 → ERROR 로 승격 → C2 │ + ├─ ERROR / CRITICAL → C2 강제 모달(복구 GUI) │ + │ └ GUI 기동 실패 → C5 MessageBox │ + │ └ .NET 실패 → C6 msg.exe │ + └────────────────────────────────────────────┘ + │ + [CRITICAL 이고 webhook_enabled] → C7 웹훅 발사(표시 성공 여부와 무관하게) + │ + 표시 성공 → alerts.mark_shown(alert_id) → state/alerts.json 재미러 +``` + +**규칙 3개** + +1. **기록과 표시는 분리된다.** 표시 실패는 기록을 되돌리지 않는다. +2. **한 단계 아래로만 떨어진다.** C1 실패가 곧바로 C6 로 가지 않는다. 단계마다 이벤트 로그에 `NOTIFY_DEGRADED` 를 남겨 어느 단에서 떨어졌는지 사후 추적이 된다. +3. **웹훅은 폴백이 아니라 병렬 채널이다.** CRITICAL 은 화면 표시 성공 여부와 무관하게 웹훅을 쏜다. 화면을 본 사람과 웹훅을 받는 사람이 같은 사람이라도, "PC 앞에 있었는가"는 알 수 없기 때문이다. + +### 1.4 웹훅 채널 정책 + +| 항목 | 확정 | +|---|---| +| 기본 상태 | **off** (`notify.webhook_enabled = false`). 온보딩 GUI 의 선택 단계에서 켠다 | +| 발사 조건 | `severity >= notify.webhook_min_severity`(기본 `"CRITICAL"`) **또는** 에스컬레이션 5일차 이상 | +| 지원 형식 | `discord` / `slack` / `telegram` / `generic`(JSON POST) — `notify.webhook_kind` 로 선택 | +| URL 보관 | **평문 금지.** `secrets_dpapi.save("webhook_url", url)` 로 DPAPI 암호화. `config.toml` 에는 `webhook_kind` 와 on/off 만 들어간다 | +| 타임아웃 | `notify.webhook_timeout_seconds` 기본 10초. 실패해도 **절대 예외를 전파하지 않는다**(알림기가 파이프라인을 죽이면 안 된다) | +| 재시도 | 1회만. 웹훅 실패는 이벤트 로그에 `NOTIFY_DEGRADED` 로만 남긴다 | +| 내용 | 4요소 전문 + `run_id` + 로그 디렉터리 **절대경로**. **API 키·토큰·URL 파라미터는 절대 포함하지 않는다**(`logging.mask_patterns` 를 웹훅 본문에도 적용) | +| dead-man switch | `notify.deadman_url` 이 설정되면 배치 성공 시 루트 URL, 실패 시 `/fail` 을 GET. PC 가 꺼져 있으면 상대편이 알아챈다 | + +### 1.5 채널 라우팅 매트릭스 + +| 등급 | C1 토스트 | C2 모달 | C3 이벤트 로그 | C4 alerts.json | C7 웹훅 | 재표시 | +|---|---|---|---|---|---|---| +| INFO | 선택(`notify.show_info_toast`, 기본 false) | ✕ | ✅ | ✅ | ✕ | 안 함 | +| WARN | ✅ 12초 | ✕ | ✅ | ✅ | ✕ | 쿨다운 후 1회 | +| ERROR | ✕ | ✅ 모달 | ✅ | ✅ | 설정 시 | 60분마다 | +| CRITICAL | ✕ | ✅ 모달 + 포커스 강제 | ✅ | ✅ | ✅(켜져 있으면) | 60분마다, 해소까지 | + +--- + +## 2. 알림 등급 체계 + +### 2.1 4등급 정의 — AMD-01 (아키텍처 §3.14 개정) + +아키텍처 §3.14 의 `Severity` 는 `INFO / WARN / CRITICAL` 3값이다. 이 문서는 **`ERROR` 를 추가해 4값으로 개정한다.** + +**개정 사유**: 3값 체계에서는 "리포트가 안 나왔다"(오늘 산출물 부재)와 "인증이 만료됐다"(사람이 손대야만 풀림)가 둘 다 `CRITICAL` 로 뭉개진다. 그런데 두 상황의 **필요한 사용자 행동이 다르다** — 전자는 "지금 다시 실행" 한 번이면 풀릴 수 있고, 후자는 반드시 사람이 계정 작업을 해야 한다. 채널·재표시 주기·에스컬레이션 카운터가 전부 갈리므로 등급을 분리하는 편이 싸다. + +```python +# src/dmf_crawler/alerts.py (개정) +class Severity(StrEnum): + INFO = "INFO" + WARN = "WARN" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + @property + def rank(self) -> int: + return {"INFO": 0, "WARN": 1, "ERROR": 2, "CRITICAL": 3}[self.value] + + def at_least(self, other: "Severity") -> bool: + return self.rank >= other.rank +``` + +**판정 기준 — 질문 두 개로 끝난다.** + +``` +Q1. 오늘 xlsx 리포트 파일이 존재하는가? + +- 예 -> Q2 로 + +- 아니오 -> ERROR (최소) +Q2. 사람이 개입해야만 풀리는가? / 이미 반복되고 있는가? + +- 예 -> CRITICAL + +- 아니오 -> 데이터 품질에 영향이 있으면 WARN, 없으면 INFO +``` + +| 등급 | 정의 | 실행 상태 | 종료 코드 | 사용자에게 요구하는 것 | +|---|---|---|---|---| +| `INFO` | 정상 완료, 또는 자동 회복된 일시 장애 | `SUCCESS` | 0 | **아무것도**. 기록만 남는다 | +| `WARN` | 부분 실패. **리포트는 생성됐다.** 데이터가 스테일하거나 일부 계층이 빠졌다 | `SUCCESS` 또는 `PARTIAL` | 0 | 인지. 오늘 리포트를 볼 때 배너를 확인 | +| `ERROR` | **리포트 생성 실패.** 오늘 산출물이 없다. 일시적 원인일 수 있어 재시도 가치가 있다 | `FAILED` | 1 | 지금 조치. "다시 실행" 한 번으로 풀릴 수 있다 | +| `CRITICAL` | 전제조건 붕괴(인증·키·DB·스케줄) 또는 **연속 실패**. 재시도해도 같은 결과 | `FAILED` / `BLOCKED` | 1 또는 2 | 반드시 사람이 손을 대야 한다. 모달로 막는다 | + +### 2.2 등급별 채널·표시 방식·빈도 + +| 등급 | 표시 | 지속 | 최초 표시 지연 | 재표시 주기 | 쿨다운(동일 코드) | 웹훅 | +|---|---|---|---|---|---|---| +| INFO | (기본 미표시) | — | — | 없음 | 1440분 | ✕ | +| WARN | 우하단 토스트 | `notify.toast_seconds` 12초 자동소멸 | 최대 15분 | 없음(쿨다운 만료 시 1회) | `notify.cooldown_minutes` 240분 | ✕ | +| ERROR | 강제 모달(복구 GUI, `recover` 모드) | 사용자가 닫을 때까지 | 최대 15분 | `notify.modal_repeat_minutes` 60분 | 60분 | 설정 시 | +| CRITICAL | 강제 모달 + `focus_key` 로 해당 체크 항목 강조 + 창 포커스 강제 | 사용자가 닫을 때까지 | 최대 15분 | 60분 | 60분 | 켜져 있으면 ✅ | + +**표시 지연이 최대 15분인 이유**: Agent 작업의 반복 주기(`schedule.agent_repeat_minutes` 기본 15)다. 요구 N3("24시간 내 인지")에 대해 96배의 여유가 있으므로 더 짧게 만들 이유가 없다. 주기를 줄이면 로그온 세션에서 도는 프로세스 기동 횟수만 늘어난다. + +### 2.3 `dedup_key` 와 쿨다운 + +**`dedup_key` 산출 규칙** + +```python +def make_dedup_key(code: str, run_date: str | None, scope: str | None = None) -> str: + """ + code : 알림 코드 (AGY_AUTH, REPORT_LOCKED 등). 대문자 스네이크. + run_date : YYYY-MM-DD. 하루 단위로 재발생을 허용할 알림만 넣는다. + scope : 같은 코드라도 대상이 다르면 별개 알림인 경우의 구분자 + (예: 디스크 부족의 드라이브 문자 'D:', 잠긴 파일명). + """ + parts = [code] + if run_date: + parts.append(run_date) + if scope: + parts.append(scope) + return "|".join(parts) +``` + +| 코드 유형 | `run_date` 포함? | 이유 | +|---|---|---| +| 일자성 실패(`FETCH_FAILED`, `INTEGRITY_BLOCKED`, `REPORT_LOCKED`, `API_QUOTA_EXCEEDED`) | **포함** | 어제 발생했던 것이 오늘 또 발생하면 **새 사건**이다. 알려야 한다 | +| 상태성 실패(`AGY_AUTH`, `API_KEY_MISSING`, `DB_CORRUPT`, `TASK_MISSING`, `AGY_MISSING`) | **미포함** | 해소될 때까지 하나의 사건이다. 매일 새 알림을 만들면 목록이 오염된다. 재촉은 `modal_repeat_minutes` 가 담당 | +| 누적성(`CONSECUTIVE_FAILURES`, `WATCHDOG_STALE`) | **미포함** | 위와 동일. 다만 본문의 "N일째"가 갱신된다 | + +**쿨다운 동작** + +``` +raise_alert(code=X) 호출 + | + +- 같은 dedup_key 의 미해소 행이 있는가? + +- 없다 -> INSERT, occurrences=1, 반환 True (표시 대상) + +- 있다 -> 마지막 발생 시각으로부터 cooldown 이 지났는가? + +- 안 지남 -> occurrences += 1, last_seen_at 갱신, + | 페이로드만 최신으로 UPDATE, 반환 False (표시 안 함) + +- 지남 -> occurrences += 1, last_seen_at 갱신, + shown_at = NULL 로 되돌림, 반환 True (재표시) +``` + +> **`alerts` 는 append-only 인데 UPDATE 를 하는가?** — AMD-03 +> 아키텍처 §3.9 불변식은 "`alerts` 를 UPDATE/DELETE 하지 않는다"고 못박았다. 이 문서는 그 불변식을 **깨지 않기 위해** 테이블을 둘로 나눈다. 발생 사실은 `alert_events`(순수 append-only, 매 발생마다 1행), 표시·해소 상태는 `alerts`(dedup_key 유니크, 상태 머신)다. `occurrences`·`last_seen_at`·`shown_at`·`resolved_at` 은 **상태이지 사실이 아니다.** 감사 추적은 `alert_events` 가 온전히 보존한다. + +### 2.4 알림 폭주 억제 3중 방어 + +| 층 | 이름 | 대상 | 동작 | 설정 키 | +|---|---|---|---|---| +| L1 | **코드별 쿨다운** | 같은 코드의 반복 | 2.3 참조. 쿨다운 안이면 카운터만 증가 | `notify.cooldown_minutes`(WARN 240분), `notify.modal_repeat_minutes`(ERROR/CRITICAL 60분) | +| L2 | **주기 내 병합** | 서로 다른 코드가 동시에 여러 개 | 한 pump 주기에서 표시 대상이 2건 이상이면 **토스트 1장에 요약**하고 "자세히 보기"로 GUI 를 연다 | `notify.merge_threshold`(기본 2) | +| L3 | **총량 상한** | 하루 전체 | 시간당 토스트 `notify.max_toasts_per_hour`(6), 일일 알림 표시 `notify.daily_alert_cap`(20) 초과 시 그날은 표시를 멈추고 `NOTIFY_SUPPRESSED` INFO 만 기록 | 위 두 키 | + +**L3 의 CRITICAL 면제**: CRITICAL 은 상한을 적용받지 않는다. 대신 스누즈(`notify.snooze_minutes` 기본 60)가 있어 사용자가 "나중에"를 누르면 그 시간만큼 조용해진다. 상한으로 CRITICAL 을 막으면 **가장 중요한 알림이 가장 먼저 침묵하는** 역설이 생긴다. + +**L2 병합 문구 실제 예시** + +``` +제목: DMF 크롤러 — 확인이 필요한 항목 3건 +본문: 2026-09-02 06:04 실행에서 확인할 항목이 3건 있습니다. + · [주의] 수집 건수가 어제보다 6.2% 줄어 비교를 건너뛰었습니다 + · [주의] 리포트를 다른 이름으로 저장했습니다 (원본이 열려 있음) + · [주의] AI 요약을 건너뛰었습니다 (일일 쿼터 소진) + 오늘 리포트는 정상 생성됐습니다. +버튼: [자세히 보기] [리포트 열기] [닫기] +``` + +### 2.5 승격 규칙과 해소 규칙 + +| 규칙 | 조건 | 결과 | +|---|---|---| +| R-P1 | 같은 WARN 코드가 `notify.consecutive_failure_critical`(기본 3) 회 연속 실행에서 발생 | **CRITICAL 로 승격.** 코드에 `_PERSIST` 접미사를 붙인 별개 알림 발생 | +| R-P2 | `runs` 에서 `status='FAILED'` 가 3일 연속 | `CONSECUTIVE_FAILURES` CRITICAL 발생 (8절) | +| R-P3 | WARN 토스트 표시가 3회 연속 실패(창 생성 예외) | 해당 알림을 ERROR 로 승격해 모달 경로로 보냄 | +| R-P4 | 서킷 브레이커가 `CLOSED -> OPEN` 으로 전환 | 전환 **그 순간에만** CRITICAL 1회. OPEN 유지 동안은 침묵 | +| R-P5 | 워치독이 heartbeat 나이 > `notify.watchdog_stale_minutes`(120) 판정 | `WATCHDOG_STALE` CRITICAL | + +| 규칙 | 조건 | 결과 | +|---|---|---| +| R-D1 | 다음 실행이 `SUCCESS` 로 끝남 | 일자성 WARN 코드 전부 `resolve(code, note="다음 실행 성공")` | +| R-D2 | `checks.run_one(key)` 가 `ok=True` 로 바뀜 | 해당 상태성 CRITICAL 해소 (예: 재로그인 후 `AGY_AUTH`) | +| R-D3 | 서킷이 `OPEN -> HALF_OPEN -> CLOSED` 복귀 | `*_CIRCUIT_OPEN` 해소 + INFO 1회("자동 복구됨") | +| R-D4 | 사용자가 모달에서 액션을 수행하고 성공 검증을 통과 | 즉시 해소. **모달을 닫기만 해서는 해소되지 않는다** | + +> **해소는 사용자가 창을 닫는 것으로 이뤄지지 않는다.** 반드시 `checks` 재실행 또는 다음 실행 성공이라는 **객관적 증거**가 있어야 한다. "닫으면 해결된 것으로 친다"는 조용한 실패의 교과서적 원인이다. + +### 2.6 `alerts` / `alert_events` DDL + +`storage/migrations/0003_ops.sql` 의 알림 관련 부분 전문이다. + +```sql +-- ============================================================ +-- 0003_ops.sql — 운영 계층 (알림 부분) +-- ============================================================ + +-- 발생 사실. 순수 append-only. 절대 UPDATE/DELETE 하지 않는다. +CREATE TABLE IF NOT EXISTS alert_events ( + event_id INTEGER PRIMARY KEY AUTOINCREMENT, + occurred_at TEXT NOT NULL, -- ISO8601 with offset (+09:00) + run_id TEXT, -- 실행 밖(Agent 워치독)이면 NULL + code TEXT NOT NULL, + severity TEXT NOT NULL CHECK (severity IN ('INFO','WARN','ERROR','CRITICAL')), + dedup_key TEXT NOT NULL, + what TEXT NOT NULL, + why TEXT NOT NULL, + how TEXT NOT NULL, + next_actions TEXT NOT NULL, -- JSON 배열: ["run_now","open_log_dir"] + context_json TEXT NOT NULL DEFAULT '{}', -- 템플릿 치환에 쓴 값 원본 + log_dir TEXT, -- 절대경로 + source TEXT NOT NULL DEFAULT 'pipeline' -- pipeline|watchdog|checks|agent +); +CREATE INDEX IF NOT EXISTS ix_alert_events_time ON alert_events(occurred_at DESC); +CREATE INDEX IF NOT EXISTS ix_alert_events_code ON alert_events(code, occurred_at DESC); + +-- 표시·해소 상태 머신. dedup_key 당 1행. +CREATE TABLE IF NOT EXISTS alerts ( + alert_id INTEGER PRIMARY KEY AUTOINCREMENT, + dedup_key TEXT NOT NULL UNIQUE, + code TEXT NOT NULL, + severity TEXT NOT NULL CHECK (severity IN ('INFO','WARN','ERROR','CRITICAL')), + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + occurrences INTEGER NOT NULL DEFAULT 1, + last_event_id INTEGER NOT NULL REFERENCES alert_events(event_id), + shown_at TEXT, -- NULL 이면 표시 대기 + shown_channel TEXT, -- toast|modal|messagebox|msgexe|webhook + show_attempts INTEGER NOT NULL DEFAULT 0, + snoozed_until TEXT, + resolved_at TEXT, + resolved_note TEXT +); +CREATE INDEX IF NOT EXISTS ix_alerts_pending + ON alerts(resolved_at, shown_at, severity); +CREATE INDEX IF NOT EXISTS ix_alerts_code ON alerts(code); + +-- 일일 표시 총량 상한(L3) 계산용 뷰 +CREATE VIEW IF NOT EXISTS v_alert_shown_today AS +SELECT COUNT(*) AS shown_count +FROM alerts +WHERE shown_at IS NOT NULL + AND substr(shown_at, 1, 10) = strftime('%Y-%m-%d', 'now', 'localtime'); +``` + +**표시 대기 알림 조회 SQL** (`alerts.pending()` 의 본체) + +```sql +SELECT a.alert_id, a.dedup_key, a.code, a.severity, + a.first_seen_at, a.last_seen_at, a.occurrences, a.show_attempts, + e.what, e.why, e.how, e.next_actions, e.context_json, e.log_dir, e.run_id +FROM alerts a +JOIN alert_events e ON e.event_id = a.last_event_id +WHERE a.resolved_at IS NULL + AND (a.snoozed_until IS NULL OR a.snoozed_until < :now) + AND ( + a.shown_at IS NULL -- 아직 못 보여줌 + OR (a.severity IN ('ERROR','CRITICAL') -- 재촉 대상 + AND julianday(:now) - julianday(a.shown_at) + > :modal_repeat_minutes / 1440.0) + ) +ORDER BY CASE a.severity WHEN 'CRITICAL' THEN 0 WHEN 'ERROR' THEN 1 + WHEN 'WARN' THEN 2 ELSE 3 END, + a.last_seen_at DESC; +``` + +**연속 실패 일수 조회 SQL** (8절 에스컬레이션용) + +```sql +WITH daily AS ( + SELECT substr(started_at, 1, 10) AS d, + MAX(CASE WHEN status IN ('SUCCESS','PARTIAL') THEN 1 ELSE 0 END) AS ok + FROM runs + WHERE started_at >= date('now', '-14 days') + GROUP BY d +), +ranked AS ( + SELECT d, ok, ROW_NUMBER() OVER (ORDER BY d DESC) AS rn + FROM daily +) +SELECT COALESCE(MIN(rn), 0) - 1 AS consecutive_failed_days +FROM ranked +WHERE ok = 1; +-- 성공 행이 하나도 없으면 결과가 -1 이 되므로 호출측에서 +-- COUNT(*) FROM daily 로 대체 계산한다(코드 6.6 참조). +``` + +### 2.7 `state/alerts.json` 미러 스키마 + +Agent 프로세스가 **SQLite 를 열지 않고도** 알림 유무를 알 수 있어야 한다(배치가 DB 를 잠그고 있을 수 있고, PowerShell 폴백은 sqlite3 를 못 읽는다). 원자적 교체(`os.replace`)로 갱신한다. + +```json +{ + "schema": 1, + "updated_at": "2026-09-02T06:04:11+09:00", + "host": "DESKTOP-ABC", + "pending": [ + { + "alert_id": 42, + "dedup_key": "AGY_AUTH", + "code": "AGY_AUTH", + "severity": "CRITICAL", + "first_seen_at": "2026-09-01T06:03:55+09:00", + "last_seen_at": "2026-09-02T06:03:58+09:00", + "occurrences": 2, + "title": "AI 요약을 만들지 못했습니다 — 로그인 만료", + "what": "AI 요약 단계가 로그인 만료로 중단됐습니다.", + "why": "Antigravity CLI(agy)의 Google 계정 인증이 만료되어 자동 갱신에 실패했습니다.", + "how": "아래 [로그인 창 열기]를 누르고 Google 계정으로 다시 로그인하세요. 약 1분 걸립니다.", + "next_actions": ["agy_relogin", "open_log_dir", "snooze", "dismiss"], + "log_dir": "D:\\workspace\\DMF_Crawler\\logs\\run_20260902_060012", + "run_id": "20260902_060012" + } + ], + "counts": {"INFO": 0, "WARN": 1, "ERROR": 0, "CRITICAL": 1} +} +``` + +### 2.8 Windows 이벤트 로그 ID 배정 + +**결정적 제약**: `eventcreate.exe` 는 **`/ID` 를 1~1000 범위로만 받는다.** 범위를 벗어나면 `오류: 잘못된 인수/옵션` 으로 실패한다. 연구 문서 08 에 나온 `1001` 은 그대로 쓸 수 없다. 아래 표가 정본이다. + +| ID | `/T` | 의미 | 기록 주체 | +|---|---|---|---| +| 100 | INFORMATION | 실행 시작 | pipeline | +| 110 | SUCCESS | 실행 성공(리포트 생성 완료) | pipeline | +| 120 | WARNING | 실행 부분 성공(PARTIAL) | pipeline | +| 130 | INFORMATION | 중복 실행 스킵(SKIPPED) | pipeline | +| 200 | WARNING | WARN 등급 알림 발생 | alerts | +| 300 | ERROR | ERROR 등급 알림 발생(리포트 미생성) | alerts | +| 400 | ERROR | CRITICAL 등급 알림 발생 | alerts | +| 410 | ERROR | 워치독 — 배치 미실행 감지 | watchdog | +| 420 | ERROR | 연속 실패 임계 도달 | watchdog | +| 430 | ERROR | 자동 실행 일시중지(7일차 에스컬레이션) | watchdog | +| 500 | INFORMATION | 알림 표시 성공 | notify.pump | +| 510 | WARNING | 알림 표시 실패 — 한 단계 강등(`NOTIFY_DEGRADED`) | notify.pump | +| 520 | WARNING | 알림 표시 억제(일일 상한 도달) | notify.pump | +| 900 | ERROR | 알리미 자신이 죽음(pump 예외) | notify.pump | +| 910 | ERROR | PowerShell 폴백 알림기가 발동함(= Python 계층이 죽었다는 증거) | notify.ps1 | + +**소스 이름**: `notify.eventlog_source` = `"DMF Crawler"`. `eventcreate` 의 `/SO` 는 **이미 시스템에 등록된 원본 이름과 충돌하면 안 되고**, 지정한 이름은 Application 로그에 자동 등록된다. 표준 사용자 권한으로 Application 로그 쓰기는 가능하다. (⚠️ 미검증: 그룹 정책으로 Application 로그 쓰기가 제한된 환경에서의 동작) + +**조회 명령** + +```powershell +# 최근 20건 +Get-WinEvent -FilterHashtable @{LogName='Application'; ProviderName='DMF Crawler'} -MaxEvents 20 | + Format-Table TimeCreated, Id, LevelDisplayName, Message -AutoSize -Wrap + +# CRITICAL 만 +Get-WinEvent -FilterHashtable @{LogName='Application'; ProviderName='DMF Crawler'; Id=400,410,420,430} | + Select-Object TimeCreated, Id, Message +``` + +--- + +## 3. 알림 문구 전집 + +### 3.1 4요소 계약 + +모든 알림은 아래 4요소를 **반드시** 갖는다. `raise_alert()` 는 하나라도 비면 `ValueError` 를 던진다 — 규격을 문서가 아니라 **코드 계약**으로 승격시킨다(아키텍처 §3.14). + +| 요소 | 질문 | 작성 규칙 | +|---|---|---| +| **무엇** (`what`) | 무엇이 실패했는가 / 언제 | 한 문장. **시각을 반드시 포함**한다(`2026-09-02 06:04`). 기술 용어 대신 사용자가 아는 말로 — "fetch 스테이지"가 아니라 "식약처 데이터 받아오기" | +| **왜** (`why`) | 왜 그런가 | 한두 문장. 원인을 **추정이 아니라 관측된 사실**로 쓴다. 모르면 "원인을 특정하지 못했습니다"라고 정직하게 쓴다 | +| **어떻게** (`how`) | 지금 무엇을 하면 되는가 | **명령형 한 문장 + 소요 시간.** "약 1분 걸립니다" 같은 시간 표시가 착수율을 크게 올린다. 여러 방법이 있으면 가장 쉬운 것 하나만 | +| **다음 행동** (`next_actions`) | 누를 것 | **액션 키 배열.** 최소 1개(막다른 골목 금지, R7.7). `dismiss` 만 있는 실패 알림은 등록 거부된다 | + +**추가 필수 필드** + +| 필드 | 규칙 | +|---|---| +| `title` | 40자 이내. 토스트 제목줄에 잘리지 않아야 한다. 등급 접두어를 붙이지 않는다(색으로 표현) | +| `log_dir` | **절대경로.** 상대경로는 사용자가 어디서 여는지 몰라 쓸모없다 | +| `where` | 본문 말미에 자동 부착: `로그: ` | + +**금지 표현** + +| 금지 | 이유 | 대체 | +|---|---|---| +| "오류가 발생했습니다" | 아무 정보가 없다 | 실제로 무엇이 실패했는지 | +| "관리자에게 문의하세요" | 1인 운영에서 관리자는 본인이다 | 구체적 조치 | +| 예외 클래스명·스택트레이스 | 비개발자가 읽을 수 없다 | 사람의 말. 스택은 로그에만 | +| "잠시 후 다시 시도하세요" (단독) | 언제까지 기다릴지 모른다 | "내일 06:00에 자동으로 다시 시도합니다" | +| API 키·토큰·URL 쿼리스트링 | 유출 | 앞 8자 지문만 | + +### 3.2 시나리오 카탈로그 + +| # | 코드 | 등급 | 트리거 | dedup 에 날짜 포함 | 쿨다운 | 기본 채널 | +|---|---|---|---|---|---|---| +| S01 | `RUN_OK` | INFO | 실행 성공 | 예 | 1440분 | 로그·이벤트로그만 | +| S02 | `FETCH_FAILED` | WARN | 재시도 4회 소진 | 예 | 240분 | 토스트 | +| S03 | `SOURCE_CIRCUIT_OPEN` | CRITICAL | 서킷 CLOSED→OPEN 전환 | 아니오 | 60분 | 모달 + 웹훅 | +| S04 | `HTTP_BLOCKED_BODY` | WARN | 200인데 본문이 빈/HTML/차단 안내 | 예 | 240분 | 토스트 | +| S05 | `ZERO_RECORDS` | CRITICAL | `totalCount`>0인데 수집 0건, 또는 `totalCount`=0 | 예 | 60분 | 모달 + 웹훅 | +| S06 | `INTEGRITY_BLOCKED` | WARN | 무결성 게이트 2/4/5 차단 | 예 | 240분 | 토스트 | +| S07 | `INTEGRITY_DROP` | CRITICAL | 게이트 3 — `totalCount` 5% 이상 급감 | 예 | 60분 | 모달 + 웹훅 | +| S08 | `SCHEMA_DRIFT` | CRITICAL | 예상 필드 부재 / 널 비율 급증 | 예 | 60분 | 모달 + 웹훅 | +| S09 | `API_KEY_MISSING` | CRITICAL | DPAPI 복호화 결과 없음 | 아니오 | 60분 | 모달(강제) | +| S10 | `API_KEY_INVALID` | CRITICAL | `resultCode != "00"` / HTTP 401·403 | 아니오 | 60분 | 모달(강제) | +| S11 | `API_QUOTA_EXCEEDED` | WARN | 일일 트래픽 초과 오류 코드 | 예 | 240분 | 토스트 | +| S12 | `AGY_MISSING` | CRITICAL | `agy.exe` 경로 부재 | 아니오 | 60분 | 모달 | +| S13 | `AGY_AUTH` | CRITICAL | `classify_error(env) == AUTH` | 아니오 | 60분 | 모달 + 웹훅 | +| S14 | `AGY_QUOTA` | WARN | `classify_error(env) == QUOTA` | 예 | 240분 | 토스트 | +| S15 | `AGY_TIMEOUT` | INFO | `--print-timeout` 초과 | 예 | 1440분 | 로그만 | +| S16 | `REPORT_LOCKED` | WARN | `os.replace` → `PermissionError` 3회 | 예 | 240분 | 토스트 | +| S17 | `REPORT_FAILED` | ERROR | report 스테이지 실패, 파일 없음 | 예 | 60분 | 모달 | +| S18 | `DISK_LOW` | WARN | 여유 < `backup.min_free_gb` | 예 + scope=드라이브 | 240분 | 토스트 | +| S19 | `DB_LOCKED` | ERROR | `sqlite3.OperationalError: database is locked` | 예 | 60분 | 모달 | +| S20 | `DB_CORRUPT` | CRITICAL | `PRAGMA integrity_check` 실패 | 아니오 | 60분 | 모달(강제) | +| S21 | `MIGRATION_FAILED` | CRITICAL | 마이그레이션 중 예외 | 아니오 | 60분 | 모달(강제) | +| S22 | `WATCHDOG_STALE` | CRITICAL | heartbeat 나이 > 120분 | 아니오 | 60분 | 모달 + 웹훅 | +| S23 | `TASK_MISSING` | CRITICAL | `checks` ⑨ 실패 | 아니오 | 60분 | 모달 | +| S24 | `CONSECUTIVE_FAILURES` | CRITICAL | 3일 연속 FAILED | 아니오 | 60분 | 모달 + 웹훅 | +| S25 | `PYTHON_BROKEN` | CRITICAL | pump 스탬프 미갱신(PowerShell 폴백이 판정) | 아니오 | 240분 | MessageBox | +| S26 | `NOTIFY_DEGRADED` | WARN | 표시 채널이 한 단계 떨어짐 | 예 + scope=채널 | 240분 | 이벤트로그만 | +| S27 | `RUN_PAUSED` | CRITICAL | 7일 연속 실패로 자동 실행 중단 | 아니오 | 60분 | 모달 + 웹훅 | + +> **"셀렉터 깨짐" 시나리오는 이 프로젝트에 존재하지 않는다.** ADR-01/ADR-06 이 HTML 스크래핑과 브라우저 자동화를 코드에서 제거했으므로 CSS/XPath 셀렉터라는 개념 자체가 없다. **기능적으로 등가인 실패는 S08 `SCHEMA_DRIFT`(API 응답 필드 구조 변경)** 이며, 아래 문구는 그 전제로 작성했다. + +--- + +### 3.3 문구 전문 + +각 항목은 **토스트(WARN)** 또는 **모달(ERROR/CRITICAL)** 로 렌더링된 최종 결과다. `{중괄호}` 는 `context_json` 에서 치환된다. + +--- + +#### S02 `FETCH_FAILED` — 크롤링(수집) 실패 · WARN + +``` +제목 식약처 데이터를 받아오지 못했습니다 + +본문 [무엇] {run_date} {run_time} 실행에서 식약처 DMF 목록을 받아오지 못했습니다. + {pages_ok}/{pages_total} 페이지까지만 받았습니다. + [왜] 공공데이터포털 API 응답이 {attempts}회 연속 실패했습니다. + 마지막 오류: {last_error_short} + [어떻게] 오늘 리포트는 마지막으로 성공한 {stale_date} 자료로 만들었습니다. + 내일 06:00에 자동으로 다시 시도합니다. 급하면 [지금 다시 실행]을 누르세요(약 2분). + + 로그: {log_dir} + +버튼 [지금 다시 실행] [로그 열기] [리포트 열기] [닫기] +``` + +--- + +#### S03 `SOURCE_CIRCUIT_OPEN` — 소스 차단·연속 실패로 호출 중단 · CRITICAL + +``` +제목 식약처 API 호출을 24시간 중단했습니다 + +본문 [무엇] {run_date} {run_time} 기준, 식약처 데이터 수집이 {fail_count}회 연속 실패해 + 자동 호출을 중단(차단 회로 열림)했습니다. + [왜] 같은 오류가 반복되면 상대 서버에 부담을 주고 차단당할 수 있어, + {cooldown_hours}시간 동안 호출 자체를 멈춥니다. + 최근 오류: {last_error_short} + [어떻게] 원인을 확인하려면 [진단 실행]을 누르세요(약 10초). + 원인이 해결됐다고 판단되면 [지금 다시 실행]으로 즉시 재시도할 수 있습니다. + 그대로 두면 {resume_at}에 자동으로 한 번 시험 호출합니다. + + 로그: {log_dir} + +버튼 [진단 실행] [지금 다시 실행] [로그 열기] [나중에] +``` + +--- + +#### S04 `HTTP_BLOCKED_BODY` — 차단 감지(200인데 내용이 없음) · WARN + +``` +제목 API가 정상 응답 대신 안내 페이지를 보냈습니다 + +본문 [무엇] {run_date} {run_time} 실행에서 {page_no}페이지 요청에 대해 + 정상 코드(HTTP 200)가 왔지만 내용이 데이터가 아니었습니다. + [왜] 응답 본문이 {body_bytes}바이트로 최소 기준({min_bytes}바이트)에 못 미치거나 + HTML 안내 페이지였습니다. 점검 중이거나 호출이 차단됐을 때 나타나는 형태입니다. + [어떻게] 받은 원문을 그대로 보관했습니다. [원문 폴더 열기]에서 직접 확인할 수 있습니다. + 오늘 비교는 건너뛰었고 리포트는 이전 자료로 생성했습니다. + 내일 06:00에 자동 재시도합니다. + + 원문: {raw_dir} + 로그: {log_dir} + +버튼 [원문 폴더 열기] [지금 다시 실행] [로그 열기] [닫기] +``` + +--- + +#### S05 `ZERO_RECORDS` — 0건 수집 · CRITICAL + +``` +제목 수집 결과가 0건입니다 — 비교를 중단했습니다 + +본문 [무엇] {run_date} {run_time} 실행에서 원료의약품 등록(DMF) 자료를 한 건도 받지 못했습니다. + (API가 알려준 전체 건수: {total_count}건, 실제 받은 건수: 0건) + [왜] 0건을 그대로 받아들이면 어제 있던 {prev_count}건 전부가 + "취하됨"으로 잘못 기록됩니다. 그래서 저장과 비교를 모두 중단했습니다. + [어떻게] 오늘 리포트는 {stale_date} 자료로 만들었고 맨 위에 경고 배너가 붙어 있습니다. + [원문 폴더 열기]에서 실제 응답을 확인하세요(약 1분). + 공공데이터포털 점검 중이면 내일 자동 복구됩니다. + + 원문: {raw_dir} + 로그: {log_dir} + +버튼 [원문 폴더 열기] [진단 실행] [지금 다시 실행] [나중에] +``` + +--- + +#### S06 `INTEGRITY_BLOCKED` — 무결성 게이트 차단 · WARN + +``` +제목 데이터 검증에 걸려 오늘 비교를 건너뛰었습니다 + +본문 [무엇] {run_date} {run_time} 실행에서 안전장치 "{gate_name}"에 걸려 + 오늘 자료를 기준값으로 저장하지 않았습니다. + [왜] {gate_detail} + (기준: {gate_threshold} / 실측: {gate_observed}) + 기준을 벗어난 자료를 저장하면 이후 모든 비교가 오염됩니다. + [어떻게] 오늘 리포트는 정상 생성됐지만 "오늘 변경분" 시트는 비어 있습니다. + 받은 원문은 {raw_dir}에 보관돼 있습니다. + 내일 정상 수집되면 자동으로 복구됩니다. 확인이 필요하면 [원문 폴더 열기]. + + 로그: {log_dir} + +버튼 [리포트 열기] [원문 폴더 열기] [로그 열기] [닫기] +``` + +--- + +#### S07 `INTEGRITY_DROP` — 전체 건수 급감 · CRITICAL + +``` +제목 전체 등록 건수가 {drop_pct}% 줄었습니다 — 확인이 필요합니다 + +본문 [무엇] {run_date} {run_time} 실행에서 전체 DMF 등록 건수가 + {prev_count}건 → {curr_count}건으로 {drop_count}건({drop_pct}%) 줄었습니다. + [왜] 하루 만에 {threshold_pct}% 이상 줄어드는 것은 정상적인 변동이 아닙니다. + 대량 취하가 실제로 일어났거나, API가 일부 자료만 내려준 경우입니다. + 오탐으로 "전건 취하" 리포트가 나가는 것을 막기 위해 비교를 중단했습니다. + [어떻게] [원문 폴더 열기]에서 오늘 받은 응답의 totalCount를 직접 확인하세요(약 2분). + 실제로 정상적인 감소라면 [지금 다시 실행 (검증 우회)]를 눌러 반영할 수 있습니다. + + 원문: {raw_dir} + 로그: {log_dir} + +버튼 [원문 폴더 열기] [지금 다시 실행 (검증 우회)] [로그 열기] [나중에] +``` + +--- + +#### S08 `SCHEMA_DRIFT` — API 응답 구조 변경(구 "셀렉터 깨짐" 등가) · CRITICAL + +``` +제목 식약처 API 응답 형식이 바뀐 것 같습니다 + +본문 [무엇] {run_date} {run_time} 실행에서 필수 항목 {missing_fields_str}을(를) + 응답에서 찾지 못했습니다. 값이 빈 비율이 {null_ratio_pct}%까지 올랐습니다. + (평소 {baseline_null_pct}% 이하) + [왜] 공공데이터포털 쪽에서 응답 필드 이름이나 구조를 바꿨을 가능성이 큽니다. + 잘못 해석한 자료를 저장하지 않기 위해 저장과 비교를 중단했습니다. + [어떻게] 오늘 받은 원문 전체를 보관했습니다. 이 원문만 있으면 나중에 통째로 + 다시 해석할 수 있으니 자료는 유실되지 않습니다. + [원문 폴더 열기]로 실제 응답을 확인하고, 필드 이름이 바뀌었다면 + 프로그램의 매핑을 고쳐야 합니다. AI 진단 결과가 있으면 {proposal_path}에 있습니다. + + 원문: {raw_dir} + 로그: {log_dir} + +버튼 [원문 폴더 열기] [AI 진단 결과 열기] [진단 실행] [나중에] +``` + +> **AI 제안은 자동 적용되지 않는다**(ADR-25). `state/proposals/` 에 저장만 하고 사람이 승인한다. 이 버튼은 파일을 여는 것 이상을 하지 않는다. + +--- + +#### S09 `API_KEY_MISSING` — API 키 미설정 · CRITICAL (강제 창) + +``` +제목 공공데이터포털 인증키가 없습니다 + +본문 [무엇] {run_date} {run_time}에 배치를 시작하려 했으나 + 공공데이터포털 인증키가 저장돼 있지 않아 실행하지 못했습니다. + [왜] 이 프로그램은 식약처 원료의약품 등록 자료를 공공데이터포털 공식 API로 받아옵니다. + API를 쓰려면 무료 인증키가 필요하고, 아직 한 번도 입력되지 않았습니다. + [어떻게] [인증키 입력]을 누르면 입력 창이 열립니다. + 키가 없다면 [발급 페이지 열기]로 공공데이터포털에서 신청하세요(무료, 약 3분). + 입력하면 즉시 실제 호출로 검증한 뒤 이 PC에만 암호화해 저장합니다. + + * 이 알림은 키가 저장될 때까지 계속 표시됩니다. + +버튼 [인증키 입력] [발급 페이지 열기] [나중에] +``` + +--- + +#### S10 `API_KEY_INVALID` — 인증키 무효·만료 · CRITICAL (강제 창) + +``` +제목 공공데이터포털 인증키가 거부됐습니다 + +본문 [무엇] {run_date} {run_time} 실행에서 식약처 API가 인증키를 거부했습니다. + (응답 코드: {result_code} / {result_msg}) + [왜] 저장된 키(앞 8자: {key_fingerprint}…)가 만료됐거나, + 해당 서비스의 활용 신청이 승인되지 않았거나, 키를 잘못 붙여넣었을 수 있습니다. + 특히 "인코딩 키"를 붙여넣으면 이 오류가 납니다 — **디코딩 키**를 써야 합니다. + [어떻게] [인증키 다시 입력]을 눌러 공공데이터포털 마이페이지의 + **일반 인증키(Decoding)** 값을 붙여넣으세요(약 2분). + 입력 즉시 실제 호출로 검증하므로 맞는지 바로 알 수 있습니다. + + 로그: {log_dir} + +버튼 [인증키 다시 입력] [마이페이지 열기] [로그 열기] [나중에] +``` + +--- + +#### S11 `API_QUOTA_EXCEEDED` — 일일 트래픽 초과 · WARN + +``` +제목 오늘 API 사용량을 모두 썼습니다 + +본문 [무엇] {run_date} {run_time} 실행에서 공공데이터포털이 + 일일 트래픽 초과를 알려왔습니다. ({error_code}) + [왜] 개발계정의 하루 호출 한도(기본 10,000회)를 넘었습니다. + 오늘 {calls_today}회 호출했습니다. 한도는 매일 자정에 초기화됩니다. + [어떻게] 오늘은 더 호출하지 않고 마지막으로 성공한 {stale_date} 자료로 리포트를 만들었습니다. + 내일 06:00에 자동으로 정상 수집합니다. 아무것도 하지 않아도 됩니다. + 매일 반복된다면 공공데이터포털에서 운영계정 전환을 신청하세요. + + 로그: {log_dir} + +버튼 [리포트 열기] [로그 열기] [닫기] +``` + +--- + +#### S12 `AGY_MISSING` — agy 미설치 · CRITICAL + +``` +제목 AI 요약 도구(agy)가 설치돼 있지 않습니다 + +본문 [무엇] {run_date} {run_time} 실행에서 AI 요약 단계를 건너뛰었습니다. + Antigravity CLI(agy) 실행 파일을 찾지 못했습니다. + 찾아본 경로: {agy_path} + [왜] AI 요약은 매일 변경 내용을 자연어 브리핑으로 정리하는 부가 기능입니다. + 도구가 설치되지 않았거나 경로가 바뀌었습니다. + [어떻게] [지금 설치]를 누르면 공식 설치 스크립트를 자동으로 실행합니다(약 2분, 인터넷 필요). + 설치 후 최초 1회 Google 계정 로그인이 필요하며 창이 자동으로 열립니다. + AI 요약이 필요 없다면 [AI 기능 끄기]를 눌러 이 알림을 영구히 멈출 수 있습니다. + + * 오늘 리포트는 AI 요약 없이 정상 생성됐습니다. + +버튼 [지금 설치] [AI 기능 끄기] [리포트 열기] [나중에] +``` + +--- + +#### S13 `AGY_AUTH` — agy 인증 만료 · CRITICAL (강제 창) + +``` +제목 AI 요약을 만들지 못했습니다 — 로그인 만료 + +본문 [무엇] {run_date} {run_time} 실행에서 AI 요약 단계가 중단됐습니다. + {occurrences}일째 같은 상태입니다. + [왜] Antigravity CLI(agy)의 Google 계정 인증이 만료되어 자동 갱신에 실패했습니다. + (agy 응답: {agy_error_short}) + [어떻게] [로그인 창 열기]를 누르면 검은 콘솔 창이 뜨고 브라우저가 열립니다. + Google 계정으로 로그인한 뒤 창을 닫으면 끝입니다(약 1분). + 로그인이 끝나면 자동으로 확인해서 이 알림을 지웁니다. + + * 오늘 리포트는 AI 요약 없이 정상 생성됐습니다. 급하지 않다면 나중에 해도 됩니다. + + 로그: {log_dir} + +버튼 [로그인 창 열기] [리포트 열기] [로그 열기] [나중에] +``` + +--- + +#### S14 `AGY_QUOTA` — agy 쿼터 소진 · WARN + +``` +제목 AI 요약을 건너뛰었습니다 — 사용 한도 소진 + +본문 [무엇] {run_date} {run_time} 실행에서 AI 요약을 만들지 못했습니다. + [왜] Antigravity CLI의 모델 사용 한도를 다 썼습니다. + (오늘 사용 토큰 {tokens_used}, 설정 상한 {tokens_cap}) + [어떻게] 아무것도 하지 않아도 됩니다. 내일 06:00에 자동으로 다시 시도합니다. + 오늘 리포트의 "대시보드" 시트 상단에 "AI 요약 없음: 사용 한도"라고 표시했습니다. + 자주 반복되면 config.toml 의 agy.daily_token_cap 을 조정하세요. + + 로그: {log_dir} + +버튼 [리포트 열기] [로그 열기] [닫기] +``` + +--- + +#### S16 `REPORT_LOCKED` — xlsx 파일 잠김 · WARN + +``` +제목 리포트를 다른 이름으로 저장했습니다 + +본문 [무엇] {run_date} {run_time} 실행에서 오늘 리포트를 원래 파일 이름으로 + 저장하지 못하고 "{fallback_name}"으로 저장했습니다. + [왜] "{target_name}" 파일이 Excel에서 열려 있어 덮어쓸 수 없었습니다. + {retries}회 다시 시도했지만 계속 잠겨 있었습니다. + [어떻게] Excel에서 해당 파일을 닫고 [리포트 다시 만들기]를 누르면 + 원래 이름으로 정리됩니다(약 20초). + 지금 당장은 [폴더 열기]로 "{fallback_name}"을 그대로 열어 보셔도 됩니다. + + 폴더: {report_dir} + 로그: {log_dir} + +버튼 [리포트 다시 만들기] [폴더 열기] [닫기] +``` + +--- + +#### S17 `REPORT_FAILED` — 리포트 생성 실패 · ERROR (모달) + +``` +제목 오늘 리포트를 만들지 못했습니다 + +본문 [무엇] {run_date} {run_time} 실행에서 Excel 리포트 생성 단계가 실패했습니다. + 오늘 날짜의 리포트 파일이 없습니다. + [왜] {failure_summary} + (자료 수집과 비교는 정상적으로 끝났고 데이터베이스에 저장돼 있습니다. + 문제는 파일을 만드는 마지막 단계입니다.) + [어떻게] 저장된 자료로 리포트만 다시 만들 수 있습니다. + [리포트 다시 만들기]를 누르세요(약 20초, 인터넷 불필요). + 그래도 실패하면 [로그 열기]의 pipeline.log 마지막 30줄을 확인하세요. + + 로그: {log_dir} + +버튼 [리포트 다시 만들기] [로그 열기] [진단 실행] [나중에] +``` + +--- + +#### S18 `DISK_LOW` — 디스크 부족 · WARN + +``` +제목 {drive} 드라이브 여유 공간이 부족합니다 + +본문 [무엇] {run_date} {run_time} 실행에서 {drive} 드라이브 여유 공간이 + {free_gb}GB 남았습니다. (필요 최소 {min_gb}GB) + [왜] 공간이 부족하면 데이터베이스 백업이 실패하고, + 더 줄어들면 리포트 저장도 실패합니다. 오늘은 백업만 건너뛰었습니다. + [어떻게] [정리하기]를 누르면 보존 기간이 지난 로그·원문·오래된 백업을 + 한 번에 지웁니다. 예상 확보량 약 {reclaim_mb}MB (약 10초). + 그래도 부족하면 config.toml 의 backup.dir 을 다른 드라이브로 바꾸세요. + + * 오늘 리포트는 정상 생성됐습니다. + + 로그: {log_dir} + +버튼 [정리하기] [백업 폴더 열기] [로그 열기] [닫기] +``` + +--- + +#### S19 `DB_LOCKED` — 데이터베이스 잠김 · ERROR (모달) + +``` +제목 데이터베이스가 잠겨 있어 저장하지 못했습니다 + +본문 [무엇] {run_date} {run_time} 실행에서 수집한 자료를 저장하지 못했습니다. + {busy_timeout_s}초를 기다렸지만 데이터베이스가 계속 잠겨 있었습니다. + [왜] 다른 프로그램이 dmf.sqlite3 파일을 붙잡고 있습니다. + DB 브라우저 같은 도구를 열어 두었거나, 이전 실행이 아직 끝나지 않았을 수 있습니다. + [어떻게] SQLite 관련 프로그램을 모두 닫고 [지금 다시 실행]을 누르세요(약 2분). + 아무것도 열어 둔 것이 없다면 PC를 재시작한 뒤 다시 시도하세요. + + DB: {db_path} + 로그: {log_dir} + +버튼 [지금 다시 실행] [DB 폴더 열기] [로그 열기] [나중에] +``` + +--- + +#### S20 `DB_CORRUPT` — 데이터베이스 손상 · CRITICAL (강제 창) + +``` +제목 데이터베이스 파일이 손상됐습니다 + +본문 [무엇] {check_time} 점검에서 데이터베이스 무결성 검사가 실패했습니다. + 배치를 시작하지 않고 중단했습니다. + (검사 결과: {integrity_result}) + [왜] 저장 중 강제 종료나 디스크 오류로 파일이 깨졌을 수 있습니다. + 손상된 파일에 계속 쓰면 남은 자료까지 잃습니다. + [어떻게] 백업본이 {backup_count}개 있습니다. 가장 최근 것은 {latest_backup_date}입니다. + [백업으로 복원]을 누르면 목록에서 고를 수 있습니다(약 30초). + 복원하면 그 날짜 이후 자료는 다시 수집해야 하며, 자동으로 채워집니다. + + 현재 DB: {db_path} + 백업 폴더: {backup_dir} + +버튼 [백업으로 복원] [백업 폴더 열기] [진단 실행] [나중에] +``` + +--- + +#### S21 `MIGRATION_FAILED` — 스키마 마이그레이션 실패 · CRITICAL (강제 창) + +``` +제목 데이터베이스 업그레이드에 실패했습니다 + +본문 [무엇] {check_time}에 데이터베이스 구조 업그레이드({from_version} → {to_version})가 + 실패해 되돌렸습니다. 배치는 실행하지 않았습니다. + [왜] {failure_summary} + 변경은 트랜잭션으로 묶여 있어 **자료는 손상되지 않았습니다.** + [어떻게] 업그레이드 직전 백업본을 아래 경로에 만들어 두었습니다. + 프로그램 버전을 이전으로 되돌리거나, 개발자에게 이 문구와 로그를 전달하세요. + [백업 폴더 열기]로 백업본을 확인할 수 있습니다. + + 업그레이드 직전 백업: {pre_migration_backup} + 로그: {log_dir} + +버튼 [백업 폴더 열기] [로그 열기] [진단 실행] [나중에] +``` + +--- + +#### S22 `WATCHDOG_STALE` — 배치 미실행 워치독 경보 · CRITICAL (강제 창) + +``` +제목 06:00 자동 실행이 되지 않았습니다 + +본문 [무엇] 지금 {now_time} 기준으로 오늘 06:00 배치가 실행된 흔적이 없습니다. + 마지막으로 성공한 실행은 {last_success_at} ({stale_hours}시간 전)입니다. + [왜] 다음 중 하나입니다. + · PC가 06:00에 꺼져 있었고 아직 따라잡기가 실행되지 않음 + · 작업 스케줄러 항목이 꺼졌거나 삭제됨 + · 실행이 시작됐지만 제한 시간({exec_limit_min}분)을 넘겨 강제 종료됨 + [어떻게] [지금 실행]을 누르면 즉시 오늘 자료를 수집합니다(약 2분). + 반복된다면 [작업 상태 확인]으로 스케줄러 등록 상태를 점검하세요. + + 마지막 로그: {log_dir} + +버튼 [지금 실행] [작업 상태 확인] [로그 폴더 열기] [나중에] +``` + +--- + +#### S23 `TASK_MISSING` — 작업 스케줄러 등록 소실 · CRITICAL + +``` +제목 자동 실행 등록이 사라졌습니다 + +본문 [무엇] {check_time} 점검에서 Windows 작업 스케줄러 항목 {missing_tasks_str}을(를) + 찾지 못했습니다. + [왜] 시스템 정리 도구나 다른 프로그램이 지웠거나, 사용자가 직접 비활성화했을 수 있습니다. + 등록이 없으면 매일 06:00 자동 실행이 되지 않습니다. + [어떻게] 의도적으로 끈 것이 아니라면 [자동 실행 다시 등록]을 누르세요(약 10초). + 같은 이름의 작업 3개를 다시 만듭니다. 기존 설정은 그대로 유지됩니다. + + * 자동으로 다시 등록하지 않는 이유: 사용자가 일부러 끈 것을 되살리면 안 되기 때문입니다. + +버튼 [자동 실행 다시 등록] [작업 스케줄러 열기] [진단 실행] [나중에] +``` + +--- + +#### S24 `CONSECUTIVE_FAILURES` — 연속 3일 실패 · CRITICAL (강제 창) + +``` +제목 {failed_days}일 연속으로 실패하고 있습니다 + +본문 [무엇] {first_failed_date}부터 {last_failed_date}까지 {failed_days}일 연속 + 배치가 실패했습니다. 그 기간 리포트가 만들어지지 않았습니다. + [왜] 가장 많이 나온 원인 3가지입니다. + 1. {top_cause_1} ({top_cause_1_count}회) + 2. {top_cause_2} ({top_cause_2_count}회) + 3. {top_cause_3} ({top_cause_3_count}회) + [어떻게] [전체 진단]을 누르면 전제조건 12가지를 한 번에 점검하고 + 문제 항목마다 해결 버튼을 보여 줍니다(약 15초). + 대부분 인증키 또는 로그인 만료입니다. + + * {pause_day}일째에도 해결되지 않으면 자동 실행을 잠시 멈추고 다시 안내합니다. + + 로그: {log_dir} + +버튼 [전체 진단] [지금 다시 실행] [로그 폴더 열기] [나중에] +``` + +--- + +#### S25 `PYTHON_BROKEN` — 알림 계층 자체가 죽음 · CRITICAL (MessageBox 폴백) + +이 문구만은 **PowerShell 이 렌더링**한다(`scripts/notify.ps1`). Python 이 죽었을 때 뜨는 유일한 화면이므로 템플릿 치환을 최소화하고 상수 문자열 위주로 구성한다. + +``` +제목 DMF 크롤러 — 프로그램이 실행되지 않습니다 + +본문 [무엇] DMF 크롤러의 알림 프로그램이 {stale_min}분째 응답하지 않습니다. + (마지막 정상 동작: {last_pump_at}) + [왜] Python 실행 환경(.venv)이 손상됐거나 삭제됐을 가능성이 큽니다. + 이 상태에서는 매일 06:00 자동 수집도 함께 멈춥니다. + [어떻게] 프로젝트 폴더의 bootstrap.cmd 를 더블클릭해 다시 설치하세요(약 3분). + 기존 데이터와 설정은 그대로 유지됩니다. + + 폴더: {project_root} + Windows 이벤트 로그(Application) 원본 "DMF Crawler" 에 기록을 남겼습니다. + +버튼 [확인] ← MessageBox 는 버튼을 커스터마이즈하지 않는다(§6.1 참조) +``` + +--- + +#### S27 `RUN_PAUSED` — 자동 실행 일시중지 · CRITICAL (강제 창) + +``` +제목 자동 실행을 잠시 멈췄습니다 + +본문 [무엇] {failed_days}일 연속 실패해 {pause_at}부터 매일 06:00 자동 실행을 멈췄습니다. + [왜] 고쳐지지 않은 상태로 매일 같은 실패를 반복하면 + 공공데이터포털에 불필요한 호출이 계속 쌓이기 때문입니다. + 멈춤은 작업 스케줄러를 지우는 것이 아니라 플래그 파일 하나로 이뤄집니다. + [어떻게] [전체 진단]으로 원인을 먼저 해결한 뒤 [자동 실행 재개]를 누르세요. + 재개 버튼은 진단이 모두 통과해야 활성화됩니다. + + 멈춤 플래그: {pause_flag_path} + 로그: {log_dir} + +버튼 [전체 진단] [자동 실행 재개] [로그 폴더 열기] [나중에] +``` + +--- + +#### 나머지 INFO / 로그 전용 문구 + +| 코드 | 제목 | 본문(1줄 요약) | +|---|---|---| +| `RUN_OK` | 오늘 리포트가 준비됐습니다 | `{run_date} 수집 완료 — 신규 {new_n}건 / 변경 {chg_n}건 / 취하 {wdr_n}건. 리포트: {report_path}` | +| `AGY_TIMEOUT` | (표시 안 함) | `AI 요약이 {timeout}을 초과해 중단됨. 리포트는 정상 생성. 내일 자동 재시도.` | +| `NOTIFY_DEGRADED` | (표시 안 함) | `알림 표시가 {from_channel} → {to_channel} 로 강등됨. 사유: {reason}` | +| `NOTIFY_SUPPRESSED` | (표시 안 함) | `일일 알림 표시 상한 {cap}건 도달. 오늘 남은 알림 {pending_n}건은 표시하지 않음.` | + +### 3.4 `notify/messages.py` 전문 + +위 문구의 **단일 정본**이다. 문서와 코드가 어긋나지 않도록, 이 파일이 원본이고 §3.3 은 그 렌더링 결과다. + +```python +# src/dmf_crawler/notify/messages.py +"""알림 문구 템플릿 레지스트리. + +계약(요구 R7.3): + 모든 템플릿은 what / why / how / next_actions 4요소를 갖는다. + 하나라도 비면 모듈 임포트 시점에 AssertionError 로 즉시 죽는다. + -> 문구 누락이 06:00 런타임까지 잠복하지 않는다. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Mapping + +from dmf_crawler.alerts import Severity + +# ---------------------------------------------------------------- 액션 키 +# gui/steps.py 의 ACTIONS 레지스트리와 키가 1:1로 일치해야 한다. +VALID_ACTIONS: frozenset[str] = frozenset({ + "run_now", # 지금 다시 실행 + "run_now_force", # 검증 우회 재실행 (--force) + "report_only", # 리포트만 다시 만들기 + "open_log_dir", # 로그 폴더 열기 + "open_report_dir", # 리포트 폴더 열기 + "open_report_file", # 리포트 파일 바로 열기 + "open_raw_dir", # API 원문 폴더 열기 + "open_backup_dir", # 백업 폴더 열기 + "open_db_dir", # DB 폴더 열기 + "open_proposal", # AI 진단 결과 파일 열기 + "enter_api_key", # 인증키 입력 창 + "open_api_portal", # 공공데이터포털 발급/마이페이지 열기 + "agy_relogin", # agy 재로그인 콘솔 열기 + "install_agy", # agy 설치 + "disable_agy", # AI 기능 끄기 + "reregister_tasks", # 작업 스케줄러 3종 재등록 + "open_task_scheduler",# 작업 스케줄러 GUI 열기 + "restore_backup", # 백업 복원 마법사 + "cleanup_disk", # 로그/원문/백업 정리 + "open_doctor", # 전체 진단 화면 + "resume_schedule", # 자동 실행 재개(일시중지 해제) + "snooze", # 나중에 (기본 60분) + "dismiss", # 닫기 +}) + +# 실패 알림에서 이것만 있으면 "막다른 골목"이다 (요구 R7.7). +_TERMINAL_ONLY = frozenset({"snooze", "dismiss"}) + + +@dataclass(frozen=True, slots=True) +class AlertTemplate: + code: str + severity: Severity + title: str + what: str + why: str + how: str + next_actions: tuple[str, ...] + date_scoped: bool = True # dedup_key 에 run_date 를 넣는가 + cooldown_minutes: int | None = None # None 이면 등급 기본값 사용 + eventlog_id: int = 0 # 0 이면 등급 기본값 사용 + note: str = "" # 본문 하단 * 주석 (선택) + + def render(self, ctx: Mapping[str, Any]) -> "RenderedAlert": + safe = _SafeDict(ctx) + body_lines = [ + f"[무엇] {self.what.format_map(safe)}", + f"[왜] {self.why.format_map(safe)}", + f"[어떻게] {self.how.format_map(safe)}", + ] + if self.note: + body_lines += ["", self.note.format_map(safe)] + for label, key in (("원문", "raw_dir"), ("로그", "log_dir")): + if ctx.get(key): + body_lines += [f"{label}: {ctx[key]}"] + return RenderedAlert( + code=self.code, + severity=self.severity, + title=self.title.format_map(safe), + what=self.what.format_map(safe), + why=self.why.format_map(safe), + how=self.how.format_map(safe), + body="\n".join(body_lines), + next_actions=self.next_actions, + ) + + +@dataclass(frozen=True, slots=True) +class RenderedAlert: + code: str + severity: Severity + title: str + what: str + why: str + how: str + body: str + next_actions: tuple[str, ...] + + +class _SafeDict(dict): + """치환값이 없어도 죽지 않는다. 알림기는 절대 예외로 죽으면 안 된다.""" + + def __init__(self, src: Mapping[str, Any]) -> None: + super().__init__(src) + + def __missing__(self, key: str) -> str: # noqa: D105 + return "(정보 없음)" + + +def _t(**kw: Any) -> AlertTemplate: + return AlertTemplate(**kw) + + +TEMPLATES: dict[str, AlertTemplate] = { + + "RUN_OK": _t( + code="RUN_OK", severity=Severity.INFO, eventlog_id=110, + title="오늘 리포트가 준비됐습니다", + what="{run_date} {run_time} 수집이 정상적으로 끝났습니다.", + why="신규 {new_n}건 / 변경 {chg_n}건 / 취하 {wdr_n}건이 확인됐습니다.", + how="리포트를 열어 확인하세요. 파일: {report_path}", + next_actions=("open_report_file", "dismiss"), + cooldown_minutes=1440, + ), + + "FETCH_FAILED": _t( + code="FETCH_FAILED", severity=Severity.WARN, eventlog_id=200, + title="식약처 데이터를 받아오지 못했습니다", + what="{run_date} {run_time} 실행에서 식약처 DMF 목록을 받아오지 못했습니다. " + "{pages_ok}/{pages_total} 페이지까지만 받았습니다.", + why="공공데이터포털 API 응답이 {attempts}회 연속 실패했습니다. " + "마지막 오류: {last_error_short}", + how="오늘 리포트는 마지막으로 성공한 {stale_date} 자료로 만들었습니다. " + "내일 06:00에 자동으로 다시 시도합니다. " + "급하면 [지금 다시 실행]을 누르세요(약 2분).", + next_actions=("run_now", "open_log_dir", "open_report_file", "dismiss"), + ), + + "SOURCE_CIRCUIT_OPEN": _t( + code="SOURCE_CIRCUIT_OPEN", severity=Severity.CRITICAL, eventlog_id=400, + date_scoped=False, + title="식약처 API 호출을 {cooldown_hours}시간 중단했습니다", + what="{run_date} {run_time} 기준, 식약처 데이터 수집이 {fail_count}회 연속 실패해 " + "자동 호출을 중단(차단 회로 열림)했습니다.", + why="같은 오류가 반복되면 상대 서버에 부담을 주고 차단당할 수 있어, " + "{cooldown_hours}시간 동안 호출 자체를 멈춥니다. 최근 오류: {last_error_short}", + how="원인을 확인하려면 [진단 실행]을 누르세요(약 10초). " + "원인이 해결됐다고 판단되면 [지금 다시 실행]으로 즉시 재시도할 수 있습니다. " + "그대로 두면 {resume_at}에 자동으로 한 번 시험 호출합니다.", + next_actions=("open_doctor", "run_now", "open_log_dir", "snooze"), + ), + + "HTTP_BLOCKED_BODY": _t( + code="HTTP_BLOCKED_BODY", severity=Severity.WARN, eventlog_id=200, + title="API가 정상 응답 대신 안내 페이지를 보냈습니다", + what="{run_date} {run_time} 실행에서 {page_no}페이지 요청에 대해 " + "정상 코드(HTTP 200)가 왔지만 내용이 데이터가 아니었습니다.", + why="응답 본문이 {body_bytes}바이트로 최소 기준({min_bytes}바이트)에 못 미치거나 " + "HTML 안내 페이지였습니다. 점검 중이거나 호출이 차단됐을 때 나타나는 형태입니다.", + how="받은 원문을 그대로 보관했습니다. [원문 폴더 열기]에서 직접 확인할 수 있습니다. " + "오늘 비교는 건너뛰었고 리포트는 이전 자료로 생성했습니다. " + "내일 06:00에 자동 재시도합니다.", + next_actions=("open_raw_dir", "run_now", "open_log_dir", "dismiss"), + ), + + "ZERO_RECORDS": _t( + code="ZERO_RECORDS", severity=Severity.CRITICAL, eventlog_id=400, + title="수집 결과가 0건입니다 — 비교를 중단했습니다", + what="{run_date} {run_time} 실행에서 원료의약품 등록(DMF) 자료를 한 건도 받지 못했습니다. " + "(API가 알려준 전체 건수: {total_count}건, 실제 받은 건수: 0건)", + why="0건을 그대로 받아들이면 어제 있던 {prev_count}건 전부가 '취하됨'으로 " + "잘못 기록됩니다. 그래서 저장과 비교를 모두 중단했습니다.", + how="오늘 리포트는 {stale_date} 자료로 만들었고 맨 위에 경고 배너가 붙어 있습니다. " + "[원문 폴더 열기]에서 실제 응답을 확인하세요(약 1분). " + "공공데이터포털 점검 중이면 내일 자동 복구됩니다.", + next_actions=("open_raw_dir", "open_doctor", "run_now", "snooze"), + ), + + "INTEGRITY_BLOCKED": _t( + code="INTEGRITY_BLOCKED", severity=Severity.WARN, eventlog_id=200, + title="데이터 검증에 걸려 오늘 비교를 건너뛰었습니다", + what="{run_date} {run_time} 실행에서 안전장치 '{gate_name}'에 걸려 " + "오늘 자료를 기준값으로 저장하지 않았습니다.", + why="{gate_detail} (기준: {gate_threshold} / 실측: {gate_observed}) " + "기준을 벗어난 자료를 저장하면 이후 모든 비교가 오염됩니다.", + how="오늘 리포트는 정상 생성됐지만 '오늘 변경분' 시트는 비어 있습니다. " + "받은 원문은 보관돼 있습니다. 내일 정상 수집되면 자동으로 복구됩니다. " + "확인이 필요하면 [원문 폴더 열기]를 누르세요.", + next_actions=("open_report_file", "open_raw_dir", "open_log_dir", "dismiss"), + ), + + "INTEGRITY_DROP": _t( + code="INTEGRITY_DROP", severity=Severity.CRITICAL, eventlog_id=400, + title="전체 등록 건수가 {drop_pct}% 줄었습니다 — 확인이 필요합니다", + what="{run_date} {run_time} 실행에서 전체 DMF 등록 건수가 " + "{prev_count}건에서 {curr_count}건으로 {drop_count}건({drop_pct}%) 줄었습니다.", + why="하루 만에 {threshold_pct}% 이상 줄어드는 것은 정상적인 변동이 아닙니다. " + "대량 취하가 실제로 일어났거나, API가 일부 자료만 내려준 경우입니다. " + "오탐으로 '전건 취하' 리포트가 나가는 것을 막기 위해 비교를 중단했습니다.", + how="[원문 폴더 열기]에서 오늘 받은 응답의 totalCount를 직접 확인하세요(약 2분). " + "실제로 정상적인 감소라면 [지금 다시 실행 (검증 우회)]를 눌러 반영할 수 있습니다.", + next_actions=("open_raw_dir", "run_now_force", "open_log_dir", "snooze"), + ), + + "SCHEMA_DRIFT": _t( + code="SCHEMA_DRIFT", severity=Severity.CRITICAL, eventlog_id=400, + title="식약처 API 응답 형식이 바뀐 것 같습니다", + what="{run_date} {run_time} 실행에서 필수 항목 {missing_fields_str}을(를) " + "응답에서 찾지 못했습니다. 값이 빈 비율이 {null_ratio_pct}%까지 올랐습니다. " + "(평소 {baseline_null_pct}% 이하)", + why="공공데이터포털 쪽에서 응답 필드 이름이나 구조를 바꿨을 가능성이 큽니다. " + "잘못 해석한 자료를 저장하지 않기 위해 저장과 비교를 중단했습니다.", + how="오늘 받은 원문 전체를 보관했습니다. 이 원문만 있으면 나중에 통째로 " + "다시 해석할 수 있으니 자료는 유실되지 않습니다. " + "[원문 폴더 열기]로 실제 응답을 확인하세요. " + "AI 진단 결과가 있으면 [AI 진단 결과 열기]에서 볼 수 있습니다.", + next_actions=("open_raw_dir", "open_proposal", "open_doctor", "snooze"), + note="* AI가 제안한 수정은 자동으로 적용되지 않습니다. 사람이 확인한 뒤 반영합니다.", + ), + + "API_KEY_MISSING": _t( + code="API_KEY_MISSING", severity=Severity.CRITICAL, eventlog_id=400, + date_scoped=False, + title="공공데이터포털 인증키가 없습니다", + what="{run_date} {run_time}에 배치를 시작하려 했으나 공공데이터포털 인증키가 " + "저장돼 있지 않아 실행하지 못했습니다.", + why="이 프로그램은 식약처 원료의약품 등록 자료를 공공데이터포털 공식 API로 받아옵니다. " + "API를 쓰려면 무료 인증키가 필요하고, 아직 한 번도 입력되지 않았습니다.", + how="[인증키 입력]을 누르면 입력 창이 열립니다. " + "키가 없다면 [발급 페이지 열기]로 공공데이터포털에서 신청하세요(무료, 약 3분). " + "입력하면 즉시 실제 호출로 검증한 뒤 이 PC에만 암호화해 저장합니다.", + next_actions=("enter_api_key", "open_api_portal", "snooze"), + note="* 이 알림은 키가 저장될 때까지 계속 표시됩니다.", + ), + + "API_KEY_INVALID": _t( + code="API_KEY_INVALID", severity=Severity.CRITICAL, eventlog_id=400, + date_scoped=False, + title="공공데이터포털 인증키가 거부됐습니다", + what="{run_date} {run_time} 실행에서 식약처 API가 인증키를 거부했습니다. " + "(응답 코드: {result_code} / {result_msg})", + why="저장된 키(앞 8자: {key_fingerprint})가 만료됐거나, 해당 서비스의 활용 신청이 " + "승인되지 않았거나, 키를 잘못 붙여넣었을 수 있습니다. " + "특히 '인코딩 키'를 붙여넣으면 이 오류가 납니다 — 디코딩 키를 써야 합니다.", + how="[인증키 다시 입력]을 눌러 공공데이터포털 마이페이지의 " + "일반 인증키(Decoding) 값을 붙여넣으세요(약 2분). " + "입력 즉시 실제 호출로 검증하므로 맞는지 바로 알 수 있습니다.", + next_actions=("enter_api_key", "open_api_portal", "open_log_dir", "snooze"), + ), + + "API_QUOTA_EXCEEDED": _t( + code="API_QUOTA_EXCEEDED", severity=Severity.WARN, eventlog_id=200, + title="오늘 API 사용량을 모두 썼습니다", + what="{run_date} {run_time} 실행에서 공공데이터포털이 일일 트래픽 초과를 " + "알려왔습니다. ({error_code})", + why="개발계정의 하루 호출 한도(기본 10,000회)를 넘었습니다. " + "오늘 {calls_today}회 호출했습니다. 한도는 매일 자정에 초기화됩니다.", + how="오늘은 더 호출하지 않고 마지막으로 성공한 {stale_date} 자료로 리포트를 만들었습니다. " + "내일 06:00에 자동으로 정상 수집합니다. 아무것도 하지 않아도 됩니다. " + "매일 반복된다면 공공데이터포털에서 운영계정 전환을 신청하세요.", + next_actions=("open_report_file", "open_log_dir", "dismiss"), + ), + + "AGY_MISSING": _t( + code="AGY_MISSING", severity=Severity.CRITICAL, eventlog_id=400, + date_scoped=False, + title="AI 요약 도구(agy)가 설치돼 있지 않습니다", + what="{run_date} {run_time} 실행에서 AI 요약 단계를 건너뛰었습니다. " + "Antigravity CLI(agy) 실행 파일을 찾지 못했습니다. 찾아본 경로: {agy_path}", + why="AI 요약은 매일 변경 내용을 자연어 브리핑으로 정리하는 부가 기능입니다. " + "도구가 설치되지 않았거나 경로가 바뀌었습니다.", + how="[지금 설치]를 누르면 공식 설치 스크립트를 자동으로 실행합니다(약 2분, 인터넷 필요). " + "설치 후 최초 1회 Google 계정 로그인이 필요하며 창이 자동으로 열립니다. " + "AI 요약이 필요 없다면 [AI 기능 끄기]를 눌러 이 알림을 영구히 멈출 수 있습니다.", + next_actions=("install_agy", "disable_agy", "open_report_file", "snooze"), + note="* 오늘 리포트는 AI 요약 없이 정상 생성됐습니다.", + ), + + "AGY_AUTH": _t( + code="AGY_AUTH", severity=Severity.CRITICAL, eventlog_id=400, + date_scoped=False, + title="AI 요약을 만들지 못했습니다 — 로그인 만료", + what="{run_date} {run_time} 실행에서 AI 요약 단계가 중단됐습니다. " + "{occurrences}일째 같은 상태입니다.", + why="Antigravity CLI(agy)의 Google 계정 인증이 만료되어 자동 갱신에 실패했습니다. " + "(agy 응답: {agy_error_short})", + how="[로그인 창 열기]를 누르면 검은 콘솔 창이 뜨고 브라우저가 열립니다. " + "Google 계정으로 로그인한 뒤 창을 닫으면 끝입니다(약 1분). " + "로그인이 끝나면 자동으로 확인해서 이 알림을 지웁니다.", + next_actions=("agy_relogin", "open_report_file", "open_log_dir", "snooze"), + note="* 오늘 리포트는 AI 요약 없이 정상 생성됐습니다. 급하지 않다면 나중에 해도 됩니다.", + ), + + "AGY_QUOTA": _t( + code="AGY_QUOTA", severity=Severity.WARN, eventlog_id=200, + title="AI 요약을 건너뛰었습니다 — 사용 한도 소진", + what="{run_date} {run_time} 실행에서 AI 요약을 만들지 못했습니다.", + why="Antigravity CLI의 모델 사용 한도를 다 썼습니다. " + "(오늘 사용 토큰 {tokens_used}, 설정 상한 {tokens_cap})", + how="아무것도 하지 않아도 됩니다. 내일 06:00에 자동으로 다시 시도합니다. " + "오늘 리포트의 '대시보드' 시트 상단에 'AI 요약 없음: 사용 한도'라고 표시했습니다. " + "자주 반복되면 config.toml 의 agy.daily_token_cap 을 조정하세요.", + next_actions=("open_report_file", "open_log_dir", "dismiss"), + ), + + "AGY_TIMEOUT": _t( + code="AGY_TIMEOUT", severity=Severity.INFO, eventlog_id=100, + title="AI 요약이 시간 안에 끝나지 않았습니다", + what="{run_date} {run_time} 실행에서 AI 요약이 {timeout} 안에 끝나지 않아 중단했습니다.", + why="모델 응답이 느렸거나 네트워크가 불안정했습니다.", + how="리포트는 정상 생성됐습니다. 내일 자동으로 다시 시도합니다.", + next_actions=("dismiss",), + cooldown_minutes=1440, + ), + + "REPORT_LOCKED": _t( + code="REPORT_LOCKED", severity=Severity.WARN, eventlog_id=200, + title="리포트를 다른 이름으로 저장했습니다", + what="{run_date} {run_time} 실행에서 오늘 리포트를 원래 파일 이름으로 저장하지 못하고 " + "'{fallback_name}'으로 저장했습니다.", + why="'{target_name}' 파일이 Excel에서 열려 있어 덮어쓸 수 없었습니다. " + "{retries}회 다시 시도했지만 계속 잠겨 있었습니다.", + how="Excel에서 해당 파일을 닫고 [리포트 다시 만들기]를 누르면 원래 이름으로 " + "정리됩니다(약 20초). 지금 당장은 [폴더 열기]로 그대로 열어 보셔도 됩니다.", + next_actions=("report_only", "open_report_dir", "dismiss"), + ), + + "REPORT_FAILED": _t( + code="REPORT_FAILED", severity=Severity.ERROR, eventlog_id=300, + title="오늘 리포트를 만들지 못했습니다", + what="{run_date} {run_time} 실행에서 Excel 리포트 생성 단계가 실패했습니다. " + "오늘 날짜의 리포트 파일이 없습니다.", + why="{failure_summary} 자료 수집과 비교는 정상적으로 끝났고 데이터베이스에 " + "저장돼 있습니다. 문제는 파일을 만드는 마지막 단계입니다.", + how="저장된 자료로 리포트만 다시 만들 수 있습니다. " + "[리포트 다시 만들기]를 누르세요(약 20초, 인터넷 불필요). " + "그래도 실패하면 [로그 열기]의 pipeline.log 마지막 30줄을 확인하세요.", + next_actions=("report_only", "open_log_dir", "open_doctor", "snooze"), + ), + + "DISK_LOW": _t( + code="DISK_LOW", severity=Severity.WARN, eventlog_id=200, + title="{drive} 드라이브 여유 공간이 부족합니다", + what="{run_date} {run_time} 실행에서 {drive} 드라이브 여유 공간이 " + "{free_gb}GB 남았습니다. (필요 최소 {min_gb}GB)", + why="공간이 부족하면 데이터베이스 백업이 실패하고, 더 줄어들면 리포트 저장도 " + "실패합니다. 오늘은 백업만 건너뛰었습니다.", + how="[정리하기]를 누르면 보존 기간이 지난 로그·원문·오래된 백업을 한 번에 지웁니다. " + "예상 확보량 약 {reclaim_mb}MB (약 10초). " + "그래도 부족하면 config.toml 의 backup.dir 을 다른 드라이브로 바꾸세요.", + next_actions=("cleanup_disk", "open_backup_dir", "open_log_dir", "dismiss"), + note="* 오늘 리포트는 정상 생성됐습니다.", + ), + + "DB_LOCKED": _t( + code="DB_LOCKED", severity=Severity.ERROR, eventlog_id=300, + title="데이터베이스가 잠겨 있어 저장하지 못했습니다", + what="{run_date} {run_time} 실행에서 수집한 자료를 저장하지 못했습니다. " + "{busy_timeout_s}초를 기다렸지만 데이터베이스가 계속 잠겨 있었습니다.", + why="다른 프로그램이 dmf.sqlite3 파일을 붙잡고 있습니다. " + "DB 브라우저 같은 도구를 열어 두었거나, 이전 실행이 아직 끝나지 않았을 수 있습니다.", + how="SQLite 관련 프로그램을 모두 닫고 [지금 다시 실행]을 누르세요(약 2분). " + "아무것도 열어 둔 것이 없다면 PC를 재시작한 뒤 다시 시도하세요.", + next_actions=("run_now", "open_db_dir", "open_log_dir", "snooze"), + ), + + "DB_CORRUPT": _t( + code="DB_CORRUPT", severity=Severity.CRITICAL, eventlog_id=400, + date_scoped=False, + title="데이터베이스 파일이 손상됐습니다", + what="{check_time} 점검에서 데이터베이스 무결성 검사가 실패했습니다. " + "배치를 시작하지 않고 중단했습니다. (검사 결과: {integrity_result})", + why="저장 중 강제 종료나 디스크 오류로 파일이 깨졌을 수 있습니다. " + "손상된 파일에 계속 쓰면 남은 자료까지 잃습니다.", + how="백업본이 {backup_count}개 있습니다. 가장 최근 것은 {latest_backup_date}입니다. " + "[백업으로 복원]을 누르면 목록에서 고를 수 있습니다(약 30초). " + "복원하면 그 날짜 이후 자료는 다시 수집해야 하며, 자동으로 채워집니다.", + next_actions=("restore_backup", "open_backup_dir", "open_doctor", "snooze"), + ), + + "MIGRATION_FAILED": _t( + code="MIGRATION_FAILED", severity=Severity.CRITICAL, eventlog_id=400, + date_scoped=False, + title="데이터베이스 업그레이드에 실패했습니다", + what="{check_time}에 데이터베이스 구조 업그레이드({from_version} → {to_version})가 " + "실패해 되돌렸습니다. 배치는 실행하지 않았습니다.", + why="{failure_summary} 변경은 트랜잭션으로 묶여 있어 자료는 손상되지 않았습니다.", + how="업그레이드 직전 백업본을 {pre_migration_backup} 에 만들어 두었습니다. " + "프로그램 버전을 이전으로 되돌리거나, 이 문구와 로그를 개발자에게 전달하세요.", + next_actions=("open_backup_dir", "open_log_dir", "open_doctor", "snooze"), + ), + + "WATCHDOG_STALE": _t( + code="WATCHDOG_STALE", severity=Severity.CRITICAL, eventlog_id=410, + date_scoped=False, + title="06:00 자동 실행이 되지 않았습니다", + what="지금 {now_time} 기준으로 오늘 06:00 배치가 실행된 흔적이 없습니다. " + "마지막으로 성공한 실행은 {last_success_at} ({stale_hours}시간 전)입니다.", + why="다음 중 하나입니다. " + "(1) PC가 06:00에 꺼져 있었고 아직 따라잡기가 실행되지 않음 " + "(2) 작업 스케줄러 항목이 꺼졌거나 삭제됨 " + "(3) 실행이 시작됐지만 제한 시간({exec_limit_min}분)을 넘겨 강제 종료됨", + how="[지금 실행]을 누르면 즉시 오늘 자료를 수집합니다(약 2분). " + "반복된다면 [작업 상태 확인]으로 스케줄러 등록 상태를 점검하세요.", + next_actions=("run_now", "open_task_scheduler", "open_log_dir", "snooze"), + ), + + "TASK_MISSING": _t( + code="TASK_MISSING", severity=Severity.CRITICAL, eventlog_id=400, + date_scoped=False, + title="자동 실행 등록이 사라졌습니다", + what="{check_time} 점검에서 Windows 작업 스케줄러 항목 {missing_tasks_str}을(를) " + "찾지 못했습니다.", + why="시스템 정리 도구나 다른 프로그램이 지웠거나, 사용자가 직접 비활성화했을 수 " + "있습니다. 등록이 없으면 매일 06:00 자동 실행이 되지 않습니다.", + how="의도적으로 끈 것이 아니라면 [자동 실행 다시 등록]을 누르세요(약 10초). " + "같은 이름의 작업 3개를 다시 만듭니다. 기존 설정은 그대로 유지됩니다.", + next_actions=("reregister_tasks", "open_task_scheduler", "open_doctor", "snooze"), + note="* 자동으로 다시 등록하지 않는 이유: 사용자가 일부러 끈 것을 되살리면 안 되기 때문입니다.", + ), + + "CONSECUTIVE_FAILURES": _t( + code="CONSECUTIVE_FAILURES", severity=Severity.CRITICAL, eventlog_id=420, + date_scoped=False, + title="{failed_days}일 연속으로 실패하고 있습니다", + what="{first_failed_date}부터 {last_failed_date}까지 {failed_days}일 연속 배치가 " + "실패했습니다. 그 기간 리포트가 만들어지지 않았습니다.", + why="가장 많이 나온 원인 3가지입니다. " + "(1) {top_cause_1} ({top_cause_1_count}회) " + "(2) {top_cause_2} ({top_cause_2_count}회) " + "(3) {top_cause_3} ({top_cause_3_count}회)", + how="[전체 진단]을 누르면 전제조건 12가지를 한 번에 점검하고 " + "문제 항목마다 해결 버튼을 보여 줍니다(약 15초). " + "대부분 인증키 또는 로그인 만료입니다.", + next_actions=("open_doctor", "run_now", "open_log_dir", "snooze"), + note="* {pause_day}일째에도 해결되지 않으면 자동 실행을 잠시 멈추고 다시 안내합니다.", + ), + + "RUN_PAUSED": _t( + code="RUN_PAUSED", severity=Severity.CRITICAL, eventlog_id=430, + date_scoped=False, + title="자동 실행을 잠시 멈췄습니다", + what="{failed_days}일 연속 실패해 {pause_at}부터 매일 06:00 자동 실행을 멈췄습니다.", + why="고쳐지지 않은 상태로 매일 같은 실패를 반복하면 공공데이터포털에 " + "불필요한 호출이 계속 쌓이기 때문입니다. " + "멈춤은 작업 스케줄러를 지우는 것이 아니라 플래그 파일 하나로 이뤄집니다.", + how="[전체 진단]으로 원인을 먼저 해결한 뒤 [자동 실행 재개]를 누르세요. " + "재개 버튼은 진단이 모두 통과해야 활성화됩니다.", + next_actions=("open_doctor", "resume_schedule", "open_log_dir", "snooze"), + ), + + "NOTIFY_DEGRADED": _t( + code="NOTIFY_DEGRADED", severity=Severity.WARN, eventlog_id=510, + title="알림 표시 방식이 바뀌었습니다", + what="{now_time}에 알림을 {from_channel} 방식으로 띄우지 못했습니다.", + why="{reason}", + how="{to_channel} 방식으로 대신 표시했습니다. 반복되면 [진단 실행]을 눌러 확인하세요.", + next_actions=("open_doctor", "dismiss"), + ), + + "NOTIFY_SUPPRESSED": _t( + code="NOTIFY_SUPPRESSED", severity=Severity.INFO, eventlog_id=520, + title="오늘 알림 표시를 멈췄습니다", + what="{now_time} 기준 오늘 알림 표시가 상한 {cap}건에 도달했습니다.", + why="같은 문제가 반복돼 알림이 지나치게 많이 뜨는 것을 막기 위한 조치입니다.", + how="남은 {pending_n}건은 [전체 진단] 화면에서 한꺼번에 확인할 수 있습니다.", + next_actions=("open_doctor", "dismiss"), + cooldown_minutes=1440, + ), +} + + +# ---------------------------------------------------- 임포트 시점 계약 검증 +def _validate_registry() -> None: + for code, tpl in TEMPLATES.items(): + assert tpl.code == code, f"{code}: code 필드 불일치" + for field_name in ("title", "what", "why", "how"): + value = getattr(tpl, field_name) + assert value and value.strip(), f"{code}: {field_name} 가 비었다 (요구 R7.3)" + assert tpl.next_actions, f"{code}: next_actions 가 비었다 (요구 R7.7)" + unknown = set(tpl.next_actions) - VALID_ACTIONS + assert not unknown, f"{code}: 알 수 없는 액션 키 {sorted(unknown)}" + if tpl.severity.at_least(Severity.WARN): + actionable = set(tpl.next_actions) - _TERMINAL_ONLY + assert actionable, ( + f"{code}: 실패 알림에 실행 가능한 액션이 없다 — 막다른 골목 금지(R7.7)" + ) + assert len(tpl.title) <= 60, f"{code}: title 이 너무 길다 ({len(tpl.title)}자)" + + +_validate_registry() + + +def render(code: str, ctx: Mapping[str, Any]) -> RenderedAlert: + """알림 코드와 컨텍스트로 최종 문구를 만든다. 미등록 코드도 죽지 않는다.""" + tpl = TEMPLATES.get(code) + if tpl is None: + return RenderedAlert( + code=code, severity=Severity.ERROR, + title=f"알 수 없는 오류가 기록됐습니다 ({code})", + what=f"{code} 오류가 발생했습니다.", + why="이 오류에 대한 안내 문구가 아직 등록되지 않았습니다.", + how="[로그 열기]로 pipeline.log 를 확인하세요.", + body=(f"[무엇] {code} 오류가 발생했습니다.\n" + f"[왜] 이 오류에 대한 안내 문구가 아직 등록되지 않았습니다.\n" + f"[어떻게] [로그 열기]로 pipeline.log 를 확인하세요.\n" + f"로그: {ctx.get('log_dir', '(정보 없음)')}"), + next_actions=("open_log_dir", "open_doctor", "dismiss"), + ) + return tpl.render(ctx) +``` + +--- + +## 4. 버튼 액션 구현 + +### 4.1 액션 키 레지스트리 + +토스트 버튼과 복구 GUI 버튼은 **같은 레지스트리를 호출한다.** 액션은 `src/dmf_crawler/gui/steps.py` 에 산다(아키텍처 트리: "단계별 액션: 키 입력·발급페이지 열기·agy 설치·재로그인·작업 등록"). + +| 액션 키 | 라벨 | 하는 일 | 창을 닫는가 | 성공 검증 | +|---|---|---|---|---| +| `run_now` | 지금 다시 실행 | `dmf run --trigger manual` 을 자식 프로세스로 기동, 진행 창 표시 | 아니오(진행률로 전환) | 종료 코드 0 | +| `run_now_force` | 지금 다시 실행 (검증 우회) | `dmf run --trigger manual --force` | 아니오 | 종료 코드 0 | +| `report_only` | 리포트 다시 만들기 | `dmf report-only` | 아니오 | 리포트 파일 mtime 갱신 | +| `open_log_dir` | 로그 열기 | `explorer.exe ` | 아니오 | — | +| `open_report_dir` | 폴더 열기 | `explorer.exe ` | 아니오 | — | +| `open_report_file` | 리포트 열기 | `os.startfile(report_path)` | 예 | — | +| `open_raw_dir` | 원문 폴더 열기 | `explorer.exe ` | 아니오 | — | +| `open_backup_dir` | 백업 폴더 열기 | `explorer.exe ` | 아니오 | — | +| `open_db_dir` | DB 폴더 열기 | `explorer.exe /select,` | 아니오 | — | +| `open_proposal` | AI 진단 결과 열기 | `os.startfile(proposal_path)`, 없으면 폴더 | 아니오 | — | +| `enter_api_key` | 인증키 입력 | 키 입력 다이얼로그 → 실호출 검증 → DPAPI 저장 | 아니오 | `checks.run_one("api_key_live")` | +| `open_api_portal` | 발급 페이지 열기 | `webbrowser.open(공공데이터포털 URL)` | 아니오 | — | +| `agy_relogin` | 로그인 창 열기 | 새 콘솔 창에 대화형 `agy` 기동 (§5) | 아니오 | `checks.run_one("agy_auth")` | +| `install_agy` | 지금 설치 | `scripts/bootstrap_agy.ps1` 무인 실행 + 진행률 | 아니오 | `agy --version` 성공 | +| `disable_agy` | AI 기능 끄기 | `config.local.toml` 에 `[agy] enabled = false` 기록 | 예 | 설정 재로드 | +| `reregister_tasks` | 자동 실행 다시 등록 | `dmf install-task` | 아니오 | `checks.run_one("tasks")` | +| `open_task_scheduler` | 작업 스케줄러 열기 | `mmc.exe taskschd.msc` | 아니오 | — | +| `restore_backup` | 백업으로 복원 | 백업 목록 다이얼로그 → 현재 DB 대피 → 복사 | 아니오 | `PRAGMA integrity_check` | +| `cleanup_disk` | 정리하기 | 보존 기간 지난 로그·원문·백업 삭제 후 확보량 표시 | 아니오 | 여유 공간 재측정 | +| `open_doctor` | 전체 진단 / 진단 실행 | `gui.app.launch(mode="inspect")` | 아니오 | — | +| `resume_schedule` | 자동 실행 재개 | `state/paused.flag` 삭제. **진단 전부 통과해야 활성화** | 예 | 플래그 부재 | +| `snooze` | 나중에 | `alerts.snooze(alert_id, minutes)` | 예 | — | +| `dismiss` | 닫기 | `alerts.mark_shown(alert_id)` 만 하고 닫음 | 예 | — | + +**설계 규칙 3개** + +1. **모든 액션은 예외를 던지지 않는다.** `ActionResult(ok, message)` 를 반환한다. 액션이 죽어서 복구 창이 사라지는 것은 최악이다. +2. **성공 검증이 있는 액션은 검증을 통과해야만 알림을 해소한다**(R-D4). 버튼을 눌렀다는 사실만으로는 해소하지 않는다. +3. **모든 액션은 `logs/agent/actions.jsonl` 에 기록된다.** 사용자가 무엇을 눌렀고 결과가 무엇이었는지 사후 추적이 가능해야 "고쳤는데 또 뜬다"를 진단할 수 있다. + +### 4.2 `gui/steps.py` 전문 + +```python +# src/dmf_crawler/gui/steps.py +"""알림·복구 화면의 버튼 액션 구현. + +계약: + - 모든 액션은 ActionResult 를 반환하며 예외를 밖으로 던지지 않는다. + - 액션 키는 notify.messages.VALID_ACTIONS 와 1:1 로 일치한다. + - 액션 실행은 전부 logs/agent/actions.jsonl 에 기록된다. +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import webbrowser +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Callable, Mapping + +from dmf_crawler import paths +from dmf_crawler.config import Config + +# 공공데이터포털 — 인증키 발급/조회 +API_PORTAL_URL = "https://www.data.go.kr/iim/api/selectAPIAcountView.do" +# agy 공식 설치 스크립트 (docs/research/05a-agy-cli-ssot.md §3.2) +AGY_INSTALL_URL = "https://antigravity.google/cli/install.ps1" + +# 콘솔 창을 만들지 않는다. pythonw 로 떠 있는 GUI 에서 검은 창이 번쩍이면 안 된다. +CREATE_NO_WINDOW = 0x08000000 +# 새 콘솔 창을 "보이게" 만든다. agy 재로그인에서만 쓴다. +CREATE_NEW_CONSOLE = 0x00000010 + + +@dataclass(frozen=True, slots=True) +class ActionResult: + ok: bool + message: str + close_window: bool = False + verify_check_key: str | None = None # 성공 검증에 쓸 checks 키 + + +# ------------------------------------------------------------------ 유틸 +def _venv_python(windowed: bool = False) -> Path: + """현재 프로젝트 .venv 의 인터프리터. windowed=True 면 pythonw.""" + exe = "pythonw.exe" if windowed else "python.exe" + candidate = paths.PROJECT_ROOT / ".venv" / "Scripts" / exe + if candidate.exists(): + return candidate + return Path(sys.executable) + + +def _explorer(target: Path, select: bool = False) -> ActionResult: + if not target.exists(): + parent = target.parent + if not parent.exists(): + return ActionResult(False, f"경로를 찾을 수 없습니다: {target}") + target, select = parent, False + args = ["explorer.exe"] + args.append(f"/select,{target}" if select else str(target)) + # explorer.exe 는 성공해도 종료 코드 1 을 반환하는 일이 잦다. 코드를 보지 않는다. + subprocess.Popen(args, creationflags=CREATE_NO_WINDOW) + return ActionResult(True, f"탐색기를 열었습니다: {target}") + + +def _spawn_cli(cfg: Config, argv: list[str], *, new_console: bool = False, + env_extra: Mapping[str, str] | None = None) -> subprocess.Popen: + env = os.environ.copy() + # 배치 중 agy 자동 업데이트가 끼어드는 것을 막는다 (agy SSOT §3.4) + env["AGY_CLI_DISABLE_AUTO_UPDATE"] = "true" + if env_extra: + env.update(env_extra) + flags = CREATE_NEW_CONSOLE if new_console else CREATE_NO_WINDOW + py = _venv_python(windowed=not new_console) + return subprocess.Popen( + [str(py), "-m", "dmf_crawler", *argv], + cwd=str(paths.PROJECT_ROOT), + env=env, + creationflags=flags, + ) + + +def _log_action(key: str, result: ActionResult, ctx: Mapping[str, Any]) -> None: + """액션 감사 로그. 실패해도 조용히 넘어간다.""" + try: + log_dir = paths.LOGS_DIR / "agent" + log_dir.mkdir(parents=True, exist_ok=True) + record = { + "at": datetime.now().astimezone().isoformat(timespec="seconds"), + "action": key, + "ok": result.ok, + "message": result.message, + "alert_code": ctx.get("code"), + "alert_id": ctx.get("alert_id"), + } + with (log_dir / "actions.jsonl").open("a", encoding="utf-8") as fh: + fh.write(json.dumps(record, ensure_ascii=False) + "\n") + except Exception: + pass + + +# ------------------------------------------------------------ 액션 구현부 +def act_run_now(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + _spawn_cli(cfg, ["run", "--trigger", "manual"]) + return ActionResult(True, "수집을 시작했습니다. 약 2분 걸립니다.", + verify_check_key="last_run") + + +def act_run_now_force(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + _spawn_cli(cfg, ["run", "--trigger", "manual", "--force"]) + return ActionResult(True, "검증을 우회해 수집을 시작했습니다.", + verify_check_key="last_run") + + +def act_report_only(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + _spawn_cli(cfg, ["report-only"]) + return ActionResult(True, "리포트를 다시 만들고 있습니다. 약 20초 걸립니다.", + verify_check_key="report_writable") + + +def act_open_log_dir(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + raw = ctx.get("log_dir") + target = Path(raw) if raw else paths.LOGS_DIR + return _explorer(target) + + +def act_open_report_dir(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + return _explorer(paths.reports_dir(cfg)) + + +def act_open_report_file(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + raw = ctx.get("report_path") + target = Path(raw) if raw else paths.latest_report_path(cfg) + if not target.exists(): + return _explorer(paths.reports_dir(cfg)) + try: + os.startfile(str(target)) # noqa: S606 — Windows 전용, 의도된 호출 + except OSError as exc: + return ActionResult(False, f"리포트를 열지 못했습니다: {exc}") + return ActionResult(True, "리포트를 열었습니다.", close_window=True) + + +def act_open_raw_dir(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + raw = ctx.get("raw_dir") + target = Path(raw) if raw else paths.RAW_DIR + return _explorer(target) + + +def act_open_backup_dir(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + return _explorer(paths.backup_dir(cfg)) + + +def act_open_db_dir(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + return _explorer(paths.db_path(cfg), select=True) + + +def act_open_proposal(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + raw = ctx.get("proposal_path") + if raw and Path(raw).exists(): + try: + os.startfile(raw) # noqa: S606 + return ActionResult(True, "AI 진단 결과를 열었습니다.") + except OSError as exc: + return ActionResult(False, f"파일을 열지 못했습니다: {exc}") + return _explorer(paths.PROPOSALS_DIR) + + +def act_open_api_portal(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + webbrowser.open(API_PORTAL_URL) + return ActionResult(True, "브라우저에서 공공데이터포털을 열었습니다.") + + +def act_open_task_scheduler(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + subprocess.Popen(["mmc.exe", "taskschd.msc"], creationflags=CREATE_NO_WINDOW) + return ActionResult(True, "작업 스케줄러를 열었습니다. " + "'작업 스케줄러 라이브러리'에서 DMF_Crawler 로 시작하는 " + "항목 3개를 확인하세요.") + + +def act_reregister_tasks(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + proc = _spawn_cli(cfg, ["install-task"]) + try: + rc = proc.wait(timeout=60) + except subprocess.TimeoutExpired: + return ActionResult(False, "등록이 60초 안에 끝나지 않았습니다. " + "관리자 권한이 필요할 수 있습니다.") + if rc != 0: + return ActionResult(False, f"등록에 실패했습니다(코드 {rc}). " + "이 창을 관리자 권한으로 다시 실행해 보세요.") + return ActionResult(True, "자동 실행 작업 3개를 다시 등록했습니다.", + verify_check_key="tasks") + + +def act_disable_agy(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + local = paths.CONFIG_DIR / "config.local.toml" + try: + existing = local.read_text(encoding="utf-8") if local.exists() else "" + if "[agy]" in existing: + lines = [] + in_agy = False + wrote = False + for line in existing.splitlines(): + stripped = line.strip() + if stripped.startswith("["): + if in_agy and not wrote: + lines.append("enabled = false") + wrote = True + in_agy = stripped == "[agy]" + if in_agy and stripped.startswith("enabled"): + lines.append("enabled = false") + wrote = True + continue + lines.append(line) + if in_agy and not wrote: + lines.append("enabled = false") + new_text = "\n".join(lines) + "\n" + else: + new_text = existing.rstrip() + "\n\n[agy]\nenabled = false\n" + tmp = local.with_suffix(".toml.tmp") + tmp.write_text(new_text, encoding="utf-8") + os.replace(tmp, local) + except OSError as exc: + return ActionResult(False, f"설정을 저장하지 못했습니다: {exc}") + return ActionResult(True, "AI 요약 기능을 껐습니다. 리포트는 계속 생성됩니다.", + close_window=True) + + +def act_cleanup_disk(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + freed = 0 + now = datetime.now() + + def _prune(root: Path, days: int) -> None: + nonlocal freed + if not root.exists(): + return + cutoff = now - timedelta(days=days) + for child in sorted(root.iterdir()): + try: + mtime = datetime.fromtimestamp(child.stat().st_mtime) + if mtime >= cutoff: + continue + size = sum(f.stat().st_size for f in child.rglob("*") if f.is_file()) \ + if child.is_dir() else child.stat().st_size + if child.is_dir(): + shutil.rmtree(child, ignore_errors=True) + else: + child.unlink(missing_ok=True) + freed += size + except OSError: + continue + + _prune(paths.LOGS_DIR, cfg.logging.retain_days) + _prune(paths.RAW_DIR, cfg.source.archive_retain_days) + + # 백업은 날짜가 아니라 개수 기준(보존 정책과 동일) + backups = sorted(paths.backup_dir(cfg).glob("dmf_*.sqlite3")) + for old in backups[:-cfg.backup.keep_count] if len(backups) > cfg.backup.keep_count else []: + try: + freed += old.stat().st_size + old.unlink() + except OSError: + continue + + mb = freed / (1024 * 1024) + return ActionResult(True, f"{mb:,.0f}MB 를 정리했습니다.", + verify_check_key="disk_free") + + +def act_snooze(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + minutes = int(ctx.get("snooze_minutes", cfg.notify.snooze_minutes)) + return ActionResult(True, f"{minutes}분 뒤에 다시 알려드립니다.", close_window=True) + + +def act_dismiss(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + return ActionResult(True, "닫았습니다.", close_window=True) + + +# --- 아래 4개는 별도 절에서 상세히 다룬다 ------------------------------- +# act_enter_api_key : 온보딩 마법사 명세(docs/design/04-onboarding-wizard.md) +# act_restore_backup : 백업 복원 마법사(같은 문서) +# act_open_doctor : gui.app.launch(mode="inspect") +# act_install_agy : §5.4 +# act_agy_relogin : §5.2 <- 이 문서의 핵심 +def act_open_doctor(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + from dmf_crawler.gui import app as gui_app + gui_app.launch(mode="inspect") + return ActionResult(True, "진단 화면을 열었습니다.") + + +def act_resume_schedule(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + from dmf_crawler import checks + failing = [c for c in checks.run_all(cfg) if not c.ok] + if failing: + names = ", ".join(c.title for c in failing[:3]) + return ActionResult(False, + f"아직 해결되지 않은 항목이 있습니다: {names}. " + f"먼저 [전체 진단]에서 해결하세요.") + try: + paths.PAUSE_FLAG.unlink(missing_ok=True) + except OSError as exc: + return ActionResult(False, f"멈춤 해제에 실패했습니다: {exc}") + return ActionResult(True, "자동 실행을 재개했습니다. 내일 06:00부터 정상 동작합니다.", + close_window=True) + + +# ------------------------------------------------------------- 레지스트리 +ACTIONS: dict[str, tuple[str, Callable[[Config, Mapping[str, Any]], ActionResult]]] = { + "run_now": ("지금 다시 실행", act_run_now), + "run_now_force": ("지금 다시 실행 (검증 우회)", act_run_now_force), + "report_only": ("리포트 다시 만들기", act_report_only), + "open_log_dir": ("로그 열기", act_open_log_dir), + "open_report_dir": ("폴더 열기", act_open_report_dir), + "open_report_file": ("리포트 열기", act_open_report_file), + "open_raw_dir": ("원문 폴더 열기", act_open_raw_dir), + "open_backup_dir": ("백업 폴더 열기", act_open_backup_dir), + "open_db_dir": ("DB 폴더 열기", act_open_db_dir), + "open_proposal": ("AI 진단 결과 열기", act_open_proposal), + "open_api_portal": ("발급 페이지 열기", act_open_api_portal), + "open_task_scheduler": ("작업 스케줄러 열기", act_open_task_scheduler), + "reregister_tasks": ("자동 실행 다시 등록", act_reregister_tasks), + "disable_agy": ("AI 기능 끄기", act_disable_agy), + "cleanup_disk": ("정리하기", act_cleanup_disk), + "open_doctor": ("전체 진단", act_open_doctor), + "resume_schedule": ("자동 실행 재개", act_resume_schedule), + "snooze": ("나중에", act_snooze), + "dismiss": ("닫기", act_dismiss), + # §5 에서 정의 + "agy_relogin": ("로그인 창 열기", None), # noqa: 아래에서 채움 + "install_agy": ("지금 설치", None), + # 온보딩 마법사 문서에서 정의 + "enter_api_key": ("인증키 입력", None), + "restore_backup": ("백업으로 복원", None), +} + + +def label_of(key: str) -> str: + entry = ACTIONS.get(key) + return entry[0] if entry else key + + +def invoke(key: str, cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + """액션 실행 단일 진입점. 절대 예외를 던지지 않는다.""" + entry = ACTIONS.get(key) + if entry is None or entry[1] is None: + result = ActionResult(False, f"'{key}' 동작이 아직 구현되지 않았습니다.") + _log_action(key, result, ctx) + return result + try: + result = entry[1](cfg, ctx) + except Exception as exc: # noqa: BLE001 — 액션은 절대 창을 죽이면 안 된다 + result = ActionResult(False, f"동작 중 문제가 생겼습니다: {exc}") + _log_action(key, result, ctx) + return result +``` + +### 4.3 성공 검증과 알림 해소의 연결 + +버튼을 누른 뒤 **검증 키가 있는 액션**은 다음 흐름을 탄다. + +```python +# gui/app.py 안 (발췌) +def on_action_clicked(self, alert: AlertRow, action_key: str) -> None: + result = steps.invoke(action_key, self.cfg, alert.as_context()) + self.status_bar.set(result.message, ok=result.ok) + + if result.ok and result.verify_check_key: + # 즉시 검증하지 않고, 최대 120초 동안 2초 간격으로 폴링한다. + # (run_now 처럼 자식 프로세스가 끝나야 결과가 나오는 액션이 있다) + self.after(2000, lambda: self._poll_verify(alert, result.verify_check_key, 0)) + + if result.close_window: + if action_key == "snooze": + alerts.snooze(self.conn, alert.alert_id, + minutes=self.cfg.notify.snooze_minutes) + else: + alerts.mark_shown(self.conn, alert.alert_id, channel="modal") + self.destroy() + + +def _poll_verify(self, alert: AlertRow, check_key: str, elapsed_s: int) -> None: + outcome = checks.run_one(check_key, self.cfg) + if outcome.ok: + alerts.resolve(self.conn, alert.code, + note=f"사용자 조치 후 {check_key} 통과") + self.status_bar.set("해결됐습니다. 이 알림을 닫습니다.", ok=True) + self.after(1500, self.destroy) + return + if elapsed_s >= 120: + self.status_bar.set( + f"아직 해결되지 않았습니다: {outcome.detail}", ok=False) + return + self.after(2000, lambda: self._poll_verify(alert, check_key, elapsed_s + 2)) +``` + +> **왜 폴링인가**: `run_now` 는 자식 프로세스를 띄우고 즉시 반환한다. 버튼을 누른 직후 검증하면 항상 실패한다. 사용자가 "고쳤는데 왜 안 없어지지"라고 느끼는 지점이 정확히 여기다. 최대 120초 폴링이 이 간극을 메운다. + +### 4.4 프로토콜 핸들러 (`dmf://`) — 지금은 불필요, 등록 방법은 완비 + +**결론부터: 기본 경로에서는 등록하지 않는다.** 우리 토스트는 tkinter 창이고, 버튼은 같은 프로세스 안에서 `steps.invoke()` 를 직접 호출한다. OS 를 경유할 이유가 없다. + +**필요해지는 경우**는 두 가지다. + +1. 진짜 Windows 토스트(액션 센터 잔류)를 선택 의존성으로 켜는 날. XML 토스트의 `` 는 프로토콜 등록 없이는 동작하지 않는다. +2. 리포트 xlsx 안에서 하이퍼링크로 액션을 걸고 싶을 때(예: 대시보드 시트의 "지금 다시 실행" 링크). + +**등록 방법 — HKCU 만 쓴다(관리자 권한 불필요).** + +```powershell +# scripts/register_protocol.ps1 +# dmf:// 프로토콜 핸들러를 현재 사용자에게만 등록한다. 관리자 권한이 필요 없다. +[CmdletBinding()] +param( + [string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot), + [switch]$Unregister +) + +$ErrorActionPreference = 'Stop' +$key = 'HKCU:\Software\Classes\dmf' + +if ($Unregister) { + if (Test-Path $key) { Remove-Item $key -Recurse -Force } + Write-Host 'dmf:// 프로토콜 등록을 해제했습니다.' + return +} + +$pythonw = Join-Path $ProjectRoot '.venv\Scripts\pythonw.exe' +if (-not (Test-Path $pythonw)) { throw "pythonw.exe 를 찾을 수 없습니다: $pythonw" } + +# "URL Protocol" 이라는 (값이 빈) 값 이름이 있어야 셸이 프로토콜로 인식한다. +New-Item -Path $key -Force | Out-Null +Set-ItemProperty -Path $key -Name '(default)' -Value 'URL:DMF Crawler Protocol' +Set-ItemProperty -Path $key -Name 'URL Protocol' -Value '' + +New-Item -Path "$key\DefaultIcon" -Force | Out-Null +Set-ItemProperty -Path "$key\DefaultIcon" -Name '(default)' -Value "$pythonw,0" + +New-Item -Path "$key\shell\open\command" -Force | Out-Null +# %1 은 전체 URL(dmf://run_now?alert_id=42)이 통째로 넘어온다. 반드시 큰따옴표로 감싼다. +$cmd = "`"$pythonw`" -m dmf_crawler handle-uri `"%1`"" +Set-ItemProperty -Path "$key\shell\open\command" -Name '(default)' -Value $cmd + +Write-Host "dmf:// 프로토콜을 등록했습니다." +Write-Host "테스트: Win+R 에 다음을 입력하세요 → dmf://open_doctor" +``` + +**동등한 .reg 파일** (수동 배포용) + +```reg +Windows Registry Editor Version 5.00 + +[HKEY_CURRENT_USER\Software\Classes\dmf] +@="URL:DMF Crawler Protocol" +"URL Protocol"="" + +[HKEY_CURRENT_USER\Software\Classes\dmf\DefaultIcon] +@="D:\\workspace\\DMF_Crawler\\.venv\\Scripts\\pythonw.exe,0" + +[HKEY_CURRENT_USER\Software\Classes\dmf\shell\open\command] +@="\"D:\\workspace\\DMF_Crawler\\.venv\\Scripts\\pythonw.exe\" -m dmf_crawler handle-uri \"%1\"" +``` + +**수신측 — `dmf handle-uri` 서브커맨드** + +URI 는 **외부에서 들어오는 신뢰할 수 없는 입력**이다. 화이트리스트 검증 없이 실행하면 임의 명령 실행 취약점이 된다. + +```python +# src/dmf_crawler/cli.py (발췌) — handle-uri 구현 +from urllib.parse import urlparse, parse_qs + +def cmd_handle_uri(args) -> int: + """dmf://?alert_id= 형태만 허용한다.""" + from dmf_crawler.gui import steps + from dmf_crawler.config import load_config + from dmf_crawler.notify import messages + + parsed = urlparse(args.uri) + if parsed.scheme != "dmf": + return 2 + # netloc 에 액션 키가 온다: dmf://run_now -> netloc == "run_now" + action_key = (parsed.netloc or parsed.path.lstrip("/")).strip().lower() + + # 화이트리스트 검증. 등록되지 않은 키는 무조건 거부한다. + if action_key not in messages.VALID_ACTIONS: + return 2 + + qs = parse_qs(parsed.query) + ctx: dict[str, object] = {} + raw_id = qs.get("alert_id", [""])[0] + if raw_id.isdigit(): # 숫자만 허용 + ctx["alert_id"] = int(raw_id) + + cfg = load_config() + result = steps.invoke(action_key, cfg, ctx) + return 0 if result.ok else 1 +``` + +| 검증 항목 | 규칙 | +|---|---| +| scheme | `dmf` 가 아니면 즉시 거부 | +| 액션 키 | `VALID_ACTIONS` 화이트리스트에 없으면 거부. **경로·명령 문자열을 URI 에서 받지 않는다** | +| `alert_id` | 숫자만. 그 외 쿼리 파라미터는 전부 버린다 | +| 파일 경로 | **URI 로 절대 받지 않는다.** 경로는 항상 `paths.py` 와 DB 에서만 온다 | + +--- + +## 5. agy 재로그인 유도 + +### 5.1 문제 정의와 Session 0 제약 + +| 사실 | 출처 | 함의 | +|---|---|---| +| `agy` OAuth 토큰은 `~/.gemini/antigravity-cli/antigravity-oauth-token` **평문 파일** | agy SSOT §4.2 실측 | 작업 스케줄러를 **동일 사용자 계정**으로 돌리면 인증이 통과한다. SYSTEM 계정 금지 | +| Windows Credential Manager 에는 항목이 없다 | agy SSOT §4.2 실측 (`cmdkey /list` 무결과) | S4U(암호 미저장)로 실행해도 키링 잠금 문제가 없다. **다만 사용자 프로필이 로드돼야 한다** | +| `access_token` 은 만료된다 | agy SSOT §4.2 | refresh 실패 시 배치가 인증 오류로 죽는다. **미인증 감지 → 사용자 알림 경로가 필수** | +| 최초 1회는 대화형 로그인이 필수 | agy SSOT §0 | 헤드리스는 캐시된 자격증명만 쓴다. 로그인은 브라우저 OAuth 를 요구한다 | +| 로그인 흐름은 **기본 브라우저를 연다** | agy SSOT §4.1 | 데스크톱이 없는 세션에서는 브라우저가 뜨지 않는다 | + +**Session 0 제약이 만드는 막힘** + +``` +06:00 DMF_Crawler_Daily (S4U, 비대화형) + -> agy -p ... 실행 + -> 토큰 만료, refresh 실패 + -> agy 가 브라우저를 열려고 시도 + -> S4U 세션에는 데스크톱이 없다 -> 아무 창도 안 뜬다 + -> agy 가 stderr 로 인증 프롬프트를 출력하지만 읽을 사람이 없다 + -> --print-timeout 까지 대기하다 타임아웃 + ===> 배치가 침묵 속에 매일 실패한다 +``` + +**우회 = 구조적 분리 (ADR-10/11)** + +``` +[배치 S4U] agy 실패 -> classify_error == AUTH + -> AgyEnvelope 를 값으로 반환 (예외 없음) + -> alerts.raise_alert(code="AGY_AUTH", severity=CRITICAL) + -> 리포트는 AI 요약 없이 정상 생성, 종료 코드 0 + | + | (최대 15분) + v +[Agent Interactive] pending() 에서 AGY_AUTH 발견 + -> 강제 모달 표시, [로그인 창 열기] 버튼 + | + | 사용자 클릭 + v +[새 콘솔 창] CREATE_NEW_CONSOLE 로 agy 대화형 기동 + -> agy 가 기본 브라우저를 연다 (여기는 데스크톱이 있다) + -> 사용자가 Google 로그인 + -> 토큰 파일 갱신 + | + v +[Agent] 토큰 파일 mtime 변화 감지 -> 헬스 프롬프트 1회로 실증 + -> 성공하면 alerts.resolve("AGY_AUTH") +``` + +**절대 하지 말 것** + +| 금지 | 이유 | +|---|---| +| 배치(S4U)에서 `CREATE_NEW_CONSOLE` 로 agy 를 띄우기 | Session 0 에 콘솔이 만들어지고 아무도 못 본다. 프로세스만 영원히 남는다 | +| `psexec -i 1` 등으로 세션 주입 | 관리자 권한·보안 소프트웨어 충돌. 로그온 세션이 없으면 여전히 실패 | +| 토큰 파일을 코드가 직접 갱신 | 리프레시 프로토콜을 재구현하는 것. agy 가 바뀌면 즉시 깨진다 | +| `GEMINI_API_KEY` 로 전환해 로그인 자체를 없애기 | 가능은 하다(agy SSOT §4.1). 그러나 **다른 과금 체계**로 넘어가는 결정이므로 알림 설계가 임의로 할 수 없다. 부록에 미결로 남긴다 | + +### 5.2 `act_agy_relogin` 전문 + +```python +# src/dmf_crawler/gui/steps.py (이어서) +"""agy 재로그인 — 새 콘솔 창을 띄우는 유일한 액션.""" +from __future__ import annotations + +import subprocess +import threading +import time +from pathlib import Path +from typing import Any, Mapping + +AGY_TOKEN_PATH = Path.home() / ".gemini" / "antigravity-cli" / "antigravity-oauth-token" +AGY_DEFAULT_EXE = Path(os.environ.get("LOCALAPPDATA", "")) / "agy" / "bin" / "agy.exe" + +# 사용자가 콘솔 창에서 무엇을 해야 하는지 안내하는 배너. +# agy 자체는 한국어 안내를 하지 않으므로 우리가 감싼다. +_RELOGIN_BANNER = r""" +@echo off +chcp 65001 > nul +title DMF 크롤러 - Antigravity CLI 로그인 +echo. +echo ============================================================ +echo Antigravity CLI (agy) 로그인 +echo ============================================================ +echo. +echo 1. 잠시 후 기본 브라우저가 자동으로 열립니다. +echo 2. Google 계정으로 로그인하세요. +echo 3. 브라우저에 "로그인 완료" 가 뜨면 이 창으로 돌아오세요. +echo 4. 이 창에 프롬프트가 보이면 /quit 를 입력하고 Enter 를 누르세요. +echo. +echo * 브라우저가 열리지 않으면 이 창에 표시되는 URL 을 복사해 +echo 브라우저 주소창에 붙여넣으세요. +echo. +echo ============================================================ +echo. +"%AGY_EXE%" +echo. +echo ============================================================ +echo 로그인 절차가 끝났습니다. 이 창은 닫아도 됩니다. +echo DMF 크롤러 창으로 돌아가면 자동으로 확인합니다. +echo ============================================================ +echo. +pause +""" + + +def _agy_exe(cfg: Config) -> Path: + configured = (cfg.agy.binary_path or "").strip() + if configured: + return Path(configured) + return AGY_DEFAULT_EXE + + +def act_agy_relogin(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + """새 콘솔 창에서 대화형 agy 를 띄운다. + + 핵심: + - CREATE_NEW_CONSOLE : 반드시 보이는 콘솔이어야 한다. pythonw 에서 뜨는 + 자식은 부모의 콘솔을 물려받지 않으므로 이 플래그가 없으면 + stdin 이 없어 agy 가 즉시 종료된다. + - AGY_CLI_DISABLE_AUTO_UPDATE : 로그인 중 바이너리가 교체되는 사고를 막는다. + - .cmd 래퍼 : agy 를 직접 띄우면 사용자가 무엇을 해야 하는지 모른다. + 한국어 안내 배너를 앞뒤로 감싼다. + """ + exe = _agy_exe(cfg) + if not exe.exists(): + return ActionResult( + False, + f"agy 실행 파일이 없습니다: {exe}\n" + f"먼저 [지금 설치]를 눌러 설치하세요.") + + # 래퍼 배치 파일을 로그 디렉터리에 만든다(임시 폴더는 백신이 막는 경우가 있다). + wrapper_dir = paths.STATE_DIR / "tmp" + wrapper_dir.mkdir(parents=True, exist_ok=True) + wrapper = wrapper_dir / "agy_login.cmd" + try: + wrapper.write_text(_RELOGIN_BANNER, encoding="utf-8") + except OSError as exc: + return ActionResult(False, f"로그인 도우미 파일을 만들지 못했습니다: {exc}") + + env = os.environ.copy() + env["AGY_EXE"] = str(exe) + env["AGY_CLI_DISABLE_AUTO_UPDATE"] = "true" + + token_mtime_before = _token_mtime() + + try: + subprocess.Popen( + ["cmd.exe", "/c", str(wrapper)], + cwd=str(paths.PROJECT_ROOT), + env=env, + creationflags=CREATE_NEW_CONSOLE, # <- 이것이 전부다 + close_fds=True, + ) + except OSError as exc: + return ActionResult(False, f"로그인 창을 열지 못했습니다: {exc}") + + # 백그라운드로 토큰 파일 변화를 지켜본다. 최대 10분. + threading.Thread( + target=_watch_token_change, + args=(cfg, token_mtime_before), + daemon=True, + name="agy-token-watch", + ).start() + + return ActionResult( + True, + "로그인 창을 열었습니다. 브라우저에서 Google 계정으로 로그인하세요. " + "완료되면 이 창이 자동으로 확인합니다.", + verify_check_key="agy_auth") + + +def _token_mtime() -> float: + try: + return AGY_TOKEN_PATH.stat().st_mtime + except OSError: + return 0.0 + + +def _watch_token_change(cfg: Config, before: float, timeout_s: int = 600) -> None: + """토큰 파일이 갱신되면 실제 호출 1회로 인증을 실증한다.""" + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + time.sleep(3) + if _token_mtime() <= before: + continue + # 파일이 갱신됐다. 쓰기가 끝날 시간을 준 뒤 실증한다. + time.sleep(2) + if verify_agy_auth(cfg).ok: + return + # 갱신은 됐는데 실증 실패 -> 계속 지켜본다(사용자가 재시도 중일 수 있다) + before = _token_mtime() + + +def verify_agy_auth(cfg: Config) -> ActionResult: + """헤드리스 최소 호출로 인증 상태를 실증한다. + + agy SSOT §4.3 의 권장 헬스체크를 그대로 옮긴 것이다. + 주의: 이 호출도 토큰을 소비한다. 재로그인 직후와 checks 화면에서만 호출하고, + 06:00 배치에서는 절대 호출하지 않는다(ADR-16: 인증 상태는 실작업 결과로 판정). + """ + exe = _agy_exe(cfg) + if not exe.exists(): + return ActionResult(False, "agy 실행 파일이 없습니다.") + + env = os.environ.copy() + env["AGY_CLI_DISABLE_AUTO_UPDATE"] = "true" + try: + proc = subprocess.run( + [str(exe), "-p", "Reply with exactly: PONG", + "--output-format", "json", "--print-timeout", "90s"], + capture_output=True, text=True, encoding="utf-8", errors="replace", + env=env, timeout=120, creationflags=CREATE_NO_WINDOW, + ) + except subprocess.TimeoutExpired: + return ActionResult(False, "확인 요청이 시간 안에 끝나지 않았습니다.") + except OSError as exc: + return ActionResult(False, f"agy 를 실행하지 못했습니다: {exc}") + + # agy SSOT §13: 종료 코드와 status 를 둘 다 확인해야 한다. + if proc.returncode != 0: + return ActionResult(False, f"agy 가 오류로 끝났습니다(코드 {proc.returncode}).") + + from dmf_crawler.agy import extract + envelope = extract.parse_envelope(proc.stdout) + if envelope is None: + return ActionResult(False, "agy 응답을 해석하지 못했습니다.") + if envelope.get("status") != "SUCCESS": + return ActionResult(False, f"인증이 아직 유효하지 않습니다: {envelope.get('error')}") + return ActionResult(True, "agy 로그인이 정상 확인됐습니다.") +``` + +**`CREATE_NEW_CONSOLE` 이 반드시 필요한 이유** + +| 실행 주체 | 콘솔 상속 | `agy` 대화형 동작 | +|---|---|---| +| `pythonw.exe`(GUI) 에서 플래그 없이 `Popen` | 부모에 콘솔이 없으므로 자식도 없음 | **stdin 부재 → 즉시 종료**. 사용자는 아무것도 못 본다 | +| `CREATE_NO_WINDOW` | 콘솔은 생기지만 **숨겨짐** | 프롬프트가 보이지 않아 로그인 코드를 붙여넣을 수 없다 | +| **`CREATE_NEW_CONSOLE`** | 새 콘솔이 **보이게** 생성 | ✅ 정답. 브라우저가 열리고 프롬프트가 보인다 | +| `start` 를 셸로 호출(`shell=True`) | 동작은 하지만 인자 이스케이프가 취약 | 경로에 공백이 있으면 깨진다. 쓰지 않는다 | + +### 5.3 재로그인 성공 판정 + +**세 단계로 판정한다. 하나라도 건너뛰면 오탐이 난다.** + +| 단계 | 판정 | 실패 시 | +|---|---|---| +| 1. 토큰 파일 mtime 변화 | `antigravity-oauth-token` 의 mtime 이 클릭 시점보다 커졌는가 | 계속 대기(최대 10분) | +| 2. 토큰 파일 형태 확인 | JSON 이고 `token.access_token` 키가 있는가. **값은 절대 로그에 남기지 않는다** | 손상 판정 → 재로그인 재안내 | +| 3. 실호출 실증 | `agy -p "Reply with exactly: PONG"` 가 종료 코드 0 + `status == "SUCCESS"` | 만료 상태 유지 → 알림 해소하지 않음 | + +**3단계를 반드시 하는 이유**: 파일이 갱신됐다고 인증이 유효하다는 보장이 없다. 다른 계정으로 로그인했거나, 쓰기가 중간에 끊겼거나, 조직 정책으로 토큰이 즉시 무효화됐을 수 있다. **"파일이 바뀌었으니 됐겠지"는 조용한 실패의 전형이다.** + +**단, 3단계는 토큰을 소비한다.** 그래서 호출 시점을 엄격히 제한한다. + +| 호출해도 되는 곳 | 호출하면 안 되는 곳 | +|---|---| +| 재로그인 직후(토큰 mtime 변화 감지 시) | 06:00 배치의 preflight | +| `dmf doctor` / 진단 GUI 를 사용자가 직접 열었을 때 | Agent 의 15분 주기 pump | +| 온보딩 마법사의 agy 단계 | `checks.run_all()` 의 자동 실행 경로 | + +> `checks` ⑦ "agy 인증 상태"는 기본적으로 **`agy_calls` 테이블의 최근 결과로 판정**한다(아키텍처 §3.15). 실호출은 사용자가 명시적으로 요청했을 때만 한다. + +### 5.4 `act_install_agy` 전문 + +```python +# src/dmf_crawler/gui/steps.py (이어서) + +def act_install_agy(cfg: Config, ctx: Mapping[str, Any]) -> ActionResult: + """scripts/bootstrap_agy.ps1 을 무인 실행한다. + + 설치가 끝나면 최초 1회 로그인이 필요하므로, 성공 시 곧바로 + act_agy_relogin 을 이어서 호출한다. + """ + script = paths.SCRIPTS_DIR / "bootstrap_agy.ps1" + if not script.exists(): + return ActionResult(False, f"설치 스크립트를 찾을 수 없습니다: {script}") + + try: + proc = subprocess.run( + ["powershell.exe", "-NoProfile", "-NonInteractive", + "-ExecutionPolicy", "Bypass", "-File", str(script)], + capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=300, creationflags=CREATE_NO_WINDOW, + ) + except subprocess.TimeoutExpired: + return ActionResult(False, "설치가 5분 안에 끝나지 않았습니다. " + "인터넷 연결을 확인하고 다시 시도하세요.") + except OSError as exc: + return ActionResult(False, f"설치 스크립트를 실행하지 못했습니다: {exc}") + + if proc.returncode != 0: + tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-3:] + return ActionResult(False, "설치에 실패했습니다.\n" + "\n".join(tail)) + + if not _agy_exe(cfg).exists(): + return ActionResult(False, "설치는 끝났지만 실행 파일을 찾지 못했습니다. " + "PC를 재시작한 뒤 다시 시도하세요.") + + # 설치 직후에는 반드시 로그인이 필요하다. 바로 이어서 띄운다. + relogin = act_agy_relogin(cfg, ctx) + if relogin.ok: + return ActionResult(True, "설치가 끝났습니다. 이어서 로그인 창을 열었습니다.", + verify_check_key="agy_auth") + return ActionResult(True, "설치가 끝났습니다. [로그인 창 열기]를 눌러 " + "Google 계정 로그인을 진행하세요.", + verify_check_key="agy_auth") +``` + +**`scripts/bootstrap_agy.ps1` 전문** + +```powershell +# scripts/bootstrap_agy.ps1 +# agy 존재 확인 -> 미설치 시 공식 install.ps1 무인 실행 -> 버전 출력 +# 종료 코드: 0 = 사용 가능, 1 = 실패 +[CmdletBinding()] +param( + [switch]$Force # 이미 설치돼 있어도 재설치 +) + +$ErrorActionPreference = 'Stop' +$agyExe = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe' + +# 설치 중 자동 업데이터가 끼어들지 않게 한다 (agy SSOT §3.4) +$env:AGY_CLI_DISABLE_AUTO_UPDATE = 'true' + +function Test-Agy { + param([string]$Path) + if (-not (Test-Path $Path)) { return $false } + try { + $v = & $Path --version 2>&1 + Write-Host "agy 확인됨: $Path ($v)" + return $true + } catch { + return $false + } +} + +if ((Test-Agy -Path $agyExe) -and (-not $Force)) { + exit 0 +} + +if ($Force -and (Test-Path $agyExe)) { + # install.ps1 은 기존 설치를 감지하면 아무것도 하지 않고 0 으로 빠진다 + # (agy SSOT §3.3 3단계). 재설치하려면 바이너리를 먼저 지워야 한다. + Write-Host '기존 agy 바이너리를 제거합니다(재설치 요청).' + Remove-Item $agyExe -Force +} + +Write-Host 'Antigravity CLI 를 설치합니다. 인터넷 연결이 필요합니다...' +try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $script = Invoke-RestMethod -Uri 'https://antigravity.google/cli/install.ps1' -TimeoutSec 60 + Invoke-Expression $script +} catch { + Write-Error "설치 스크립트 실행에 실패했습니다: $($_.Exception.Message)" + exit 1 +} + +if (Test-Agy -Path $agyExe) { + Write-Host '설치가 완료됐습니다.' + Write-Host '최초 1회 Google 계정 로그인이 필요합니다.' + exit 0 +} + +Write-Error "설치는 끝났지만 $agyExe 를 찾을 수 없습니다." +exit 1 +``` + +> **`where.exe agy` 를 쓰지 않는 이유**: agy SSOT §3.2 실측에 따르면 winget 설치 이력이 있으면 `%LOCALAPPDATA%\Microsoft\WinGet\Links\agy.EXE` 가 함께 잡힌다. **배치 스크립트는 절대 경로를 고정해서 쓴다.** + +--- + +## 6. 알림 스크립트 전문 + +### 6.0 파일 구성과 아키텍처 트리 증분 (AMD-02) + +| 파일 | 상태 | 역할 | +|---|---|---| +| `src/dmf_crawler/notify/toast.py` | 아키텍처 트리에 있음 | tkinter 자동소멸 토스트 | +| `src/dmf_crawler/notify/eventlog.py` | 아키텍처 트리에 있음 | `eventcreate.exe` 래퍼 | +| `src/dmf_crawler/notify/messages.py` | 아키텍처 트리에 있음 | 문구 템플릿(§3.4) | +| `src/dmf_crawler/notify/pump.py` | 아키텍처 트리에 있음 | 대화형 에이전트 본체 | +| `src/dmf_crawler/notify/webhook.py` | **신규(AMD-02)** | 웹훅·dead-man switch. 선택 기능이므로 파일 하나로 격리 | +| `scripts/notify.ps1` | **신규(AMD-02)** | Python 계층이 죽었을 때의 최후 알림기 | +| `scripts/register_protocol.ps1` | **신규(AMD-02)** | `dmf://` 등록(§4.4). 기본 경로에서는 실행하지 않음 | + +**`scripts/notify.ps1` 을 신설하는 근거**: 실패 시나리오 #32(Python/venv 손상)에서 tkinter 토스트는 **원리적으로** 뜰 수 없다. 아키텍처는 이 경우 "Event Log 가 유일한 흔적"이라고 적었는데, 이벤트 로그는 사용자가 보러 가지 않으면 아무 소용이 없다. PowerShell 은 Windows 에 내장돼 있고 우리 venv 와 독립적이므로, 이 하나의 시나리오를 위해 파일 하나를 추가할 가치가 있다. + +--- + +### 6.1 `scripts/notify.ps1` 전문 + +```powershell +<# +.SYNOPSIS + DMF 크롤러 최후 알림기 (Python 독립). + +.DESCRIPTION + Python/venv 가 손상돼 notify/pump.py 가 돌지 못할 때를 위한 폴백 알림기다. + DMF_Crawler_Agent 작업의 "두 번째 액션"으로 등록되어, pump 가 스탬프를 + 남기지 못했을 때만 화면에 뜬다. 정상 상태에서는 아무것도 하지 않는다. + + 표시 사다리: + 1) System.Windows.Forms.MessageBox (기본) + 2) msg.exe * <메시지> (.NET 로드 실패 시) + 3) Windows 이벤트 로그 (항상 병행) + +.PARAMETER Mode + Guard : pump 스탬프 나이를 검사해 필요할 때만 알린다 (스케줄러가 쓰는 모드) + Force : 무조건 state/alerts.json 의 미해소 알림을 표시한다 (테스트용) + Test : 더미 알림 1건을 표시한다 (설치 검증용) + +.PARAMETER ProjectRoot + 프로젝트 루트. 기본값은 이 스크립트의 부모 디렉터리. + +.EXAMPLE + powershell -NoProfile -ExecutionPolicy Bypass -File scripts\notify.ps1 -Mode Guard +#> +[CmdletBinding()] +param( + [ValidateSet('Guard', 'Force', 'Test')] + [string]$Mode = 'Guard', + + [string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot), + + [int]$StaleMinutes = 45 +) + +$ErrorActionPreference = 'Continue' # 알림기는 절대 죽지 않는다 +$EventSource = 'DMF Crawler' + +# ------------------------------------------------------------------ 경로 +$StateDir = Join-Path $ProjectRoot 'state' +$StampPath = Join-Path $StateDir 'pump.stamp' +$AlertsPath = Join-Path $StateDir 'alerts.json' +$BootstrapPath = Join-Path $ProjectRoot 'bootstrap.cmd' + +# ------------------------------------------------------- 이벤트 로그 기록 +function Write-DmfEvent { + param( + [Parameter(Mandatory)][int]$Id, + [ValidateSet('INFORMATION', 'WARNING', 'ERROR', 'SUCCESS')] + [string]$Type = 'WARNING', + [Parameter(Mandatory)][string]$Message + ) + # eventcreate.exe 는 /ID 를 1~1000 으로만 받는다. + if ($Id -lt 1 -or $Id -gt 1000) { $Id = 910 } + # /D 는 명령줄 길이 제한이 있다. 안전하게 자른다. + $desc = $Message -replace '\r?\n', ' | ' + if ($desc.Length -gt 900) { $desc = $desc.Substring(0, 900) + '...' } + try { + & eventcreate.exe /L APPLICATION /SO $EventSource /T $Type /ID $Id /D $desc 2>&1 | + Out-Null + } catch { + # 이벤트 로그 기록 실패는 무시한다. 화면 알림이 본체다. + } +} + +# --------------------------------------------------------- 표시 사다리 1단 +function Show-MessageBoxAlert { + param( + [Parameter(Mandatory)][string]$Title, + [Parameter(Mandatory)][string]$Body, + [ValidateSet('Error', 'Warning', 'Information')] + [string]$Icon = 'Error' + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop + Add-Type -AssemblyName System.Drawing -ErrorAction Stop + } catch { + return $false + } + try { + # MB_SYSTEMMODAL 에 해당하는 TopMost 를 주기 위해 더미 폼을 소유자로 쓴다. + # 이것이 없으면 다른 창 뒤로 숨어 사용자가 영영 못 본다. + $owner = New-Object System.Windows.Forms.Form + $owner.TopMost = $true + $owner.ShowInTaskbar = $false + $owner.StartPosition = 'CenterScreen' + $owner.Size = New-Object System.Drawing.Size(1, 1) + $owner.Opacity = 0 + $owner.Show() + + [void][System.Windows.Forms.MessageBox]::Show( + $owner, + $Body, + $Title, + [System.Windows.Forms.MessageBoxButtons]::OK, + [System.Windows.Forms.MessageBoxIcon]::$Icon, + [System.Windows.Forms.MessageBoxDefaultButton]::Button1 + ) + $owner.Close() + $owner.Dispose() + return $true + } catch { + return $false + } +} + +# --------------------------------------------------------- 표시 사다리 2단 +function Show-MsgExeAlert { + param([Parameter(Mandatory)][string]$Body) + # msg.exe 는 Windows Home 에디션에 없는 경우가 있다. 존재부터 확인한다. + $msg = Get-Command msg.exe -ErrorAction SilentlyContinue + if (-not $msg) { return $false } + try { + # 한 줄로 눌러 보낸다. msg.exe 는 개행을 잘 다루지 못한다. + $flat = ($Body -replace '\r?\n', ' ') + if ($flat.Length -gt 250) { $flat = $flat.Substring(0, 250) + '...' } + & $msg.Source '*' '/TIME:120' $flat 2>&1 | Out-Null + return $true + } catch { + return $false + } +} + +# ------------------------------------------------------------ 표시 오케스트레이션 +function Invoke-Alert { + param( + [Parameter(Mandatory)][string]$Title, + [Parameter(Mandatory)][string]$Body, + [int]$EventId = 910, + [string]$EventType = 'ERROR' + ) + Write-DmfEvent -Id $EventId -Type $EventType -Message "$Title | $Body" + + if (Show-MessageBoxAlert -Title $Title -Body $Body -Icon Error) { + Write-Host "[notify.ps1] MessageBox 로 표시했습니다." + return + } + Write-DmfEvent -Id 510 -Type 'WARNING' ` + -Message 'MessageBox 표시 실패. msg.exe 로 강등합니다.' + + if (Show-MsgExeAlert -Body "$Title`n$Body") { + Write-Host "[notify.ps1] msg.exe 로 표시했습니다." + return + } + Write-DmfEvent -Id 510 -Type 'ERROR' ` + -Message '모든 화면 알림 채널이 실패했습니다. 이벤트 로그만 남습니다.' + Write-Host "[notify.ps1] 화면 표시에 모두 실패했습니다." +} + +# --------------------------------------------------------------- 모드별 동작 +function Get-StampAgeMinutes { + if (-not (Test-Path $StampPath)) { return [int]::MaxValue } + try { + $mtime = (Get-Item $StampPath).LastWriteTime + return [int]((Get-Date) - $mtime).TotalMinutes + } catch { + return [int]::MaxValue + } +} + +function Invoke-GuardMode { + $age = Get-StampAgeMinutes + if ($age -le $StaleMinutes) { + # 정상. 아무것도 하지 않는다. 이것이 대부분의 실행 경로다. + Write-Host "[notify.ps1] 정상 (스탬프 ${age}분 전). 아무것도 하지 않습니다." + return 0 + } + + $lastText = if (Test-Path $StampPath) { + (Get-Item $StampPath).LastWriteTime.ToString('yyyy-MM-dd HH:mm') + } else { + '기록 없음' + } + + $title = 'DMF 크롤러 - 프로그램이 실행되지 않습니다' + $body = @" +[무엇] DMF 크롤러의 알림 프로그램이 ${age}분째 응답하지 않습니다. + (마지막 정상 동작: $lastText) + +[왜] Python 실행 환경(.venv)이 손상됐거나 삭제됐을 가능성이 큽니다. + 이 상태에서는 매일 06:00 자동 수집도 함께 멈춥니다. + +[어떻게] 아래 폴더의 bootstrap.cmd 를 더블클릭해 다시 설치하세요(약 3분). + 기존 데이터와 설정은 그대로 유지됩니다. + +폴더: $ProjectRoot +설치: $BootstrapPath + +* 자세한 기록은 이벤트 뷰어 > Windows 로그 > 응용 프로그램에서 + 원본 "DMF Crawler" 로 확인할 수 있습니다. +"@ + Invoke-Alert -Title $title -Body $body -EventId 910 -EventType 'ERROR' + + # 폴더를 함께 열어 준다. 사용자가 경로를 타이핑하지 않아도 되게. + try { Start-Process explorer.exe -ArgumentList $ProjectRoot } catch { } + return 1 +} + +function Invoke-ForceMode { + if (-not (Test-Path $AlertsPath)) { + Write-Host "[notify.ps1] $AlertsPath 가 없습니다." + return 0 + } + try { + $data = Get-Content -Path $AlertsPath -Raw -Encoding UTF8 | ConvertFrom-Json + } catch { + Invoke-Alert -Title 'DMF 크롤러 - 알림 파일을 읽지 못했습니다' ` + -Body "state\alerts.json 이 손상됐습니다.`n경로: $AlertsPath" ` + -EventId 910 + return 1 + } + + $pending = @($data.pending | Where-Object { $_.severity -in @('ERROR', 'CRITICAL') }) + if ($pending.Count -eq 0) { + Write-Host "[notify.ps1] 표시할 ERROR/CRITICAL 알림이 없습니다." + return 0 + } + + foreach ($a in $pending) { + $body = @" +[무엇] $($a.what) + +[왜] $($a.why) + +[어떻게] $($a.how) + +발생: $($a.last_seen_at) (누적 $($a.occurrences)회) +로그: $($a.log_dir) +"@ + Invoke-Alert -Title "DMF 크롤러 - $($a.title)" -Body $body ` + -EventId 400 -EventType 'ERROR' + } + return 1 +} + +function Invoke-TestMode { + $body = @" +[무엇] 이것은 알림 채널 점검용 시험 메시지입니다. + +[왜] scripts\notify.ps1 -Mode Test 로 직접 실행했습니다. + +[어떻게] 이 창이 보인다면 폴백 알림 채널이 정상입니다. [확인]을 누르세요. + +프로젝트: $ProjectRoot +표시 시각: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') +"@ + Invoke-Alert -Title 'DMF 크롤러 - 알림 시험' -Body $body ` + -EventId 500 -EventType 'INFORMATION' + return 0 +} + +# ------------------------------------------------------------------- 진입점 +switch ($Mode) { + 'Guard' { exit (Invoke-GuardMode) } + 'Force' { exit (Invoke-ForceMode) } + 'Test' { exit (Invoke-TestMode) } +} +``` + +**Agent 작업에 2번째 액션으로 등록** (`scripts/install_tasks.ps1` 발췌) + +```powershell +# DMF_Crawler_Agent — 액션 2개. 순서대로 실행된다. +$agentActions = @( + # 1) 정상 경로: Python 알림 펌프. 콘솔 창이 뜨지 않는다. + (New-ScheduledTaskAction -Execute $PythonwExe ` + -Argument '-m dmf_crawler notify-pump --once' ` + -WorkingDirectory $ProjectRoot), + + # 2) 폴백 경로: pump 가 스탬프를 못 남겼을 때만 동작한다. + # 정상 상태에서는 즉시 종료되므로 부담이 없다. + (New-ScheduledTaskAction -Execute 'powershell.exe' ` + -Argument ("-NoProfile -NonInteractive -WindowStyle Hidden " + + "-ExecutionPolicy Bypass -File `"$ProjectRoot\scripts\notify.ps1`" " + + "-Mode Guard -StaleMinutes 45") ` + -WorkingDirectory $ProjectRoot) +) + +$agentTrigger = @( + (New-ScheduledTaskTrigger -AtLogOn -User $TargetUser), + (New-ScheduledTaskTrigger -Once -At (Get-Date).Date.AddMinutes(1) ` + -RepetitionInterval (New-TimeSpan -Minutes 15)) +) + +# LogonType Interactive 가 절대적으로 중요하다. 이것이 UI 를 띄울 수 있는 유일한 조건이다. +$agentPrincipal = New-ScheduledTaskPrincipal -UserId $TargetUser ` + -LogonType Interactive -RunLevel Limited + +$agentSettings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -StartWhenAvailable -MultipleInstances IgnoreNew ` + -ExecutionTimeLimit (New-TimeSpan -Minutes 10) -Priority 7 + +Register-ScheduledTask -TaskName 'DMF_Crawler_Agent' ` + -Action $agentActions -Trigger $agentTrigger ` + -Principal $agentPrincipal -Settings $agentSettings -Force | Out-Null +``` + +> **작업 스케줄러의 다중 액션은 순차 실행이며, 앞 액션의 종료 코드를 보지 않는다.** 즉 pump 가 죽어도 2번 액션은 반드시 실행된다 — 이것이 폴백이 성립하는 이유다. 반대로 pump 가 정상이면 `notify.ps1 -Mode Guard` 는 스탬프를 보고 즉시 0으로 빠지므로 중복 알림이 나지 않는다. + +--- + +### 6.2 `src/dmf_crawler/notify/toast.py` 전문 + +```python +# src/dmf_crawler/notify/toast.py +"""tkinter 로 그린 우하단 자동소멸 알림 창. + +왜 tkinter 인가 (ADR-12): + - 표준 라이브러리다. 의존성이 늘지 않는다. + - 집중 지원(방해 금지) 모드의 영향을 받지 않는다. OS 알림 API 를 쓰지 않기 때문. + - BurntToast/win11toast 는 외부 설치가 전제여서, + "설치가 깨지면 알림도 안 뜬다"는 순환 실패를 만든다. + +한계 (정직하게 기록): + - 액션 센터에 남지 않는다. 놓치면 사라진다. + -> 그래서 alerts 테이블과 state/alerts.json 이 원본이고 이 창은 사본일 뿐이다. + - 배타적 전체 화면 앱 위에는 뜨지 못한다. -> is_presentation_mode() 로 회피한다. +""" +from __future__ import annotations + +import ctypes +import tkinter as tk +from dataclasses import dataclass +from typing import Callable, Sequence + +# --------------------------------------------------------------- 디자인 토큰 +# 리포트와 같은 Okabe-Ito 계열. 색각이상 안전. +_BG = "#1B1D23" +_FG = "#F2F3F5" +_FG_DIM = "#A8ADB7" +_BORDER = "#3A3F4B" +_ACCENT = { + "INFO": "#0072B2", # 파랑 + "WARN": "#E69F00", # 주황 + "ERROR": "#D55E00", # 주황빨강 + "CRITICAL": "#CC3311", # 빨강 +} +_BTN_BG = "#2A2E38" +_BTN_BG_HOVER = "#3A3F4B" +_FONT_FAMILY = "맑은 고딕" + +_MARGIN_RIGHT = 24 +_MARGIN_BOTTOM = 56 # 작업 표시줄을 피한다 +_WIDTH = 440 +_GAP = 12 # 토스트 여러 장을 쌓을 때의 간격 + +# 이미 떠 있는 토스트들의 높이 누적(스택 배치용) +_stack_offset = 0 + + +@dataclass(frozen=True, slots=True) +class ToastButton: + key: str + label: str + + +@dataclass(frozen=True, slots=True) +class ToastResult: + clicked: str | None # 눌린 버튼의 key. 자동 소멸이면 None + shown: bool # 창이 실제로 화면에 그려졌는가 + + +def enable_dpi_awareness() -> None: + """고DPI 화면에서 창이 흐려지는 것을 막는다. 실패해도 무시한다.""" + try: + # PROCESS_PER_MONITOR_DPI_AWARE = 2 + ctypes.windll.shcore.SetProcessDpiAwareness(2) + except Exception: + try: + ctypes.windll.user32.SetProcessDPIAware() + except Exception: + pass + + +def is_presentation_mode() -> bool: + """전체 화면 앱·프레젠테이션 모드인지 확인한다(B4 대응). + + SHQueryUserNotificationState 반환값: + 1 NOT_PRESENT 2 BUSY 3 RUNNING_D3D_FULL_SCREEN + 4 PRESENTATION_MODE 5 ACCEPTS_NOTIFICATIONS 6 QUIET_TIME + 7 APP (Windows 8+: 전체 화면 앱) + """ + try: + state = ctypes.c_int(0) + hr = ctypes.windll.shell32.SHQueryUserNotificationState(ctypes.byref(state)) + if hr != 0: + return False + return state.value in (2, 3, 4, 7) + except Exception: + return False + + +def reset_stack() -> None: + """pump 주기 시작 시 호출. 토스트 쌓임 위치를 초기화한다.""" + global _stack_offset + _stack_offset = 0 + + +def show( + *, + title: str, + body: str, + severity: str = "WARN", + buttons: Sequence[ToastButton] = (), + seconds: int = 12, + on_click: Callable[[str], None] | None = None, +) -> ToastResult: + """토스트를 띄우고 닫힐 때까지 블록한다. + + 반환: + ToastResult(clicked=눌린 버튼 키 또는 None, shown=실제로 그려졌는지) + + 이 함수는 절대 예외를 밖으로 던지지 않는다. 실패하면 shown=False 로 알린다. + """ + global _stack_offset + try: + return _show_impl(title, body, severity, tuple(buttons), seconds, on_click) + except Exception: + return ToastResult(clicked=None, shown=False) + + +def _show_impl( + title: str, + body: str, + severity: str, + buttons: tuple[ToastButton, ...], + seconds: int, + on_click: Callable[[str], None] | None, +) -> ToastResult: + global _stack_offset + + enable_dpi_awareness() + accent = _ACCENT.get(severity.upper(), _ACCENT["WARN"]) + clicked: dict[str, str | None] = {"key": None} + + root = tk.Tk() + root.withdraw() + + win = tk.Toplevel(root) + win.overrideredirect(True) # 제목 표시줄 없음 + win.attributes("-topmost", True) + win.configure(bg=_BORDER) # 바깥 1px 테두리 역할 + + # ------------------------------------------------------------ 레이아웃 + outer = tk.Frame(win, bg=_BG) + outer.pack(padx=1, pady=1, fill="both", expand=True) + + # 왼쪽 등급 색 띠 + tk.Frame(outer, bg=accent, width=5).pack(side="left", fill="y") + + inner = tk.Frame(outer, bg=_BG) + inner.pack(side="left", fill="both", expand=True, padx=16, pady=14) + + tk.Label( + inner, text=title, bg=_BG, fg=_FG, justify="left", anchor="w", + font=(_FONT_FAMILY, 11, "bold"), wraplength=_WIDTH - 60, + ).pack(fill="x") + + tk.Label( + inner, text=body, bg=_BG, fg=_FG_DIM, justify="left", anchor="w", + font=(_FONT_FAMILY, 9), wraplength=_WIDTH - 60, + ).pack(fill="x", pady=(8, 0)) + + # ------------------------------------------------------------- 버튼들 + def _close(key: str | None) -> None: + clicked["key"] = key + try: + win.destroy() + root.quit() + except tk.TclError: + pass + + if buttons: + bar = tk.Frame(inner, bg=_BG) + bar.pack(fill="x", pady=(14, 0)) + for btn in buttons: + b = tk.Button( + bar, text=btn.label, bg=_BTN_BG, fg=_FG, + activebackground=_BTN_BG_HOVER, activeforeground=_FG, + relief="flat", bd=0, padx=12, pady=5, cursor="hand2", + font=(_FONT_FAMILY, 9), + command=lambda k=btn.key: _on_button(k, on_click, _close), + ) + b.pack(side="left", padx=(0, 8)) + b.bind("", lambda e, w=b: w.configure(bg=_BTN_BG_HOVER)) + b.bind("", lambda e, w=b: w.configure(bg=_BTN_BG)) + + # ------------------------------------------------- 위치 계산(우하단 스택) + win.update_idletasks() + height = win.winfo_reqheight() + screen_w = win.winfo_screenwidth() + screen_h = win.winfo_screenheight() + x = screen_w - _WIDTH - _MARGIN_RIGHT + y = screen_h - _MARGIN_BOTTOM - height - _stack_offset + if y < 40: # 화면 위로 넘치면 스택을 접는다 + _stack_offset = 0 + y = screen_h - _MARGIN_BOTTOM - height + win.geometry(f"{_WIDTH}x{height}+{x}+{y}") + _stack_offset += height + _GAP + + # --------------------------------------------------------- 동작 바인딩 + # 본문 클릭 = 첫 번째 버튼과 같은 동작(가장 흔한 조작을 쉽게) + default_key = buttons[0].key if buttons else None + for widget in (inner, outer): + widget.bind("", + lambda e: _on_button(default_key, on_click, _close) + if default_key else _close(None)) + # 오른쪽 클릭 = 그냥 닫기 + win.bind("", lambda e: _close(None)) + win.bind("", lambda e: _close(None)) + + # 자동 소멸 타이머 + timer_id = win.after(max(1, seconds) * 1000, lambda: _close(None)) + + # 마우스를 올리면 타이머를 멈춘다(읽는 중에 사라지면 안 된다) + def _pause(_e: object) -> None: + try: + win.after_cancel(timer_id) + except (tk.TclError, ValueError): + pass + + def _resume(_e: object) -> None: + nonlocal timer_id + try: + timer_id = win.after(3000, lambda: _close(None)) + except tk.TclError: + pass + + win.bind("", _pause) + win.bind("", _resume) + + # 페이드 인 + try: + win.attributes("-alpha", 0.0) + for step in range(0, 11): + win.attributes("-alpha", step / 10.0 * 0.97) + win.update() + win.after(12) + except tk.TclError: + pass + + root.mainloop() + try: + root.destroy() + except tk.TclError: + pass + + return ToastResult(clicked=clicked["key"], shown=True) + + +def _on_button( + key: str | None, + on_click: Callable[[str], None] | None, + close: Callable[[str | None], None], +) -> None: + if key and on_click: + try: + on_click(key) + except Exception: + pass # 액션 실패가 창을 죽이면 안 된다 + close(key) + + +def show_merged( + *, + count: int, + lines: Sequence[str], + severity: str, + seconds: int, + on_click: Callable[[str], None] | None = None, +) -> ToastResult: + """여러 알림을 한 장으로 묶어 표시한다(L2 병합).""" + body = "\n".join(f" · {line}" for line in lines) + return show( + title=f"DMF 크롤러 — 확인이 필요한 항목 {count}건", + body=body, + severity=severity, + buttons=( + ToastButton("open_doctor", "자세히 보기"), + ToastButton("open_report_file", "리포트 열기"), + ToastButton("dismiss", "닫기"), + ), + seconds=seconds, + on_click=on_click, + ) +``` + +--- + +### 6.3 `src/dmf_crawler/notify/eventlog.py` 전문 + +```python +# src/dmf_crawler/notify/eventlog.py +"""Windows 이벤트 로그 병행 기록. + +pywin32 를 쓰지 않는다(ADR 의존성 최소주의). Windows 내장 eventcreate.exe 를 부른다. + +알려진 제약 (반드시 지킬 것): + 1. /ID 는 1~1000 범위만 허용된다. 벗어나면 명령 자체가 실패한다. + -> EVENT_IDS 상수가 이 범위를 강제한다. + 2. /D 는 명령줄 인자이므로 길이 제한이 있다. 900자로 자른다. + 3. /SO 로 지정한 원본은 Application 로그에 자동 등록된다. + 이미 시스템에 등록된 원본 이름과 충돌하면 실패하므로 고유한 이름을 쓴다. + 4. PowerShell 7 에는 Write-EventLog/New-EventLog 가 없다. + eventcreate.exe 는 exe 이므로 셸 종류와 무관하게 동작한다. +""" +from __future__ import annotations + +import subprocess +from typing import Literal + +from dmf_crawler.alerts import Severity + +CREATE_NO_WINDOW = 0x08000000 + +EventType = Literal["INFORMATION", "WARNING", "ERROR", "SUCCESS"] + +# 등급 -> eventcreate /T 매핑 +_TYPE_BY_SEVERITY: dict[str, EventType] = { + "INFO": "INFORMATION", + "WARN": "WARNING", + "ERROR": "ERROR", + "CRITICAL": "ERROR", # eventcreate 에 CRITICAL 타입은 없다 +} + +# 등급 기본 ID (템플릿이 eventlog_id 를 지정하면 그것이 우선한다) +_ID_BY_SEVERITY: dict[str, int] = { + "INFO": 100, "WARN": 200, "ERROR": 300, "CRITICAL": 400, +} + +# 이 문서 §2.8 의 ID 배정표 +EVENT_IDS: dict[str, int] = { + "RUN_START": 100, "RUN_SUCCESS": 110, "RUN_PARTIAL": 120, "RUN_SKIPPED": 130, + "ALERT_WARN": 200, "ALERT_ERROR": 300, "ALERT_CRITICAL": 400, + "WATCHDOG_STALE": 410, "CONSECUTIVE_FAILURES": 420, "RUN_PAUSED": 430, + "NOTIFY_SHOWN": 500, "NOTIFY_DEGRADED": 510, "NOTIFY_SUPPRESSED": 520, + "PUMP_CRASHED": 900, "FALLBACK_FIRED": 910, +} + +_MAX_DESC = 900 + + +def write( + *, + source: str, + message: str, + severity: Severity | str = Severity.INFO, + event_id: int | None = None, + timeout_s: float = 10.0, +) -> bool: + """이벤트 로그에 1건 기록한다. 실패해도 예외를 던지지 않고 False 를 반환한다.""" + sev = str(severity) + etype: EventType = _TYPE_BY_SEVERITY.get(sev, "WARNING") + eid = event_id if event_id else _ID_BY_SEVERITY.get(sev, 200) + if not (1 <= eid <= 1000): # eventcreate 의 하드 제약 + eid = _ID_BY_SEVERITY.get(sev, 200) + + desc = " | ".join(line.strip() for line in message.splitlines() if line.strip()) + if len(desc) > _MAX_DESC: + desc = desc[: _MAX_DESC - 3] + "..." + if not desc: + desc = "(내용 없음)" + + try: + proc = subprocess.run( + ["eventcreate.exe", + "/L", "APPLICATION", + "/SO", source, + "/T", etype, + "/ID", str(eid), + "/D", desc], + capture_output=True, text=True, encoding="cp949", errors="replace", + timeout=timeout_s, creationflags=CREATE_NO_WINDOW, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return proc.returncode == 0 + + +def write_alert(cfg, alert_code: str, severity: Severity, title: str, + body: str, event_id: int | None = None) -> bool: + """알림 1건을 이벤트 로그에 남긴다.""" + return write( + source=cfg.notify.eventlog_source, + message=f"[{alert_code}] {title} :: {body}", + severity=severity, + event_id=event_id, + ) +``` + +**수동 검증** + +```powershell +# 기록 +eventcreate.exe /L APPLICATION /SO "DMF Crawler" /T ERROR /ID 400 /D "테스트 알림" + +# 확인 +Get-WinEvent -FilterHashtable @{LogName='Application'; ProviderName='DMF Crawler'} -MaxEvents 5 | + Format-List TimeCreated, Id, LevelDisplayName, Message +``` + +--- + +### 6.4 `src/dmf_crawler/alerts.py` 전문 + +```python +# src/dmf_crawler/alerts.py +"""알림 의도의 기록·중복 억제·표시 추적. + +원칙 (ADR-11): + 이 모듈은 절대로 화면에 무엇을 띄우지 않는다. 기록만 한다. + 표시는 notify/pump.py 가 로그온 세션에서 한다. + +계약 (요구 R7.3): + raise_alert 는 what/why/how/next_actions 중 하나라도 비면 ValueError 를 던진다. +""" +from __future__ import annotations + +import json +import os +import socket +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timedelta +from enum import StrEnum +from pathlib import Path +from typing import Any, Mapping, Sequence + + +class Severity(StrEnum): + INFO = "INFO" + WARN = "WARN" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + @property + def rank(self) -> int: + return {"INFO": 0, "WARN": 1, "ERROR": 2, "CRITICAL": 3}[self.value] + + def at_least(self, other: "Severity") -> bool: + return self.rank >= other.rank + + +@dataclass(frozen=True, slots=True) +class AlertRow: + alert_id: int + dedup_key: str + code: str + severity: Severity + first_seen_at: str + last_seen_at: str + occurrences: int + show_attempts: int + title: str + what: str + why: str + how: str + next_actions: tuple[str, ...] + context: dict[str, Any] + log_dir: str | None + run_id: str | None + + def as_context(self) -> dict[str, Any]: + ctx = dict(self.context) + ctx.update({ + "alert_id": self.alert_id, + "code": self.code, + "occurrences": self.occurrences, + "log_dir": self.log_dir, + "run_id": self.run_id, + }) + return ctx + + @property + def body(self) -> str: + lines = [f"[무엇] {self.what}", + f"[왜] {self.why}", + f"[어떻게] {self.how}"] + if self.log_dir: + lines += ["", f"로그: {self.log_dir}"] + return "\n".join(lines) + + +def _now_iso() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def make_dedup_key(code: str, run_date: str | None, + scope: str | None = None) -> str: + parts = [code] + if run_date: + parts.append(run_date) + if scope: + parts.append(scope) + return "|".join(parts) + + +def raise_alert( + conn: sqlite3.Connection, + *, + run_id: str | None, + severity: Severity, + code: str, + what: str, + why: str, + how: str, + next_actions: Sequence[str], + context: Mapping[str, Any] | None = None, + log_dir: Path | None = None, + cooldown_minutes: int, + dedup_scope: str | None = None, + date_scoped: bool = True, + source: str = "pipeline", +) -> bool: + """알림 의도를 기록한다. + + 반환: + True -> 새 알림이거나 쿨다운이 지나 표시 대상이 됐다 + False -> 쿨다운 안이라 카운터만 올렸다 + """ + # ---- 4요소 계약 강제 (요구 R7.3) -------------------------------- + for name, value in (("what", what), ("why", why), ("how", how)): + if not value or not value.strip(): + raise ValueError(f"알림 {code}: '{name}' 이 비었습니다. " + f"4요소(무엇/왜/어떻게/다음 행동)는 필수입니다.") + actions = tuple(a for a in next_actions if a) + if not actions: + raise ValueError(f"알림 {code}: next_actions 가 비었습니다. " + f"막다른 골목 알림은 금지입니다(R7.7).") + if severity.at_least(Severity.WARN) and set(actions) <= {"snooze", "dismiss"}: + raise ValueError(f"알림 {code}: 실행 가능한 액션이 없습니다(R7.7).") + + now = _now_iso() + run_date = now[:10] if date_scoped else None + key = make_dedup_key(code, run_date, dedup_scope) + ctx_json = json.dumps(dict(context or {}), ensure_ascii=False) + log_dir_str = str(log_dir) if log_dir else None + + cur = conn.cursor() + # 1) 발생 사실은 무조건 append + cur.execute( + """INSERT INTO alert_events + (occurred_at, run_id, code, severity, dedup_key, + what, why, how, next_actions, context_json, log_dir, source) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + (now, run_id, code, str(severity), key, + what, why, how, json.dumps(list(actions), ensure_ascii=False), + ctx_json, log_dir_str, source), + ) + event_id = cur.lastrowid + + # 2) 상태 행 조회 + cur.execute( + """SELECT alert_id, last_seen_at, occurrences, shown_at, resolved_at + FROM alerts WHERE dedup_key = ?""", (key,)) + row = cur.fetchone() + + if row is None or row["resolved_at"] is not None: + # 새 알림, 또는 이미 해소된 뒤 재발생 -> 새 상태로 시작 + cur.execute( + """INSERT INTO alerts + (dedup_key, code, severity, first_seen_at, last_seen_at, + occurrences, last_event_id, show_attempts) + VALUES (?,?,?,?,?,1,?,0) + ON CONFLICT(dedup_key) DO UPDATE SET + severity = excluded.severity, + last_seen_at = excluded.last_seen_at, + occurrences = alerts.occurrences + 1, + last_event_id = excluded.last_event_id, + shown_at = NULL, + shown_channel = NULL, + show_attempts = 0, + snoozed_until = NULL, + resolved_at = NULL, + resolved_note = NULL""", + (key, code, str(severity), now, now, event_id), + ) + conn.commit() + return True + + # 3) 기존 미해소 알림 -> 쿨다운 판정 + last_seen = datetime.fromisoformat(row["last_seen_at"]) + elapsed = datetime.now().astimezone() - last_seen + within_cooldown = elapsed < timedelta(minutes=cooldown_minutes) + + if within_cooldown: + cur.execute( + """UPDATE alerts + SET last_seen_at = ?, occurrences = occurrences + 1, + last_event_id = ?, severity = ? + WHERE alert_id = ?""", + (now, event_id, str(severity), row["alert_id"]), + ) + conn.commit() + return False + + cur.execute( + """UPDATE alerts + SET last_seen_at = ?, occurrences = occurrences + 1, + last_event_id = ?, severity = ?, + shown_at = NULL, shown_channel = NULL, show_attempts = 0 + WHERE alert_id = ?""", + (now, event_id, str(severity), row["alert_id"]), + ) + conn.commit() + return True + + +def pending(conn: sqlite3.Connection, *, + modal_repeat_minutes: int = 60) -> list[AlertRow]: + """표시 대기 알림 목록. 심각도 내림차순.""" + now = _now_iso() + cur = conn.execute( + """SELECT a.alert_id, a.dedup_key, a.code, a.severity, + a.first_seen_at, a.last_seen_at, a.occurrences, a.show_attempts, + e.what, e.why, e.how, e.next_actions, e.context_json, + e.log_dir, e.run_id + FROM alerts a + JOIN alert_events e ON e.event_id = a.last_event_id + WHERE a.resolved_at IS NULL + AND (a.snoozed_until IS NULL OR a.snoozed_until < :now) + AND (a.shown_at IS NULL + OR (a.severity IN ('ERROR','CRITICAL') + AND julianday(:now) - julianday(a.shown_at) + > :repeat / 1440.0)) + ORDER BY CASE a.severity + WHEN 'CRITICAL' THEN 0 WHEN 'ERROR' THEN 1 + WHEN 'WARN' THEN 2 ELSE 3 END, + a.last_seen_at DESC""", + {"now": now, "repeat": modal_repeat_minutes}, + ) + from dmf_crawler.notify import messages # 순환 방지를 위해 지연 임포트 + + rows: list[AlertRow] = [] + for r in cur.fetchall(): + ctx = json.loads(r["context_json"] or "{}") + tpl = messages.TEMPLATES.get(r["code"]) + title = tpl.title.format_map(messages._SafeDict(ctx)) if tpl else r["code"] + rows.append(AlertRow( + alert_id=r["alert_id"], dedup_key=r["dedup_key"], code=r["code"], + severity=Severity(r["severity"]), + first_seen_at=r["first_seen_at"], last_seen_at=r["last_seen_at"], + occurrences=r["occurrences"], show_attempts=r["show_attempts"], + title=title, what=r["what"], why=r["why"], how=r["how"], + next_actions=tuple(json.loads(r["next_actions"])), + context=ctx, log_dir=r["log_dir"], run_id=r["run_id"], + )) + return rows + + +def mark_shown(conn: sqlite3.Connection, alert_id: int, *, channel: str) -> None: + conn.execute( + """UPDATE alerts + SET shown_at = ?, shown_channel = ?, show_attempts = show_attempts + 1 + WHERE alert_id = ?""", + (_now_iso(), channel, alert_id)) + conn.commit() + + +def mark_show_failed(conn: sqlite3.Connection, alert_id: int) -> int: + """표시 실패 카운터를 올리고 현재 값을 반환한다(R-P3 승격 판정용).""" + conn.execute( + "UPDATE alerts SET show_attempts = show_attempts + 1 WHERE alert_id = ?", + (alert_id,)) + conn.commit() + cur = conn.execute("SELECT show_attempts FROM alerts WHERE alert_id = ?", + (alert_id,)) + row = cur.fetchone() + return int(row["show_attempts"]) if row else 0 + + +def snooze(conn: sqlite3.Connection, alert_id: int, *, minutes: int) -> None: + until = (datetime.now().astimezone() + + timedelta(minutes=minutes)).isoformat(timespec="seconds") + conn.execute( + """UPDATE alerts + SET snoozed_until = ?, shown_at = ?, shown_channel = 'modal', + show_attempts = show_attempts + 1 + WHERE alert_id = ?""", + (until, _now_iso(), alert_id)) + conn.commit() + + +def resolve(conn: sqlite3.Connection, code: str, note: str) -> int: + """해당 코드의 미해소 알림을 전부 해소한다. 해소한 건수를 반환한다.""" + cur = conn.execute( + """UPDATE alerts SET resolved_at = ?, resolved_note = ? + WHERE code = ? AND resolved_at IS NULL""", + (_now_iso(), note, code)) + conn.commit() + return cur.rowcount + + +def resolve_date_scoped(conn: sqlite3.Connection, note: str) -> int: + """다음 실행이 성공했을 때 일자성 WARN 을 일괄 해소한다(R-D1).""" + cur = conn.execute( + """UPDATE alerts SET resolved_at = ?, resolved_note = ? + WHERE resolved_at IS NULL + AND severity IN ('INFO','WARN') + AND instr(dedup_key, '|') > 0""", + (_now_iso(), note)) + conn.commit() + return cur.rowcount + + +def shown_today(conn: sqlite3.Connection) -> int: + cur = conn.execute("SELECT shown_count FROM v_alert_shown_today") + row = cur.fetchone() + return int(row["shown_count"]) if row else 0 + + +def mirror_to_file(conn: sqlite3.Connection, path: Path) -> None: + """state/alerts.json 을 원자적으로 갱신한다. + + PowerShell 폴백(notify.ps1)과 GUI 가 SQLite 잠금 없이 읽는 유일한 경로다. + 실패해도 예외를 던지지 않는다. + """ + try: + rows = pending(conn) + counts = {s.value: 0 for s in Severity} + payload_rows = [] + for row in rows: + counts[row.severity.value] += 1 + payload_rows.append({ + "alert_id": row.alert_id, "dedup_key": row.dedup_key, + "code": row.code, "severity": row.severity.value, + "first_seen_at": row.first_seen_at, "last_seen_at": row.last_seen_at, + "occurrences": row.occurrences, "title": row.title, + "what": row.what, "why": row.why, "how": row.how, + "next_actions": list(row.next_actions), + "log_dir": row.log_dir, "run_id": row.run_id, + }) + payload = { + "schema": 1, + "updated_at": _now_iso(), + "host": socket.gethostname(), + "pending": payload_rows, + "counts": counts, + } + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8") + os.replace(tmp, path) + except Exception: + pass +``` + +--- + +### 6.5 `src/dmf_crawler/watchdog.py` 전문 + +```python +# src/dmf_crawler/watchdog.py +"""heartbeat 신선도 판정과 연속 실패 감시. + +아키텍처 부록 결정: 워치독을 별도 작업으로 분리하지 않는다. +로그온 세션의 Agent(15분 주기) 안에서 판정하면 작업이 하나 줄고, +"알림이 뜨는 세션에서 판정한다"는 성질이 공짜로 따라온다. +""" +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path + +from dmf_crawler import paths +from dmf_crawler.alerts import Severity, raise_alert +from dmf_crawler.config import Config + + +@dataclass(frozen=True, slots=True) +class Heartbeat: + exists: bool + last_success_at: datetime | None + run_id: str | None + status: str | None + report_path: str | None + log_dir: str | None + + @property + def age_minutes(self) -> float: + if self.last_success_at is None: + return float("inf") + delta = datetime.now().astimezone() - self.last_success_at + return delta.total_seconds() / 60.0 + + +def read_heartbeat(path: Path | None = None) -> Heartbeat: + target = path or paths.HEARTBEAT_PATH + try: + data = json.loads(target.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return Heartbeat(False, None, None, None, None, None) + try: + ts = datetime.fromisoformat(data["last_success_at"]) + except (KeyError, ValueError): + ts = None + return Heartbeat( + exists=True, last_success_at=ts, + run_id=data.get("run_id"), status=data.get("status"), + report_path=data.get("report_path"), log_dir=data.get("log_dir"), + ) + + +def write_heartbeat(cfg: Config, *, run_id: str, status: str, + report_path: str | None, log_dir: str) -> None: + """성공/부분성공일 때만 호출한다. FAILED 에서는 절대 갱신하지 않는다. + + (심사에서 후보 C 가 '항상 갱신'해 dead-man switch 를 설계상 무력화한 것이 + 최대 감점이었다. 여기서 같은 실수를 하면 워치독 전체가 무의미해진다.) + """ + payload = { + "last_success_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "run_id": run_id, + "status": status, + "report_path": report_path, + "log_dir": log_dir, + } + paths.STATE_DIR.mkdir(parents=True, exist_ok=True) + tmp = paths.HEARTBEAT_PATH.with_suffix(".json.tmp") + tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + import os + os.replace(tmp, paths.HEARTBEAT_PATH) + + +def touch_pump_stamp() -> None: + """notify.ps1 폴백이 읽는 생존 신호. pump 가 매 주기 갱신한다.""" + paths.STATE_DIR.mkdir(parents=True, exist_ok=True) + paths.PUMP_STAMP.write_text( + datetime.now().astimezone().isoformat(timespec="seconds"), encoding="utf-8") + + +def consecutive_failed_days(conn: sqlite3.Connection) -> int: + """오늘부터 거슬러 올라가며 연속 실패한 날 수를 센다.""" + cur = conn.execute( + """SELECT substr(started_at, 1, 10) AS d, + MAX(CASE WHEN status IN ('SUCCESS','PARTIAL') THEN 1 ELSE 0 END) AS ok + FROM runs + WHERE started_at >= date('now', '-14 days') + GROUP BY d + ORDER BY d DESC""") + days = 0 + for row in cur.fetchall(): + if row["ok"] == 1: + break + days += 1 + return days + + +def top_failure_causes(conn: sqlite3.Connection, limit: int = 3 + ) -> list[tuple[str, int]]: + """최근 14일 실패 알림 코드 상위 N개. CONSECUTIVE_FAILURES 문구에 쓴다.""" + cur = conn.execute( + """SELECT code, COUNT(*) AS n + FROM alert_events + WHERE occurred_at >= datetime('now', '-14 days') + AND severity IN ('ERROR','CRITICAL') + AND code NOT IN ('CONSECUTIVE_FAILURES','RUN_PAUSED','WATCHDOG_STALE') + GROUP BY code + ORDER BY n DESC + LIMIT ?""", (limit,)) + return [(r["code"], r["n"]) for r in cur.fetchall()] + + +def evaluate(conn: sqlite3.Connection, cfg: Config) -> list[str]: + """워치독 판정 1회. 발생시킨 알림 코드 목록을 반환한다.""" + fired: list[str] = [] + hb = read_heartbeat() + now = datetime.now().astimezone() + + # ---- 1) heartbeat 신선도 ------------------------------------------- + # 06:00 실행 + 여유 2시간. 08:00 이전에는 판정하지 않는다. + scheduled_hour, scheduled_min = (int(x) for x in cfg.schedule.daily_time.split(":")) + today_due = now.replace(hour=scheduled_hour, minute=scheduled_min, + second=0, microsecond=0) + grace = timedelta(minutes=cfg.notify.watchdog_stale_minutes) + if now >= today_due + grace and hb.age_minutes > cfg.notify.watchdog_stale_minutes: + last_txt = (hb.last_success_at.strftime("%Y-%m-%d %H:%M") + if hb.last_success_at else "기록 없음") + stale_h = (int(hb.age_minutes // 60) + if hb.last_success_at else "알 수 없음") + if raise_alert( + conn, run_id=None, severity=Severity.CRITICAL, code="WATCHDOG_STALE", + what=f"지금 {now:%H:%M} 기준으로 오늘 {cfg.schedule.daily_time} 배치가 " + f"실행된 흔적이 없습니다. 마지막으로 성공한 실행은 " + f"{last_txt} ({stale_h}시간 전)입니다.", + why="(1) PC가 실행 시각에 꺼져 있었고 아직 따라잡기가 실행되지 않음 " + "(2) 작업 스케줄러 항목이 꺼졌거나 삭제됨 " + f"(3) 실행이 제한 시간({cfg.schedule.execution_time_limit_minutes}분)을 " + "넘겨 강제 종료됨", + how="[지금 실행]을 누르면 즉시 오늘 자료를 수집합니다(약 2분). " + "반복된다면 [작업 상태 확인]으로 스케줄러 등록 상태를 점검하세요.", + next_actions=("run_now", "open_task_scheduler", "open_log_dir", "snooze"), + context={"now_time": f"{now:%H:%M}", "last_success_at": last_txt, + "stale_hours": stale_h, + "exec_limit_min": cfg.schedule.execution_time_limit_minutes}, + log_dir=Path(hb.log_dir) if hb.log_dir else None, + cooldown_minutes=cfg.notify.modal_repeat_minutes, + date_scoped=False, source="watchdog", + ): + fired.append("WATCHDOG_STALE") + + # ---- 2) 연속 실패 -------------------------------------------------- + failed_days = consecutive_failed_days(conn) + if failed_days >= cfg.notify.escalation_days: + causes = top_failure_causes(conn, 3) + while len(causes) < 3: + causes.append(("(추가 원인 없음)", 0)) + ctx = { + "failed_days": failed_days, + "first_failed_date": (now - timedelta(days=failed_days - 1)).strftime("%Y-%m-%d"), + "last_failed_date": now.strftime("%Y-%m-%d"), + "top_cause_1": causes[0][0], "top_cause_1_count": causes[0][1], + "top_cause_2": causes[1][0], "top_cause_2_count": causes[1][1], + "top_cause_3": causes[2][0], "top_cause_3_count": causes[2][1], + "pause_day": cfg.notify.escalation_stop_after_days, + } + from dmf_crawler.notify import messages + rendered = messages.render("CONSECUTIVE_FAILURES", ctx) + if raise_alert( + conn, run_id=None, severity=Severity.CRITICAL, + code="CONSECUTIVE_FAILURES", + what=rendered.what, why=rendered.why, how=rendered.how, + next_actions=rendered.next_actions, context=ctx, + log_dir=Path(hb.log_dir) if hb.log_dir else None, + cooldown_minutes=cfg.notify.modal_repeat_minutes, + date_scoped=False, source="watchdog", + ): + fired.append("CONSECUTIVE_FAILURES") + + # ---- 3) 자동 실행 일시중지 (7일차) ---------------------------------- + if failed_days >= cfg.notify.escalation_stop_after_days: + if not paths.PAUSE_FLAG.exists(): + paths.STATE_DIR.mkdir(parents=True, exist_ok=True) + paths.PAUSE_FLAG.write_text( + json.dumps({"paused_at": now.isoformat(timespec="seconds"), + "reason": f"{failed_days}일 연속 실패", + "failed_days": failed_days}, + ensure_ascii=False), encoding="utf-8") + ctx = {"failed_days": failed_days, + "pause_at": now.strftime("%Y-%m-%d %H:%M"), + "pause_flag_path": str(paths.PAUSE_FLAG)} + from dmf_crawler.notify import messages + rendered = messages.render("RUN_PAUSED", ctx) + if raise_alert( + conn, run_id=None, severity=Severity.CRITICAL, code="RUN_PAUSED", + what=rendered.what, why=rendered.why, how=rendered.how, + next_actions=rendered.next_actions, context=ctx, + log_dir=Path(hb.log_dir) if hb.log_dir else None, + cooldown_minutes=cfg.notify.modal_repeat_minutes, + date_scoped=False, source="watchdog", + ): + fired.append("RUN_PAUSED") + + return fired +``` + +--- + +### 6.6 `src/dmf_crawler/notify/pump.py` 전문 + +```python +# src/dmf_crawler/notify/pump.py +"""대화형 알림 에이전트 — UI 를 띄울 권한을 가진 유일한 프로세스. + +실행 주체: + Task Scheduler 작업 DMF_Crawler_Agent + (LogonType Interactive, 15분 반복 + AtLogOn) + +원칙: + - 어떤 예외도 사용자에게 보이지 않게 삼킨다. 다만 이벤트 로그에는 반드시 남긴다. + "알리미가 죽어서 조용해지는 것"이 이 시스템의 최악의 실패다. + - 매 주기 pump.stamp 를 갱신한다. 이것이 없으면 notify.ps1 폴백이 발동한다. +""" +from __future__ import annotations + +import sys +import traceback +from datetime import datetime + +from dmf_crawler import checks, paths, watchdog +from dmf_crawler.alerts import AlertRow, Severity +from dmf_crawler.alerts import (mark_show_failed, mark_shown, mirror_to_file, + pending, snooze) +from dmf_crawler.config import Config, load_config +from dmf_crawler.gui import steps +from dmf_crawler.notify import eventlog, toast, webhook +from dmf_crawler.runlock import file_lock +from dmf_crawler.storage import db + +_PUMP_LOCK = "pump.lock" + + +def pump_once(cfg: Config) -> int: + """1회 주기. 종료 코드를 반환한다(0=정상, 1=표시 실패 있음, 2=치명).""" + # 자체 락. 이전 주기의 모달이 아직 떠 있으면 새로 띄우지 않는다. + try: + with file_lock(paths.STATE_DIR / _PUMP_LOCK, timeout_s=0): + return _pump_body(cfg) + except TimeoutError: + # 이미 다른 pump 가 돌고 있다. 정상적인 상황이다. + watchdog.touch_pump_stamp() + return 0 + + +def _pump_body(cfg: Config) -> int: + watchdog.touch_pump_stamp() + toast.reset_stack() + + conn = db.connect(cfg, readonly=False) + try: + # ---- 1) 워치독 판정 -> 필요하면 alerts 에 기록 ------------------- + try: + watchdog.evaluate(conn, cfg) + except Exception: + eventlog.write(source=cfg.notify.eventlog_source, + severity=Severity.ERROR, event_id=900, + message="워치독 판정 중 오류: " + traceback.format_exc(limit=3)) + + # ---- 2) 자동 해소 판정 (R-D2) ------------------------------------ + _auto_resolve(conn, cfg) + + # ---- 3) 표시 대상 조회 ------------------------------------------- + rows = pending(conn, modal_repeat_minutes=cfg.notify.modal_repeat_minutes) + mirror_to_file(conn, paths.ALERTS_MIRROR) + if not rows: + return 0 + + # ---- 4) 일일 상한(L3) 판정 — CRITICAL 은 면제 --------------------- + from dmf_crawler.alerts import shown_today + used = shown_today(conn) + criticals = [r for r in rows if r.severity is Severity.CRITICAL] + others = [r for r in rows if r.severity is not Severity.CRITICAL] + if used >= cfg.notify.daily_alert_cap and others: + eventlog.write(source=cfg.notify.eventlog_source, + severity=Severity.INFO, event_id=520, + message=f"일일 알림 표시 상한 {cfg.notify.daily_alert_cap}건 도달. " + f"남은 {len(others)}건은 표시하지 않음.") + others = [] + + # ---- 5) 표시 ------------------------------------------------------ + failures = 0 + # 5-a) CRITICAL / ERROR -> 강제 모달. 한 주기에 하나만 띄운다. + modal_targets = criticals + [r for r in others if r.severity is Severity.ERROR] + if modal_targets: + top = modal_targets[0] + if not _show_modal(conn, cfg, top): + failures += 1 + # 모달은 사용자가 응답할 때까지 블록한다. 나머지는 다음 주기로 미룬다. + return 1 if failures else 0 + + # 5-b) WARN/INFO -> 토스트. 2건 이상이면 병합(L2). + toast_targets = [r for r in others + if r.severity is Severity.WARN + or (r.severity is Severity.INFO and cfg.notify.show_info_toast)] + if not toast_targets: + return 0 + + if toast.is_presentation_mode(): + eventlog.write(source=cfg.notify.eventlog_source, + severity=Severity.INFO, event_id=510, + message="전체 화면/프레젠테이션 모드 감지. " + "토스트 표시를 다음 주기로 미룸.") + return 0 + + if len(toast_targets) >= cfg.notify.merge_threshold: + if not _show_merged(conn, cfg, toast_targets): + failures += 1 + else: + for row in toast_targets[: cfg.notify.max_toasts_per_hour]: + if not _show_toast(conn, cfg, row): + failures += 1 + + mirror_to_file(conn, paths.ALERTS_MIRROR) + return 1 if failures else 0 + finally: + conn.close() + + +# ------------------------------------------------------------------ 표시부 +def _show_toast(conn, cfg: Config, row: AlertRow) -> bool: + buttons = tuple( + toast.ToastButton(key, steps.label_of(key)) + for key in row.next_actions[:3] # 토스트에는 최대 3개 + ) + + def _on_click(key: str) -> None: + if key == "snooze": + snooze(conn, row.alert_id, minutes=cfg.notify.snooze_minutes) + return + steps.invoke(key, cfg, row.as_context()) + + result = toast.show( + title=row.title, body=row.body, severity=row.severity.value, + buttons=buttons, seconds=cfg.notify.toast_seconds, on_click=_on_click, + ) + + if result.shown: + mark_shown(conn, row.alert_id, channel="toast") + eventlog.write_alert(cfg, row.code, row.severity, row.title, row.what, + event_id=eventlog.EVENT_IDS["NOTIFY_SHOWN"]) + return True + + attempts = mark_show_failed(conn, row.alert_id) + eventlog.write(source=cfg.notify.eventlog_source, severity=Severity.WARN, + event_id=510, + message=f"[{row.code}] 토스트 표시 실패 ({attempts}회째).") + if attempts >= 3: + # R-P3: 3회 실패하면 모달 경로로 승격한다. + return _show_modal(conn, cfg, row) + return False + + +def _show_merged(conn, cfg: Config, rows: list[AlertRow]) -> bool: + lines = [f"[{'주의' if r.severity is Severity.WARN else '정보'}] {r.title}" + for r in rows[:5]] + if len(rows) > 5: + lines.append(f"그 외 {len(rows) - 5}건") + + def _on_click(key: str) -> None: + steps.invoke(key, cfg, rows[0].as_context()) + + result = toast.show_merged( + count=len(rows), lines=lines, severity="WARN", + seconds=cfg.notify.toast_seconds, on_click=_on_click, + ) + if not result.shown: + for row in rows: + mark_show_failed(conn, row.alert_id) + return False + for row in rows: + mark_shown(conn, row.alert_id, channel="toast") + eventlog.write_alert(cfg, row.code, row.severity, row.title, row.what, + event_id=eventlog.EVENT_IDS["NOTIFY_SHOWN"]) + return True + + +def _show_modal(conn, cfg: Config, row: AlertRow) -> bool: + # CRITICAL 은 화면 표시 성공 여부와 무관하게 웹훅을 병렬 발사한다. + if row.severity is Severity.CRITICAL: + webhook.send_alert(cfg, row) + + eventlog.write_alert(cfg, row.code, row.severity, row.title, row.body, + event_id=_eventlog_id_for(row)) + + focus_key = _CHECK_KEY_BY_CODE.get(row.code) + try: + from dmf_crawler.gui import app as gui_app + gui_app.launch(mode="recover", focus_key=focus_key, alert_id=row.alert_id) + except Exception: + eventlog.write(source=cfg.notify.eventlog_source, severity=Severity.ERROR, + event_id=510, + message=f"[{row.code}] 복구 창을 띄우지 못했습니다. " + f"MessageBox 폴백으로 강등합니다. " + + traceback.format_exc(limit=2)) + return _show_messagebox_fallback(conn, cfg, row) + + mark_shown(conn, row.alert_id, channel="modal") + return True + + +def _show_messagebox_fallback(conn, cfg: Config, row: AlertRow) -> bool: + """tkinter 가 완전히 불가능할 때 PowerShell 로 MessageBox 를 띄운다.""" + import subprocess + script = paths.SCRIPTS_DIR / "notify.ps1" + try: + subprocess.run( + ["powershell.exe", "-NoProfile", "-NonInteractive", + "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", + "-File", str(script), "-Mode", "Force"], + timeout=180, creationflags=steps.CREATE_NO_WINDOW, + ) + except Exception: + mark_show_failed(conn, row.alert_id) + return False + mark_shown(conn, row.alert_id, channel="messagebox") + return True + + +def _eventlog_id_for(row: AlertRow) -> int: + from dmf_crawler.notify import messages + tpl = messages.TEMPLATES.get(row.code) + if tpl and tpl.eventlog_id: + return tpl.eventlog_id + return {"WARN": 200, "ERROR": 300, "CRITICAL": 400}.get(row.severity.value, 200) + + +# 알림 코드 -> 복구 GUI 에서 포커스할 체크 항목 키 +_CHECK_KEY_BY_CODE: dict[str, str] = { + "API_KEY_MISSING": "api_key", + "API_KEY_INVALID": "api_key_live", + "AGY_MISSING": "agy_binary", + "AGY_AUTH": "agy_auth", + "DB_CORRUPT": "db_integrity", + "MIGRATION_FAILED": "db_schema", + "TASK_MISSING": "tasks", + "DISK_LOW": "disk_free", + "REPORT_FAILED": "report_writable", + "REPORT_LOCKED": "report_writable", + "WATCHDOG_STALE": "last_run", + "CONSECUTIVE_FAILURES": "last_run", + "RUN_PAUSED": "last_run", +} + + +def _auto_resolve(conn, cfg: Config) -> None: + """상태성 CRITICAL 은 해당 체크가 통과하면 자동 해소한다(R-D2).""" + from dmf_crawler.alerts import resolve + open_codes = {r.code for r in pending(conn, modal_repeat_minutes=10 ** 6)} + for code, check_key in _CHECK_KEY_BY_CODE.items(): + if code not in open_codes: + continue + try: + outcome = checks.run_one(check_key, cfg) + except Exception: + continue + if outcome.ok: + n = resolve(conn, code, note=f"진단 '{check_key}' 통과로 자동 해소") + if n: + eventlog.write( + source=cfg.notify.eventlog_source, severity=Severity.INFO, + event_id=500, + message=f"[{code}] 문제가 해결돼 알림을 지웠습니다.") + + +def main(argv: list[str] | None = None) -> int: + """cli.py 의 notify-pump 서브커맨드 본체.""" + try: + cfg = load_config() + except Exception: + # 설정조차 못 읽으면 이벤트 로그가 유일한 통로다. + eventlog.write(source="DMF Crawler", severity=Severity.CRITICAL, + event_id=900, + message="설정을 읽지 못해 알림 에이전트를 시작하지 못했습니다: " + + traceback.format_exc(limit=3)) + return 2 + try: + return pump_once(cfg) + except Exception: + eventlog.write(source=cfg.notify.eventlog_source, severity=Severity.CRITICAL, + event_id=900, + message="알림 에이전트가 예외로 종료됐습니다: " + + traceback.format_exc(limit=5)) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) +``` + +--- + +### 6.7 `src/dmf_crawler/notify/webhook.py` 전문 + +```python +# src/dmf_crawler/notify/webhook.py +"""웹훅 알림과 dead-man switch. 선택 기능(기본 off). + +원칙: + - 절대 예외를 밖으로 던지지 않는다. + - 재시도하지 않는다(1회만). 웹훅 실패는 화면 알림을 대체하지 않는다. + - URL 은 DPAPI 로 암호화 저장된다. config.toml 에 평문으로 두지 않는다. + - 본문에 인증키·토큰을 절대 포함하지 않는다(mask 적용). +""" +from __future__ import annotations + +import json +import re +from typing import Any + +import httpx + +from dmf_crawler import secrets_dpapi +from dmf_crawler.alerts import AlertRow, Severity +from dmf_crawler.config import Config + +_SECRET_NAME = "webhook_url" +_DEADMAN_NAME = "deadman_url" + +# 로그·웹훅 본문에서 지워야 하는 패턴 +_MASK_PATTERNS = ( + re.compile(r"(serviceKey=)[^&\s]+", re.I), + re.compile(r"(access_token[\"'\s:=]+)[A-Za-z0-9._\-]+", re.I), + re.compile(r"\bya29\.[A-Za-z0-9._\-]+"), +) + + +def _mask(text: str) -> str: + out = text + for pat in _MASK_PATTERNS: + out = pat.sub(lambda m: (m.group(1) if m.lastindex else "") + "***", out) + return out + + +def _url() -> str | None: + try: + value = secrets_dpapi.load(_SECRET_NAME) + except Exception: + return None + return value.strip() if value else None + + +def _payload(cfg: Config, title: str, body: str, severity: Severity) -> dict[str, Any]: + kind = cfg.notify.webhook_kind + icon = {"INFO": "ℹ️", "WARN": "⚠️", "ERROR": "⛔", "CRITICAL": "🚨"}.get( + severity.value, "⚠️") + text = f"{icon} **{title}**\n```\n{body}\n```" + + if kind == "discord": + return {"content": text[:1900]} + if kind == "slack": + return {"text": text[:3000]} + if kind == "telegram": + # 텔레그램은 URL 에 chat_id 가 포함된 형태를 전제로 한다. + return {"text": text[:4000], "parse_mode": "Markdown"} + return {"title": title, "body": body, "severity": severity.value} + + +def send(cfg: Config, *, title: str, body: str, severity: Severity) -> bool: + """웹훅 1건 발사. 성공하면 True.""" + if not cfg.notify.webhook_enabled: + return False + if not severity.at_least(Severity(cfg.notify.webhook_min_severity)): + return False + url = _url() + if not url: + return False + + payload = _payload(cfg, _mask(title), _mask(body), severity) + try: + with httpx.Client(timeout=cfg.notify.webhook_timeout_seconds) as client: + resp = client.post(url, json=payload) + return 200 <= resp.status_code < 300 + except Exception: + return False + + +def send_alert(cfg: Config, row: AlertRow) -> bool: + body = (f"{row.body}\n\n" + f"발생: {row.last_seen_at} (누적 {row.occurrences}회)\n" + f"코드: {row.code}") + return send(cfg, title=row.title, body=body, severity=row.severity) + + +# ------------------------------------------------------- dead-man switch +def ping(cfg: Config, event: str) -> bool: + """healthchecks.io 계열 dead-man switch. + + event: "start" | "success" | "fail" + PC 가 통째로 꺼져 있어도 상대편이 알아채는 유일한 경로다. + """ + if not cfg.notify.deadman_enabled: + return False + try: + base = secrets_dpapi.load(_DEADMAN_NAME) + except Exception: + return False + if not base: + return False + base = base.rstrip("/") + suffix = {"start": "/start", "success": "", "fail": "/fail"}.get(event, "") + try: + with httpx.Client(timeout=cfg.notify.webhook_timeout_seconds) as client: + resp = client.get(base + suffix) + return 200 <= resp.status_code < 300 + except Exception: + return False + + +def test(cfg: Config) -> tuple[bool, str]: + """온보딩 GUI 의 '웹훅 테스트' 버튼이 부르는 함수.""" + url = _url() + if not url: + return False, "웹훅 주소가 저장돼 있지 않습니다." + ok = send(cfg, + title="DMF 크롤러 — 웹훅 연결 시험", + body="이 메시지가 보이면 웹훅이 정상 연결됐습니다.\n" + "앞으로 심각한 문제가 생기면 여기로 알려드립니다.", + severity=Severity.CRITICAL) + return (True, "테스트 메시지를 보냈습니다. 채널을 확인하세요.") if ok \ + else (False, "전송에 실패했습니다. 주소와 인터넷 연결을 확인하세요.") +``` + +--- + +### 6.8 설정 키 증분 (AMD-04) + +아키텍처 §6.1 의 `[notify]` 섹션에 아래 키를 추가한다. 기존 5개는 그대로 유지된다. + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `notify.modal_repeat_minutes` | int | `60` | ERROR·CRITICAL 모달 재촉 주기 | +| `notify.snooze_minutes` | int | `60` | "나중에" 를 눌렀을 때 조용해지는 시간 | +| `notify.merge_threshold` | int | `2` | 이 건수 이상이면 토스트를 한 장으로 병합 | +| `notify.max_toasts_per_hour` | int | `6` | 시간당 토스트 상한 | +| `notify.daily_alert_cap` | int | `20` | 하루 알림 표시 상한(CRITICAL 면제) | +| `notify.show_info_toast` | bool | `false` | INFO 등급도 토스트로 띄울지 | +| `notify.pump_stamp_stale_minutes` | int | `45` | 이보다 오래되면 `notify.ps1` 폴백 발동 | +| `notify.escalation_days` | int | `3` | 연속 실패 이 일수부터 CRITICAL | +| `notify.escalation_webhook_days` | int | `5` | 이 일수부터 웹훅 강제 발사 | +| `notify.escalation_stop_after_days` | int | `7` | 이 일수부터 자동 실행 일시중지 | +| `notify.webhook_enabled` | bool | `false` | 웹훅 사용 | +| `notify.webhook_kind` | str | `"discord"` | `discord` \| `slack` \| `telegram` \| `generic` | +| `notify.webhook_min_severity` | str | `"CRITICAL"` | 이 등급 이상만 웹훅 발사 | +| `notify.webhook_timeout_seconds` | float | `10.0` | 웹훅 타임아웃 | +| `notify.deadman_enabled` | bool | `false` | dead-man switch 사용 | + +**`config/config.toml` 의 `[notify]` 완성형** + +```toml +[notify] +# --- 감지 --------------------------------------------------------------- +watchdog_stale_minutes = 120 # heartbeat 신선도 한계(06:00 + 2시간) +pump_stamp_stale_minutes = 45 # 이보다 오래되면 PowerShell 폴백 발동 +consecutive_failure_critical = 3 # 같은 WARN 이 이 횟수 반복되면 CRITICAL 승격 + +# --- 표시 --------------------------------------------------------------- +toast_seconds = 12 # 자동 소멸 알림 표시 시간 +modal_repeat_minutes = 60 # ERROR/CRITICAL 재촉 주기 +snooze_minutes = 60 # "나중에" 를 눌렀을 때 조용해지는 시간 +show_info_toast = false # INFO 도 토스트로 띄울지 + +# --- 폭주 억제 ----------------------------------------------------------- +cooldown_minutes = 240 # 동일 코드 알림 억제 시간(요구 R7.8) +merge_threshold = 2 # 이 건수 이상이면 한 장으로 병합 +max_toasts_per_hour = 6 +daily_alert_cap = 20 # CRITICAL 은 이 상한을 적용받지 않는다 + +# --- 에스컬레이션 --------------------------------------------------------- +escalation_days = 3 # 연속 실패 3일 -> CRITICAL + 진단 자동 표시 +escalation_webhook_days = 5 # 5일 -> 웹훅 강제 발사 +escalation_stop_after_days = 7 # 7일 -> 자동 실행 일시중지 + +# --- 기록 --------------------------------------------------------------- +eventlog_source = "DMF Crawler" + +# --- 보조 채널(선택, 기본 꺼짐) -------------------------------------------- +# 주소는 config 에 넣지 않는다. 온보딩 GUI 에서 입력하면 DPAPI 로 암호화 저장된다. +webhook_enabled = false +webhook_kind = "discord" # discord | slack | telegram | generic +webhook_min_severity = "CRITICAL" +webhook_timeout_seconds = 10.0 +deadman_enabled = false +``` + +--- + +## 7. 알림 테스트 절차 + +### 7.1 `dmf alert-test` 서브커맨드 (AMD-05) + +**아키텍처 §5 의 서브커맨드 표에 `alert-test` 를 추가한다.** 근거: 알림 계층은 "실패해야만 검증되는" 코드다. 실제 장애를 기다려 검증하면 영원히 검증되지 않는다. 인위적 유발 수단이 없으면 §7.2 의 절차 절반이 실행 불가능하다. + +| 커맨드 | 인자 | 동작 | +|---|---|---| +| `alert-test` | `--code ` | 해당 코드의 알림을 더미 컨텍스트로 1건 발생시킨다(DB 에 기록) | +| | `--all` | 등록된 모든 템플릿을 순회하며 문구를 **렌더링만** 하고 콘솔에 출력(발생시키지 않음) | +| | `--show` | 발생 후 즉시 `pump_once()` 를 불러 화면 표시까지 확인 | +| | `--channel {toast,modal,messagebox,msgexe,webhook,eventlog}` | 특정 채널만 강제로 시험 | +| | `--cleanup` | `alert-test` 가 만든 알림·이벤트를 전부 해소·삭제 | + +```python +# src/dmf_crawler/cli.py (발췌) +def cmd_alert_test(args) -> int: + from dmf_crawler.config import load_config + from dmf_crawler.notify import messages, eventlog, toast, webhook + from dmf_crawler.storage import db + from dmf_crawler import alerts, paths + from dmf_crawler.notify import pump + + cfg = load_config() + + # --all : 전 템플릿 렌더링 검사. DB 를 건드리지 않는다. + if args.all: + for code, tpl in sorted(messages.TEMPLATES.items()): + rendered = tpl.render(_DUMMY_CTX) + print("=" * 72) + print(f"[{tpl.severity.value}] {code}") + print(f"제목: {rendered.title}") + print(rendered.body) + print("버튼: " + " ".join( + f"[{steps.label_of(k)}]" for k in rendered.next_actions)) + return 0 + + # --channel : 채널 단독 시험. 알림을 만들지 않는다. + if args.channel: + return _test_channel(cfg, args.channel) + + code = args.code + if code not in messages.TEMPLATES: + print(f"알 수 없는 코드: {code}") + print("사용 가능: " + ", ".join(sorted(messages.TEMPLATES))) + return 2 + + tpl = messages.TEMPLATES[code] + rendered = tpl.render(_DUMMY_CTX) + conn = db.connect(cfg, readonly=False) + try: + if args.cleanup: + n = 0 + for c in messages.TEMPLATES: + n += alerts.resolve(conn, c, note="alert-test --cleanup") + print(f"{n}건을 해소했습니다.") + return 0 + + fired = alerts.raise_alert( + conn, run_id="TEST", severity=tpl.severity, code=code, + what=rendered.what, why=rendered.why, how=rendered.how, + next_actions=rendered.next_actions, + context=dict(_DUMMY_CTX), log_dir=paths.LOGS_DIR, + cooldown_minutes=0, # 시험에서는 쿨다운을 무시한다 + date_scoped=tpl.date_scoped, source="alert-test", + ) + alerts.mirror_to_file(conn, paths.ALERTS_MIRROR) + print(f"{code} 알림을 기록했습니다 (표시대상={fired}).") + if args.show: + rc = pump.pump_once(cfg) + print(f"pump_once 종료 코드: {rc}") + return 0 + finally: + conn.close() + + +_DUMMY_CTX: dict[str, object] = { + "run_date": "2026-09-02", "run_time": "06:04", "check_time": "2026-09-02 06:04", + "now_time": "08:15", "stale_date": "2026-09-01", + "pages_ok": 7, "pages_total": 12, "attempts": 4, + "last_error_short": "연결 시간 초과 (30초)", + "fail_count": 3, "cooldown_hours": 24, "resume_at": "2026-09-03 06:00", + "page_no": 3, "body_bytes": 84, "min_bytes": 200, + "total_count": 15832, "prev_count": 15840, "curr_count": 14851, + "drop_count": 989, "drop_pct": "6.2", "threshold_pct": "5.0", + "gate_name": "수집 건수 일치 검사", "gate_detail": "받은 건수가 API 가 알려준 전체 건수와 다릅니다.", + "gate_threshold": "0건 차이", "gate_observed": "12건 차이", + "missing_fields_str": "등록번호, 제조원", "null_ratio_pct": "37.4", + "baseline_null_pct": "1.0", "proposal_path": r"D:\workspace\DMF_Crawler\state\proposals\2026-09-02.json", + "result_code": "30", "result_msg": "SERVICE KEY IS NOT REGISTERED ERROR", + "key_fingerprint": "9f3aB1c2", "error_code": "LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR", + "calls_today": 10021, + "agy_path": r"C:\Users\encep\AppData\Local\agy\bin\agy.exe", + "agy_error_short": "authentication required", "occurrences": 2, + "tokens_used": 298_500, "tokens_cap": 300_000, "timeout": "10m", + "fallback_name": "DMF_리포트_2026-09-02_060412.xlsx", + "target_name": "DMF_리포트_2026-09-02.xlsx", "retries": 3, + "report_dir": r"D:\workspace\DMF_Crawler\reports", + "report_path": r"D:\workspace\DMF_Crawler\reports\DMF_리포트_2026-09-02.xlsx", + "failure_summary": "시트 '성분별 집계' 생성 중 오류가 발생했습니다.", + "drive": "D:", "free_gb": "1.4", "min_gb": "2.0", "reclaim_mb": 830, + "busy_timeout_s": 15, "db_path": r"D:\workspace\DMF_Crawler\data\dmf.sqlite3", + "integrity_result": "*** in database main *** Page 412: btreeInitPage() returns error code 11", + "backup_count": 12, "latest_backup_date": "2026-09-01", + "backup_dir": r"D:\workspace\DMF_Crawler\backup", + "from_version": 2, "to_version": 3, + "pre_migration_backup": r"D:\workspace\DMF_Crawler\backup\dmf_premigrate_0003.sqlite3", + "last_success_at": "2026-09-01 06:03", "stale_hours": 26, + "exec_limit_min": 30, "missing_tasks_str": "DMF_Crawler_Daily", + "failed_days": 3, "first_failed_date": "2026-08-31", "last_failed_date": "2026-09-02", + "top_cause_1": "AGY_AUTH", "top_cause_1_count": 3, + "top_cause_2": "FETCH_FAILED", "top_cause_2_count": 2, + "top_cause_3": "INTEGRITY_BLOCKED", "top_cause_3_count": 1, + "pause_day": 7, "pause_at": "2026-09-06 08:15", + "pause_flag_path": r"D:\workspace\DMF_Crawler\state\paused.flag", + "from_channel": "토스트", "to_channel": "복구 창", "reason": "창을 만들지 못했습니다", + "cap": 20, "pending_n": 4, "stale_min": 47, + "last_pump_at": "2026-09-02 07:30", "project_root": r"D:\workspace\DMF_Crawler", + "new_n": 12, "chg_n": 3, "wdr_n": 1, + "log_dir": r"D:\workspace\DMF_Crawler\logs\run_20260902_060012", + "raw_dir": r"D:\workspace\DMF_Crawler\data\raw\2026-09-02", +} + + +def _test_channel(cfg, channel: str) -> int: + from dmf_crawler.notify import eventlog, toast, webhook + from dmf_crawler.alerts import Severity + import subprocess + from dmf_crawler import paths + + if channel == "toast": + r = toast.show( + title="DMF 크롤러 — 알림 시험", + body="[무엇] 알림 채널 점검용 시험 메시지입니다.\n" + "[왜] dmf alert-test --channel toast 로 직접 실행했습니다.\n" + "[어떻게] 이 창이 보이면 토스트 채널이 정상입니다.", + severity="WARN", + buttons=(toast.ToastButton("open_doctor", "자세히 보기"), + toast.ToastButton("dismiss", "닫기")), + seconds=cfg.notify.toast_seconds, + ) + print(f"표시됨={r.shown}, 눌린버튼={r.clicked}") + return 0 if r.shown else 1 + + if channel == "modal": + from dmf_crawler.gui import app as gui_app + return gui_app.launch(mode="inspect") + + if channel in ("messagebox", "msgexe"): + rc = subprocess.run( + ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-File", str(paths.SCRIPTS_DIR / "notify.ps1"), "-Mode", "Test"] + ).returncode + return rc + + if channel == "webhook": + ok, msg = webhook.test(cfg) + print(msg) + return 0 if ok else 1 + + if channel == "eventlog": + ok = eventlog.write(source=cfg.notify.eventlog_source, + severity=Severity.WARN, event_id=500, + message="알림 채널 점검용 시험 기록입니다.") + print("기록 성공" if ok else "기록 실패") + return 0 if ok else 1 + + print(f"알 수 없는 채널: {channel}") + return 2 +``` + +### 7.2 시나리오별 인위적 유발 절차 + +각 행은 **"이렇게 하면 반드시 그 알림이 뜬다"** 는 재현 절차다. 검증 담당자는 이 표를 그대로 따라가면 된다. + +| # | 코드 | 유발 방법 | 기대 결과 | 원복 | +|---|---|---|---|---| +| T01 | `FETCH_FAILED` | `config.local.toml` 에 `[source] base_url = "https://127.0.0.1:9/none"` 지정 후 `dmf run --force` | 4회 재시도 후 WARN 토스트. 리포트는 스테일 자료로 생성 | 해당 줄 삭제 | +| T02 | `SOURCE_CIRCUIT_OPEN` | T01 을 3회 연속 실행 | 3회차에 CRITICAL 모달 1회. 4회차에는 침묵(전환 시에만 알림) | `DELETE FROM component_health WHERE component='source_mfds'` | +| T03 | `HTTP_BLOCKED_BODY` | 로컬에 200 + 본문 `점검중` 을 반환하는 1줄 서버를 띄우고 `base_url` 을 그리로 | WARN 토스트. `data/raw/` 에 원문 보존 | 설정 원복 | +| T04 | `ZERO_RECORDS` | 로컬 서버가 `{"response":{"body":{"totalCount":0,"items":[]}}}` 반환 | CRITICAL 모달 + 웹훅(켜져 있으면). 스냅샷 미저장 | 설정 원복 | +| T05 | `INTEGRITY_BLOCKED` | `config.local.toml` 에 `[integrity] max_null_ratio = 0.0` | WARN 토스트. diff 미수행 | 값 원복 | +| T06 | `INTEGRITY_DROP` | `[integrity] max_drop_ratio = 0.0000001` 로 낮춤 | CRITICAL 모달 | 값 원복 | +| T07 | `SCHEMA_DRIFT` | 로컬 서버가 필드명을 바꾼 응답 반환(`dmfRegNo` → `regNo`) | CRITICAL 모달. 원문 보존 | 설정 원복 | +| T08 | `API_KEY_MISSING` | `dmf secrets delete service_key` 후 `dmf run` | 종료 코드 2, CRITICAL 강제 모달. GUI 에 키 입력 버튼 | 키 재입력 | +| T09 | `API_KEY_INVALID` | 인증키를 `INVALID_TEST_KEY` 로 저장 후 `dmf run --force` | CRITICAL 강제 모달. 지문 앞 8자만 표시되는지 확인 | 정상 키 복원 | +| T10 | `API_QUOTA_EXCEEDED` | 로컬 서버가 `LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR` 반환 | WARN 토스트. 리포트 생성됨 | 설정 원복 | +| T11 | `AGY_MISSING` | `config.local.toml` 에 `[agy] binary_path = "C:\\nope\\agy.exe"` | CRITICAL 모달. **리포트는 정상 생성**(핵심 확인 항목) | 값 삭제 | +| T12 | `AGY_AUTH` | `~/.gemini/antigravity-cli/antigravity-oauth-token` 을 `.bak` 로 **이름 변경** | CRITICAL 모달. [로그인 창 열기] 클릭 시 **보이는 콘솔 창**이 뜨는지 확인 | 파일 이름 복원 | +| T13 | `AGY_QUOTA` | `[agy] daily_token_cap = 1` 로 설정 후 `dmf run --force` | WARN 토스트. 대시보드 시트에 "AI 요약 없음" 배지 | 값 원복 | +| T14 | `REPORT_LOCKED` | 오늘 리포트를 Excel 로 열어 둔 채 `dmf run --force` | WARN 토스트 + `_HHMMSS` 폴백 파일 생성 | Excel 닫고 `dmf report-only` | +| T15 | `REPORT_FAILED` | `reports/` 디렉터리를 읽기 전용으로 만들거나 이름을 바꿔 둠 | ERROR 모달. [리포트 다시 만들기] 버튼 존재 | 권한 원복 | +| T16 | `DISK_LOW` | `[backup] min_free_gb = 99999` | WARN 토스트 + [정리하기] 버튼. 백업만 스킵 | 값 원복 | +| T17 | `DB_LOCKED` | DB Browser for SQLite 로 `dmf.sqlite3` 를 열고 쓰기 트랜잭션 시작 후 `dmf run --force` | ERROR 모달 | 도구 닫기 | +| T18 | `DB_CORRUPT` | DB 사본을 만들어 헥스 편집기로 중간 바이트를 훼손하고 `[storage] sqlite_path` 를 그리로 | CRITICAL 강제 모달 + [백업으로 복원] | 설정 원복 | +| T19 | `WATCHDOG_STALE` | `state/heartbeat.json` 의 `last_success_at` 을 3일 전으로 수정 후 `dmf notify-pump --once` | CRITICAL 강제 모달 | 파일 삭제 후 정상 실행 | +| T20 | `TASK_MISSING` | `Disable-ScheduledTask -TaskName DMF_Crawler_Daily` | CRITICAL 모달 + [자동 실행 다시 등록] | `Enable-ScheduledTask` | +| T21 | `CONSECUTIVE_FAILURES` | `runs` 테이블에 최근 3일치 `status='FAILED'` 행을 직접 INSERT 후 pump 실행 | CRITICAL 모달 + 원인 상위 3개 표시 | 해당 행 DELETE | +| T22 | `RUN_PAUSED` | 위와 같이 7일치 FAILED INSERT 후 pump 실행 | `state/paused.flag` 생성 + CRITICAL 모달. **다음 `dmf run` 이 즉시 종료되는지 확인** | 플래그 삭제 | +| T23 | `PYTHON_BROKEN` | `.venv` 폴더를 `.venv_bak` 로 이름 변경 후 Agent 작업 수동 실행 | pump 액션 실패 → `notify.ps1 -Mode Guard` 가 MessageBox 표시 | 이름 복원 | +| T24 | 병합(L2) | `dmf alert-test --code FETCH_FAILED`, `--code REPORT_LOCKED`, `--code AGY_QUOTA` 를 연속 실행 후 `dmf notify-pump --once` | **토스트 1장**에 3건 요약 | `--cleanup` | +| T25 | 상한(L3) | `[notify] daily_alert_cap = 1` 로 낮추고 알림 2건 발생 | 2번째는 표시되지 않고 이벤트 ID 520 기록 | 값 원복 | +| T26 | 쿨다운(L1) | 같은 코드로 `alert-test` 를 2회 연속 실행(쿨다운 240분 기본값 사용) | 2번째는 `표시대상=False`, `occurrences=2` | `--cleanup` | +| T27 | 로그오프 축적(B1) | 알림 발생 후 로그오프 → 재로그온 | 로그온 직후 `AtLogOn` 트리거로 밀린 알림 표시 | — | +| T28 | 전체 화면(B4) | 게임/PPT 를 전체 화면으로 띄운 상태에서 WARN 발생 | 토스트가 미뤄지고 이벤트 ID 510 기록. 창을 내리면 다음 주기에 표시 | — | + +### 7.3 채널별 단독 점검 (설치 직후 필수) + +```powershell +# 1) 토스트 — 우하단에 12초짜리 창이 떠야 한다 +D:\workspace\DMF_Crawler\.venv\Scripts\python.exe -m dmf_crawler alert-test --channel toast + +# 2) 복구 GUI 모달 — 진단 체크리스트 창이 떠야 한다 +D:\workspace\DMF_Crawler\.venv\Scripts\pythonw.exe -m dmf_crawler alert-test --channel modal + +# 3) MessageBox 폴백 — 항상 맨 앞에 뜨는지 확인 +powershell -NoProfile -ExecutionPolicy Bypass ` + -File D:\workspace\DMF_Crawler\scripts\notify.ps1 -Mode Test + +# 4) 이벤트 로그 +D:\workspace\DMF_Crawler\.venv\Scripts\python.exe -m dmf_crawler alert-test --channel eventlog +Get-WinEvent -FilterHashtable @{LogName='Application'; ProviderName='DMF Crawler'} -MaxEvents 3 | + Format-List TimeCreated, Id, LevelDisplayName, Message + +# 5) 웹훅(켜 두었을 때만) +D:\workspace\DMF_Crawler\.venv\Scripts\python.exe -m dmf_crawler alert-test --channel webhook + +# 6) 전체 문구 렌더링 검사 — 치환 누락, 깨진 줄바꿈, 과도한 길이 확인 +D:\workspace\DMF_Crawler\.venv\Scripts\python.exe -m dmf_crawler alert-test --all +``` + +### 7.4 자동 회귀 테스트 + +`tests/test_alerts.py` 로 CI 없이도 `pytest` 한 번에 검증되는 항목들이다. + +```python +# tests/test_alerts.py +import json +import pytest + +from dmf_crawler.alerts import Severity, raise_alert, pending, resolve, snooze +from dmf_crawler.notify import messages + + +def test_모든_템플릿이_4요소를_갖는다(): + """messages 모듈 임포트만으로 계약이 검증된다(_validate_registry).""" + assert messages.TEMPLATES # 임포트가 성공했다는 것이 곧 통과 + for code, tpl in messages.TEMPLATES.items(): + assert tpl.what and tpl.why and tpl.how, code + assert tpl.next_actions, code + + +def test_실패_알림에_실행가능한_액션이_있다(): + for code, tpl in messages.TEMPLATES.items(): + if tpl.severity.at_least(Severity.WARN): + actionable = set(tpl.next_actions) - {"snooze", "dismiss"} + assert actionable, f"{code}: 막다른 골목 알림(R7.7 위반)" + + +def test_모든_액션키가_레지스트리에_존재한다(): + from dmf_crawler.gui import steps + for code, tpl in messages.TEMPLATES.items(): + for key in tpl.next_actions: + assert key in steps.ACTIONS, f"{code}: 미등록 액션 {key}" + + +def test_이벤트로그_ID가_1에서_1000_범위다(): + """eventcreate.exe 의 하드 제약.""" + from dmf_crawler.notify import eventlog + for name, eid in eventlog.EVENT_IDS.items(): + assert 1 <= eid <= 1000, f"{name}={eid} 는 eventcreate 범위를 벗어난다" + for code, tpl in messages.TEMPLATES.items(): + if tpl.eventlog_id: + assert 1 <= tpl.eventlog_id <= 1000, code + + +def test_4요소_누락시_ValueError(tmp_conn): + with pytest.raises(ValueError, match="why"): + raise_alert(tmp_conn, run_id="T", severity=Severity.WARN, code="X", + what="무엇", why=" ", how="어떻게", + next_actions=("run_now",), cooldown_minutes=0) + + +def test_막다른골목_알림은_거부된다(tmp_conn): + with pytest.raises(ValueError, match="R7.7"): + raise_alert(tmp_conn, run_id="T", severity=Severity.CRITICAL, code="X", + what="a", why="b", how="c", + next_actions=("dismiss",), cooldown_minutes=0) + + +def test_쿨다운_안에서는_표시대상이_아니다(tmp_conn): + kw = dict(run_id="T", severity=Severity.WARN, code="FETCH_FAILED", + what="a", why="b", how="c", next_actions=("run_now",), + cooldown_minutes=240) + assert raise_alert(tmp_conn, **kw) is True + assert raise_alert(tmp_conn, **kw) is False # 쿨다운 안 + rows = pending(tmp_conn) + assert len(rows) == 1 + assert rows[0].occurrences == 2 # 카운터는 올라간다 + + +def test_alert_events는_매번_쌓인다(tmp_conn): + kw = dict(run_id="T", severity=Severity.WARN, code="FETCH_FAILED", + what="a", why="b", how="c", next_actions=("run_now",), + cooldown_minutes=240) + raise_alert(tmp_conn, **kw) + raise_alert(tmp_conn, **kw) + n = tmp_conn.execute("SELECT COUNT(*) c FROM alert_events").fetchone()["c"] + assert n == 2 # append-only 불변식 + + +def test_해소된_알림은_pending에_없다(tmp_conn): + raise_alert(tmp_conn, run_id="T", severity=Severity.CRITICAL, code="AGY_AUTH", + what="a", why="b", how="c", next_actions=("agy_relogin",), + cooldown_minutes=0, date_scoped=False) + assert len(pending(tmp_conn)) == 1 + assert resolve(tmp_conn, "AGY_AUTH", note="테스트") == 1 + assert pending(tmp_conn) == [] + + +def test_스누즈_동안은_표시되지_않는다(tmp_conn): + raise_alert(tmp_conn, run_id="T", severity=Severity.CRITICAL, code="AGY_AUTH", + what="a", why="b", how="c", next_actions=("agy_relogin",), + cooldown_minutes=0, date_scoped=False) + row = pending(tmp_conn)[0] + snooze(tmp_conn, row.alert_id, minutes=60) + assert pending(tmp_conn) == [] + + +def test_문구에_비밀값_패턴이_없다(): + """문구 템플릿이 실수로 키·토큰을 노출하지 않는지.""" + banned = ("serviceKey=", "access_token", "ya29.") + for code, tpl in messages.TEMPLATES.items(): + blob = " ".join((tpl.title, tpl.what, tpl.why, tpl.how)) + for word in banned: + assert word not in blob, f"{code}: 비밀값 패턴 '{word}' 노출" + + +def test_제목이_60자를_넘지_않는다(): + for code, tpl in messages.TEMPLATES.items(): + assert len(tpl.title) <= 60, f"{code}: {len(tpl.title)}자" +``` + +### 7.5 설치 직후 승인 체크리스트 + +운영 담당자가 최초 설치 후 한 번 통과시키는 목록이다. 하나라도 실패하면 배치를 신뢰할 수 없다. + +- [ ] `alert-test --all` 이 전 템플릿을 오류 없이 렌더링하고, `(정보 없음)` 이 하나도 안 보인다 +- [ ] `alert-test --channel toast` — 우하단에 창이 뜨고 12초 뒤 사라진다 +- [ ] 토스트에 마우스를 올리면 사라지지 않고, 떼면 3초 뒤 사라진다 +- [ ] `alert-test --channel modal` — 복구 창이 뜨고 진단 12항목이 보인다 +- [ ] `notify.ps1 -Mode Test` — MessageBox 가 **다른 창들보다 앞에** 뜬다 +- [ ] `alert-test --channel eventlog` 후 이벤트 뷰어에 "DMF Crawler" 원본이 보인다 +- [ ] `alert-test --code AGY_AUTH --show` — 모달이 뜨고 [로그인 창 열기]가 있다 +- [ ] 그 버튼을 누르면 **보이는 검은 콘솔 창**이 뜨고 한국어 안내가 나온다 +- [ ] `alert-test --code REPORT_LOCKED --show` — 토스트 [리포트 다시 만들기]가 실제로 동작한다 +- [ ] T24(병합)를 수행하면 창이 3장이 아니라 **1장** 뜬다 +- [ ] T19(워치독)를 수행하면 CRITICAL 모달이 뜬다 +- [ ] T23(`.venv` 제거)을 수행하면 MessageBox 폴백이 뜬다 — **가장 중요한 항목** +- [ ] 위 시험 후 `alert-test --cleanup` 으로 시험 알림이 전부 사라진다 +- [ ] `state/alerts.json` 의 `pending` 이 빈 배열이 된다 + +--- + +## 8. 에스컬레이션 + +### 8.1 3단 에스컬레이션 + +| 일차 | 트리거 | 자동 조치 | 사용자에게 보이는 것 | +|---|---|---|---| +| **1~2일** | `runs.status='FAILED'` | 없음. 다음 실행을 기다린다 | 매일 ERROR 모달 1회(60분마다 재촉) | +| **3일** | `consecutive_failed_days >= notify.escalation_days` | `CONSECUTIVE_FAILURES` CRITICAL 발생. **복구 GUI 가 `inspect` 모드로 자동 기동**되어 진단 12종을 즉시 보여준다 | 원인 상위 3개가 적힌 CRITICAL 모달 + 진단 화면 | +| **5일** | `>= notify.escalation_webhook_days` | **웹훅 강제 발사.** `webhook_min_severity` 설정과 무관하게, 그리고 `webhook_enabled=false` 여도 URL 이 저장돼 있으면 보낸다 | 디스코드/슬랙/텔레그램 메시지 | +| **7일** | `>= notify.escalation_stop_after_days` | **`state/paused.flag` 생성 → 다음 `dmf run` 이 즉시 종료 0.** API 호출을 멈춘다 | `RUN_PAUSED` CRITICAL 모달. [자동 실행 재개] 버튼은 **진단 전부 통과해야 활성화** | + +### 8.2 왜 7일에 멈추는가 + +| 근거 | 설명 | +|---|---| +| **API 예의** | 고장난 상태로 매일 수십~수백 회 호출하면 공공데이터포털 쪽에 무의미한 부하를 준다. 요구 N1(정중한 접근)의 직접 귀결이다 | +| **쿼터 보호** | 개발계정 일일 10,000회 한도를 실패 재시도로 태우면, 정작 고친 날 쓸 수 없다 | +| **알림 피로 차단** | 7일째면 사용자는 이미 알림을 무시하고 있다. 조용해지되 **멈췄다는 사실 자체를 알리는 것**이 더 강한 신호다 | +| **되돌리기 쉬움** | 작업 스케줄러를 지우지 않고 플래그 파일 하나로 멈춘다. 재개는 파일 삭제 한 번이다 | + +### 8.3 일시중지의 구현 + +```python +# src/dmf_crawler/pipeline.py 의 stage_preflight 안 (발췌) +def _check_paused(ctx: RunContext) -> None: + """일시중지 플래그가 있으면 즉시 SKIPPED 로 끝낸다. + + --force 로도 뚫리지 않는다. 사람이 명시적으로 재개해야 한다. + (--force 는 '오늘 이미 성공했지만 다시 돌린다'는 뜻이지 + '고장난 채로 계속 두드린다'는 뜻이 아니다.) + """ + if not paths.PAUSE_FLAG.exists(): + return + try: + info = json.loads(paths.PAUSE_FLAG.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + info = {} + raise PausedError( + f"연속 실패로 자동 실행이 멈춰 있습니다 " + f"(멈춘 시각: {info.get('paused_at', '알 수 없음')}, " + f"사유: {info.get('reason', '알 수 없음')}). " + f"복구 창의 [자동 실행 재개]를 눌러 다시 켜세요." + ) +``` + +`PausedError` 는 `run_pipeline` 최상위에서 잡혀 **`status='SKIPPED'`, 종료 코드 0** 으로 처리된다. 종료 코드 1 을 쓰면 Task Scheduler 가 `RestartCount` 재시도를 3번 더 돌려 "멈췄는데 계속 도는" 모순이 생긴다. + +### 8.4 재개 경로 + +| 경로 | 조건 | 방법 | +|---|---|---| +| GUI | 진단 12종 전부 통과 | 복구 창 → [자동 실행 재개] | +| CLI | 없음(강제) | `dmf run --resume` 또는 `del state\paused.flag` | +| 자동 | **없다** | 자동 재개는 하지 않는다. 자동으로 멈춘 것을 자동으로 풀면 멈춘 의미가 사라진다 | + +### 8.5 에스컬레이션 흐름도 + +``` +매일 06:00 실행 + | + +- SUCCESS -> heartbeat 갱신, 일자성 WARN 자동 해소(R-D1), 카운터 0 으로 + | + +- FAILED + | + v + Agent(15분 주기) 워치독이 consecutive_failed_days 계산 + | + +----+-----------------------------------------+ + | 1~2일 : ERROR/CRITICAL 모달 (60분 재촉) | + | 3일 : CONSECUTIVE_FAILURES + 진단 자동 표시 | + | 5일 : 웹훅 강제 발사 | + | 7일 : paused.flag 생성 + RUN_PAUSED | + +----+-----------------------------------------+ + | + v + 사람이 원인 해결 -> 진단 통과 -> [자동 실행 재개] + | + v + plag 삭제, 다음 06:00 정상 복귀 + (다음 SUCCESS 에서 R-D1/R-D2 가 모든 알림을 해소) +``` + +--- + +## 9. 이 문서가 상위 정본에 요구하는 개정 목록 + +문서 사이의 어긋남을 방지하기 위해, 이 문서가 `docs/design/01-architecture.md` 에 대해 만든 개정을 한곳에 모은다. 아키텍처를 갱신할 때 이 표를 그대로 반영하면 된다. + +| ID | 대상 | 개정 내용 | 근거 | +|---|---|---|---| +| **AMD-01** | §3.14 `alerts.py` | `Severity` 를 3값 → **4값**(`INFO/WARN/ERROR/CRITICAL`)으로 확장 | "리포트 미생성"과 "사람 개입 필수"는 채널·재촉 주기·에스컬레이션 카운터가 전부 다르다(§2.1) | +| **AMD-02** | §2 디렉터리 트리 | `src/dmf_crawler/notify/webhook.py`, `scripts/notify.ps1`, `scripts/register_protocol.ps1` **3개 파일 추가** | 실패 시나리오 #32 에서 tkinter 는 원리적으로 뜰 수 없다. PowerShell 폴백이 유일한 화면 경로다(§6.0) | +| **AMD-03** | §3.9 불변식 | `alerts` 를 `alert_events`(append-only 사실) + `alerts`(상태 머신)로 **2분할** | 기존 불변식 "alerts 를 UPDATE 하지 않는다"를 깨지 않으면서 dedup·표시·해소 상태를 관리하는 유일한 방법(§2.3) | +| **AMD-04** | §6.1 설정 키 | `[notify]` 에 15개 키 추가 | 폭주 억제 3층·에스컬레이션 3단·웹훅 채널에 필요(§6.8) | +| **AMD-05** | §5 서브커맨드 표 | `alert-test` 추가 | 알림 계층은 인위적 유발 수단 없이는 검증 불가(§7.1) | +| **AMD-06** | §3.16 `pump.py` | `pump_once` 가 매 주기 `state/pump.stamp` 를 갱신하고, Agent 작업에 **두 번째 액션**(`notify.ps1 -Mode Guard`)을 등록 | pump 가 죽었다는 사실 자체를 감지할 주체가 필요하다(§6.1) | +| **AMD-07** | §5 종료 코드 | `PausedError` → `status='SKIPPED'` + 종료 코드 **0** | 코드 1 이면 Task Scheduler 가 3회 재시도해 "멈췄는데 계속 도는" 모순이 생긴다(§8.3) | +| **AMD-08** | §7 실패 시나리오 대응표 | 시나리오 #28(배치 미실행)의 알림 코드를 `WATCHDOG_STALE` 로, #30(연속 3일)을 `CONSECUTIVE_FAILURES` 로 명시. 신규 #34 `RUN_PAUSED` 추가 | 코드 이름이 dedup_key 의 일부이므로 표기가 일치해야 한다 | + +**연구 문서 08 과의 차이** (08 은 리서치, 01-architecture 가 정본이다) + +| 항목 | 연구 08 | 이 문서(=아키텍처 정본) | 이유 | +|---|---|---|---| +| 작업 3종 이름 | Daily / Watchdog / Notify | **Daily / Agent / AgyUpdate** | 워치독을 별도 작업으로 두지 않고 Agent 안에서 판정한다(아키텍처 부록). 대신 주간 `agy update` 작업이 필요해졌다 | +| 토스트 구현 | BurntToast | **tkinter 자체 창** | ADR-12. 외부 모듈 의존은 순환 실패를 만든다 | +| 이벤트 ID | 1000 / 1001 | **100~910 (§2.8)** | `eventcreate.exe` 의 `/ID` 는 1~1000 만 허용한다. 1001 은 실행 자체가 실패한다 | +| dead-man switch | 필수 | **선택(기본 off)** | 외부 서비스 계정이 전제이므로 비개발자 온보딩에서 기본값으로 강제할 수 없다. 온보딩에서 권유만 한다 | +| 웹훅 | 3단 폴백의 3단 | **병렬 채널** | 화면 표시 성공 여부와 무관하게 CRITICAL 은 발사한다. "화면을 봤다"와 "PC 앞에 있었다"는 다른 사실이다(§1.3) | + +--- + +## 부록. 미해결 / 실측 필요 + +### A. 실측이 필요한 항목 + +- [ ] **`eventcreate.exe` 의 `/ID` 상한이 정확히 1000 인가.** 문서상 1~1000 이지만 Windows 11 26xxx 빌드에서 재확인 필요. 1001 을 넣었을 때의 정확한 오류 메시지도 기록할 것. (⚠️ 미검증) +- [ ] **`/SO` 로 지정한 원본 이름이 Application 로그에 자동 등록되는가.** 그룹 정책으로 Application 로그 쓰기가 제한된 환경에서의 동작. 최초 1회 관리자 권한이 필요한지. (⚠️ 미검증) +- [ ] **`msg.exe` 가 Windows 11 Pro 26220 에 존재하는가.** Home 에디션에는 없는 것으로 알려져 있으나 Pro 실측 필요. `/TIME:` 파라미터의 실제 동작도. (⚠️ 미검증) +- [ ] **`SHQueryUserNotificationState` 의 Windows 11 반환값.** 특히 값 7(APP, 전체 화면 앱)이 실제로 반환되는지, 그리고 집중 지원(방해 금지) ON 일 때 값 6(QUIET_TIME)이 오는지. 우리 tkinter 창은 QUIET_TIME 에도 떠야 하므로 판정에서 6 을 제외한 것이 옳은지 확인. (⚠️ 미검증) +- [ ] **`overrideredirect(True)` + `-topmost` 창이 잠금 화면·UAC 프롬프트 위에 뜨는가.** 뜨면 안 된다(보안). 실제로는 뜨지 않을 것으로 보이나 확인 필요. (⚠️ 미검증) +- [ ] **Task Scheduler 다중 액션이 정말 앞 액션의 종료 코드를 무시하고 순차 실행하는가.** §6.1 폴백 설계의 전제다. `notify.ps1` 이 pump 실패와 무관하게 실행되는지 실측. (⚠️ 미검증) +- [ ] **`CREATE_NEW_CONSOLE` 로 띄운 `agy` 가 실제로 기본 브라우저 OAuth 를 여는가.** agy SSOT 는 "기본 브라우저로 OAuth 로그인"이라고 적었지만 재로그인(만료 후) 경로도 같은지 실측. 다르다면 §5.2 의 안내 배너 문구를 고쳐야 한다. (⚠️ 미검증) +- [ ] **`agy` 인증 만료 시 `error` 필드의 정확한 문자열과 종료 코드.** `classify_error` 의 `AUTH` 판정 규칙이 여기에 달려 있다. agy SSOT 부록 B 에도 같은 항목이 미결로 남아 있다. (⚠️ 미검증) +- [ ] **`agy` 쿼터 소진 시 `error` 문자열.** `QUOTA` 판정 규칙. 위와 동일. (⚠️ 미검증) +- [ ] **DPAPI 로 저장한 웹훅 URL 을 S4U 세션에서 복호화할 수 있는가.** 연구 08 은 S4U 가 "encrypted files 접근 불가"라고 경고한다. DPAPI 사용자 범위가 여기 해당하는지가 관건이다 — **해당한다면 웹훅을 배치가 아니라 Agent 만 발사할 수 있고, 설계를 바꿔야 한다.** 이 문서에서 가장 위험한 미검증 항목이다. (⚠️ 미검증, 우선순위 최상) +- [ ] **`file_lock`(msvcrt) 을 pump 와 파이프라인이 서로 다른 파일로 잡을 때 충돌이 없는가.** `run.lock` 과 `pump.lock` 분리가 의도대로 동작하는지. (⚠️ 미검증) + +### B. 설계 결정을 미룬 항목 + +- [ ] **`GEMINI_API_KEY` 모드로 전환해 agy 로그인 자체를 없앨 것인가.** agy SSOT §4.1 에 따르면 `modelProvider: "gemini"` + 환경변수로 완전 비대화형이 된다. 그러면 `AGY_AUTH` 시나리오 자체가 사라진다. 다만 **과금 체계가 달라지는 결정**이므로 알림 설계가 임의로 정할 수 없다. 사용자 판단 필요. +- [ ] **액션 센터 잔류가 필요한가.** tkinter 토스트는 놓치면 사라진다. 요구 N3(24시간 내 인지)는 만족하지만, "자리를 비운 사이 WARN 이 지나갔다"를 사용자가 불편해하면 `win11toast` 를 선택 의존성으로 켜는 안을 재검토한다. 그때 §4.4 의 프로토콜 등록이 필요해진다. +- [ ] **이메일 채널을 정말 안 만들 것인가.** 웹훅이 없는 사용자에게는 PC 밖 채널이 dead-man switch 뿐이다. 필요해지면 `notify/webhook.py` 에 `smtp` kind 를 추가하는 형태로 확장한다(파일 신설 없이). +- [ ] **알림 이력 화면.** 지금은 `alert_events` 에 쌓기만 하고 사람이 볼 화면이 없다. 리포트 xlsx 의 `s99_meta` 시트에 최근 14일 알림 요약을 넣을지, 복구 GUI 에 탭을 하나 더 둘지 미정. 리포트 명세(`docs/design/03-xlsx-report-spec.md`) 작성 시 결정한다. +- [ ] **`INTEGRITY_BLOCKED` 의 게이트별 문구 분리.** 지금은 게이트 이름을 `{gate_name}` 으로 치환하는 단일 템플릿이다. 게이트 2/4/5 의 사용자 조치가 실제로 다르다면 코드를 `INTEGRITY_COUNT_MISMATCH` / `INTEGRITY_NULL_RATIO` / `INTEGRITY_DUPLICATE` 로 쪼개야 한다. 실운영 데이터를 보고 결정한다. +- [ ] **다국어.** `general.language` 키가 있지만 문구는 한국어 상수로 하드코딩돼 있다. 영어가 필요해지면 `TEMPLATES` 를 언어별 딕셔너리로 감싼다. 지금은 필요 없다. + +### C. 운영 중 재확인할 임계값 + +- [ ] `notify.cooldown_minutes = 240` 이 적절한가. 하루 1회 배치이므로 240분이면 사실상 "하루 1회"다. 너무 조용하면 120 으로 내린다. +- [ ] `notify.daily_alert_cap = 20` 이 실제로 도달하는 날이 있는가. 도달한다면 병합(L2) 임계값을 낮추는 편이 낫다. +- [ ] `notify.watchdog_stale_minutes = 120` 이 06:00 + 2시간이라는 전제와 맞는가. `schedule.daily_time` 을 바꾸면 이 값도 함께 조정해야 한다 — **두 값을 연동시킬지, 독립으로 둘지** 정할 것. +- [ ] `notify.escalation_stop_after_days = 7` 이 너무 늦은가. 실운영에서 3일이면 이미 방치 상태라면 5일로 당긴다. +- [ ] `notify.toast_seconds = 12` 로 4요소 본문(약 6줄)을 다 읽을 수 있는가. 못 읽으면 15~20 으로 올린다. diff --git a/docs/ops/03-api-usage-policy.md b/docs/ops/03-api-usage-policy.md new file mode 100644 index 0000000..40bddbd --- /dev/null +++ b/docs/ops/03-api-usage-policy.md @@ -0,0 +1,1796 @@ +# 공공데이터 Open API 이용 정책과 운영 제약 대응 + +> **이 문서의 역할**: 이 프로젝트가 쓰는 공공데이터포털 오픈API 의 **법적·계약적·기술적 제약을 확정**하고, 무인 배치가 그 제약에 부딪혔을 때 **무엇을 자동으로 하고 무엇을 사람에게 넘길지**를 코드 수준까지 규정한다. 데이터를 "어디서" 가져올지는 `design/00-DATA-SOURCE-DECISION.md` 가, "어떤 구조로" 처리할지는 `design/01-architecture.md` 가 정한다. 이 문서는 **"그 소스를 어떤 규칙으로 오래 쓸 것인가"** 를 정한다. + +**작성일**: 2026-09-02 +**상태**: ✅ 확정 (미검증 항목은 `⚠️ 미검증` 표기) +**상위 문서**: `design/00-DATA-SOURCE-DECISION.md` (데이터 소스 SSOT) · `design/01-architecture.md` (구조 SSOT) · `design/00b-baseline-data-analysis.md` (기준선 실측) +**관련 요구**: `docs/00-REQUIREMENTS.md` — N1(법적 안전성), N2(무인 운영), N7(보안), R1.4(수집 완결성), R6.3~R6.5(키 입력·검증), R7.1~R7.8(오류 알림) + +--- + +## 0. 한눈에 보기 + +- **이 API 에는 사실상 "제약"이 없다.** 개발·운영 모두 자동승인이고, DCAT 메타의 `license` 는 `"이용허락범위 제한 없음"` 이며, 심사·서류·활용사례 등록이 **전부 불필요**하다. 걱정할 것은 이용 허락이 아니라 **인증키의 수명**이다. +- **트래픽 10,000 은 레코드 수가 아니라 하루 API 호출 건수**이고 매일 자정 00시에 초기화된다(공식 FAQ). 실측 `totalCount` **9,084건**(`design/00b-baseline-data-analysis.md`) 기준 하루 소요는 **약 15호출, 한도의 0.15%** 다. **운영계정 전환·활용사례 등록·트래픽 증량 신청은 영원히 필요 없다.** +- **진짜 시한폭탄은 두 개다.** ① 서비스키는 회원당 **단 1개**이고 재발급하면 기존 키가 **자동 폐기**된다(에러 30). ② 인증키에는 **사용 기한**이 있어 만료되면 에러 31 이 난다. 둘 다 사용자가 포털에서 무심코 한 행동으로 촉발되며, 배치는 조용히 죽는다. → §5 에서 이중 경보로 흡수한다. +- **오류 봉투는 두 종류다.** GW 레벨(HTTP 401/403 + `OpenAPI_ServiceResponse/cmmMsgHeader`)과 서비스 레벨(HTTP 200 + `response/header`). `type=json` 을 줘도 XML 이 올 수 있다. **JSON→XML 폴백 파서가 필수**이며, 분기는 `returnReasonCode` 가 아니라 **`errMsg` 문자열을 1차 키**로 한다. +- **모든 오류를 7개 조치 등급으로 환산한다**: `OK` / `EMPTY` / `RETRY` / `SLOW_DOWN` / `QUOTA` / `USER_ACTION` / `SCHEMA`. 등급이 **파이프라인 상태**(SUCCESS/PARTIAL/BLOCKED)와 **알림 등급**을 결정한다. 배치는 오류 코드를 사용자에게 절대 노출하지 않는다. +- **레이트 리밋은 아키텍처 §3.3 `http.py` 의 고정 정책을 그대로 따른다.** 요청 간 최소 간격 **0.7초 + 0~0.3초 지터**, 최대 **4회** 시도, 백오프 **base 5s · factor 2 · cap 300s · full jitter**, `Retry-After` **절대 우선**, 동시성 **1(직렬)**. 2025년 신설된 코드 23(초당 제한)·29(IP 차단)과 이용약관 제14조5항 때문에 병렬 호출은 **금지**한다. +- **출처 표시는 법적 의무가 확인되지 않았으나 무조건 넣는다.** 리포트 xlsx(XlsxWriter)와 문서에 들어갈 정확한 문구를 §7 에서 확정한다. +- **API 폐기·버전 상승 감지는 코드 12 관측이 유일한 실용 수단**이다. 여기에 응답 필드 집합을 매 실행 대조하는 **스키마 드리프트 게이트**를 `integrity.py` 의 여섯 번째 게이트로 추가한다(§8.4). +- **인증키는 `.env` 평문이 아니라 Windows DPAPI 로 암호화해 `%LOCALAPPDATA%\DMF_Crawler\service_key.bin` 에 저장한다**(아키텍처 §3.2 `secrets_dpapi.py`). 이는 `design/00-DATA-SOURCE-DECISION.md` §9 의 `.env` 안내를 **배포 경로에 한해 대체**한다(§1.4). + +--- + +## 1. 목차와 전제 + +1. [전제 — 상위 문서와의 정합](#14-전제--상위-문서와의-정합) +2. [제약 사실 확정표](#2-제약-사실-확정표) +3. [활용신청·승인 절차 — 비개발자용 화면 단계별](#3-활용신청승인-절차--비개발자용-화면-단계별) +4. [트래픽 계산](#4-트래픽-계산) +5. [인증키 만료·폐기 대응 — 무인 운영의 시한폭탄](#5-인증키-만료폐기-대응--무인-운영의-시한폭탄) +6. [오류 코드 대응표](#6-오류-코드-대응표) +7. [출처 표시 문구 확정](#7-출처-표시-문구-확정) +8. [API 버전·중단 대응과 스키마 변경 감지](#8-api-버전중단-대응과-스키마-변경-감지) +9. [레이트 리밋 매너 — 확정 파라미터](#9-레이트-리밋-매너--확정-파라미터) +10. [부록 A. 출처](#부록-a-출처) +11. [부록 B. 미해결 / 실측 필요](#부록-b-미해결--실측-필요) + +### 1.4 전제 — 상위 문서와의 정합 + +`design/01-architecture.md` 가 디렉터리 구조·모듈 계약·CLI·종료 코드의 **정본**이다. 이 문서는 그 안에서 **API 이용 정책에 해당하는 부분만** 채운다. 새 모듈·새 파일·새 서브커맨드를 만들지 않는다. + +**이 문서가 다루는 정책이 사는 곳** + +| 이 문서의 관심사 | 구현 위치 (아키텍처 정본) | 비고 | +|---|---|---| +| 인증키 저장·읽기·지문 | `src/dmf_crawler/secrets_dpapi.py` — `%LOCALAPPDATA%\DMF_Crawler\service_key.bin` | 아키텍처 §3.2 | +| HTTP 정중함·재시도·백오프 | `src/dmf_crawler/http.py` — `HttpClient` | 아키텍처 §3.3. §9 가 파라미터의 근거를 제공 | +| 전량 수집·페이지네이션·본문 시그니처 | `src/dmf_crawler/source_mfds.py` — `fetch_all()`, `parse_body()` | 아키텍처 §3.4 | +| **오류코드 → 조치등급 분류기** | `src/dmf_crawler/source_mfds.py` (같은 모듈 안) | §6.4. **새 모듈을 만들지 않는다** | +| 수집 완결성·스키마 드리프트 게이트 | `src/dmf_crawler/integrity.py` — 게이트 5종 + **게이트 6(신규 제안)** | 아키텍처 §3.6, 이 문서 §8.4 | +| 키 존재·실호출 검증·수명 경고 | `src/dmf_crawler/checks.py` — 체크 ④⑤ + **체크 ⑬(신규 제안)** | 아키텍처 §3.15, 이 문서 §5.5 | +| 알림 문구·등급·쿨다운 | `src/dmf_crawler/alerts.py`, `notify/messages.py` | 아키텍처 §3.14·§3.16. §5.7 이 문구를 제공 | +| 복구 GUI (키 재입력) | `src/dmf_crawler/gui/app.py`, `gui/steps.py` — `python -m dmf_crawler onboard --mode recover` | 아키텍처 §3.17 | +| 출처 표시 | `src/dmf_crawler/report/widgets.py`, `report/sheets/s99_meta.py` | 아키텍처 §3.13. **XlsxWriter** | +| 트래픽 예산 판정 | `src/dmf_crawler/checks.py` 체크 ⑭(신규 제안) + 문서 §4.4 참고 스크립트 | 상시 통과할 체크이므로 가벼움 | +| 키 수명 메타 | `state/key_meta.json` (런타임, 아키텍처 `state/` 규약) | 잠금 없이 읽는 가벼운 상태 파일 | + +**이 문서가 아키텍처에 추가를 요청하는 것 — 3건** + +| # | 요청 | 대상 | 이유 | +|---|---|---|---| +| A1 | `integrity.py` 에 **게이트 6 — 스키마 드리프트** 추가 | 아키텍처 §3.6 | 필드가 조용히 바뀌면 게이트 1~5 를 전부 통과하면서 빈 값이 들어온다 (§8.4) | +| A2 | `checks.py` 에 **체크 ⑬ — 인증키 수명 경고** 추가 | 아키텍처 §3.15 | 만료는 무인 운영을 깨는 1순위 원인인데 기존 12종에 없다 (§5.5) | +| A3 | `checks.py` 에 **체크 ⑭ — 트래픽 예산** 추가 | 아키텍처 §3.15 | 상시 통과하지만, 통과 사실을 숫자로 보여주는 것이 "증량 신청이 필요한가"라는 질문을 영구히 닫는다 (§4.4) | +| A4 | `state/key_meta.json` 을 런타임 상태 파일 목록에 추가 | 아키텍처 §2 트리 `state/` | 키 지문·최초 성공일·만료일·경고 이력 (§5.3) | + +**`design/00-DATA-SOURCE-DECISION.md` §9 와의 관계** + +그 문서는 `.env` 의 `DATA_GO_KR_SERVICE_KEY` 에 **Decoding 키**를 넣으라고 안내한다. 요구 R6.3(다이얼로그 입력·안전 저장)과 N7(키를 로그·저장소·문서에 남기지 않음)이 추가된 지금, **배포본의 정본 저장소는 DPAPI 파일**이다. + +| 우선순위 | 소스 | 용도 | +|---|---|---| +| 1 | `%LOCALAPPDATA%\DMF_Crawler\service_key.bin` (DPAPI, 현재 사용자) | **정본.** 비개발자 경로 | +| 2 | 환경변수 `DMF_SERVICE_KEY` | 임시 진단·테스트 (세션 한정) | +| 3 | `.env` 의 `DATA_GO_KR_SERVICE_KEY` | **개발자 전용 폴백.** 배포본에 포함하지 않음 | + +`httpx` 의 `params=` 는 값을 자동 인코딩하므로 **Decoding 키가 전제**다(아키텍처 §8.1). 비개발자는 마이페이지에서 보이는 아무 키나 복사하므로, `secrets_dpapi.save_service_key()` 가 **저장 시점에 정규화**해 항상 Decoding 형태로 보관한다(§5.3). 그러면 `http.py` 이하는 이 문제를 영원히 신경 쓰지 않아도 된다. + +> **개정 요청**: `design/00-DATA-SOURCE-DECISION.md` §9 6~7항에 "배포본은 DPAPI 저장소(`secrets_dpapi.py`)를 쓰고 `.env` 는 개발자 폴백이다. 어느 형태의 키를 넣어도 저장 시 Decoding 형태로 정규화된다 — `ops/03-api-usage-policy.md` §1.4" 를 추가할 것. + +--- + +## 2. 제약 사실 확정표 + +### 2.1 확정표 + +| # | 항목 | 실제 제약 내용 (원문 근거) | 출처 URL | 이 프로젝트에 미치는 영향 | 대응 | +|---|---|---|---|---|---| +| C1 | **활용신청 심의** | 「심의유형: 개발단계 : 자동승인 / 운영단계 : 자동승인」 | https://www.data.go.kr/data/15057075/openapi.do | **없음.** 심사 대기가 없다 | 온보딩에서 "신청 버튼을 누르면 즉시 승인됩니다"로 안내 (§3) | +| C2 | **이용허락범위** | DCAT/schema.org 메타에 `"license":"이용허락범위 제한 없음"`. 상세페이지 표기도 동일 | https://www.data.go.kr/catalog/15057075/openapi.json | **없음.** 재가공·사내 배포에 제약 없음 | 리포트 배포에 별도 절차 불필요 | +| C3 | **비용** | 「비용부과유무: 무료」 | https://www.data.go.kr/data/15057075/openapi.do | 없음 | — | +| C4 | **일일 트래픽** | 「트래픽은 하나의 서비스키로 하루 동안 호출할 수 있는 **API 호출 건수**를 의미합니다. … 매일 자정(00시)에 초기화됩니다」. 개발계정 **10,000** | https://www.data.go.kr/bbs/faq/selectFaqList.do (FAQ_0000000000000208) | 하루 약 15호출 = 한도의 **0.15%**. 실질 무제한 | 예산 체크 상시화(§4.4). 코드 22 는 자정 이후 재예약 | +| C5 | **초당 호출 제한** | `LIMITED_NUMBER_OF_SERVICE_REQUESTS_PER_SECOND_EXCEEDS_ERROR` / **23** — 「짧은 시간에 많은 요청이 발생하여 초당 호출 허용량을 초과했습니다」 (2025-09-19 갱신 표에 신설) | https://www.data.go.kr/data/15057075/openapi.do | 병렬·고속 페이징이 곧 실패 | **동시성 1 + 요청 간 0.7초 최소 간격**(§9) | +| C6 | **IP 차단** | `BLACKLIST_IP_ACCESS_ERROR` / **29** — 「차단된 IP에서 호출한 요청입니다」 (2025-09-19 신설) | https://www.data.go.kr/data/15057075/openapi.do | 걸리면 코드로 복구 불가. 사람이 전화해야 함 | §9 파라미터 준수. 29 관측 시 **재시도 금지**·즉시 CRITICAL 알림 | +| C7 | **이용약관상 이용 제한** | 제14조5항 「제공기관은 특정 회원의 이용형태로 인해 제공기관의 업무에 지장을 초래하거나 제공시스템의 성능 저하 등의 문제가 발생할 경우 서비스 이용을 제한할 수 있습니다」 | https://www.data.go.kr/ugs/selectPortalPolicyView.do | 하루 15호출·직렬은 사정권 밖 | §9 파라미터를 규범으로 고정. 임의 상향 금지 | +| C8 | **🔴 서비스키 단일성** | 「회원 계정 단위로 서비스키가 **1개만** 발급되며 … 기존 키를 보유한 상태에서 새로 신청하여 키를 재발급하면 **기존 키는 자동 폐기**되므로」 | https://www.data.go.kr/bbs/faq/selectFaqList.do (FAQ_0000000000000171, 159) | **최우선 위험.** 사용자가 무관한 목적으로 재발급하면 다음 06:00 에 코드 30 으로 사망 | 키 지문 저장 + 코드 30 → 강제 복구 GUI "인증키가 바뀐 것 같습니다" (§5.4) | +| C9 | **🔴 인증키 사용 기한** | 「`DEADLINE_HAS_EXPIRED_ERROR` / 31 / API 인증키의 사용 기한이 만료되었습니다. 공공데이터포털에서 이용 기간을 확인하거나 갱신해 주세요」 | https://www.data.go.kr/data/15057075/openapi.do | **최우선 위험.** 언젠가 반드시 온다 | 이중 경보: 주=코드 31 관측, 보조=수명 카운터 D-30/D-7 (§5) | +| C10 | **활용기간의 길이** | 2차 출처 다수가 「승인일로부터 24개월」이라 서술. 1차 출처(신청 폼)는 비로그인 시 `/index.do` 로 리다이렉트되어 **원문 확인 실패** | https://beaver-sohyun.tistory.com/38 | 하드코딩하면 오경보 또는 무경보 | **24개월을 하드코딩하지 않는다.** 사용자 입력값 우선, 없으면 "가정치" 라벨을 붙여 보조 경보만 (§5.5) `⚠️ 미검증` | +| C11 | **활용사례 등록** | 「운영신청 시 개발된 어플리케이션에 대해 내용을 활용 사례로 등록해야 함」 — **운영계정 신청 시에만** | https://data.mfds.go.kr/cntnts/11 | **없음.** 개발계정으로 완결 | 운영계정 전환 자체를 비목표로 확정(§3.3) | +| C12 | **상업적 이용·재가공** | 「공공기관이 제공하는 … 오픈API는 「공공데이터법」에 따라 **상업적 이용이 원칙적으로 허용**됩니다」. 단 「사실적 내용을 위·변조 및 왜곡하여 사용하는 것까지 보장하는 것은 아닙니다」 | https://www.data.go.kr/bbs/faq/selectFaqList.do (FAQ 210, 186) | 사내 xlsx 배포에 법적 걸림돌 없음 | **정규화 컬럼 옆에 원본 컬럼을 병기**해 왜곡 아님을 구조로 증명 (§7.4) | +| C13 | **출처표시 의무** | 이 데이터셋에 **공공누리 배지가 없고**, `license` 는 공공누리 유형이 아니라 "이용허락범위 제한 없음". 공공누리 전 유형은 출처표시가 필수지만 이 API 는 해당하지 않음 | https://www.kogl.or.kr/info/license.do | 법적 의무 **미확인** | 의무 여부와 무관하게 **항상 표기**한다. 문구 확정은 §7 | +| C14 | **CORS** | 「공공데이터포털의 오픈API(https://apis.data.go.kr)는 **CORS 허용 상태**로 제공되고 있습니다」 | https://www.data.go.kr/bbs/faq/selectFaqList.do (FAQ_0000000000000211) | 브라우저 직접 호출이 기술적으로 가능 → 키 노출 유혹 | **금지.** 키는 DPAPI 로 로컬 보관, 호출은 `http.py` 만 (아키텍처 §3.3 "유일한 네트워크 창구") | +| C15 | **serviceKey 인코딩** | 요청변수 표 원문: 「인증키 / serviceKey / 100 / 필 / **인증키 (URL Encode)**」 | https://www.data.go.kr/data/15057075/openapi.do | `httpx` 의 `params=` 는 자동 인코딩하므로 Encoding 키를 넣으면 `%2B`→`%252B` 이중 인코딩 → 코드 30 | **저장 시점 정규화**로 원천 차단. `secrets_dpapi` 가 항상 Decoding 형태로 보관 (§5.3, §6.6) | +| C16 | **오류 봉투 이원화** | 실측: 잘못된 키 → HTTP **403** + `{"OpenAPI_ServiceResponse":{"cmmMsgHeader":{...}}}`, 키 누락 → HTTP **401** + XML 동일 구조. 정상·서비스 오류 → HTTP **200** + `response/header` | https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01 (실측) | 단일 봉투 가정 파서는 인증 오류에서 즉사 | `parse_body()` 에 JSON→XML 폴백 (§6.4) | +| C17 | **코드 20 의 다의성** | 코드 `20` 에 `SERVICE_KEY_IS_NULL`·`PERMISSION_DENIED`·`SERVICE_ACCESS_DENIED_ERROR` 가 모두 매핑. FAQ 214: 「해당 API 서비스를 신청하지 않았거나, 변경신청 등으로 인해 일시 중지된 경우」 | https://www.data.go.kr/bbs/faq/selectFaqList.do (FAQ_0000000000000214) | 코드로 분기하면 엉뚱한 안내를 띄움 | **`errMsg` 1차 키, 코드는 폴백** (§6.4) | +| C18 | **문의 응답 지연** | Q&A 안내문: 「각 기관담당자에게 … 이관된 경우에는 기관담당자 답변까지 **최대 10일** 정도 소요될 수 있습니다」 | https://www.data.go.kr/catalog/15057075/openapi.json (contactPoint) | 장애 시 외부 지원에 기댈 수 없음 | 배치는 자립적으로 실패를 견디고 설명해야 함 (§6.5) | +| C19 | **구버전 엔드포인트** | `http://data.mfds.go.kr/openapi/MdcDmfInfoService/getMdcDmfList` 는 생존하나 실측 응답이 HTTP 200 + `20` `SERVICE ACCESS DENIED ERROR!` — **별도 활용신청 필요**, 봉투 구조도 다름 | https://www.data.go.kr/data/2095/linkedData.do | 드롭인 폴백 **불가** | 문서에 폴백 후보로만 기록. 코드 재사용 금지 (§8.5) | +| C20 | **갱신 주기** | 현행 데이터셋 페이지에 갱신주기 표기 없음. 연계데이터 2095 는 「수시」 | https://www.data.go.kr/data/2095/linkedData.do | 06:00 이 최적인지 불명 | `totalCount`·데이터 해시를 매일 기록해 2~4주 관측 후 재조정 (부록 B) | +| C21 | **API 는 정상 건만 반환한다** | 웹 화면 실측 **9,840건** vs API 실측 **9,084건** — **756건 차이** | `design/00b-baseline-data-analysis.md` §4 | 취하 판정이 "API 응답에서의 소멸"로 성립한다 (결정 D2) | 전량 수집 + 완결성 게이트가 취하 판정의 **전제조건**이 된다 (§6.7) | + +### 2.2 이 표에서 나오는 단 하나의 결론 + +> **"제약"이라 불릴 만한 것은 이 API 에 거의 없다. 있는 것은 인증키의 수명과 단일성뿐이며, 그 둘은 코드가 아니라 GUI 로 해소해야 한다.** + +트래픽·라이선스·심의·상업적 이용은 전부 통과다. 그러므로 이 문서의 분량 대부분(§5, §6)은 **인증키와 오류 처리**에 배정된다. 그것이 실제 위험이 있는 곳이다. + +--- + +## 3. 활용신청·승인 절차 — 비개발자용 화면 단계별 + +> **원칙**: 사용자에게 "API 키"라는 말을 최소한만 쓰고 **"인증키"** 로 통일한다. URL 구조를 설명하지 않는다. 모든 단계에 **버튼**을 제공한다. 이 절의 문구는 `gui/steps.py` 의 `enter_api_key` 액션과 `notify/messages.py` 가 그대로 가져다 쓴다. + +### 3.1 최초 발급 — 9단계 + +| # | 화면 | 사용자가 하는 일 | GUI 가 돕는 방법 | 걸리는 시간 | +|---|---|---|---|---| +| 1 | 공공데이터포털 메인 | 회원가입 (없으면) | [회원가입 페이지 열기] → `https://www.data.go.kr` | 3분 | +| 2 | 로그인 | 로그인 | 동일 버튼 | 30초 | +| 3 | 데이터셋 상세 | 페이지 진입 | [인증키 발급 페이지 열기] → `https://www.data.go.kr/data/15057075/openapi.do` | 즉시 | +| 4 | 상세 페이지 우측 상단 | **[활용신청]** 버튼 클릭 | 안내 문구로 위치 설명 | 즉시 | +| 5 | 활용신청 폼 — 활용목적 | 라디오에서 **"기타"** 또는 **"참고자료"** 선택, 목적란에 한 줄 입력 | 복사용 예시 문구 제공: `사내 원료의약품 등록(DMF) 현황 일일 모니터링 및 내부 보고서 작성` | 1분 | +| 6 | 활용신청 폼 — 상세기능정보 | `getMdcDmfList01` 체크 | "체크박스가 하나만 있습니다. 그것을 켜세요" | 즉시 | +| 7 | 활용신청 폼 — 라이선스 | 「이용허락범위 제한 없음」 동의 체크 → **[신청]** | — | 즉시 | +| 8 | **자동승인** | 아무것도 안 함 | "심사가 없습니다. 바로 승인됩니다" | **즉시** | +| 9 | 마이페이지 | **마이페이지 > 데이터 활용 > Open API > 활용신청 현황** 에서 인증키 복사 | [포털 마이페이지 열기] + "인증키 두 개(Encoding/Decoding) 중 **아무거나** 복사해 붙여넣으세요" | 1분 | + +> **9단계의 핵심 UX 결정**: 포털은 같은 키를 `일반 인증키(Encoding)` 와 `일반 인증키(Decoding)` 두 형태로 보여준다. 개발자도 여기서 자주 틀린다. 우리 GUI 는 **"아무거나 복사하세요"** 라고 말한다. `secrets_dpapi.save_service_key()` 가 저장 시점에 정규화하기 때문이다(§5.3). 이 한 줄이 비개발자 온보딩의 가장 큰 실패 지점을 제거한다. + +### 3.2 GUI 확정 문구 (최초 입력 화면) + +``` +처음 실행입니다. 공공데이터포털에서 발급받은 인증키가 필요합니다. + +아직 없다면 아래 [인증키 발급 페이지 열기] 를 누르세요. +로그인한 뒤 '활용신청' 버튼을 누르면 심사 없이 바로 승인됩니다. + +승인 후 + 마이페이지 > 데이터 활용 > Open API > 활용신청 현황 +에서 인증키를 복사해 아래 칸에 붙여넣으세요. + +인증키가 두 개(Encoding / Decoding) 보이더라도 +아무거나 복사하시면 됩니다. 프로그램이 알아서 처리합니다. +``` + +포털 경로 문자열은 **공식 FAQ 두 곳(FAQ_0000000000000264, FAQ_0000000000000208)이 지정한 그대로** 쓴다. 프로젝트 전역에서 이 문자열은 하나여야 하므로 `config/config.toml` 이 아니라 **코드 상수**로 고정한다(설정으로 바꿀 값이 아니다). + +```python +# src/dmf_crawler/source_mfds.py (발췌) — 사용자 대면 문자열 상수 +PORTAL_MYPAGE_HINT = "마이페이지 > 데이터 활용 > Open API > 활용신청 현황" +PORTAL_HOME_URL = "https://www.data.go.kr" +PORTAL_DATASET_URL = "https://www.data.go.kr/data/15057075/openapi.do" +SUPPORT_CENTER = "공공데이터포털 활용지원센터(1566-0025, 평일 09~18시)" +``` + +### 3.3 개발계정 vs 운영계정 + +| 구분 | 개발계정 | 운영계정 | +|---|---|---| +| 신청 시점 | 활용신청 시 자동 부여 | 개발계정 사용 후 별도 신청 | +| 심의 | 자동승인 | 자동승인 (이 API 한정) | +| 일일 트래픽 | **10,000** | 증량 신청 가능 (기본값 ⚠️ 미검증, 2차 출처는 10만) | +| 전제조건 | 없음 | **활용사례 등록 필수** | +| 이 프로젝트 | ✅ **채택** | ❌ **영구 비채택** | + +**언제 운영계정이 필요한가 — 판정 규칙** + +운영계정은 다음이 **모두** 참일 때만 검토한다. + +- [ ] 트래픽 예산 체크(§4.4)의 사용률이 **50%** 를 넘는다, 그리고 +- [ ] `numOfRows` 를 실측 최대값까지 올렸는데도 넘는다, 그리고 +- [ ] 하루 실행 횟수를 줄일 수 없다 + +§4 의 계산에 따르면 사용률은 **0.15%** 이고, DMF 등록 건수가 지금의 **약 300배(약 273만 건)** 가 되어야 50% 에 닿는다. 이 조건은 이 프로젝트의 수명 안에 성립하지 않는다. **운영계정 전환을 검토하지 않는다.** + +### 3.4 연장·재발급 시의 절차 (기존 사용자) + +| 상황 | 사용자가 갈 곳 | 결과 | +|---|---|---| +| 사용 기한 만료 임박/만료 (코드 31) | 마이페이지 > 데이터 활용 > Open API > 활용신청 현황 → 해당 API → **[활용연장신청]** | 기간 연장. 키 문자열 유지 여부는 ⚠️ 미검증 | +| 키가 안 먹음 (코드 30) | 같은 화면에서 **현재 인증키를 다시 복사** | 재발급을 누르지 말고 **복사만** 하도록 안내 | +| 활용신청 상태 확인 (코드 20) | 같은 화면에서 상태 확인 | "승인완료"가 아니면 신청이 안 끝난 것 | + +> **GUI 의 절대 금기**: "인증키 **재발급**" 버튼을 누르라고 안내하지 않는다. 재발급은 회원의 **모든** 오픈API 를 끊는다(C8). 우리 안내는 언제나 **"복사"** 또는 **"활용연장신청"** 이다. + +--- + +## 4. 트래픽 계산 + +### 4.1 입력값 + +| 항목 | 값 | 근거 | 신뢰도 | +|---|---|---|---| +| 전체 DMF 건수 (`totalCount`) | **9,084건** | `design/00b-baseline-data-analysis.md` — 프로토타입이 API 로 받아 적재한 xlsx 실측 (2026-09-02). 고유 등록번호 9,084, 중복 0 | ✅ **실측** | +| 참고: 웹 화면 총건수 | 9,840건 | `research/01-dmf-domain-and-sources.md` §4.1. API 와 **756건 차이** = 취하·취소 건 (C21) | ✅ 실측 | +| `numOfRows` 최대값 | **999** (추정) | 명세상 항목크기 3자리 | ⚠️ 미검증 | +| 일일 실행 횟수 | **1회** (06:00) | `00-REQUIREMENTS.md` R5.1 | 확정 | +| totalCount 취득 호출 | **1회/실행** | 아키텍처 §3.4 — `numOfRows=1` 선행 호출 | 확정 | +| 재시도 여유율 | **1.3배 (30%)** | 일시 오류 대비 | 확정 | +| 일일 한도 | **10,000 호출** | 「개발계정 : 10,000」 | 확정 | + +### 4.2 계산 + +``` +페이지 수 = ceil(9,084 / 999) = 10 +1회 수집 호출 = 10 + 1(totalCount 취득) = 11 +하루 호출 = ceil(11 × 1회 × 1.3) = 15 +사용률 = 15 / 10,000 = 0.15 % +남는 여유 = 10,000 − 15 = 9,985 호출 +``` + +### 4.3 시나리오 표 — 최악을 가정해도 안전한가 + +| 시나리오 | `numOfRows` | 하루 실행 | 페이지 수 | 하루 호출(×1.3) | 사용률 | 판정 | +|---|---|---|---|---|---|---| +| **기준(확정)** | 999 | 1 | 10 | **15** | **0.15%** | ✅ | +| 999 가 안 먹혀 100 으로 후퇴 | 100 | 1 | 91 | 120 | 1.20% | ✅ | +| 100 + 하루 2회 (오후 재수집) | 100 | 2 | 91 | 240 | 2.40% | ✅ | +| 명세 샘플값 그대로 10 | 10 | 1 | 909 | 1,183 | 11.83% | ✅ | +| 10 + 하루 2회 | 10 | 2 | 909 | 2,366 | 23.66% | ⚠️ 안전마진(50%) 이내이나 낭비 | +| 건수 10배 성장(90,840) + 999 | 999 | 1 | 91 | 120 | 1.20% | ✅ | +| 건수 300배(2,725,200) + 999 | 999 | 1 | 2,728 | 3,548 | 35.48% | ⚠️ 여기서 처음 검토 대상 | +| 30일 백필을 하루에 (`backfill --rereport` 는 호출 0) | 999 | 30 | 10 | 429 | 4.29% | ✅ | + +**결론**: **어떤 현실적 시나리오에서도 한도의 절반에 닿지 않는다.** 트래픽은 이 프로젝트의 제약이 아니다. + +> **백필은 호출을 거의 쓰지 않는다.** 아키텍처 §5 의 `backfill` 은 `--reparse`(원문 아카이브 재파싱)·`--rediff`·`--rereport` 를 쓰므로 **네트워크를 타지 않는다.** `data/raw//page_%04d.json` 원문 보존이 트래픽 절약 장치이기도 하다는 뜻이다. + +### 4.4 트래픽 예산 판정 (요청 A3 — `checks.py` 체크 ⑭) + +상시 통과할 체크지만, 통과 사실을 **숫자로** 보여주는 것이 "증량 신청이 필요한가"라는 질문을 영구히 닫는다. `doctor --json` 출력과 리포트 `s99_meta` 시트에 함께 실린다. + +```python +# src/dmf_crawler/checks.py (발췌) — 체크 ⑭ 트래픽 예산 +# +# 확정 사실 (공공데이터포털 공식 FAQ_0000000000000208): +# * 트래픽 = '하나의 서비스키로 하루 동안 호출할 수 있는 API 호출 건수'. +# 레코드 수가 아니다. +# * 제한은 매일 자정(00시)에 초기화된다. +# * 이 데이터셋의 개발계정 한도는 10,000. + +import math +from dataclasses import dataclass +from datetime import datetime, time as dtime, timedelta + +DAILY_QUOTA_DEV = 10_000 # 데이터셋 상세페이지 '신청 가능 트래픽: 개발계정 : 10,000' +SAFETY_RATIO = 0.5 # 한도의 50% 를 넘으면 재검토 +RETRY_FACTOR = 1.3 # 일시 오류로 인한 재호출 여유 30% + + +@dataclass(frozen=True, slots=True) +class Budget: + total_count: int + page_size: int + runs_per_day: int + calls_per_run: int + calls_per_day: int + quota: int = DAILY_QUOTA_DEV + + @property + def usage_ratio(self) -> float: + return self.calls_per_day / self.quota if self.quota else float("inf") + + @property + def headroom(self) -> int: + return self.quota - self.calls_per_day + + @property + def needs_review(self) -> bool: + return self.calls_per_day > self.quota * SAFETY_RATIO + + def detail(self) -> str: + return (f"전체 {self.total_count:,}건 / 페이지당 {self.page_size:,}건 " + f"→ 1회 {self.calls_per_run:,}호출 × {self.runs_per_day}회 " + f"= 하루 {self.calls_per_day:,}호출 " + f"(한도 {self.quota:,}, 사용률 {self.usage_ratio * 100:.2f}%, " + f"여유 {self.headroom:,})") + + def fix_hint(self) -> str: + if not self.needs_review: + return ("여유가 충분합니다. 운영계정 전환도, 활용사례 등록도, " + "트래픽 증량 신청도 필요하지 않습니다.") + return ("한도의 50%를 넘습니다. ① numOfRows 상향 ② 하루 실행 횟수 축소 " + "순으로 조정하고, 그래도 넘으면 운영계정 전환을 검토하세요.") + + +def plan_budget(total_count: int, page_size: int, runs_per_day: int = 1) -> Budget: + """수집 계획의 호출 예산을 계산한다. + + calls_per_run = ceil(totalCount / numOfRows) + 1(totalCount 취득 선행 호출) + """ + page_size = max(1, page_size) + pages = max(1, math.ceil(total_count / page_size)) + calls_per_run = pages + 1 + return Budget( + total_count=total_count, + page_size=page_size, + runs_per_day=runs_per_day, + calls_per_run=calls_per_run, + calls_per_day=int(math.ceil(calls_per_run * runs_per_day * RETRY_FACTOR)), + ) + + +def next_quota_reset(now: datetime | None = None) -> datetime: + """트래픽이 초기화되는 다음 자정(00시) + 5분 여유. + + 공식 FAQ: '해당 제한은 매일 자정(00시)에 초기화됩니다.' + 코드 22(QUOTA)를 만나면 이 시각 이후로 재시도를 예약한다. + """ + now = now or datetime.now() + return datetime.combine((now + timedelta(days=1)).date(), dtime(0, 5)) + + +def seconds_until_reset(now: datetime | None = None) -> int: + now = now or datetime.now() + return max(0, int((next_quota_reset(now) - now).total_seconds())) + + +def check_quota_budget(cfg, last_total_count: int | None) -> "CheckResult": + """체크 ⑭. 마지막 성공 실행의 totalCount 를 근거로 예산을 판정한다.""" + total = last_total_count if last_total_count else 9_084 # 기준선 실측값 + budget = plan_budget( + total_count=total, + page_size=cfg.source.page_size, + runs_per_day=cfg.source.runs_per_day, + ) + return CheckResult( + key="quota_budget", + title="일일 트래픽 예산", + ok=not budget.needs_review, + detail=budget.detail(), + fix_hint=budget.fix_hint(), + fix_action=None, + severity=Severity.WARN if budget.needs_review else Severity.INFO, + ) +``` + +### 4.5 여유가 부족해질 경우의 대응 순서 (사전 확정) + +| 순위 | 대응 | 비용 | 효과 | +|---|---|---|---| +| 1 | `numOfRows` 를 실측 최대값까지 상향 (`config.toml` 의 `page_size`) | 없음 | 호출 수 선형 감소 | +| 2 | 하루 실행 횟수를 1회로 고정 | 없음 | 배수 감소 | +| 3 | `totalCount` 선행 호출을 첫 페이지 응답의 `totalCount` 로 대체 | 진단 정보 소폭 감소 | 11→10 | +| 4 | 증분 수집 — `entp_name` / `ingr_kor_name` 로 부분 조회 | **취하 판정 불가**해짐 | ❌ **채택 불가** | +| 5 | 운영계정 전환 + 활용사례 등록 후 증량 신청 | 사람 작업 + 사례 공개 | 최후 수단 | + +> **4번(증분 수집)은 트래픽 절감 수단으로 채택하지 않는다.** 전량 스냅샷을 받지 않으면 "어제 있던 키가 오늘 없다"를 판정할 수 없고, 그러면 **취하 탐지가 원리적으로 불가능**해진다(C21, 결정 D2). 트래픽은 남아도는데 핵심 기능을 버리는 거래는 성립하지 않는다. + +--- + +## 5. 인증키 만료·폐기 대응 — 무인 운영의 시한폭탄 + +> **이 절이 이 문서에서 가장 중요하다.** 30일 무인 운영(N2)을 깨뜨리는 원인 1위는 네트워크도, 스키마 변경도 아니고 **인증키**다. + +### 5.1 위협 모델 — 키는 세 가지 방식으로 죽는다 + +| # | 죽는 방식 | 촉발 원인 | 관측되는 오류 | 사용자가 원인을 아는가 | +|---|---|---|---|---| +| K1 | **재발급으로 폐기** | 사용자가 다른 목적(날씨 API 등)으로 포털에서 "일반 인증키 재발급"을 누름. 회원당 키는 1개이므로 기존 키 자동 폐기 (C8) | `SERVICE_KEY_IS_NOT_REGISTERED_ERROR` / **30** / HTTP 403 | ❌ **전혀 모른다.** 몇 주 전 행동의 결과다 | +| K2 | **기한 만료** | 활용기간 경과 (C9) | `DEADLINE_HAS_EXPIRED_ERROR` / **31** | ❌ 모른다 | +| K3 | **일시 중지 / 변경신청 중** | 사용자가 포털에서 활용신청 내용을 변경 중이거나 신청이 미완료 (C17) | `SERVICE_ACCESS_DENIED_ERROR` / **20**, `TEMPORARILY_DISABLE_THE_SERVICEKEY_ERROR` / **21** | ❌ 모른다 | + +셋 다 공통점이 있다. **재시도해도 절대 낫지 않고, 사용자만 고칠 수 있으며, 사용자는 무엇이 깨졌는지 모른다.** 그래서 이 셋은 `USER_ACTION` 등급으로 묶여 파이프라인을 **BLOCKED(종료 코드 2)** 로 끝내고 복구 GUI 를 띄운다(R7.2). + +### 5.2 이중 경보 체계 + +``` + ┌───────────────────────────────────────────────┐ + 주 경보 (확실) │ checks.py 체크 ⑤ (API 키 실호출 검증) │ → USER_ACTION + Primary │ → 코드 30 / 31 / 20 / 21 관측 │ BLOCKED(2) + 복구 GUI + │ = 이미 죽었다. 사후 탐지. │ + └───────────────────────────────────────────────┘ + ┌───────────────────────────────────────────────┐ + 보조 경보 (예측) │ checks.py 체크 ⑬ (인증키 수명) │ → WARN 알림 + Secondary │ → state/key_meta.json 의 카운터 │ D-30 / D-7 각 1회 + │ = 아직 살아 있다. 사전 예방. │ 배치는 계속 진행 + └───────────────────────────────────────────────┘ +``` + +**주 경보가 진실이고 보조 경보는 편의다.** 보조 경보는 만료일을 모를 때 가정치로 돌아가므로 틀릴 수 있다(C10). 따라서 **보조 경보만으로 배치를 멈추지 않고, 주 경보만으로 배치를 멈춘다.** + +### 5.3 인증키 저장소 — `secrets_dpapi.py` 구현 (완결 코드) + +아키텍처 §3.2 가 정한 공개 API 4개(`save_service_key` / `load_service_key` / `delete_service_key` / `key_fingerprint`)를 이 문서가 정책까지 포함해 완성한다. 여기에 **키 수명 메타 갱신**(요청 A4)이 함께 들어간다. + +```python +# -*- coding: utf-8 -*- +"""secrets_dpapi.py — 공공데이터포털 serviceKey 를 사용자 범위 DPAPI 로 보관한다. + +설계 결정 (ops/03-api-usage-policy.md 1.4, 5.3): + * 저장 위치는 %LOCALAPPDATA%\\DMF_Crawler\\service_key.bin (아키텍처 3.2). + 다른 사용자 계정이나 다른 PC 로 복사해도 복호화되지 않는다. + * 평문은 메모리에만 존재한다. 로그·예외 메시지·표준출력에 절대 넣지 않는다 (요구 N7). + * **저장 시점에 정규화**한다. 포털이 보여주는 Encoding 키든 Decoding 키든 + 항상 Decoding(원본) 형태로 보관하므로, httpx 의 params= 자동 인코딩과 + 정확히 한 번만 맞물린다. 이중 인코딩(코드 30)이 원천적으로 불가능해진다. + * 키가 바뀌면 state/key_meta.json 의 수명 카운터를 초기화한다. + 새 키는 새 활용기간을 갖기 때문이다. +""" + +from __future__ import annotations + +import ctypes +import ctypes.wintypes as wintypes +import hashlib +import json +import os +import re +import sys +import urllib.parse +from datetime import datetime, timezone +from pathlib import Path + +from . import paths + +KEY_FILE = paths.local_app_dir() / "service_key.bin" # %LOCALAPPDATA%\DMF_Crawler\ +META_FILE = paths.state_dir() / "key_meta.json" # <프로젝트>\state\ + +_PCT = re.compile(r"%[0-9A-Fa-f]{2}") +_CRYPTPROTECT_UI_FORBIDDEN = 0x01 + +_crypt32 = ctypes.WinDLL("crypt32.dll") if sys.platform == "win32" else None +_kernel32 = ctypes.WinDLL("kernel32.dll") if sys.platform == "win32" else None + + +# --- DPAPI 바인딩 ---------------------------------------------------------- + +class _DataBlob(ctypes.Structure): + _fields_ = [("cbData", wintypes.DWORD), + ("pbData", ctypes.POINTER(ctypes.c_char))] + + +def _blob_in(data: bytes) -> _DataBlob: + buf = ctypes.create_string_buffer(data, len(data)) + return _DataBlob(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))) + + +def _blob_out(blob: _DataBlob) -> bytes: + out = ctypes.string_at(blob.pbData, int(blob.cbData)) + _kernel32.LocalFree(blob.pbData) + return out + + +def _protect(plaintext: bytes) -> bytes: + if _crypt32 is None: + raise RuntimeError("DPAPI 는 Windows 에서만 사용할 수 있습니다.") + src, dst = _blob_in(plaintext), _DataBlob() + if not _crypt32.CryptProtectData(ctypes.byref(src), None, None, None, None, + _CRYPTPROTECT_UI_FORBIDDEN, ctypes.byref(dst)): + raise OSError(ctypes.get_last_error(), "CryptProtectData 실패") + return _blob_out(dst) + + +def _unprotect(ciphertext: bytes) -> bytes: + if _crypt32 is None: + raise RuntimeError("DPAPI 는 Windows 에서만 사용할 수 있습니다.") + src, dst = _blob_in(ciphertext), _DataBlob() + if not _crypt32.CryptUnprotectData(ctypes.byref(src), None, None, None, None, + _CRYPTPROTECT_UI_FORBIDDEN, ctypes.byref(dst)): + raise OSError(ctypes.get_last_error(), "CryptUnprotectData 실패") + return _blob_out(dst) + + +# --- 정규화 — Encoding / Decoding 어느 쪽을 붙여넣어도 같은 결과 ---------- + +def normalize_service_key(key: str) -> str: + """포털이 보여주는 두 형태 중 어느 쪽이 와도 '디코딩된 원본'으로 되돌린다. + + Decoding : abc+def/ghi= + Encoding : abc%2Bdef%2Fghi%3D + + 비개발자는 둘 중 아무거나 복사한다. 공백·따옴표·개행도 함께 흡수한다. + 멱등하다 — 이미 원본인 값을 다시 넣어도 변하지 않는다. + """ + k = key.strip().strip('"').strip("'").strip() + if _PCT.search(k): + decoded = urllib.parse.unquote(k) + if decoded != k: + return decoded + return k + + +# --- 공개 API (아키텍처 3.2 계약) ------------------------------------------ + +def save_service_key(plaintext: str) -> Path: + """정규화 → DPAPI 암호화 → 원자적 교체. 저장 경로를 돌려준다.""" + normalized = normalize_service_key(plaintext) + if not normalized: + raise ValueError("빈 인증키는 저장할 수 없습니다.") + + KEY_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = KEY_FILE.with_suffix(".tmp") + tmp.write_bytes(_protect(normalized.encode("utf-8"))) + os.replace(tmp, KEY_FILE) + + _on_key_saved(_fingerprint_of(normalized)) + return KEY_FILE + + +def load_service_key() -> str | None: + """우선순위: DPAPI 파일 → 환경변수 → .env(개발자 폴백). 실패는 예외 없이 None.""" + if KEY_FILE.exists(): + try: + return _unprotect(KEY_FILE.read_bytes()).decode("utf-8") + except OSError: + pass # 다른 계정에서 복사된 파일 등 — 미설정과 동일 취급 + + env_key = os.environ.get("DMF_SERVICE_KEY", "").strip() + if env_key: + return normalize_service_key(env_key) + + dotenv = paths.project_root() / ".env" + if dotenv.exists(): + for line in dotenv.read_text(encoding="utf-8", errors="replace").splitlines(): + if line.strip().startswith("DATA_GO_KR_SERVICE_KEY="): + return normalize_service_key(line.split("=", 1)[1]) + return None + + +def delete_service_key() -> None: + if KEY_FILE.exists(): + KEY_FILE.unlink() + + +def key_fingerprint() -> str | None: + """sha256 앞 8자. 로그·GUI 표시용이며 원문이 아니다 (아키텍처 3.2).""" + key = load_service_key() + return _fingerprint_of(key) if key else None + + +def _fingerprint_of(key: str) -> str: + return hashlib.sha256(normalize_service_key(key).encode("utf-8")).hexdigest()[:8] + + +# --- 키 수명 메타 (state/key_meta.json) ------------------------------------ + +def _utcnow() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def load_meta() -> dict: + if not META_FILE.exists(): + return {} + try: + return json.loads(META_FILE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + + +def save_meta(meta: dict) -> None: + META_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = META_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, META_FILE) + + +def _on_key_saved(fp: str) -> None: + """키가 바뀌었으면 수명 카운터를 초기화한다. 새 키는 새 활용기간을 갖는다.""" + meta = load_meta() + if meta.get("keyFingerprint") != fp: + meta = { + "keyFingerprint": fp, + "savedAtUtc": _utcnow(), + "firstSuccessUtc": None, + "lastSuccessUtc": None, + "expiresOn": None, + "expiresOnSource": None, + "lastErrorCode": None, + "warnedStages": [], + } + else: + meta["savedAtUtc"] = _utcnow() + save_meta(meta) + + +def mark_success() -> None: + """수집이 성공했을 때 파이프라인이 호출한다.""" + meta = load_meta() + now = _utcnow() + meta.setdefault("keyFingerprint", key_fingerprint()) + if not meta.get("firstSuccessUtc"): + meta["firstSuccessUtc"] = now + meta["lastSuccessUtc"] = now + meta["lastErrorCode"] = None + save_meta(meta) + + +def mark_error(code: str) -> None: + meta = load_meta() + meta["lastErrorCode"] = code + meta["lastErrorUtc"] = _utcnow() + save_meta(meta) + + +def set_expiry(date_str: str, source: str = "user_input") -> None: + """사용자가 마이페이지에서 확인한 활용기간 만료일을 등록한다. + + 이 값이 들어오는 순간 보조 경보가 '가정치'에서 '실측치'로 승격된다. + GUI 의 '인증키 만료일 등록' 액션이 호출한다. + """ + datetime.strptime(date_str, "%Y-%m-%d") # 형식 검증 + meta = load_meta() + meta["expiresOn"] = date_str + meta["expiresOnSource"] = source + meta["warnedStages"] = [] # 경고 이력 리셋 + save_meta(meta) +``` + +**`state/key_meta.json` 스키마 (요청 A4)** + +```json +{ + "keyFingerprint": "3f9a1c07", + "savedAtUtc": "2026-09-02T21:10:00+00:00", + "firstSuccessUtc": "2026-09-02T21:10:12+00:00", + "lastSuccessUtc": "2026-09-14T21:00:41+00:00", + "expiresOn": null, + "expiresOnSource": null, + "lastErrorCode": null, + "warnedStages": [] +} +``` + +| 필드 | 의미 | 왜 필요한가 | +|---|---|---| +| `keyFingerprint` | sha256 앞 8자 | 키 원문 없이 "키가 바뀌었는지"만 판정 (C8 대응) | +| `firstSuccessUtc` | 최초 성공 호출 시각 | 만료일을 모를 때 가정치 계산의 기준점 | +| `lastSuccessUtc` | 최근 성공 시각 | `doctor` 표시, 워치독 보조 | +| `expiresOn` | 사용자가 등록한 실제 만료일 | 있으면 가정치를 대체 | +| `expiresOnSource` | `"user_input"` / `null` | 경고 문구에 "추정/확정"을 표시 | +| `warnedStages` | `["D-30", "D-7", "EXPIRED"]` | 같은 단계를 매일 반복 경고하지 않는다 (R7.8) | + +### 5.4 파이프라인의 동작 — 만료·폐기가 감지되면 + +키 검증은 **`checks.py` 체크 ⑤(API 키 실호출 검증, `numOfRows=1`)** 가 담당한다. 아키텍처가 이미 이 체크를 정의해 두었으므로, 파이프라인은 수집 스테이지 진입 전에 이 체크의 결과를 본다. **잘못된 키로 10페이지를 때려 트래픽과 시간을 낭비하는 일을 막는다.** + +| 단계 | 동작 | +|---|---| +| 1 | 파이프라인 첫 스테이지에서 체크 ⑤ 실행 — 호출 1건 | +| 2 | 결과가 `USER_ACTION` 이면 → `secrets_dpapi.mark_error(code)` → **수집 스테이지를 시작하지 않는다** | +| 3 | `alerts.py` 에 **CRITICAL** 알림 기록 (dedup_key = `api_key:`) | +| 4 | 파이프라인 상태 `BLOCKED`, **종료 코드 2** (아키텍처 5절) | +| 5 | `notify/pump.py` 가 CRITICAL 알림을 보고 **복구 GUI 를 강제 기동**: `python -m dmf_crawler onboard --mode recover --focus api_key_live` | +| 6 | GUI 에서 새 키 입력 → `save_service_key()` → 체크 ⑤ 재실행 → 통과하면 [지금 실행] 활성 | +| 7 | 사용자가 창을 닫으면 알림은 **미해소 상태로 남고**, 다음 `notify-pump` 실행(로그온·주기)에서 재표시 (R7.2 제약 대응) | + +**리포트는 어떻게 되는가** — 아키텍처 §3.6 과 일관되게: + +| 상황 | 리포트 | 파이프라인 상태 | 종료 코드 | +|---|---|---|---| +| 키 무효·만료 (`USER_ACTION`) — 수집 스테이지 미진입 | **미생성.** 직전 리포트 파일은 그대로 보존 | `BLOCKED` | **2** | +| 수집 실패(`QUOTA`·`RETRY` 소진) + **직전 성공 스냅샷 있음** | 마지막 성공 스냅샷으로 재생성 + 대시보드에 "오늘 수집 실패 — 마지막 성공: YYYY-MM-DD" 배너 | `PARTIAL` | **0** | +| 수집 실패 + **직전 성공 스냅샷 없음** (최초 실행) | 미생성 | `FAILED` | **1** | +| 스키마 오류(`SCHEMA`) | 미생성 | `BLOCKED` | **2** | + +> **R4.4("AI 가 실패해도 리포트는 생성된다")와의 구분**: AI 실패는 **부가가치의 상실**이므로 리포트를 만든다. **인증 실패는 전제조건의 부재**이므로 만들지 않는다. 빈 리포트를 덮어써서 어제 것마저 잃는 것이 최악이다. 이 구분은 종료 코드 2(BLOCKED)의 정의 — "전제조건 미충족, 재시도해도 같은 결과" — 와 정확히 일치한다. + +### 5.5 사전 경고 — D-30 / D-7 (요청 A2 — `checks.py` 체크 ⑬) + +```python +# src/dmf_crawler/checks.py (발췌) — 체크 ⑬ 인증키 수명 +# +# 원칙 (C9, C10): +# * 활용기간의 실제 길이를 확정하지 못했다(2차 출처는 24개월, 1차 출처 미확인). +# 따라서 24개월을 '사실'로 다루지 않고 '가정치'로만 쓰며, 문구에 그 사실을 밝힌다. +# * 사용자가 마이페이지에서 실제 만료일을 등록하면(set_expiry) 가정치를 대체한다. +# * 이 체크는 **보조 경보**다. 배치를 멈추지 않는다. +# 배치를 멈추는 것은 오직 체크 5 의 코드 31 관측(주 경보)뿐이다. + +from dataclasses import dataclass +from datetime import date, datetime, timezone + +from . import secrets_dpapi + +ASSUMED_VALIDITY_MONTHS = 24 # 미검증 — 2차 출처 기준의 가정치 +WARN_STAGES: tuple[int, ...] = (30, 7) # D-30, D-7 +PORTAL_MYPAGE_HINT = "마이페이지 > 데이터 활용 > Open API > 활용신청 현황" + + +@dataclass(frozen=True, slots=True) +class ExpiryView: + expires_on: date | None + is_assumed: bool + days_left: int | None + stage: int | None # 30 또는 7. 아직 아니면 None + expired: bool + + def message(self) -> str: + if self.expires_on is None: + return "아직 성공한 호출이 없어 인증키 수명을 계산할 수 없습니다." + qualifier = (" (정확한 만료일을 등록하지 않아 '승인일 + 24개월'로 추정한 값입니다. " + "포털에서 실제 날짜를 확인해 등록하면 더 정확해집니다.)" + if self.is_assumed else "") + if self.expired: + return (f"인증키의 사용 기한이 지난 것으로 보입니다 " + f"(만료일 {self.expires_on:%Y-%m-%d}). " + f"{PORTAL_MYPAGE_HINT} 에서 '활용연장신청'을 해주세요.{qualifier}") + return (f"인증키 사용 기한이 {self.days_left}일 남았습니다 " + f"(만료 예정 {self.expires_on:%Y-%m-%d}). " + f"{PORTAL_MYPAGE_HINT} 에서 '활용연장신청'을 미리 해두세요.{qualifier}") + + +def _add_months(base: date, months: int) -> date: + total = base.month - 1 + months + year, month = base.year + total // 12, total % 12 + 1 + leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) + last = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] + return date(year, month, min(base.day, last)) + + +def _parse_utc(value: str | None) -> datetime | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +def compute_expiry(today: date | None = None) -> ExpiryView: + today = today or datetime.now(timezone.utc).date() + meta = secrets_dpapi.load_meta() + + explicit = meta.get("expiresOn") + if explicit: + try: + expires, assumed = datetime.strptime(explicit, "%Y-%m-%d").date(), False + except ValueError: + expires, assumed = None, True + else: + anchor = _parse_utc(meta.get("firstSuccessUtc")) or _parse_utc(meta.get("savedAtUtc")) + if anchor is None: + return ExpiryView(None, True, None, None, False) + expires, assumed = _add_months(anchor.date(), ASSUMED_VALIDITY_MONTHS), True + + if expires is None: + return ExpiryView(None, True, None, None, False) + + days_left = (expires - today).days + stage = next((t for t in WARN_STAGES if days_left <= t), None) + return ExpiryView(expires, assumed, days_left, stage, days_left < 0) + + +def check_key_lifetime(cfg) -> "CheckResult": + """체크 13. 경고 단계에 처음 진입할 때만 WARN 을 낸다 (R7.8 알림 폭주 방지).""" + view = compute_expiry() + if view.expires_on is None: + return CheckResult( + key="key_lifetime", title="인증키 사용 기한", + ok=True, detail="아직 성공한 호출이 없어 계산할 수 없습니다.", + fix_hint="", fix_action=None, severity=Severity.INFO, + ) + + label = "추정" if view.is_assumed else "확정" + detail = (f"{view.expires_on:%Y-%m-%d} 만료({label}) — " + + ("만료됨" if view.expired else f"{view.days_left}일 남음")) + + if view.stage is None and not view.expired: + return CheckResult( + key="key_lifetime", title="인증키 사용 기한", + ok=True, detail=detail, fix_hint="", fix_action=None, + severity=Severity.INFO, + ) + + # 같은 단계를 매일 반복해서 알리지 않는다. + meta = secrets_dpapi.load_meta() + warned = list(meta.get("warnedStages") or []) + stage_key = "EXPIRED" if view.expired else f"D-{view.stage}" + first_time = stage_key not in warned + if first_time: + warned.append(stage_key) + meta["warnedStages"] = warned + secrets_dpapi.save_meta(meta) + + return CheckResult( + key="key_lifetime", title="인증키 사용 기한", + ok=False, detail=detail, fix_hint=view.message(), + fix_action="open_portal_mypage", + severity=Severity.WARN if first_time else Severity.INFO, + ) +``` + +**경고 단계 확정표** + +| 단계 | 트리거 | 등급 | 복구 GUI 강제 기동 | 배치 진행 | 반복 | +|---|---|---|---|---|---| +| D-30 | 남은 일수 ≤ 30 | WARN | 아니오 (토스트) | 계속 | 1회만 | +| D-7 | 남은 일수 ≤ 7 | WARN | 아니오 (토스트) | 계속 | 1회만 | +| 추정 만료일 경과 | 남은 일수 < 0 | WARN | 아니오 (토스트) | 계속 | 1회만 | +| **코드 31 관측 (체크 ⑤)** | API 응답 | **CRITICAL** | **예** | **BLOCKED(2)** | 매 실행 (dedup 쿨다운 적용) | + +> 추정 만료일이 지나도 배치를 멈추지 않는 이유: **24개월이 틀렸을 수 있기 때문이다**(C10). 실제로 키가 죽었다면 같은 실행의 체크 ⑤ 가 코드 31 로 잡아낸다. **가정치로 서비스를 멈추지 않는다**는 것이 이 설계의 규율이다. + +### 5.6 복구 GUI — 온보딩과 같은 컴포넌트 + +별도의 PowerShell 다이얼로그를 만들지 않는다. 요구사항 4절("온보딩 = 복구, 하나의 컴포넌트")과 아키텍처 §3.17 에 따라 **`gui/app.py` 한 화면**이 세 진입 모드를 모두 처리한다. + +| 진입 | 명령 | 화면 | +|---|---|---| +| 최초 설정 | `pythonw -m dmf_crawler onboard --mode setup` | 체크 14종 전체 체크리스트 | +| 복구 | `pythonw -m dmf_crawler onboard --mode recover --focus api_key_live` | **실패한 항목만 강조** + 원인 설명 + 액션 버튼 | +| 점검 | `pythonw -m dmf_crawler onboard --mode inspect` | 전체 통과 화면 + [지금 실행] | + +`gui/steps.py` 가 제공해야 하는 **API 키 관련 액션 4종**: + +| 액션 키 | 버튼 라벨 | 동작 | +|---|---|---| +| `enter_api_key` | 인증키 입력 | 텍스트 입력 → `secrets_dpapi.save_service_key()` → 체크 ⑤ 재실행 | +| `open_issue_page` | 인증키 발급 페이지 열기 | `webbrowser.open(PORTAL_DATASET_URL)` | +| `open_portal_mypage` | 포털 마이페이지 열기 | `webbrowser.open(PORTAL_HOME_URL)` + 경로 문구 표시 | +| `set_key_expiry` | 인증키 만료일 등록 | 날짜 입력 → `secrets_dpapi.set_expiry()` → 체크 ⑬ 재실행 | + +**입력 위생 규칙** (구현 시 반드시 지킬 것) + +- 키 원문을 **명령행 인자로 전달하지 않는다.** 프로세스 목록·이벤트 로그에 남는다. GUI 는 같은 프로세스 안에서 `save_service_key()` 를 직접 호출한다. +- 입력 필드는 붙여넣기 직후 정규화한 **길이와 지문만** 표시한다(예: "인증키 확인됨 — 84자, 지문 3f9a1c07"). 원문을 화면에 다시 렌더하지 않는다. +- 저장 실패·검증 실패 메시지에 키 조각을 넣지 않는다. + +### 5.7 사용자 대면 문구 확정 (기술 용어 금지) + +`notify/messages.py` 가 요구하는 **4요소(무엇 / 왜 / 어떻게 / 다음 행동)** 형식으로 확정한다. + +| 코드 | 무엇이 | 왜 | 어떻게 고치는가 | 다음 행동(버튼) | +|---|---|---|---|---| +| **30** | 오늘 자료를 받지 못했습니다. | 저장된 인증키가 더 이상 유효하지 않습니다. 공공데이터포털에서 인증키를 새로 발급받으면 예전 키는 자동으로 폐기됩니다. | 마이페이지 > 데이터 활용 > Open API > 활용신청 현황 에서 **현재 인증키를 복사**해 다시 입력해 주세요. (재발급 버튼은 누르지 마세요.) | [인증키 입력] [포털 마이페이지 열기] | +| **31** | 오늘 자료를 받지 못했습니다. | 인증키의 사용 기한이 끝났습니다. | 마이페이지 > 데이터 활용 > Open API > 활용신청 현황 에서 **'활용연장신청'** 을 해주세요. | [포털 마이페이지 열기] [인증키 입력] | +| **20** `SERVICE_ACCESS_DENIED_ERROR` | 오늘 자료를 받지 못했습니다. | 활용신청이 아직 완료되지 않았거나, 신청 내용을 변경하는 중이라 일시 중지된 상태입니다. | 마이페이지 > 데이터 활용 > Open API > 활용신청 현황 에서 상태가 '승인완료'인지 확인해 주세요. | [포털 마이페이지 열기] | +| **20** `SERVICE_KEY_IS_NULL` | 자료를 받을 수 없습니다. | 인증키가 저장되어 있지 않습니다. | 인증키를 입력해 주세요. 아직 없다면 발급 페이지에서 '활용신청'을 누르면 즉시 승인됩니다. | [인증키 입력] [인증키 발급 페이지 열기] | +| **21** | 오늘 자료를 받지 못했습니다. | 인증키가 일시적으로 사용 중지된 상태입니다. | 마이페이지 > 데이터 활용 > Open API > 활용신청 현황 에서 상태를 확인해 주세요. | [포털 마이페이지 열기] | +| **22** | 오늘 자료를 다 받지 못했습니다. 어제 자료로 보고서를 만들었습니다. | 오늘 사용할 수 있는 조회 횟수를 모두 썼습니다. | 자정(00시)이 지나면 자동으로 초기화됩니다. 아무것도 하지 않으셔도 됩니다. | [확인] | +| **29** | 자료를 받을 수 없습니다. | 이 컴퓨터의 인터넷 주소가 차단되어 있습니다. | 공공데이터포털 활용지원센터(1566-0025, 평일 09~18시)로 문의해 주세요. | [전화번호 복사] | +| **32** | 오늘 자료를 받지 못했습니다. | 활용신청 때 등록한 인터넷 주소와 지금 이 컴퓨터의 주소가 다릅니다. | 마이페이지 > 데이터 활용 > Open API > 활용신청 현황 에서 변경신청을 해주세요. | [포털 마이페이지 열기] | +| **12** | 자료를 받을 수 없습니다. | 식약처가 제공하는 데이터의 주소가 바뀐 것 같습니다. | 프로그램 수정이 필요합니다. 담당자에게 알려주세요. | [데이터 안내 페이지 열기] [로그 폴더 열기] | +| **10 / 11 / 33** | 자료를 받을 수 없습니다. | 요청 형식이 서버와 맞지 않습니다. | 프로그램 수정이 필요합니다. 담당자에게 알려주세요. | [로그 폴더 열기] | + +> 이 문구들에는 `SERVICE_KEY_IS_NOT_REGISTERED_ERROR` 같은 영문 상수도, `resultCode` 같은 필드명도, HTTP 상태 코드도 **등장하지 않는다.** 그런 정보는 `logs/run_*/pipeline.log` 와 `events.jsonl` 에만 남는다. + +--- + +## 6. 오류 코드 대응표 + +### 6.1 조치 등급 정의 + +오류를 "무엇이 잘못됐는가"가 아니라 **"배치가 무엇을 해야 하는가"** 로 환산한다. 이 등급이 재시도 여부·파이프라인 상태·알림 등급·GUI 화면을 전부 결정한다. + +| 등급 | 의미 | 배치의 자동 대응 | 사용자 알림 | 재시도 | +|---|---|---|---|---| +| `OK` | 정상 | 진행 | 없음 | — | +| `EMPTY` | 데이터 없음. **오류가 아니다** | 빈 결과로 진행 | 없음 (단, 전량 0건이면 §6.7 이상 경보) | — | +| `RETRY` | 일시적 장애 | `http.py` 백오프 재시도 (최대 4회) | 최종 실패 시 WARN | ✅ | +| `SLOW_DOWN` | 너무 빠름 | 요청 간격 상향 후 재시도 | 없음 | ✅ | +| `QUOTA` | 오늘 한도 소진 | 즉시 중단. **다음 자정+5분으로 재예약** | WARN | ❌ (오늘은) | +| `USER_ACTION` | 사람이 포털에서 조치해야 함 | **즉시 중단** | **CRITICAL + 복구 GUI 강제 기동** | ❌ | +| `SCHEMA` | 엔드포인트·파라미터·응답 구조 불일치 | 즉시 중단 | **CRITICAL** | ❌ | + +### 6.2 전체 오류 코드 대응표 + +출처 ① `Open API 에러 코드 정리.docx` (data.go.kr 배포) +출처 ② 데이터셋 상세페이지 '오픈API 에러코드 안내' / '인증 에러' 표 (2025-09-19 갱신) + +| 코드 | `errMsg` | 한국어 원인 | 봉투 | 등급 | 배치 자동 대응 | 사용자 알림 | 복구 절차 | +|---|---|---|---|---|---|---|---| +| `00` | `NORMAL_CODE` | 정상 | 서비스 (200) | `OK` | 진행 | 없음 | — | +| `01` | `APPLICATION_ERROR` | 「GW 내부 처리 중 예기치 않은 오류가 발생했습니다」 | 양쪽 | `RETRY` | 백오프 재시도 ×4 | 최종 실패 시 WARN | 자동 회복. 반복 시 활용지원센터 | +| `02` | `DB_ERROR` | 데이터베이스 에러 | 서비스 | `RETRY` | 백오프 재시도 ×4 | 최종 실패 시 WARN | 자동 회복 | +| `03` | `NODATA_ERROR` | 데이터없음 | 서비스 | `EMPTY` | 빈 결과로 진행 | 없음 | — (첫 페이지에서 나오면 §6.7) | +| `04` | `HTTP_ERROR` | 「허용되지 않은 HTTP 요청이거나 기관 API 응답 처리에 실패했습니다」 | 양쪽 | `RETRY` | 백오프 재시도 ×4 | 최종 실패 시 WARN | 자동 회복 | +| `05` | `SERVICETIMEOUT_ERROR` | 「기관 API 또는 GW 연계 서비스와의 연결에 실패했거나 응답 대기시간을 초과했습니다」 | 양쪽 | `RETRY` | 백오프 재시도 ×4 | 최종 실패 시 WARN | 자동 회복 | +| `10` | `INVALID_REQUEST_PARAMETER_ERROR` | 잘못된 요청 파라메터 | 양쪽 | `SCHEMA` | **즉시 중단** (단, `numOfRows` 후퇴 협상 중이면 예외 — §9.4) | **CRITICAL** | 코드 수정. 데이터셋 페이지에서 명세 확인 | +| `11` | `NO_MANDATORY_REQUEST_PARAMETERS_ERROR` | 필수요청 파라메터 없음 | 양쪽 | `SCHEMA` | **즉시 중단** | **CRITICAL** | 코드 수정 | +| `12` | `NO_OPENAPI_SERVICE_ERROR` | 「요청한 오픈API 서비스가 존재하지 않거나 폐기되었습니다」 | 양쪽 | `SCHEMA` | **즉시 중단** | **CRITICAL** + 데이터셋 페이지 열기 버튼 | **버전 상승 의심.** §8.3 절차 | +| `20` | `SERVICE_KEY_IS_NULL` | 인증키 미전송 | GW (401) | `USER_ACTION` | **즉시 중단** | **CRITICAL — 키 입력** | 키 입력 | +| `20` | `PERMISSION_DENIED` | GW 접근 권한 거부 | GW | `USER_ACTION` | **즉시 중단** | **CRITICAL** | 활용신청 완료 여부 확인 | +| `20` | `SERVICE_ACCESS_DENIED_ERROR` | 「해당 API 서비스를 신청하지 않았거나, 변경신청 등으로 인해 일시 중지된 경우」 | 서비스 (200) | `USER_ACTION` | **즉시 중단** | **CRITICAL** | 마이페이지에서 신청 상태 확인 | +| `21` | `TEMPORARILY_DISABLE_THE_SERVICEKEY_ERROR` | 일시적으로 사용할 수 없는 서비스 키 | GW | `USER_ACTION` | **즉시 중단** | **CRITICAL** | 마이페이지에서 상태 확인 | +| `22` | `LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR` | 「API 서비스의 일일 호출 허용량을 초과했습니다」 | GW | `QUOTA` | **즉시 중단 + 자정+5분 재예약** | WARN | 자동. 자정 후 재시도 | +| `23` | `LIMITED_NUMBER_OF_SERVICE_REQUESTS_PER_SECOND_EXCEEDS_ERROR` | 「짧은 시간에 많은 요청이 발생하여 초당 호출 허용량을 초과했습니다」 | GW | `SLOW_DOWN` | 요청 간격 ×2 (상한 10초) 후 재시도 | 없음 | 자동 | +| `29` | `BLACKLIST_IP_ACCESS_ERROR` | 「차단된 IP에서 호출한 요청입니다」 | GW | `USER_ACTION` | **즉시 중단. 재시도 금지** | **CRITICAL** | 활용지원센터 1566-0025 전화 | +| `30` | `SERVICE_KEY_IS_NOT_REGISTERED_ERROR` | 등록되지 않은 서비스키 | GW (403) | `USER_ACTION` | **즉시 중단** | **CRITICAL — 키 재입력** | 마이페이지에서 현재 키 **복사**(재발급 금지) | +| `31` | `DEADLINE_HAS_EXPIRED_ERROR` | 「API 인증키의 사용 기한이 만료되었습니다」 | GW | `USER_ACTION` | **즉시 중단** | **CRITICAL** | 마이페이지 → **활용연장신청** | +| `32` | `UNREGISTERED_IP_ERROR` | 등록되지 않은 IP | GW | `USER_ACTION` | **즉시 중단** | **CRITICAL** | 마이페이지 → 변경신청 | +| `33` | `UNSIGNED_CALL_ERROR` | 서명되지 않은 호출 | GW | `SCHEMA` | **즉시 중단** | **CRITICAL** | 코드 수정 | +| `99` | `UNKNOWN_ERROR` | 기타에러 | 양쪽 | `RETRY` | 백오프 재시도 ×4 | 최종 실패 시 WARN | 로그 확인 | +| — | (네트워크 예외) | DNS 실패·연결 거부·타임아웃 | — | `RETRY` | 백오프 재시도 ×4 | 최종 실패 시 WARN | 인터넷 연결 확인 | +| — | (본문 시그니처 실패) | HTTP 200 인데 본문이 HTML·차단 페이지·200바이트 미만 | — | `RETRY` → 2회 후 `SCHEMA` | 아키텍처 §3.4 본문 시그니처 검사가 잡는다 | WARN → CRITICAL | 응답 원문(`data/raw/`)을 확인 | +| — | (JSON·XML 모두 파싱 실패) | 봉투 구조가 전혀 다름 | — | `RETRY` → 2회 후 `SCHEMA` | 원문을 아카이브에 남기고 중단 | CRITICAL | §8.3 절차 | + +### 6.3 재시도 가능 / 즉시 중단 요약 + +**재시도 가능 (배치가 알아서 회복)** — `01`, `02`, `04`, `05`, `23`, `99`, 네트워크 예외 +**오늘은 포기 (내일 자동 회복)** — `22` +**즉시 중단 + 사람 호출** — `10`, `11`, `12`, `20`(3종), `21`, `29`, `30`, `31`, `32`, `33` +**오류가 아님** — `03` + +> **판정 규칙**: 재시도가 상황을 개선할 가능성이 0 이면 재시도하지 않는다. 잘못된 키를 4번 더 보내봐야 트래픽만 태우고, 코드 29(IP 차단) 상황에서는 오히려 **상황을 악화**시킨다. + +### 6.4 오류 분류기 — `source_mfds.py` 안에 산다 (완결 코드) + +**새 모듈을 만들지 않는다.** 아키텍처 §3.4 의 `parse_body()` 가 이미 봉투를 해석하고 `resultCode` 를 돌려주므로, 분류 테이블도 같은 모듈에 둔다. `errors.py` 는 예외 계층(`DmfError` → `FetchError` …) 전용이며 여기에 오류 코드 표를 넣지 않는다. + +```python +# src/dmf_crawler/source_mfds.py (발췌) — GW/서비스 오류코드 분류 +# +# 설계 근거: +# * 오류 봉투가 두 종류다 (실측). +# GW 레벨 : HTTP 401/403 + OpenAPI_ServiceResponse/cmmMsgHeader +# 서비스 : HTTP 200 + response/header/{resultCode,resultMsg} +# * returnReasonCode '20' 에 세 원인이 겹친다 → errMsg 를 1차 키로 분기한다. +# * type=json 을 줘도 GW 가 XML 로 응답할 수 있다 → JSON→XML 폴백 필수. +# * 코드 23(초당 제한), 29(IP 차단)는 2025-09-19 갱신 표에 신설되었다. + +from __future__ import annotations + +import json +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from enum import StrEnum + +PORTAL_MYPAGE_HINT = "마이페이지 > 데이터 활용 > Open API > 활용신청 현황" +SUPPORT_CENTER = "공공데이터포털 활용지원센터(1566-0025, 평일 09~18시)" + + +class Act(StrEnum): + OK = "OK" + EMPTY = "EMPTY" + RETRY = "RETRY" + SLOW_DOWN = "SLOW_DOWN" + QUOTA = "QUOTA" + USER_ACTION = "USER_ACTION" + SCHEMA = "SCHEMA" + + +@dataclass(frozen=True, slots=True) +class ErrorSpec: + code: str + message: str + korean: str + act: Act + user_text: str = "" # 사용자에게 그대로 보여줄 문장 (기술 용어 금지, 5.7 표와 일치) + + +_SPECS: tuple[ErrorSpec, ...] = ( + ErrorSpec("00", "NORMAL_CODE", "정상", Act.OK), + ErrorSpec("01", "APPLICATION_ERROR", "GW 내부 처리 중 예기치 않은 오류", Act.RETRY), + ErrorSpec("02", "DB_ERROR", "데이터베이스 에러", Act.RETRY), + ErrorSpec("03", "NODATA_ERROR", "데이터없음 에러", Act.EMPTY), + ErrorSpec("04", "HTTP_ERROR", "허용되지 않은 HTTP 요청 또는 기관 API 응답 처리 실패", Act.RETRY), + ErrorSpec("05", "SERVICETIMEOUT_ERROR", "연결 실패 또는 응답 대기시간 초과", Act.RETRY), + ErrorSpec("10", "INVALID_REQUEST_PARAMETER_ERROR", "잘못된 요청 파라메터", Act.SCHEMA, + "요청 형식이 서버와 맞지 않습니다. 프로그램 수정이 필요합니다. " + "담당자에게 알려주세요."), + ErrorSpec("11", "NO_MANDATORY_REQUEST_PARAMETERS_ERROR", "필수요청 파라메터 없음", Act.SCHEMA, + "요청에 빠진 항목이 있습니다. 프로그램 수정이 필요합니다. 담당자에게 알려주세요."), + ErrorSpec("12", "NO_OPENAPI_SERVICE_ERROR", "오픈API 서비스가 없거나 폐기됨", Act.SCHEMA, + "식약처가 제공하는 데이터의 주소가 바뀐 것 같습니다. " + "프로그램 수정이 필요합니다. 담당자에게 알려주세요."), + ErrorSpec("20", "SERVICE_KEY_IS_NULL", "인증키가 요청에 포함되지 않음", Act.USER_ACTION, + "인증키가 저장되어 있지 않습니다. 인증키를 입력해 주세요."), + ErrorSpec("20", "PERMISSION_DENIED", "GW 접근 권한 검사에서 거부됨", Act.USER_ACTION, + "이 데이터에 대한 사용 권한이 확인되지 않습니다. " + "공공데이터포털에서 활용신청이 완료되었는지 확인해 주세요."), + ErrorSpec("20", "SERVICE_ACCESS_DENIED_ERROR", + "활용신청 미완료 또는 변경신청으로 일시중지", Act.USER_ACTION, + "활용신청이 아직 완료되지 않았거나, 신청 내용을 변경하는 중이라 " + "일시 중지된 상태입니다. " + PORTAL_MYPAGE_HINT + + " 에서 상태가 '승인완료'인지 확인해 주세요."), + ErrorSpec("21", "TEMPORARILY_DISABLE_THE_SERVICEKEY_ERROR", + "일시적으로 사용할 수 없는 서비스 키", Act.USER_ACTION, + "인증키가 일시적으로 사용 중지된 상태입니다. " + + PORTAL_MYPAGE_HINT + " 에서 상태를 확인해 주세요."), + ErrorSpec("22", "LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR", + "일일 호출 허용량 초과", Act.QUOTA, + "오늘 사용할 수 있는 조회 횟수를 모두 썼습니다. " + "자정(00시)이 지나면 자동으로 초기화됩니다. 아무것도 하지 않으셔도 됩니다."), + ErrorSpec("23", "LIMITED_NUMBER_OF_SERVICE_REQUESTS_PER_SECOND_EXCEEDS_ERROR", + "초당 호출 허용량 초과", Act.SLOW_DOWN), + ErrorSpec("29", "BLACKLIST_IP_ACCESS_ERROR", "차단된 IP에서의 호출", Act.USER_ACTION, + "이 컴퓨터의 인터넷 주소가 차단되어 있습니다. " + + SUPPORT_CENTER + "로 문의해 주세요."), + ErrorSpec("30", "SERVICE_KEY_IS_NOT_REGISTERED_ERROR", "등록되지 않은 서비스키", + Act.USER_ACTION, + "저장된 인증키가 더 이상 유효하지 않습니다. 공공데이터포털에서 인증키를 " + "새로 발급받으면 예전 키는 자동으로 폐기됩니다. " + PORTAL_MYPAGE_HINT + + " 에서 현재 인증키를 복사해 다시 입력해 주세요."), + ErrorSpec("31", "DEADLINE_HAS_EXPIRED_ERROR", "기한만료된 서비스키", Act.USER_ACTION, + "인증키의 사용 기한이 끝났습니다. " + PORTAL_MYPAGE_HINT + + " 에서 '활용연장신청'을 해주세요."), + ErrorSpec("32", "UNREGISTERED_IP_ERROR", "등록되지 않은 IP", Act.USER_ACTION, + "활용신청 때 등록한 인터넷 주소와 지금 이 컴퓨터의 주소가 다릅니다. " + + PORTAL_MYPAGE_HINT + " 에서 변경신청을 해주세요."), + ErrorSpec("33", "UNSIGNED_CALL_ERROR", "서명되지 않은 호출", Act.SCHEMA, + "요청 방식이 서버와 맞지 않습니다. 프로그램 수정이 필요합니다. " + "담당자에게 알려주세요."), + ErrorSpec("99", "UNKNOWN_ERROR", "기타에러", Act.RETRY), +) + +_BY_MESSAGE: dict[str, ErrorSpec] = {s.message: s for s in _SPECS} + +# 코드 폴백 — 20 은 가장 흔한 SERVICE_ACCESS_DENIED_ERROR 로 접는다(등록 순서상 첫 20). +_BY_CODE: dict[str, ErrorSpec] = {} +for _s in _SPECS: + _BY_CODE.setdefault(_s.code, _s) + _BY_CODE.setdefault(_s.code.lstrip("0") or "0", _s) + +_UNKNOWN = ErrorSpec("99", "UNKNOWN_ERROR", "미상", Act.RETRY, + "알 수 없는 오류가 발생했습니다. 잠시 후 자동으로 다시 시도합니다.") + + +def classify(message: str | None, code: str | None) -> ErrorSpec: + """errMsg 를 1차 키, resultCode 를 폴백으로 삼아 조치 등급을 결정한다. + + 'SERVICE ACCESS DENIED ERROR!' 처럼 공백·느낌표 변형도 흡수한다. + 코드로만 분기하면 20(세 가지 원인)에서 반드시 오진한다. + """ + if message: + norm = re.sub(r"[\s!.]+", "_", message.strip().upper()).strip("_") + if norm in _BY_MESSAGE: + return _BY_MESSAGE[norm] + for key, spec in _BY_MESSAGE.items(): + if norm.startswith(key) or key.startswith(norm): + return spec + if code is not None: + c = str(code).strip() + if c in _BY_CODE: + return _BY_CODE[c] + c2 = c.lstrip("0") or "0" + if c2 in _BY_CODE: + return _BY_CODE[c2] + return _UNKNOWN + + +# --- 두 종류의 봉투를 모두 해석한다 (아키텍처 3.4 parse_body 의 구현) ------- + +def _xml_text(root: ET.Element, path: str) -> str | None: + node = root.find(path) + return node.text.strip() if (node is not None and node.text) else None + + +def parse_envelope(text: str) -> tuple[ErrorSpec | None, dict | None]: + """(오류스펙, 정상페이로드) 중 하나를 채워 돌려준다. + + (None, payload) 이면 정상, (spec, None) 이면 오류다. + JSON 을 먼저 시도하고 실패하면 XML 로 폴백한다. + """ + body = text.strip() + if not body: + return _UNKNOWN, None + + # --- JSON 시도 --- + if body[0] in "{[": + try: + data = json.loads(body) + except json.JSONDecodeError: + data = None + if isinstance(data, dict): + gw = data.get("OpenAPI_ServiceResponse") + if isinstance(gw, dict): + hdr = gw.get("cmmMsgHeader") or {} + return classify(hdr.get("errMsg"), hdr.get("returnReasonCode")), None + resp = data.get("response") or data + hdr = (resp.get("header") or {}) if isinstance(resp, dict) else {} + code, msg = hdr.get("resultCode"), hdr.get("resultMsg") + if code is not None and str(code).strip().lstrip("0") not in ("", "0"): + return classify(msg, code), None + return None, (resp if isinstance(resp, dict) else data) + + # --- XML 폴백 --- + try: + root = ET.fromstring(body) + except ET.ParseError: + return _UNKNOWN, None + + if root.tag == "OpenAPI_ServiceResponse": + return classify(_xml_text(root, "./cmmMsgHeader/errMsg"), + _xml_text(root, "./cmmMsgHeader/returnReasonCode")), None + + code = _xml_text(root, "./header/resultCode") + msg = _xml_text(root, "./header/resultMsg") + if code is not None and code.strip().lstrip("0") not in ("", "0"): + return classify(msg, code), None + + return None, { + "header": {"resultCode": code, "resultMsg": msg}, + "body": { + "totalCount": _xml_text(root, "./body/totalCount"), + "pageNo": _xml_text(root, "./body/pageNo"), + "numOfRows": _xml_text(root, "./body/numOfRows"), + "items": [ + {child.tag: (child.text or "").strip() for child in item} + for item in root.findall(".//items/item") + ], + }, + } +``` + +### 6.5 등급 → 파이프라인 상태 → 종료 코드 → GUI (확정 매핑) + +아키텍처 §5 가 정한 종료 코드 규약(`0` SUCCESS/PARTIAL/SKIPPED · `1` FAILED · `2` BLOCKED · `130` 중단)을 그대로 쓴다. **이 문서가 새 종료 코드를 만들지 않는다.** + +| 등급 | 파이프라인 상태 | 종료 코드 | 리포트 | 알림 등급 | GUI | +|---|---|---|---|---|---| +| `OK` / `EMPTY` | `SUCCESS` | `0` | 신규 생성 | 없음 | 없음 (성공 팝업을 띄우지 않는다) | +| `RETRY` 소진 (직전 스냅샷 있음) | `PARTIAL` | `0` | 마지막 성공 스냅샷 + 배너 | WARN | 토스트 | +| `RETRY` 소진 (직전 스냅샷 없음) | `FAILED` | `1` | 미생성 | WARN | 토스트. 스케줄러가 최대 3회 재시도 | +| `SLOW_DOWN` | (재시도 후 흡수) | — | — | 없음 | 없음 | +| `QUOTA` | `PARTIAL` 또는 `FAILED` | `0` / `1` | 위와 동일 | WARN | 토스트 + **자정+5분 일회성 작업 등록** | +| `USER_ACTION` | `BLOCKED` | `2` | **미생성** | **CRITICAL** | **복구 GUI 강제 기동** | +| `SCHEMA` | `BLOCKED` | `2` | **미생성** | **CRITICAL** | **복구 GUI + 데이터셋 페이지 열기** | + +> **성공 시 아무 창도 띄우지 않는 것은 의도적이다.** 매일 아침 성공 팝업이 뜨면 사용자는 팝업을 무시하는 습관을 들이고, 그러면 진짜 경고도 무시한다(운영 렌즈의 "학습된 무시"). + +**알림 중복 억제** — `alerts.py` 의 `dedup_key` 규약: + +| 상황 | `dedup_key` | 쿨다운 | +|---|---|---| +| 인증키 오류 | `api_key:` | 해소될 때까지 하루 1회 | +| 트래픽 초과 | `quota:daily` | 24시간 | +| 스키마 오류 | `schema:` | 해소될 때까지 하루 1회 | +| 수집 일시 실패 | `fetch:transient` | 6시간 | +| 키 수명 경고 | `key_lifetime:D-30` / `D-7` / `EXPIRED` | **영구 1회** (`warnedStages` 로 관리) | + +### 6.6 이중 인코딩 방지 자기검증 (요청 A5 — `tests/test_service_key.py`) + +C15 는 이 프로젝트에서 유일하게 "비개발자의 정상적인 행동이 시스템을 깨뜨리는" 지점이다. 회귀 테스트로 못박는다. + +```python +# -*- coding: utf-8 -*- +"""tests/test_service_key.py — serviceKey 이중 인코딩 방지 회귀 테스트. + +배경 (공식 명세): 「인증키 / serviceKey / 100 / 필 / 인증키 (URL Encode)」 + Decoding : abc+def/ghi=jkl + Encoding : abc%2Bdef%2Fghi%3Djkl + +httpx 의 params= 는 값을 자동 인코딩한다. 여기에 Encoding 키를 넣으면 +%2B → %252B 가 되어 서버가 다른 키로 인식하고 코드 30 을 돌려준다. + +secrets_dpapi.normalize_service_key() 가 저장 시점에 원본으로 되돌리므로 +어느 형태를 붙여넣어도 같은 요청이 나가야 한다. 이 파일이 그것을 증명한다. +""" + +from __future__ import annotations + +import urllib.parse + +import httpx +import pytest + +from dmf_crawler.secrets_dpapi import normalize_service_key + +ENDPOINT = "https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01" +DECODED_KEY = "Xk9+aB/cD3fG=hIjKlM4nOp+qR/sT7uVwXyZ012345678==" +ENCODED_KEY = urllib.parse.quote(DECODED_KEY, safe="") +PARAMS = {"pageNo": 1, "numOfRows": 999, "type": "json"} + + +def server_side_view(url: str) -> str: + """서버가 실제로 보게 되는 serviceKey 값을 재현한다.""" + query = urllib.parse.urlparse(str(url)).query + return urllib.parse.parse_qs(query, keep_blank_values=True).get("serviceKey", [""])[0] + + +def request_url(service_key: str) -> str: + """httpx 가 params= 로 조립하는 실제 URL.""" + request = httpx.Request( + "GET", ENDPOINT, + params={"serviceKey": service_key, **PARAMS}, + ) + return str(request.url) + + +def test_encoding_key_without_normalization_breaks(): + """실패 재현 — 정규화 없이 Encoding 키를 넣으면 서버가 다른 키를 본다.""" + seen = server_side_view(request_url(ENCODED_KEY)) + assert seen != DECODED_KEY, "이 단언이 깨지면 httpx 동작이 바뀐 것이다" + assert "%25" in str(request_url(ENCODED_KEY)), "이중 인코딩 흔적(%25)이 있어야 한다" + + +def test_decoding_key_without_normalization_works(): + """정상 — Decoding 키는 정규화 없이도 올바르다.""" + assert server_side_view(request_url(DECODED_KEY)) == DECODED_KEY + + +@pytest.mark.parametrize("raw", [ + DECODED_KEY, + ENCODED_KEY, + f" {DECODED_KEY} ", + f'"{DECODED_KEY}"', + f"'{ENCODED_KEY}'", + f"\t{ENCODED_KEY}\n", +]) +def test_normalized_key_always_produces_same_request(raw: str): + """어느 형태를 붙여넣어도 서버가 보는 키는 언제나 원본이다.""" + assert server_side_view(request_url(normalize_service_key(raw))) == DECODED_KEY + + +def test_normalize_is_idempotent(): + once = normalize_service_key(ENCODED_KEY) + assert normalize_service_key(once) == once == DECODED_KEY + + +def test_normalize_rejects_nothing_silently(): + """빈 문자열은 빈 문자열로 남는다 — 저장 단계에서 ValueError 로 걸린다.""" + assert normalize_service_key(" ") == "" +``` + +### 6.7 오류가 아니지만 경보해야 하는 것 + +| 상황 | 왜 오류가 아닌가 | 왜 경보해야 하는가 | 게이트 | 등급 | +|---|---|---|---|---| +| `NODATA_ERROR`(03) 이 **첫 페이지**에서 발생 | 프로토콜상 정상 응답 | DMF 전체가 0건일 리 없다. 원천 이상 신호 | 게이트 2 (`len == totalCount`, 0 ≠ 9,084) | WARN + diff 중단 | +| 수집 건수 ≠ `totalCount` | 각 호출은 200 이었다 | **취하 오탐의 직접 원인** (C21) | 게이트 2 | WARN + diff 중단 | +| `totalCount` 가 전일 대비 −5% 이상 급감 | 정상 응답 | 원천 데이터 사고 또는 API 이상 | 게이트 3 | WARN + diff 중단 | +| 필수 필드 널 비율 급증 | 정상 응답 | 스키마가 조용히 바뀌었을 가능성 | 게이트 4 | WARN + diff 중단 | +| 중복 `DMF_PERMIT_NO` 급증 | 정상 응답 | 기준선은 중복 0건 (`00b` §5.1) | 게이트 5 | WARN + diff 중단 | +| **응답 필드 집합 변화** | 정상 응답 | 새 필드 추가·기존 필드 개명 | **게이트 6 (신설, §8.4)** | WARN 또는 CRITICAL | + +> 이 여섯 게이트가 전부 통과해야 diff 를 수행한다. 하나라도 실패하면 **스냅샷 INSERT 도 하지 않는다**(기준선 오염 방지, 아키텍처 §3.6). + +--- + +## 7. 출처 표시 문구 확정 + +### 7.1 법적 지위 정리 + +| 질문 | 답 | 근거 | +|---|---|---| +| 공공누리 유형이 붙어 있는가? | **아니다.** 배지도 없고 `license` 는 `"이용허락범위 제한 없음"` | DCAT 메타 (C2, C13) | +| 출처표시가 법적 의무인가? | **확인되지 않았다** (공공누리였다면 전 유형 필수였을 것) | https://www.kogl.or.kr/info/license.do | +| 그럼 왜 넣는가? | ① 데이터 신뢰성의 근거 ② 분쟁 시 방어 ③ 리포트를 받는 사람이 원본을 찾아갈 수 있다. 비용은 셀 한 줄이다 | — | +| 하면 안 되는 것은? | 「사실적 내용을 위·변조 및 왜곡」 (FAQ 186) | C12 | + +### 7.2 데이터셋 정식 명칭 — 지어내지 않는다 + +포털에 등록된 **정식 명칭은 하나**다. 리포트·문서·알림 어디서도 다른 이름을 쓰지 않는다. + +| 항목 | 확정값 | +|---|---| +| 데이터셋 정식 명칭 | **`식품의약품안전처_원료의약품등록(DMF)현황`** | +| 인용 표기 형태 | 식품의약품안전처 「원료의약품등록(DMF)현황」 | +| 데이터셋 번호 | `15057075` | +| 데이터셋 URL | `https://www.data.go.kr/data/15057075/openapi.do` | +| 제공기관 | 식품의약품안전처 | +| 관리부서 | 데이터혁신기획팀 | + +> ⚠️ **개정 요청 A6**: `design/03-xlsx-report-spec.md` 의 블록 6(출처·면책)과 `s99_meta.py` 예시가 「의약품 원료의약품 등록 정보」라는 **포털에 존재하지 않는 명칭**을 쓰고 있다. 아래 §7.3 의 확정 문구로 교체할 것. 없는 이름을 리포트에 박으면 받아보는 사람이 원본을 찾지 못하고, "이 데이터 어디서 났느냐"는 질문에 답할 수 없게 된다. + +### 7.3 확정 문구 3종 + +**① 정식 (긴 형태)** — 대시보드 블록 6, `s99_meta` 시트, README, 배포 메일 본문 + +``` +출처: 식품의약품안전처 「원료의약품등록(DMF)현황」 오픈API (공공데이터포털 데이터셋 15057075, + https://www.data.go.kr/data/15057075/openapi.do) + 수집 2026-09-02 06:02 KST · 9,084건 · 제공기관 식품의약품안전처 데이터혁신기획팀 + 본 리포트는 공공데이터를 자동 수집·가공한 참고 자료이며 법적 효력이 없습니다. +``` + +**② 표준 (짧은 형태)** — 각 데이터 시트 마지막 행 아래 한 줄, 인쇄 바닥글 + +``` +출처: 식품의약품안전처_원료의약품등록(DMF)현황 (공공데이터포털 15057075) · 수집 2026-09-02 +``` + +**③ 각주 (문서용)** — `docs/` 하위 문서에서 이 데이터를 인용할 때 + +``` +[출처] 식품의약품안전처, 「원료의약품등록(DMF)현황」, 공공데이터포털 오픈API +(데이터셋 15057075), https://www.data.go.kr/data/15057075/openapi.do, 조회일 YYYY-MM-DD. +``` + +### 7.4 xlsx 삽입 위치와 서식 (확정) + +| 시트 | 위치 | 문구 | 서식 | +|---|---|---|---| +| `s00_dashboard` | 블록 6 (`A63:E66` 영역) | ① 정식 (4줄) | 9pt, `#5B6770`, 좌측 정렬 | +| `s99_meta` | 메타 표 하단 | ① 정식 + `run_id` · 원문 아카이브 경로 | 동일 | +| `s01_changes` · `s02_ledger` · `s03_ingredient` · `s04_company` · `s05_watchlist` · `s06_trend` | 데이터 마지막 행 + 2, A열 | ② 표준 | 8pt, `#5B6770` | +| 모든 시트 | 인쇄 바닥글 좌측(`&L`) | ② 표준 | 8pt | + +**구현 — `report/widgets.py` (XlsxWriter)** + +아키텍처 §8.1 이 xlsx 생성을 **XlsxWriter 전담**으로 확정했다. `openpyxl` 을 쓰지 않는다. + +```python +# src/dmf_crawler/report/widgets.py (발췌) — 출처 표시 +# +# 문구의 정본은 ops/03-api-usage-policy.md 7.3 이다. +# 다른 모듈에서 이 문자열을 복제하지 않는다. 반드시 이 함수를 호출한다. + +from __future__ import annotations + +from datetime import datetime + +DATASET_TITLE = "식품의약품안전처_원료의약품등록(DMF)현황" +DATASET_TITLE_QUOTED = "식품의약품안전처 「원료의약품등록(DMF)현황」" +DATASET_ID = "15057075" +DATASET_URL = "https://www.data.go.kr/data/15057075/openapi.do" +PROVIDER = "식품의약품안전처" +PROVIDER_TEAM = "데이터혁신기획팀" +DISCLAIMER = "본 리포트는 공공데이터를 자동 수집·가공한 참고 자료이며 법적 효력이 없습니다." + + +def attribution_lines(collected_at: datetime, record_count: int) -> list[str]: + """① 정식 (긴 형태) — 4줄. 대시보드 블록 6 과 s99_meta 가 쓴다.""" + return [ + f"출처: {DATASET_TITLE_QUOTED} 오픈API", + f"(공공데이터포털 데이터셋 {DATASET_ID}, {DATASET_URL})", + f"수집 {collected_at:%Y-%m-%d %H:%M} KST · {record_count:,}건 · " + f"제공기관 {PROVIDER} {PROVIDER_TEAM}", + DISCLAIMER, + ] + + +def attribution_short(collected_at: datetime) -> str: + """② 표준 (짧은 형태) — 데이터 시트 하단과 인쇄 바닥글.""" + return (f"출처: {DATASET_TITLE} (공공데이터포털 {DATASET_ID}) · " + f"수집 {collected_at:%Y-%m-%d}") + + +def attribution_footnote(collected_at: datetime) -> str: + """③ 각주 (문서용).""" + return (f"[출처] {PROVIDER}, 「원료의약품등록(DMF)현황」, 공공데이터포털 오픈API " + f"(데이터셋 {DATASET_ID}), {DATASET_URL}, 조회일 {collected_at:%Y-%m-%d}.") + + +def write_attribution_block(ws, theme, first_row: int, first_col: int, + last_col: int, collected_at: datetime, + record_count: int) -> int: + """① 정식 문구를 블록으로 쓴다. 마지막으로 사용한 행 번호를 돌려준다.""" + fmt = theme.fmt("attribution") # 9pt / #5B6770 / 좌측 정렬 / 줄바꿈 없음 + row = first_row + for line in attribution_lines(collected_at, record_count): + ws.merge_range(row, first_col, row, last_col, line, fmt) + row += 1 + return row - 1 + + +def write_attribution_line(ws, theme, row: int, col: int, + last_col: int, collected_at: datetime) -> None: + """② 표준 문구를 데이터 시트 하단에 한 줄로 쓴다.""" + fmt = theme.fmt("attribution_small") # 8pt / #5B6770 + text = attribution_short(collected_at) + if last_col > col: + ws.merge_range(row, col, row, last_col, text, fmt) + else: + ws.write(row, col, text, fmt) + + +def set_attribution_footer(ws, collected_at: datetime, sheet_label: str) -> None: + """인쇄 바닥글: 좌측에 출처, 우측에 시트명과 페이지.""" + left = attribution_short(collected_at).replace("&", "&&") + ws.set_footer( + f'&L&"맑은 고딕"&8{left}' + f'&R&"맑은 고딕"&8{sheet_label} · &P/&N' + ) +``` + +> `&` 는 XlsxWriter 의 머리글·바닥글 서식 이스케이프 문자다. 출처 문구에는 지금 `&` 가 없지만, 나중에 기관명이 바뀌어 들어올 수 있으므로 `&&` 치환을 미리 넣는다. + +### 7.5 왜곡 금지 — 구조로 보장한다 + +FAQ 186 은 「사실적 내용을 위·변조 및 왜곡하여 사용하는 것까지 보장하는 것은 아닙니다」라고 못박는다. 이 프로젝트는 성분명·제조소명·국가명을 **정규화**한다(`normalize.py`, `agy` 역할 A2/A5). 정규화는 왜곡이 아니지만, **원본을 지우면 왜곡과 구분할 수 없게 된다.** + +**확정 규칙**: 정규화된 값을 넣는 모든 컬럼에는 **원본 컬럼을 나란히 보존**한다. + +| 리포트 컬럼 | 내용 | 출처 | +|---|---|---| +| `성분명` | 원본 `INGR_KOR_NAME` **그대로** | API 원본 | +| `성분명(정규화)` | 표기 통일 결과 | 파생 | +| `제조소명` | 원본 `MNFCTR_NAME` **그대로** (`CORTICOSTER OIDO` 같은 원본 오타 포함) | API 원본 | +| `제조소명(정리)` | 공백·대소문자 정리 결과 | 파생 | +| `제조국가` | 원본 `MANUF_COUNTRY_CODE_NM` **그대로** (`이탈리아,스위스`) | API 원본 | +| `제조국가(분리)` | 콤마 분해 결과 | 파생 | + +파생 컬럼의 헤더에는 셀 주석(`write_comment`)으로 「이 값은 원본을 읽기 쉽게 정리한 것입니다. 원본은 왼쪽 열에 있습니다.」를 단다. + +**AI 산출물의 구분 표시**: `agy` 가 만든 요약·해석은 **반드시 "AI 요약"이라는 라벨과 함께** 표시하고, 원본 데이터 셀에 섞어 넣지 않는다. AI 문장을 사실처럼 제시하는 것은 §C12 의 "왜곡"에 가장 가까운 행위다. + +--- + +## 8. API 버전·중단 대응과 스키마 변경 감지 + +### 8.1 URL 의 `01` 접미사 + +``` +https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01 + ^^^^^^^ ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^ + 기관코드 서비스명 + "01" 오퍼레이션 + "01" +``` + +| 관찰 | 해석 | 신뢰도 | +|---|---|---| +| 구버전은 `data.mfds.go.kr/openapi/MdcDmfInfoService/getMdcDmfList` — **접미사 없음** | 식약처가 `data.mfds.go.kr` → `apis.data.go.kr` 로 이관하며 `01` 을 붙였다 | 정황 근거 | +| `01` 이 "버전 번호"라고 명시한 문서 | **찾지 못했다** | ⚠️ **미검증** | +| `02` 로 올라간 전례 | 확인하지 못했다 | ⚠️ 미검증 | + +**설계 결론**: `01` 을 버전으로 **가정**하되, 그 가정에 의존하는 자동 동작을 만들지 않는다. `02` 를 자동으로 시도해보는 로직은 **넣지 않는다** — 존재하지 않는 엔드포인트를 때리는 것은 무의미한 트래픽이고, 만에 하나 `02` 가 다른 스키마라면 **조용히 잘못된 데이터를 넣게 된다.** 사람이 확인하고 `config/config.toml` 의 URL 을 고친다. + +> 엔드포인트 URL 을 코드 상수가 아니라 **`config.toml` 의 `[source]` 키**로 두는 이유가 여기에 있다. 버전이 올라갔을 때 재배포 없이 설정 한 줄로 대응할 수 있어야 한다. + +### 8.2 변경·중단 통보를 확인하는 방법 + +| 경로 | 통보가 오는가 | 확인 방법 | 자동화 | +|---|---|---|---| +| 포털 이용약관 제9조 | **포털 서비스 전체**의 영구 중단만 「3개월전 회원에게 공지」 | 포털 공지사항 | ❌ | +| 개별 오픈API 폐기·버전 상승 시 활용신청자에게 이메일 | ⚠️ **미검증** — 발송 여부 확인 못 함 | 가입 이메일 확인 | ❌ | +| 데이터셋 상세 페이지의 **수정일** | 명세가 바뀌면 갱신됨 (현재 2025-09-19) | 사람이 페이지 열람 | ❌ (HTML 파싱을 하지 않는다 — R1.3) | +| **코드 12 관측** | 폐기 시 반드시 발생 | 배치가 자동 감지 | ✅ **유일한 실용 수단** | +| **스키마 드리프트 게이트** | 필드가 바뀌면 발생 | 배치가 자동 감지 | ✅ | + +**결론**: 통보에 기대지 않는다. **코드 12 관측 + 스키마 드리프트 게이트**가 이 프로젝트의 조기 경보 체계 전부다. + +### 8.3 코드 12 발생 시 절차 (사람이 하는 일) + +1. 복구 GUI: 「식약처가 제공하는 데이터의 주소가 바뀐 것 같습니다. 프로그램 수정이 필요합니다.」 + [데이터 안내 페이지 열기] [로그 폴더 열기] +2. 담당자가 `https://www.data.go.kr/data/15057075/openapi.do` 를 열어 **요청 주소**와 **수정일**을 확인 +3. 주소가 `...Service02/getMdcDmfList02` 등으로 바뀌었으면 **`config/config.toml` 의 `[source] base_url` 한 줄 수정** → 재배포 불필요 +4. 응답 필드가 바뀌었으면 §8.4 의 `EXPECTED_ITEM_FIELDS` 와 `normalize.py` 의 매핑을 함께 갱신 +5. `python -m dmf_crawler doctor` 로 검증 (체크 ⑤ 통과 확인) +6. `python -m dmf_crawler backfill --reparse --from <날짜> --to <날짜>` 로 원문 아카이브를 새 파서로 재처리 +7. 이 문서 §8.1 과 `design/00-DATA-SOURCE-DECISION.md` §4.1 을 갱신 +8. 데이터셋 자체가 사라졌다면 폴백 검토 (§8.5) + +### 8.4 스키마 드리프트 게이트 (요청 A1 — `integrity.py` 게이트 6) + +코드 12(서비스 폐기)는 극단적인 경우이고, 실제로 더 흔한 것은 **같은 엔드포인트가 필드를 하나 추가하거나 이름을 바꾸는 조용한 변화**다. 그런 변화는 오류 코드로 나타나지 않으며, 파서가 조용히 빈 값을 넣는다. 기존 게이트 1~5 를 **전부 통과하면서** 데이터가 망가진다. + +```python +# src/dmf_crawler/integrity.py (발췌) — 게이트 6: 스키마 드리프트 +# +# 판정 3종: +# * 핵심 필드 누락 → CRITICAL. diff 중단 + BLOCKED. +# * 기타 필드 누락 → WARN. diff 중단. +# * 신규 필드 등장 → WARN. diff 는 수행하되 알림을 남긴다. +# (필드가 늘어난 것은 데이터를 망가뜨리지 않는다) +# +# agy 역할 A7(API 스키마 변화 대응)이 이 게이트의 출력을 입력으로 받는다. +# 자동 반영은 금지이며 사람 승인이 필요하다 — 제안은 state/proposals/ 에 쌓인다. + +from __future__ import annotations + +from dataclasses import dataclass + +# design/00-DATA-SOURCE-DECISION.md 4.3 의 응답 필드 7개 +EXPECTED_ITEM_FIELDS: frozenset[str] = frozenset({ + "DMF_PERMIT_NO", + "INGR_KOR_NAME", + "ENTP_NAME", + "MNFCTR_NAME", + "MNFCTR_PLACE", + "MANUF_COUNTRY_CODE_NM", + "DMF_PERMIT_DATE", +}) + +# 이것이 없으면 레코드를 식별하거나 diff 할 수 없다. +CRITICAL_ITEM_FIELDS: frozenset[str] = frozenset({ + "DMF_PERMIT_NO", + "INGR_KOR_NAME", + "DMF_PERMIT_DATE", +}) + + +@dataclass(frozen=True, slots=True) +class SchemaDrift: + observed: frozenset[str] + missing: frozenset[str] + missing_critical: frozenset[str] + added: frozenset[str] + + @property + def is_critical(self) -> bool: + return bool(self.missing_critical) + + @property + def blocks_diff(self) -> bool: + # 필드가 사라지면 diff 를 막는다. 늘어난 것만으로는 막지 않는다. + return bool(self.missing) + + def log_text(self) -> str: + parts = [] + if self.missing: + parts.append("missing=" + ",".join(sorted(self.missing))) + if self.added: + parts.append("added=" + ",".join(sorted(self.added))) + return " ".join(parts) if parts else "schema=OK" + + def user_text(self) -> str: + if self.missing_critical: + return ("식약처가 제공하는 데이터의 항목이 바뀌었습니다. " + "프로그램 수정이 필요합니다. 담당자에게 알려주세요.") + if self.missing: + return ("식약처 데이터에서 일부 항목이 빠졌습니다. " + "오늘 리포트의 변경 판정을 건너뛰었습니다. 담당자에게 알려주세요.") + if self.added: + return ("식약처 데이터에 새로운 항목이 추가되었습니다. " + "리포트에는 아직 반영되지 않았습니다. 담당자에게 알려주세요.") + return "" + + +def detect_schema_drift(raw_records: list[dict[str, str]]) -> SchemaDrift: + """수집된 원본 레코드의 필드 집합을 기대치와 대조한다. + + 표본이 아니라 **전량**을 본다. 일부 레코드에만 새 필드가 붙는 경우가 있고, + 그것이야말로 놓치면 안 되는 신호다. + """ + observed: set[str] = set() + for row in raw_records: + observed.update(row.keys()) + observed_fs = frozenset(observed) + + if not raw_records: + # 0건은 게이트 2가 이미 막는다. 여기서는 판정 불가로 둔다. + return SchemaDrift(frozenset(), frozenset(), frozenset(), frozenset()) + + return SchemaDrift( + observed=observed_fs, + missing=EXPECTED_ITEM_FIELDS - observed_fs, + missing_critical=CRITICAL_ITEM_FIELDS - observed_fs, + added=observed_fs - EXPECTED_ITEM_FIELDS, + ) + + +def gate_schema_drift(raw_records: list[dict[str, str]]) -> "Gate": + """게이트 6. evaluate() 의 게이트 튜플에 추가한다.""" + drift = detect_schema_drift(raw_records) + return Gate( + name="schema_drift", + passed=not drift.blocks_diff, + detail=drift.log_text(), + ) +``` + +**게이트 6 의 판정과 결과** + +| 관측 | `passed` | diff | 파이프라인 상태 | 알림 | +|---|---|---|---|---| +| 필드 집합 일치 | ✅ | 수행 | `SUCCESS` | 없음 | +| 신규 필드만 추가됨 | ✅ | 수행 | `SUCCESS` | WARN (1회, dedup `schema:added:<필드명>`) | +| 비핵심 필드 누락 | ❌ | 중단 | `PARTIAL` | WARN | +| **핵심 필드 누락** | ❌ | 중단 | **`BLOCKED`(2)** | **CRITICAL** | + +> **신규 필드에 WARN 을 붙이되 막지 않는 이유**: 식약처가 필드를 하나 추가했다는 것은 리포트에 넣을 정보가 늘었다는 뜻이지, 오늘 자료가 틀렸다는 뜻이 아니다. 막으면 좋은 소식 때문에 서비스가 멈춘다. 대신 알림으로 남겨 사람이 리포트 확장을 검토하게 한다. + +### 8.5 폴백 경로 — 기록만 하고 쓰지 않는다 + +| 후보 | 경로 | 상태 | 왜 드롭인 폴백이 아닌가 | +|---|---|---|---| +| 연계데이터 2095 | `http://data.mfds.go.kr/openapi/MdcDmfInfoService/getMdcDmfList` | 생존 (실측) | ① HTTP 평문 ② **별도 활용신청 필요** — 더미 키로도 `SERVICE ACCESS DENIED ERROR!` ③ 오류 봉투가 HTTP 200 + `response/header` 로 다름 → **파서 재작성 필요** | +| 파일데이터(벌크 CSV/XLSX) | 확인되지 않음 | ⚠️ 미검증 | 존재 여부 자체가 미확인 | +| 의약품안전나라 화면 | `nedrug.mfds.go.kr/pbp/CCBAC03` | 생존 | **`robots.txt` 전면 금지.** 요구 R1.3 에 따라 절대 쓰지 않는다 | +| 공식 데이터 제공 요청 | — | — | `ops/04-official-data-request-channels.md` 참조 | + +**폴백 발동 조건**: 현행 API 가 **코드 12 로 7일 연속 실패**하고, 데이터셋 페이지 확인 결과 **대체 엔드포인트도 없을 때**. 그때 연계데이터 2095 에 활용신청을 하고 `parse_body()` 에 분기를 추가한다. 그 전에는 절대 자동 전환하지 않는다. 자동 전환은 "다른 데이터를 같은 데이터인 척 넣는" 최악의 실패로 이어질 수 있다. + +--- + +## 9. 레이트 리밋 매너 — 확정 파라미터 + +### 9.1 왜 보수적으로 가는가 + +| 근거 | 함의 | +|---|---| +| 코드 23 (초당 제한) 신설 (C5) | 병렬 호출 금지, 요청 간 지연 필수 | +| 코드 29 (IP 차단) 신설 (C6) | 걸리면 코드로 복구 불가. 사람이 전화해야 한다 | +| 이용약관 제14조5항 (C7) | "특정 회원의 이용형태"가 문제되지 않게 | +| 트래픽 여유 **99.85%** (§4) | **빨리 받을 이유가 전혀 없다.** 10페이지에 8초 더 쓰는 것은 공짜다 | + +### 9.2 확정값 표 + +정책의 정본은 아키텍처 §3.3 `http.py` 다. 이 문서는 **그 값들이 왜 그 값인지의 근거**와, `source_mfds.py` 층에서 추가로 지킬 규칙을 확정한다. + +| 파라미터 | 확정값 | 정본 | 근거 | +|---|---|---|---| +| **동시성 (병렬 요청 수)** | **1 (완전 직렬)** | 이 문서 | 코드 23·29 회피. **협상 불가** | +| 요청 간 최소 간격 | **0.7초** (`min_interval_seconds`) | 아키텍처 §3.3 | `00-DATA-SOURCE-DECISION` §4.5 의 "0.5~1초" 범위 안 | +| 간격 지터 | **+0 ~ +0.3초** | 아키텍처 §3.3 | 정확히 주기적인 트래픽은 봇으로 보인다 | +| 최대 시도 횟수 | **4회** | 아키텍처 §3.3 | 그 이상은 회복 가능성이 낮다 | +| 백오프 | **base 5s · factor 2 · cap 300s · full jitter** | 아키텍처 §3.3 | 5 → 10 → 20 → 40 (각각 0~값 사이 난수). full jitter 는 동시 재시도 몰림을 없앤다 | +| `Retry-After` | **절대 우선** (계산된 백오프를 무시) | 아키텍처 §3.3 | 429/503 에서 서버가 말한 값을 따르는 것이 정중함의 정의 | +| 조건부 요청 | `If-None-Match` / `If-Modified-Since` | 아키텍처 §3.3 | 304 면 본문 전송이 없다. 서버 부하 절감 | +| User-Agent | `DMF-Crawler/ (+contact: )` | 아키텍처 §3.3 | 식별 가능한 클라이언트. 문제 시 기관이 연락할 수 있다 | +| **코드 23 관측 시** | 간격 **×2**, 상한 **10초** | 이 문서 | 서버가 느리라고 하면 즉시 순응 | +| **간격 회복** | 연속 **5회** 성공 시 **×0.8**, 하한 = 0.7초 | 이 문서 | 한 번 느려진 채로 남지 않게 | +| `numOfRows` | **999** (실패 시 500 → 100 자동 후퇴) | `config.toml` `[source] page_size` | ⚠️ 최대값 미검증 | +| `type` | `json` | `config.toml` | XML 폴백 파서를 항상 함께 둔다 (C16) | +| 연결 타임아웃 | **10초** | `config.toml` | | +| 읽기 타임아웃 | **30초** | `config.toml` | `numOfRows=999` 응답이 클 수 있다 | +| 페이지 순회 상한 | **10,000 페이지** | 이 문서 | 무한 루프 안전핀 | +| 전체 수집 시간 상한 | **15분** | `config.toml` | 초과 시 워치독이 종료 (R5.4) | +| **코드 22 재시도** | **금지.** 다음 자정+5분 재예약 | 이 문서 | 재시도가 카운터만 더 태운다 | +| **코드 29 재시도** | **절대 금지** | 이 문서 | 재시도가 상황을 악화시킨다 | + +### 9.3 하루 트래픽 프로파일 (이 값들의 결과) + +``` +06:00:00 체크 ⑤ (numOfRows=1) 1 호출 +06:00:01 totalCount 취득 (numOfRows=1) 1 호출 ← 아키텍처 3.4 의 선행 호출 +06:00:02 page 1 ~0.7s + 지터 +06:00:03 page 2 ~0.7s + 지터 + ... +06:00:11 page 10 +06:00:12 수집 완료 — 총 12 호출, 소요 약 12~20초 +``` + +하루 총 12~15 호출, 초당 최대 약 1.2회, 병렬 0. **어떤 기준으로도 정중하다.** + +### 9.4 `source_mfds.py` 층의 추가 규칙 (완결 코드) + +`http.py` 가 전송 계층의 정중함을 책임지고, `source_mfds.py` 는 **페이지 순회 전략**과 **코드 23 순응**을 책임진다. + +```python +# src/dmf_crawler/source_mfds.py (발췌) — 페이지 순회와 코드 23 순응 +# +# 레이트 리밋 매너 파라미터의 근거는 ops/03-api-usage-policy.md 9.2 다. +# 전송 계층(타임아웃·백오프·Retry-After)은 http.HttpClient 가 이미 처리한다. +# 여기서는 '페이지 사이'의 정중함과 numOfRows 후퇴 협상만 다룬다. + +from __future__ import annotations + +import math +import random +import time + +SLOW_DOWN_FACTOR = 2.0 +MAX_INTERVAL_SEC = 10.0 +RECOVERY_AFTER_SUCCESSES = 5 +RECOVERY_FACTOR = 0.8 +PAGE_LIMIT = 10_000 +PAGE_SIZE_FALLBACKS = (999, 500, 100) + + +class PoliteInterval: + """페이지 간 간격을 관리한다. 코드 23 을 만나면 늘리고, 안정되면 되돌린다.""" + + def __init__(self, base_seconds: float = 0.7, jitter_seconds: float = 0.3) -> None: + self._base = base_seconds + self._jitter = jitter_seconds + self._current = base_seconds + self._streak = 0 + + def sleep(self) -> None: + time.sleep(self._current + random.uniform(0.0, self._jitter)) + + def on_slow_down(self) -> float: + """코드 23 관측. 간격을 배증한다(상한 10초).""" + self._current = min(self._current * SLOW_DOWN_FACTOR, MAX_INTERVAL_SEC) + self._streak = 0 + return self._current + + def on_success(self) -> None: + self._streak += 1 + if self._streak >= RECOVERY_AFTER_SUCCESSES and self._current > self._base: + self._current = max(self._base, self._current * RECOVERY_FACTOR) + self._streak = 0 + + @property + def current(self) -> float: + return self._current + + +def negotiate_page_size(fetch_one, preferred: int) -> int: + """numOfRows 최대값이 미검증이므로 999 → 500 → 100 순으로 후퇴한다. + + 코드 10(INVALID_REQUEST_PARAMETER_ERROR)이 나면 다음 후보로 내려간다. + 그 외 오류는 그대로 올려보낸다 — 여기서 삼키면 인증 오류가 숨는다. + 확정된 값은 호출부가 로그와 fetch_stats 에 남겨 부록 B 를 갱신한다. + + fetch_one(size) -> None : 성공하면 반환, 실패하면 FetchError(spec) 을 던지는 콜러블 + """ + candidates = [preferred] + [c for c in PAGE_SIZE_FALLBACKS if c != preferred] + last_error: Exception | None = None + for size in candidates: + try: + fetch_one(size) + return size + except FetchError as exc: + if getattr(exc, "spec", None) is not None and exc.spec.code == "10": + last_error = exc + continue + raise + raise last_error or FetchError("모든 numOfRows 후보가 거부되었습니다.") + + +def plan_pages(total_count: int, page_size: int) -> int: + """순회할 페이지 수. 안전핀으로 상한을 건다.""" + return min(max(1, math.ceil(total_count / page_size)), PAGE_LIMIT) +``` + +### 9.5 하지 말아야 할 것 (금지 목록) + +| 금지 | 왜 | +|---|---| +| `ThreadPoolExecutor` / `asyncio.gather` 로 페이지 병렬 수집 | 코드 23 → 코드 29(IP 차단). 이득은 10초, 손실은 사람이 전화해야 하는 복구 | +| 요청 간격을 0.1초로 축소 | 같은 이유. 트래픽 여유가 99.85% 인데 아낄 것이 없다 | +| 코드 22 를 만나고 즉시 재시도 | 카운터만 더 태운다. 자정까지 아무것도 낫지 않는다 | +| 코드 29 를 만나고 재시도 | 상황을 악화시킨다 | +| `http.py` 를 우회해 직접 `httpx.get()` 호출 | 아키텍처 §3.3 — "유일한 네트워크 창구". 우회하면 정중함 정책이 통째로 빠진다 | +| 브라우저(WebView)에서 API 직접 호출 | CORS 는 허용되지만 키가 프론트엔드에 노출된다 (C14) | +| 키를 명령행 인자로 전달 | 프로세스 목록·이벤트 로그에 남는다 (§5.6) | +| 예외·로그에 요청 URL 을 그대로 남기기 | URL 에 `serviceKey` 가 들어 있다. 아키텍처 §3.3 이 요구하는 **마스킹**을 반드시 적용 | +| 응답 원문 전체를 `pipeline.log` 에 남기기 | 용량 문제. 원문은 `data/raw//` 에만 두고, 로그에는 앞 500자만 | +| `numOfRows` 를 낮춰 "부담을 줄인다"고 생각하기 | 정반대다. 낮추면 **호출 수가 늘어** 초당 제한에 가까워진다 | + +--- + +## 부록 A. 출처 + +| # | 제목 | URL | 확인 | +|---|---|---|---| +| 1 | **DMF 오픈API 데이터셋 상세** — 요청변수표·오류코드표·인증에러표·트래픽·심의유형·이용허락범위 | https://www.data.go.kr/data/15057075/openapi.do | ✅ 전문 확인 (핵심 출처) | +| 2 | **DCAT/schema.org 기계판독 메타** — `"license":"이용허락범위 제한 없음"`, contactPoint | https://www.data.go.kr/catalog/15057075/openapi.json | ✅ 전문 확인 | +| 3 | **공식 FAQ — 트래픽 정의와 자정 초기화** (FAQ_0000000000000208) | https://www.data.go.kr/bbs/faq/selectFaqList.do | ✅ 원문 인용 | +| 4 | **공식 FAQ — 서비스키 1인 1개·재발급 시 자동 폐기** (FAQ_0000000000000171, 159) | https://www.data.go.kr/bbs/faq/selectFaqList.do | ✅ 원문 인용 | +| 5 | **공식 FAQ — 코드 20 의 원인** (FAQ_0000000000000214) | https://www.data.go.kr/bbs/faq/selectFaqList.do | ✅ 원문 인용 | +| 6 | **공식 FAQ — 상업적 이용 허용·왜곡 금지** (FAQ_0000000000000210, 186) | https://www.data.go.kr/bbs/faq/selectFaqList.do | ✅ 원문 인용 | +| 7 | **공식 FAQ — CORS 허용** (FAQ_0000000000000211) | https://www.data.go.kr/bbs/faq/selectFaqList.do | ✅ 원문 인용 | +| 8 | **공식 FAQ — 마이페이지 경로 / 승인 확인** (FAQ_0000000000000264, 208) | https://www.data.go.kr/bbs/faq/selectFaqList.do | ✅ 원문 인용 | +| 9 | **공식 배포 문서 `Open API 에러 코드 정리.docx`** — 코드 0~99 전체표 | https://www.data.go.kr/cmm/cmm/fileDownload.do?atchFileId=FILE_000000001260631&fileDetailSn=0 | ✅ 다운로드·파싱 완료 | +| 10 | **포털 이용약관** — 제9조(중단 공지), 제14조5항(이용 제한), 제15조(지식재산권) | https://www.data.go.kr/ugs/selectPortalPolicyView.do | ✅ 원문 인용 | +| 11 | **API 엔드포인트 실측** — 403 + `OpenAPI_ServiceResponse`(JSON), 401 + 동일 구조(XML) | https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01 | ✅ 직접 호출 | +| 12 | **연계데이터 2095 (구버전 엔드포인트)** — HTTP 200 + `SERVICE ACCESS DENIED ERROR!` | https://www.data.go.kr/data/2095/linkedData.do | ✅ 열람 + 실측 | +| 13 | **식약처 식의약 데이터 포털** — 개발계정/운영계정 정의, 활용사례 등록 조건 | https://data.mfds.go.kr/cntnts/11 | ✅ 원문 인용 | +| 14 | 공공누리 이용허락범위 유형 (제1~4유형, 출처표시 필수) | https://www.kogl.or.kr/info/license.do | ✅ 열람 | +| 15 | 활용기간 "승인일로부터 24개월" 서술 (2차 출처) | https://beaver-sohyun.tistory.com/38 | ⚠️ **미검증** — 1차 출처 확인 실패 | +| 16 | 개발계정 신청 폼 (활용기간 원문 확인 시도) | https://www.data.go.kr/tcs/dss/redirectDevAcountRequestForm.do | ❌ 비로그인 시 `/index.do` 리다이렉트 | +| 17 | 활용지원센터 1566-0025 (평일 09~18시) · opendata_help@nia.or.kr · 식약처 데이터혁신기획팀 043-719-1623 | https://www.data.go.kr/catalog/15057075/openapi.json (contactPoint) | ✅ 확인 | +| 18 | 상위 문서 — 데이터 소스 결정 SSOT | `docs/design/00-DATA-SOURCE-DECISION.md` | ✅ 정독 | +| 19 | 상위 문서 — **아키텍처 SSOT** (디렉터리·모듈 계약·CLI·종료 코드) | `docs/design/01-architecture.md` | ✅ 정독 (§2, §3.2~3.6, §3.10, §3.15, §5, §6, §8.1) | +| 20 | 상위 문서 — 기준선 실측 (`totalCount` 9,084건, 중복 0, 취하 판정 근거 D2) | `docs/design/00b-baseline-data-analysis.md` | ✅ 정독 | +| 21 | 상위 문서 — 요구사항 SSOT | `docs/00-REQUIREMENTS.md` | ✅ 정독 | +| 22 | `agy` CLI 정본 (인증 실패 감지·headless 규약·권한 모델) | `docs/research/05a-agy-cli-ssot.md` | ✅ 정독 | +| 23 | DMF 웹 화면 총건수 실측 9,840건 (API 와 756건 차이의 근거) | `docs/research/01-dmf-domain-and-sources.md` §4.1 | ✅ 확인 | +| 24 | xlsx 리포트 명세 (출처 블록 위치·서식 토큰) | `docs/design/03-xlsx-report-spec.md` | ✅ 확인 — §7.2 에 개정 요청 A6 | +| 25 | 공식 데이터 제공 요청 경로 (폴백 검토 시 참조) | `docs/ops/04-official-data-request-channels.md` | 참조 | + +--- + +## 부록 B. 미해결 / 실측 필요 + +### B.1 인증키 관련 (최우선) + +- [ ] **개발계정 활용기간의 정확한 길이.** 2차 출처는 「승인일로부터 24개월」이나 신청 폼이 로그인 벽 뒤에 있어 원문 미확인. 사용자가 로그인 후 **마이페이지 > 데이터 활용 > Open API > 활용신청 현황** 화면에서 만료일을 확인해 GUI 의 [인증키 만료일 등록] 으로 넣을 것. 등록되는 순간 §5.5 의 경보가 가정치에서 실측치로 승격된다. +- [ ] **연장 신청의 조건.** 만료 전에만 가능한가, 만료 후에도 가능한가? 몇 회까지 가능한가? 연장 시 **키 문자열이 유지되는가 바뀌는가?** 바뀐다면 연장 후 재입력이 필요하므로 §5.7 의 코드 31 문구를 고쳐야 한다. +- [ ] **🔴 '프로젝트 서비스키' vs '개인 서비스키'.** 활용신청 모달에 「기업회원입니다. 활용신청할 서비스키를 선택해주세요 / 프로젝트 서비스키 / 개인 서비스키」라는 선택지가 나타난다. '서비스키는 1인당 1개'라는 FAQ 와 충돌하는 것처럼 보이는 2025년 이후 신설 개념이다. **프로젝트 서비스키가 별도 수명·별도 트래픽을 갖고 재발급 시 개인 키와 독립적이라면, C8(재발급으로 배치가 죽는 위험)을 근본적으로 제거할 수 있다.** 이 프로젝트에 가장 값어치 있는 미해결 질문이다. +- [ ] DPAPI 저장 파일(`service_key.bin`)이 Windows 사용자 프로필 백업·이전 시 어떻게 되는가? 새 PC 이전 절차(N6)에서 키를 다시 입력해야 한다는 사실을 `ops/01-scheduling-and-resilience.md` 의 신규 PC 설치 절차에 명시할 것. + +### B.2 API 명세 관련 + +- [ ] **`numOfRows` 의 실제 최대값.** 999 / 1000 / 5000 을 각각 호출해 어디서 코드 10 이 나거나 값이 잘리는지 실측. `negotiate_page_size()` 가 자동 후퇴하지만, 확정되면 `config.toml` 의 `page_size` 를 정확한 값으로 고정할 수 있다. +- [ ] **API `totalCount` 가 실제로 9,084 인가.** 기준선은 프로토타입이 만든 xlsx 의 행 수다(`00b` §4). API 응답의 `totalCount` 필드를 직접 읽어 대조할 것. 최초 실행 로그와 `fetch_stats` 에 남는다. +- [ ] **웹 9,840 − API 9,084 = 756건이 정말 취하·취소 건인가.** 표본 대조 필요. 이 가정이 결정 D2(취하 판정)의 토대다. +- [ ] `type=json` 이 **정상 응답**에서도 실제로 동작하는가? (오류 응답이 XML 로 오는 것은 실측 확인. 정상 응답은 미확인 — 그래서 XML 폴백 파서를 둔다) +- [ ] 응답에 `DMF_PERMIT_NO` 중복이 존재하는가? 기준선은 중복 0건이나, 동일 등록번호에 제조소가 여럿인 경우가 나중에 나타날 수 있다. 게이트 5 의 임계값(2%)이 적절한지 재검토. +- [ ] 취하·말소된 등록번호가 응답에서 **사라지는가**를 연속 2일 이상 수집해 관찰 (`00b` 부록의 미해결 항목과 동일) +- [ ] 참고문서 `IROS_76_원료의약품(DMF)현황_v1.1.docx` 에 명세 외 추가 정보가 있는가? (특히 `numOfRows` 상한과 오류 응답 예시) + +### B.3 운영·정책 관련 + +- [ ] **데이터 실제 갱신 주기.** 갱신주기 표기가 없다(C20). `totalCount` 와 데이터 해시를 매일(가능하면 하루 여러 시각) 기록해 2~4주 관측 후 06:00 이 적절한지 재판정. +- [ ] **운영계정의 기본 트래픽 한도.** 2차 출처는 「하루 최대 10만 건」이나 공식 문구 미확인. 이 프로젝트는 운영계정이 필요 없으므로 실무상 무해하나, 문서에 숫자를 쓸 거라면 재확인 필요. +- [ ] **개별 오픈API 폐기·버전 상승 시 활용신청자에게 이메일이 발송되는가?** 발송되지 않는다면 코드 12 관측이 유일한 탐지 수단이다 (§8.2). +- [ ] **URL 의 `01` 접미사가 버전 번호라는 해석의 근거.** 다른 식약처 API 중 `02` 로 올라간 전례가 있는가? 있다면 §8.3 대응 시나리오를 구체화할 수 있다. +- [ ] **파일데이터(벌크 CSV/XLSX) 제공 여부.** 존재한다면 API 장애 시 완전한 대체 경로가 된다 (§8.5). +- [ ] **연계데이터 2095 의 활용신청 경로와 조건.** 폴백으로 쓸 계획이라면 자동승인인지, 응답 필드가 현행과 동일한지 미리 확인해둘 것. +- [ ] **`Retry-After` 헤더가 이 API 에서 실제로 오는가?** 아키텍처 §3.3 이 이 헤더를 절대 우선으로 두는데, 공공데이터포털 GW 가 429/503 에서 이 헤더를 붙이는지 확인되지 않았다. 오지 않으면 계산된 백오프만 동작한다(무해). +- [ ] **조건부 요청(`If-None-Match` / `If-Modified-Since`)에 304 를 돌려주는가?** 이 API 는 매 요청이 동적 조회이므로 304 가 오지 않을 가능성이 높다. 오면 트래픽이 더 줄어든다. + +### B.4 상위 문서에 반영이 필요한 사항 + +- [ ] **A1** — `design/01-architecture.md` §3.6 `integrity.py` 에 **게이트 6(스키마 드리프트)** 추가 (§8.4) +- [ ] **A2** — `design/01-architecture.md` §3.15 `checks.py` 에 **체크 ⑬(인증키 수명)** 추가 (§5.5) +- [ ] **A3** — `design/01-architecture.md` §3.15 `checks.py` 에 **체크 ⑭(트래픽 예산)** 추가 (§4.4) +- [ ] **A4** — `design/01-architecture.md` §2 트리의 `state/` 에 **`key_meta.json`** 추가 (§5.3) +- [ ] **A5** — `design/01-architecture.md` §2 트리의 `tests/` 에 **`test_service_key.py`** 추가 (§6.6) +- [ ] **A6** — `design/03-xlsx-report-spec.md` 의 출처 문구를 **정식 명칭 `식품의약품안전처_원료의약품등록(DMF)현황`** 으로 교체 (§7.2·§7.3). 현재 「의약품 원료의약품 등록 정보」라는 포털에 없는 이름을 쓰고 있다 +- [ ] **A7** — `design/00-DATA-SOURCE-DECISION.md` §9 에 "배포본은 DPAPI 저장소(`secrets_dpapi.py`), `.env` 는 개발자 폴백. 어느 형태의 키를 넣어도 저장 시 Decoding 형태로 정규화된다" 한 줄 추가 (§1.4) +- [ ] **A8** — `ops/02-failure-alerting.md` 작성 시 §6.5 의 **등급 → 상태 → 종료 코드 → GUI 매핑**과 §5.7 의 **4요소 문구표**를 그대로 채택할 것. 두 문서가 다른 매핑을 가지면 복구 경로가 어긋난다 +- [ ] **A9** — `design/04-onboarding-wizard.md` 작성 시 §3.1 의 **9단계 화면 절차**와 §5.6 의 **액션 4종**을 반영할 것 + +--- + +*이 문서는 오픈API 이용 정책과 운영 제약에 관한 SSOT 다. 트래픽·인증키·오류코드·출처표시·레이트리밋에 관한 새 사실은 여기를 갱신한다. 디렉터리·모듈 계약·종료 코드가 바뀌면 `design/01-architecture.md` 를 먼저 고치고 이 문서를 맞춘다.* diff --git a/docs/ops/04-official-data-request-channels.md b/docs/ops/04-official-data-request-channels.md new file mode 100644 index 0000000..24e13e2 --- /dev/null +++ b/docs/ops/04-official-data-request-channels.md @@ -0,0 +1,401 @@ +# 공식 데이터 제공 요청 채널과 승인 절차 + +> **이 문서의 역할**: DMF_Crawler 가 식약처 원료의약품 등록(DMF) 오픈API 에서 받지 못하는 6개 필드(`최종변경일자`, `최종연차보고년도`, `취소/취하구분`, `취소/취하일자`, `문서번호`, `대상의약품`)를 **웹 크롤링 없이 공식 절차로 확보**하기 위한 채널을 조사하고, 실제로 발송 가능한 요청 문구 초안까지 준비한다. 이 프로젝트는 `nedrug.mfds.go.kr` 의 `robots.txt`(`User-agent: * / Disallow: /`, 2026-09-02 실측, `docs/research/04-anti-bot-and-legal.md` §4 참조)를 존중하기로 이미 확정했으며([`design/00-DATA-SOURCE-DECISION.md`](../design/00-DATA-SOURCE-DECISION.md)), 이 문서는 그 결정을 되돌리지 않는다. 대신 "화면에만 있고 API 에 없는 6개 필드"를 합법적·공식적 채널로 요청하는 구체적 실행 계획을 제공한다. + +--- + +## 0. 한눈에 보기 + +- **이 프로젝트가 실제로 취할 행동(1순위)**: 공공데이터포털 DMF 데이터셋 상세 페이지(`https://www.data.go.kr/data/15057075/openapi.do`)의 **"데이터 개선요청"** 기능으로 API 응답 항목에 6개 필드 추가를 요청한다. 관리부서가 **데이터혁신기획팀**으로 명시돼 있어 요청이 담당 부서로 직접 라우팅될 가능성이 가장 높고, 비용이 0원이며, 접수 즉시 처리가 시작된다. §11 에 발송 가능한 요청 문구 초안을 완성해 두었다. +- **2순위**: 1순위 요청 후 2주 내 응답이 없거나 거부되면, 식약처 **종합상담센터(1577-1255)** 를 통해 **데이터혁신기획팀** 앞 민원으로 재요청하거나, **국민신문고(epeople.go.kr)** 를 통해 동일 내용을 정식 민원으로 접수한다. 국민신문고는 처리기한이 법정으로 정해져 있어(⚠️ 정확한 일수는 미검증, §6 참조) 응답 강제력이 공공데이터포털 1:1문의보다 크다. +- **3순위(백업, 일회성)**: **정보공개청구**(`open.go.kr`, 근거: 「공공기관의 정보공개에 관한 법률」)로 현재 시점의 6개 필드 값을 전자파일(엑셀/CSV)로 청구한다. 정부24 안내 기준 처리기한은 "기본 20일"이며 수수료는 전자파일 복제 시 무료(매체비용 별도)다. **단, 이 채널은 정기 자동 수집에 부적합하다 — 이유는 §1.3, §6.3 참조.** +- **웹 크롤링/robots.txt 예외 요청은 이 프로젝트의 행동 계획에 포함하지 않는다.** 조사는 했으나(§10) 실효성이 낮고, 이미 API 우선 원칙이 확정돼 있어 우선순위가 낮다. +- **식의약 데이터 포털(`data.mfds.go.kr`)은 DMF 관련 데이터셋을 메인 화면에서 확인하지 못했다.** 로그인 후 재확인이 필요하며, 현재는 대안 채널로 채택하지 않는다(§8). +- **확인된 담당 연락처는 제한적이다.** 공공데이터포털 데이터셋 페이지에 "관리부서 전화번호" 항목은 있으나 실제 번호가 비어 있었고, 식약처 조직도에서 데이터혁신기획팀은 부서명만 확인되고 전화번호는 미기재였다. 직통 번호가 없으므로 **공공데이터포털 고객센터(1566-0025, opendata_help@nia.or.kr)** 또는 **식약처 종합상담센터(1577-1255)** 를 경유해야 한다. +- **법령 조문의 정확한 조 번호·일수는 이번 조사에서 원문 확인에 실패했다.** `law.go.kr` 이 자바스크립트 기반 SPA 로 렌더링되어 자동 조회 도구로는 조문 텍스트를 가져오지 못했다. 이 문서의 법령 관련 서술은 **정부24·공공데이터포털 등 2차 안내 페이지에서 실제로 확인한 내용**으로 한정했고, 조문 번호가 필요한 부분은 ⚠️ 미검증으로 표시했다. + +--- + +## 1. 문제 정의 + +### 1.1 데이터 격차 + +DMF_Crawler 는 식약처 공식 오픈API 로 원료의약품 등록(DMF) 데이터를 수집한다. + +| 구분 | 내용 | +|---|---| +| API 엔드포인트 | `https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01` | +| 공공데이터포털 데이터셋 | `https://www.data.go.kr/data/15057075/openapi.do` (ID `15057075`) | +| API 응답 필드(7개) | `DMF_PERMIT_NO`, `INGR_KOR_NAME`, `ENTP_NAME`, `MNFCTR_NAME`, `MNFCTR_PLACE`, `MANUF_COUNTRY_CODE_NM`, `DMF_PERMIT_DATE` | +| 웹 화면(의약품안전나라) 컬럼 | 14개 — API 7개 필드 + 아래 6개 필드 | + +같은 데이터를 사람이 웹 화면에서 조회하면 컬럼이 14개인데, API 응답은 7개뿐이다. 화면에만 있고 API 에 없는 6개 컬럼이 이 프로젝트의 핵심 기능(변경·취하 탐지)에 직결된다. + +| 화면 전용 컬럼 | 이 프로젝트에서의 의미 | +|---|---| +| `최종변경일자` | 변경 탐지의 **직접 신호**. 현재는 API 스냅샷을 매일 전량 받아 자체 diff 로 변경을 추론하는 간접 방식을 쓴다(`design/00-DATA-SOURCE-DECISION.md` §7). | +| `최종연차보고년도` | 연차보고 이벤트 추적에 필요. API 에는 대응 필드가 없다. | +| `취소/취하구분`(실측값 예: `정상`) | **취하 탐지의 직접 신호.** 현재는 "어제는 있었는데 오늘 없는 등록번호"를 취하로 추론하는 간접 방식이며, 전량 수집이 불완전하면 오판 위험이 있다(§7.2 안전장치, 같은 문서). | +| `취소/취하일자` | 취하 이벤트의 정확한 발생일. | +| `문서번호` | 등록 건의 행정 문서 추적키. | +| `대상의약품`(`별표1`/`신물질`) | 규제 분류. 현재 API 에는 없음. | + +### 1.2 왜 웹 크롤링을 하지 않는가 + +`nedrug.mfds.go.kr/robots.txt` 는 `User-agent: * / Disallow: /` 로 전면 금지다(2026-09-02 실측, `docs/research/04-anti-bot-and-legal.md` §4). 이 프로젝트는 이미 **robots.txt 를 우회하지 않고, 공식 API 를 1차 소스로 채택**하기로 확정했다(`design/00-DATA-SOURCE-DECISION.md`). 이 결정을 뒤집지 않는 것이 이 조사의 전제다. + +### 1.3 왜 "공식 절차로 요청"이 필요한가 + +크롤링을 배제한 이상, 6개 필드를 확보하는 유일하게 남은 길은 **공식 창구를 통해 데이터 제공 주체(식약처)에게 직접 요청**하는 것이다. 이 문서는 그 창구들을 비교하고, 이 프로젝트의 요구사항(① API 항목 추가가 최선, ② 안 되면 정기적 파일 제공, ③ 최후 수단으로 일회성 정보공개청구)에 맞는 우선순위를 정한다. + +**정보공개청구가 정기 자동 수집에 부적합한 이유**는 명확하다. 「공공기관의 정보공개에 관한 법률」에 따른 청구는 **건별로 접수·심사·결정하는 절차**이며, 정부24 안내 기준으로도 처리에 기본 20일이 걸린다(§6.3). 매일 자동으로 갱신되는 DMF 현황 데이터를 일일 단위로 정보공개청구로 받으려면 **매일 새 청구를 접수하고 매일 최대 20일을 기다려야 하는 구조적 모순**이 생긴다. 청구권자가 기관인지 개인인지를 떠나, 이 법은애초에 "특정 시점의 특정 정보"를 1회성으로 공개받기 위한 제도이지 상시·자동·반복 제공을 전제한 제도가 아니다. 따라서 정보공개청구는 **API 항목 추가 요청이 지연되거나 거부됐을 때, 현재 시점의 6개 필드 스냅샷 1회를 확보해 급한 불을 끄는 백업 수단**으로만 유효하다. + +--- + +## 2. 채널 비교표 + +| 채널 | 근거(확인된 범위) | 신청 방법 | 처리기한 | 정기 제공 가능 여부 | 이 프로젝트 적합도 | 권고 순위 | +|---|---|---|---|---|---|---| +| 공공데이터포털 "데이터 개선요청" | 데이터셋 상세 페이지 내 기능(URL 미확인, §4) | 로그인 후 데이터셋 페이지에서 의견 등록 | ⚠️ 미검증 | 요청이 반영되면 **API 자체가 바뀌므로 자동으로 정기 제공됨** | 매우 높음 — 담당부서(데이터혁신기획팀) 직행 가능성 | **1순위** | +| 공공데이터포털 "공공데이터 제공신청" | 「공공데이터의 제공 및 이용 활성화에 관한 법률」(추정, 조문 미검증) | 메인 메뉴 존재 확인, 정확한 신청 URL 은 자바스크립트 처리로 미확인(§3) | ⚠️ 미검증 | 신규 데이터셋 개방 요청 제도 — 기존 API 의 "항목 추가"에도 쓰이는지는 미검증 | 중간 — 절차는 있으나 실제 URL·양식 미확인 | 2순위(1순위 병행 시도) | +| 식약처 데이터혁신기획팀 직접 문의(전화/민원) | 조직도상 부서 실존 확인(§7) | 종합상담센터(1577-1255) 경유 또는 부서명 지정 민원 | ⚠️ 미검증 | 담당자 확답 시 가능 | 높음 — 관리부서 자체 | 2순위 | +| 국민신문고(epeople.go.kr) | 「민원 처리에 관한 법률」(추정, 조문 미검증) | 사이트 자바스크립트 SPA 로 상세 메뉴 확인 실패(§6) | ⚠️ 미검증 | 요청 취지에 따라 다름 | 중간 — 법정 절차라 응답 강제력은 있으나 상세 미확인 | 3순위 | +| 정보공개청구(open.go.kr / 정부24) | 「공공기관의 정보공개에 관한 법률」 | 정부24 안내 페이지에서 청구방법 확인(§6.3) | 기본 20일(정부24 안내, 조문 번호 미검증) | **부적합**(§1.3) | 낮음(정기 수집 기준) / 높음(일회성 백업 기준) | 4순위(백업 전용) | +| 식약처 정보공개제도 사전 청구 | 식약처 자체 정보공개 메뉴(`/wpge/m_11/de010101l0001.do`, URL 확인됨) | 미확인(§7) | ⚠️ 미검증 | ⚠️ 미검증 | ⚠️ 미검증 | 참고용 | +| 식의약 데이터 포털(data.mfds.go.kr) | 식약처 운영 별도 포털(존재 확인) | 회원가입 후 "데이터셋 분양" 메뉴(§8) | ⚠️ 미검증 | ⚠️ 미검증 — DMF 데이터셋 존재 자체가 메인 화면에서 미확인 | 낮음(현재 근거 부족) | 참고용 | +| robots.txt 예외 요청 | 관행(법적 근거 없음, RFC 9309 는 자발적 준수 규범) | 사이트 운영자 연락 — 구체적 창구 미확인 | 해당 없음 | 승인돼도 크롤링 허용일 뿐, API 필드 추가와 무관 | 낮음 | 채택 안 함 | + +--- + +## 3. 채널 A — 공공데이터포털 "공공데이터 제공신청" / "분쟁조정 신청" + +### 3.1 확인된 사실 + +`https://www.data.go.kr` 메인 메뉴 구조를 실제로 열어 확인한 결과, **"공공데이터"** 대분류 아래 다음 항목이 존재한다. + +``` +공공데이터 +├── AI 검색 +├── 데이터목록 +├── 데이터 큐레이션 +├── 국가중점데이터 +├── 공공데이터 제공신청 ← 이 절의 대상 +└── 분쟁조정 신청 +``` + +"분쟁조정 신청"의 실제 링크는 `/tcs/dor/insertTrublMdatReqstProcssView.do` 로 확인됐다. 페이지 내 안내 문구에는 **"공공데이터 제공을 거부당했을 때 분쟁조정신청을 할 수 있다"**는 취지가 명시돼 있어, 이 메뉴는 **공공데이터제공분쟁조정위원회**로 이어지는 창구로 보인다. + +반면 **"공공데이터 제공신청"** 항목은 여러 차례 HTML 을 직접 열어 `href`/`onclick` 속성을 확인했으나, 두 경우 모두 `href="#"` 로만 나타났고 실제 이동 대상 URL 은 자바스크립트로 동적 처리돼 자동 조회 도구로는 확인하지 못했다. ⚠️ **미검증: 정확한 신청 URL, 신청 양식 항목, 처리기한.** + +### 3.2 추정되는 절차 (⚠️ 미검증 — 근거 없이 확정하지 말 것) + +"공공데이터 제공신청" 제도는 명칭상 **공공데이터포털에 아직 개방되지 않은 데이터를 새로 개방해달라고 요청하는 제도**로 보인다. 이것이 "이미 개방된 API 의 항목을 추가해달라"는 이 프로젝트의 요구사항과 정확히 일치하는지는 이번 조사에서 확인하지 못했다. **DMF API 는 이미 개방된 데이터셋이므로, 이 창구보다는 §4 의 "데이터 개선요청"(기존 데이터셋에 대한 의견 제출 기능)이 이 프로젝트의 목적에 더 정확히 맞을 가능성이 높다.** + +### 3.3 이 채널을 이용해야 할 경우 + +만약 §4 의 데이터 개선요청이 응답 없이 종료되거나 "항목 추가는 개선요청 대상이 아니다"라는 답변을 받는다면, 그때 실제로 `data.go.kr` 사이트에 로그인해 "공공데이터 제공신청" 메뉴를 직접 열어 정확한 절차를 재확인해야 한다. 이 문서에서 추정으로 절차를 서술하지 않는 이유는, 팀 지침("연락처나 절차를 기억으로 채우지 마라")을 따르기 위함이다. + +### 3.4 분쟁조정 신청(공공데이터제공분쟁조정위원회) + +이 채널은 **"제공을 신청했지만 거부당했을 때"** 쓰는 이의절차이지, 최초 요청 채널이 아니다. 따라서 이 프로젝트는 우선 §4·§7 로 요청을 시도하고, **명시적으로 거부 통보를 받은 경우에만** 분쟁조정 신청(`/tcs/dor/insertTrublMdatReqstProcssView.do`)을 검토한다. 조정위원회의 정확한 심의 절차, 기간, 결정의 구속력은 이번 조사에서 원문을 확인하지 못했다. ⚠️ 미검증. + +--- + +## 4. 채널 B — 공공데이터포털 데이터셋 페이지 "데이터 개선요청" (1순위 채택) + +### 4.1 확인된 사실 + +DMF 데이터셋 상세 페이지(`https://www.data.go.kr/data/15057075/openapi.do`)를 직접 열어 다음을 확인했다. + +- **제공기관**: 식품의약품안전처 +- **관리부서명**: **데이터혁신기획팀** +- **관리부서 전화번호**: 항목 자체는 페이지에 있으나 **실제 번호가 비어 있음**(미기재) +- 페이지 내 버튼/링크: **"활용신청"**, "관심목록에 추가" +- 페이지 하단 섹션 "데이터 오류·개선 의견이 있으신가요?" 아래 **"데이터 개선요청"**, **"오류신고 및 문의"** 두 개의 항목이 확인됨 +- 위 두 항목의 정확한 `href`/`onclick` URL 은 자동 조회로 확인하지 못했다(로그인 후 노출되는 모달/폼일 가능성). ⚠️ 미검증 +- 공공데이터포털 전체 고객센터: **전화 1566-0025**, **이메일 opendata_help@nia.or.kr** (페이지 하단 확인) + +### 4.2 왜 이 채널을 1순위로 두는가 + +1. **관리부서가 명시적으로 데이터혁신기획팀**이다. 다른 채널(국민신문고, 정보공개청구)은 접수창구가 별도 부서를 거쳐 담당 부서로 재배정되는 구조지만, 이 채널은 데이터셋 페이지에 직접 달린 의견 기능이라 **담당 부서로 직행할 가능성이 가장 높다.** +2. 비용이 없고, 계정만 있으면 즉시 접수된다. +3. 반영되면 **API 응답 자체가 바뀌므로**, 이후 이 프로젝트가 매일 API 를 호출하는 것만으로 6개 필드가 자동으로 들어온다. 정보공개청구처럼 매번 재청구할 필요가 없다. + +### 4.3 실행 절차 (확인된 범위) + +1. `https://auth.data.go.kr/sso/login` 에서 공공데이터포털 계정으로 로그인한다(회원가입 필요 시 포털 내 가입 절차를 따른다 — 이번 조사에서 가입 절차 자체는 확인하지 않음, ⚠️ 미검증). +2. `https://www.data.go.kr/data/15057075/openapi.do` 로 이동한다. +3. "데이터 오류·개선 의견이 있으신가요?" 섹션에서 **"데이터 개선요청"**을 클릭한다(정확한 폼 화면은 로그인 후에만 노출되어 이번 조사에서 화면 구성까지는 확인하지 못했다). +4. §11.1 의 요청 문구 초안을 작성해 제출한다. +5. 응답이 없거나 "이 창구 대상이 아니다"라는 답변을 받으면 "오류신고 및 문의" 또는 공공데이터포털 고객센터(1566-0025 / opendata_help@nia.or.kr)로 재문의한다. + +--- + +## 5. 채널 C — 공공데이터포털 "활용신청" (참고 — 이미 사용 중일 가능성) + +데이터셋 페이지의 "활용신청" 버튼은 **API 이용 자체를 신청하는 절차**(인증키 발급 등)로 보이며, DMF_Crawler 는 이미 이 API 를 호출하고 있으므로 이 절차는 이미 완료됐을 것으로 추정된다. 항목 추가와는 무관한 기능이다. 별도 조치 불필요. + +--- + +## 6. 채널 D — 국민신문고(epeople.go.kr) 및 정보공개청구(open.go.kr) + +### 6.1 국민신문고 — 확인 시도와 한계 + +`https://www.epeople.go.kr` 를 여러 방식으로 열어봤으나, 이 사이트는 **자바스크립트 기반 SPA(단일 페이지 애플리케이션)로 구축돼 있어 자동 조회 도구(WebFetch)로는 페이지 제목 외의 실질적 콘텐츠를 가져오지 못했다.** 메뉴 구조, 민원 신청 폼의 정확한 URL, 처리기한 안내 문구를 이번 조사에서 확인하지 못했다. ⚠️ **미검증 — 메뉴 경로, 처리기한, 민원 유형별 차이 전부.** + +**확인된 것은 도메인 자체(`epeople.go.kr`)뿐이다.** 실제 활용 시에는 담당자가 직접 브라우저로 접속해 "민원신청" 메뉴를 찾아 진행해야 한다. + +### 6.2 국민신문고를 통한 요청의 실익 + +법적 근거를 원문으로 확인하지는 못했으나, 국민신문고는 정부 부처 횡단 민원 접수 시스템이므로 접수 후 **식약처로 이송되고, 부처는 법정 처리기한 내에 답변할 의무**를 진다고 알려져 있다(⚠️ 정확한 일수·근거 조문은 이번 조사로 원문 확인 못함, 팀 지침에 따라 확정 서술 보류). 공공데이터포털 1:1문의보다 **응답 강제력이 강할 가능성**이 있어 2순위 채택 이유가 된다. + +### 6.3 정보공개청구 — 확인된 내용 + +`open.go.kr` 자체 페이지는 SPA 구조로 직접 조회에 실패했으나, **정부24의 정보공개청구 안내 페이지**(`https://www.gov.kr/mw/AA020InfoCappView.do?CappBizCD=13110000037`)를 열어 다음을 확인했다. + +| 항목 | 확인된 내용 | +|---|---| +| 청구 방법 | 인터넷, 방문, FAX, 우편, 민원우편 | +| 처리기한 | **"기본 20일"**(정부24 페이지 원문 표현). 연장 사유로 "정보량이 많을 때 5일 이하 연장", "추가 검토 필요 시 6일 이상 연장", "대량 정보 공개 시 주·월·년 단위로 기한 재설정"이 안내됨 | +| 수수료 | 문서 사본(종이출력물): A3 이상 300원+초과분 100원/장, B4 이하 250원+초과분 50원/장. **전자파일(문서·도면·사진) 복제는 무료**(매체비용 별도). 오디오·비디오 복제는 1GB당 800원. 열람료는 1일 1시간 이내 무료, 초과 시 30분마다 1,000원 | +| 문의처 | 1588-2188 또는 02-721-0600 (09:00~18:00) | + +⚠️ **미검증**: 위 내용의 근거가 되는 정확한 법률 조문 번호(예: 「공공기관의 정보공개에 관한 법률」 제11조, 제15조 등)는 `law.go.kr` 원문 조회 실패로 조문 번호까지는 확인하지 못했다. "기본 20일"이라는 정부24 표현이 "10일 + 10일 연장"을 합산한 것인지, 별도 규정인지도 원문으로 재확인이 필요하다. + +**전자파일 제공이 무료로 확인된 점은 이 프로젝트에 유용하다** — 6개 필드의 현재 스냅샷을 엑셀/CSV 로 청구할 때 비용 부담이 없다는 뜻이다. 다만 §1.3 에서 설명한 대로 **정기·자동 제공에는 구조적으로 맞지 않으므로 백업 수단으로만 쓴다.** + +### 6.4 식약처 자체 정보공개 메뉴 + +식약처 메인 페이지에서 다음 메뉴 URL을 확인했다(둘 다 접속 가능한 링크로 확인, 내용 상세는 미확인). + +- 정보공개제도: `https://www.mfds.go.kr/wpge/m_11/de010101l0001.do` +- 사전정보공개: `https://www.mfds.go.kr/wpge/m_633/de010102l0002.do` + +정부 공통 창구인 `open.go.kr`/정부24와 별도로 식약처 자체 메뉴가 있다는 뜻이며, 두 경로 중 어느 쪽이 이 사안에 더 빠른지는 확인하지 못했다. ⚠️ 미검증. + +--- + +## 7. 채널 E — 식약처 직접 문의처 + +### 7.1 확인된 연락처 + +| 연락처 | 확인 경로 | 상태 | +|---|---|---| +| 식약처 종합상담센터 **1577-1255** (유료, 평일 09:00~18:00, 공휴일 제외) | `mfds.go.kr` 메인 페이지 및 조직도 페이지 반복 확인 | ✅ 확인 | +| 데이터혁신기획팀 (부서명) | `mfds.go.kr/wpge/m_271/de010705l0001.do`(조직도) — **기획조정관 산하**로 존재 확인 | ✅ 부서 존재 확인, 전화번호는 페이지에 **미기재** | +| 정보화담당관 (부서명) | 같은 조직도 페이지, 데이터혁신기획팀과 같은 기획조정관 산하 | ✅ 부서 존재 확인, 전화번호 미기재 | +| "허가총괄담당관" 043-719-2312, 2325 / 팩스 043-719-2300 | 팀에서 제공한 배경 정보 — 「원료의약품 등록 제도(DMF) 해설서」 명시 | ⚠️ **이번 조사에서 직접 재검증하지 못함.** 식약처 현재 조직도 페이지에서는 "허가총괄담당관"이라는 명칭의 부서를 찾지 못했고, 대신 **"의약품허가총괄과"**, **"의료기기허가과"**가 확인됐다 — 조직 개편으로 명칭이 바뀌었을 가능성이 있다. 실제 연락 전에 043-719-2312 로 먼저 걸어 현재도 유효한 번호인지 확인 필요. | +| 통합상담예약 | `mfds.go.kr/usr/tCounsel_1024/list.do` | ✅ URL 확인, 상세 절차 미확인 | +| 민원편람 | `mfds.go.kr/brd/m_1208/list.do` | ✅ URL 확인, 내용 미확인 | +| 자주하는 질문 | `mfds.go.kr/brd/m_1060/list.do` | ✅ URL 확인, 내용 미확인 | +| 의약품/화장품전자민원(의약품안전나라) | `nedrug.mfds.go.kr/index` | ✅ 확인 — 법령/자료실, 고시/공고알림, 통합검색 메뉴 존재. DMF 오픈API 전용 메뉴는 확인 안 됨 | + +### 7.2 왜 "데이터혁신기획팀 직통 연락처"를 확보하지 못했는가 + +공공데이터포털 데이터셋 페이지와 식약처 조직도 페이지 **두 곳 모두에서 부서명(데이터혁신기획팀)은 명확히 확인**됐지만, 두 페이지 모두 전화번호 필드가 비어 있거나 표시되지 않았다. 이는 실제로 번호가 없어서가 아니라, **공개 페이지에는 대표 상담센터로 안내를 유도**하는 정책일 가능성이 높다. 따라서 이 프로젝트는 부서명(데이터혁신기획팀)을 명시한 채로 **종합상담센터(1577-1255)** 나 **통합상담예약**을 거쳐 연결을 요청하는 방식을 취해야 한다. + +### 7.3 허가총괄담당관 경로에 대한 판단 + +허가총괄담당관 연락처(043-719-2312, 2325)는 DMF **제도 자체**(등록·심사 실무)를 담당하는 부서로 보이며, 데이터혁신기획팀은 **오픈API·데이터 공개**를 담당하는 부서로 보인다. 이 프로젝트가 요청하는 것은 "API 필드 추가"이므로 **1차 대상은 데이터혁신기획팀**이고, 허가총괄담당관(또는 개편된 의약품허가총괄과)은 "이 6개 필드가 왜 화면에는 있고 API 에는 없는지"에 대한 **제도적 배경을 확인하는 보조 채널**로 병행 문의하는 것이 합리적이다. + +--- + +## 8. 채널 F — 식의약 데이터 포털(data.mfds.go.kr) + +### 8.1 확인된 사실 + +`https://data.mfds.go.kr` 은 식약처가 운영하는 별도의 데이터 포털로 확인됐다. 메인 메뉴 구조는 다음과 같다. + +``` +data.mfds.go.kr +├── 공공데이터 (목록, 상세, 일상) +├── 파일데이터 +├── 데이터활용 (데이터셋 분양, 신청/결과 내역) +├── 경진대회 (참가, 개발사례) +└── 이용안내 (공지사항, 관련 사이트) +``` + +회원가입 페이지(`/OPAAB01F01`)가 확인됐고, 로그인 화면(`data.mfds.go.kr/OPAAB01F01` 접속 시)에서는 콘텐츠가 제한적으로만 노출됐다. + +### 8.2 DMF 데이터셋 존재 여부 + +메인 화면에 노출된 의약품 관련 데이터로는 DUR품목정보, 의약품 제품 허가정보, 의약품개요정보(e약은요), 임상시험 관련 정보가 확인됐으나, **"DMF" 또는 "원료의약품"을 키워드로 한 데이터셋은 로그인 없이 접근 가능한 범위에서는 발견하지 못했다.** ⚠️ **미검증 — 로그인 후 재확인 필요.** + +### 8.3 "데이터셋 분양"의 의미 + +메뉴명으로 미루어 "데이터셋 분양"은 **공공데이터포털에 개방되지 않은 원본 데이터를 신청자에게 개별 제공(분양)하는 기능**으로 추정되나, 로그인 후에만 상세를 확인할 수 있어 이번 조사로는 신청 대상·절차·처리기한을 확인하지 못했다. ⚠️ 미검증. + +### 8.4 이 채널을 참고용으로만 두는 이유 + +DMF 관련 데이터셋의 존재 자체가 확인되지 않았고, 확인하려면 회원가입과 로그인이 필요해 이번 조사 범위(공개 페이지 확인)를 벗어난다. §4 의 채널이 응답 없이 종료되면, 담당자가 직접 가입해 이 포털의 "데이터셋 분양" 메뉴를 열어 DMF 관련 항목이 있는지 재확인하는 것을 권고한다. + +--- + +## 9. API 항목 추가 요청 선례 + +이번 조사에서 **다른 공공 API 에서 항목 추가가 실제로 반영된 구체적 사례(기관명, 데이터셋명, 소요 기간)를 찾지 못했다.** 검색 결과 페이지에서는 관련 기관·포털 이름만 스치듯 나왔을 뿐, "항목 추가 요청 → 처리 결과"를 상세히 서술한 문서에 접근하지 못했다. ⚠️ **미검증 — 이 항목은 부록 B 의 우선 재조사 대상으로 남긴다.** + +**현실적 기대치에 대한 판단(추정, 근거 자료 없음으로 표시)**: 공공데이터포털의 "데이터 개선요청"은 시민 의견 수렴 성격의 창구이며, API 스키마 변경은 원본 시스템(식약처 내부 DB)과 API 게이트웨이 양쪽의 개발·검증·배포를 수반하는 작업이다. 따라서 접수만으로 즉시 반영되기보다는 **수 주에서 수 개월 단위의 검토 기간**이 걸릴 가능성이 높다고 보되, 이는 확인된 사례가 아니라 일반적인 공공기관 IT 변경관리 관행에 대한 추정이므로 계획에 반영하되(§11 의 "1주 내"·"응답 없을 때" 단계 구성 이유) 확정된 사실로 서술하지 않는다. + +--- + +## 10. robots.txt 예외 요청 + +### 10.1 조사 결과 + +"웹사이트 운영자에게 특정 크롤러의 접근을 허용해달라고 요청하는 관행"에 대해 검색을 시도했으나, 유의미한 결과를 얻지 못했다(검색 결과가 무관한 주제로 반환됨). **한국 공공기관을 대상으로 한 robots.txt 예외 요청 사례나 연락처 관행은 이번 조사로 확인하지 못했다.** ⚠️ 미검증. + +### 10.2 실효성 평가 + +이 프로젝트에는 이 채널이 애초에 우선순위가 낮다. 이유는 세 가지다. + +1. `design/00-DATA-SOURCE-DECISION.md` 에서 이미 **API 를 1차 소스로 채택**하기로 확정했고, HTML 크롤링은 "API 로 대체 불가능한 공고 게시판"에만 한정하기로 했다(같은 문서 §2 참조 배경). +2. robots.txt 예외를 받아도 **화면 크롤링이 가능해질 뿐, API 응답 필드 자체가 늘어나는 것은 아니다.** 이 프로젝트의 진짜 목표(6개 필드 확보)에는 §4·§7 의 채널이 더 직접적이다. +3. 정부기관 사이트의 robots.txt 예외 요청은 창구 자체가 불분명하고(위 10.1), 승인 여부·기간이 불확실해 계획에 넣기 어렵다. + +**결론: robots.txt 예외 요청은 이 프로젝트의 행동 계획에서 제외한다.** + +--- + +## 11. 권고 행동 계획 + +### 11.1 지금 당장 할 것 — 공공데이터포털 "데이터 개선요청" 제출 + +**대상**: `https://www.data.go.kr/data/15057075/openapi.do` 의 "데이터 개선요청" (로그인 필요) + +**요청 문구 초안** (복사해 그대로 제출 가능하도록 완성): + +> **[요청 제목]** 원료의약품등록(DMF) 오픈API(`getMdcDmfList01`) 응답 항목 추가 요청 — 최종변경일자·취소취하구분 등 6개 필드 +> +> **[요청 내용]** +> +> 안녕하십니까. 식품의약품안전처_원료의약품등록(DMF)현황 오픈API(`https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01`, 데이터셋 ID 15057075)를 활용해 제약업계 규제 정보 모니터링 시스템을 운영 중인 이용자입니다. +> +> 귀 포털이 제공하는 오픈API 응답은 `DMF_PERMIT_NO`(등록번호), `INGR_KOR_NAME`(성분명), `ENTP_NAME`(업체명), `MNFCTR_NAME`(제조소명), `MNFCTR_PLACE`(제조소 소재지), `MANUF_COUNTRY_CODE_NM`(제조국가명), `DMF_PERMIT_DATE`(발급일자) 7개 항목을 제공합니다. 그런데 동일한 데이터를 사람이 의약품안전나라 웹 화면에서 조회하면 다음 6개 항목이 추가로 표시됩니다. +> +> 1. 최종변경일자 +> 2. 최종연차보고년도 +> 3. 취소/취하구분 (예시값: 정상) +> 4. 취소/취하일자 +> 5. 문서번호 +> 6. 대상의약품 (별표1 / 신물질) +> +> 저희는 이 API 를 매일 자동 호출해 등록 현황을 수집하고, 전날 대비 오늘의 차이를 비교(diff)하는 방식으로 신규·변경·취하를 추론하고 있습니다. 그러나 API 에 상태 필드가 없어 다음과 같은 근본적 한계가 있습니다. +> +> - **취소/취하구분** 필드가 없어, "어제는 있었는데 오늘 목록에 없는 등록번호"를 취하로 추론하는 간접적 방식에 의존하고 있습니다. 이 방식은 수집이 단 1건이라도 누락되면 정상 등록 건을 취하로 오판할 위험이 있습니다. API 가 취소/취하구분을 직접 제공하면 이 위험이 완전히 사라집니다. +> - **최종변경일자**가 없어 "무엇이 바뀌었는지"는 알 수 있어도 "언제 바뀌었는지"를 API 만으로는 알 수 없습니다. +> - **최종연차보고년도, 취소/취하일자, 문서번호, 대상의약품** 역시 웹 화면에는 존재하나 API 응답에는 없어, 규제 준수 모니터링에 필요한 정보를 자동으로 얻지 못하고 있습니다. +> +> **요청 사항**: 위 6개 항목을 오픈API(`getMdcDmfList01`) 응답 스키마에 추가해 주시기를 요청드립니다. 신규 API 버전 신설(예: `getMdcDmfList02`)이나 기존 API 에 파라미터로 선택 노출하는 방식 모두 무방합니다. +> +> 만약 API 스키마 변경이 즉시 어렵다면, 대안으로 위 6개 필드를 포함한 전체 현황을 **정기적으로(예: 매일 또는 매주) 파일(CSV/Excel) 형태로 제공**받는 방법이 있는지도 함께 안내 부탁드립니다. +> +> 검토와 답변 부탁드립니다. 감사합니다. + +**제출 방법**: 공공데이터포털 계정으로 로그인 후 위 URL 접속, "데이터 개선요청" 클릭, 위 문구를 제목/내용란에 붙여넣기. 접수 후 접수번호를 반드시 캡처해 보관한다. + +### 11.2 1주 내 — 데이터혁신기획팀 병행 접촉 + +11.1 제출 후 **1주 이내 접수 확인 연락(자동 접수 메일 등)이 없으면**, 다음을 병행한다. + +1. **식약처 종합상담센터 1577-1255** 로 전화해 "데이터혁신기획팀 앞으로 오픈API 항목 추가 문의를 전달해달라"고 요청한다. 상담원에게 데이터셋명("원료의약품등록(DMF)현황"), 데이터셋 ID(15057075), 요청 요지(6개 필드 추가)를 정확히 전달한다. +2. 동시에 **국민신문고(epeople.go.kr)** 에 아래 문구로 민원을 접수한다. + +**국민신문고 제출용 문구 초안**: + +> **[민원 제목]** 식품의약품안전처 원료의약품등록(DMF) 오픈API 데이터 항목 추가 요청 +> +> **[민원 내용]** +> +> 귀 부(식품의약품안전처)가 공공데이터포털을 통해 제공하는 "원료의약품등록(DMF)현황" 오픈API(데이터셋 ID 15057075, 엔드포인트 `getMdcDmfList01`)를 제약 규제 정보 모니터링 목적으로 활용하고 있는 국민입니다. +> +> 해당 API 는 등록번호·성분명·업체명·제조소명·제조소 소재지·제조국가명·발급일자 7개 항목만 제공하나, 동일 데이터를 조회하는 의약품안전나라 웹 화면에는 최종변경일자·최종연차보고년도·취소/취하구분·취소/취하일자·문서번호·대상의약품 6개 항목이 추가로 존재합니다. 특히 취소/취하구분은 원료의약품 등록의 취하 여부를 직접 나타내는 핵심 정보인데, API 로는 확인할 방법이 없어 취하 시점의 데이터 유실 등을 우회적으로 추론해야 하는 상황입니다. +> +> 이에 아래 사항을 문의 및 요청드립니다. +> +> 1. 위 6개 항목을 오픈API 응답에 포함해 주실 수 있는지, 가능하다면 예상 반영 시기를 안내해 주시기 바랍니다. +> 2. API 반영이 어렵다면, 해당 항목을 포함한 데이터를 정기적으로 파일 형태로 제공받을 수 있는 방법이 있는지 안내해 주시기 바랍니다. +> 3. 본 오픈API 의 관리부서로 알고 있는 데이터혁신기획팀의 문의 가능한 연락처(전화 또는 이메일)를 안내해 주시면 감사하겠습니다. +> +> 성실한 답변 부탁드립니다. + +### 11.3 응답이 없거나 거부됐을 때의 대안 + +1. **명시적 거부 통보를 받은 경우**: 공공데이터포털 "분쟁조정 신청"(`/tcs/dor/insertTrublMdatReqstProcssView.do`)으로 공공데이터제공분쟁조정위원회에 조정을 신청하는 것을 검토한다. 다만 이 절차의 소요 기간·실효성은 이번 조사에서 확인하지 못했으므로(⚠️ 미검증), 신청 전 위원회 관련 안내를 재조회해 절차를 확정한다. +2. **응답이 아예 없는 경우(4주 이상)**: 정보공개청구로 **현재 시점의 6개 필드 값 전량을 전자파일(CSV/Excel)로 1회 확보**해 급한 데이터 공백을 메운다. 정기 수집 목적은 아니며, 어디까지나 "API 반영까지의 임시 우회"임을 청구서에도 명시한다. + +**정보공개청구 제출용 문구 초안**: + +> **[청구 대상 정보]** 원료의약품등록(DMF) 현황 중 오픈API 미제공 항목(최종변경일자, 최종연차보고년도, 취소/취하구분, 취소/취하일자, 문서번호, 대상의약품) 전량 +> +> **[청구 취지]** +> +> 귀 처가 공공데이터포털을 통해 공개 중인 "원료의약품등록(DMF)현황" 오픈API(데이터셋 ID 15057075)는 등록번호·성분명·업체명·제조소명·제조소 소재지·제조국가명·발급일자 7개 항목만 제공하고 있으나, 동일 데이터의 웹 화면(의약품안전나라)에는 최종변경일자·최종연차보고년도·취소/취하구분·취소/취하일자·문서번호·대상의약품 항목이 추가로 존재함을 확인했습니다. +> +> 이에 현재 등록된 전체 원료의약품 등록 건에 대한 위 6개 항목의 값을 전자파일(Excel 또는 CSV) 형태로 청구합니다. 등록번호(DMF_PERMIT_NO)를 매칭 키로 하여 오픈API 응답과 대조할 수 있도록 등록번호를 함께 포함해 주시기 바랍니다. +> +> 본 청구는 오픈API 항목 추가가 반영되기 전까지 임시로 데이터 공백을 메우기 위한 목적이며, 별도로 데이터혁신기획팀에 오픈API 항목 추가를 요청한 상태임을 참고 부탁드립니다. + +### 11.4 진행 상황 추적 + +각 채널에 접수한 날짜, 접수번호, 응답 기한, 실제 응답 여부를 아래 표 형식으로 관리할 것을 권고한다. 이 문서에는 접수 이력을 담지 않으며, 실제 접수가 이뤄지는 대로 담당자가 행을 채워 넣는다. + +| 채널 | 접수일 | 접수번호/참조번호 | 예상 응답기한 | 실제 응답일 | 결과 요약 | 다음 조치 | +|---|---|---|---|---|---|---| +| 공공데이터포털 데이터 개선요청(§11.1) | | | | | | | +| 식약처 종합상담센터 전화(§11.2) | | | | | | | +| 국민신문고 민원(§11.2) | | | | | | | +| 분쟁조정 신청(§11.3, 거부 시) | | | | | | | +| 정보공개청구(§11.3, 무응답 시) | | | | | | | + +### 11.5 채널 선택 시 판단 기준 요약 + +담당자가 매 상황마다 §2 표를 다시 읽지 않아도 되도록, 판단 기준을 한 문단으로 요약한다. **아직 아무 요청도 하지 않았다면 §11.1(데이터 개선요청)부터 시작한다.** 접수 후 1주가 지나도 자동 접수 확인조차 없다면 §11.2(전화 + 국민신문고)를 병행한다. 국민신문고나 전화 문의에서 담당 부서로부터 "항목 추가는 어렵다"는 **명시적 거부**를 받으면 분쟁조정 신청을 검토하고, 반대로 **4주 이상 아무 응답도 없다면** 분쟁조정보다 먼저 정보공개청구로 현재 스냅샷을 확보해 데이터 공백을 메운다. 즉 "거부"와 "무응답"은 서로 다른 다음 단계로 이어진다 — 거부는 이의절차(분쟁조정)로, 무응답은 백업 확보(정보공개청구)로 대응한다. + +--- + +## 부록 A. 출처 + +| 제목 | URL | 확인 여부 | +|---|---|---| +| DMF 오픈API 데이터셋 상세 | https://www.data.go.kr/data/15057075/openapi.do | ✅ 직접 확인(제공기관, 관리부서, 활용신청/데이터 개선요청 버튼, 고객센터 연락처) | +| 공공데이터포털 메인 | https://www.data.go.kr | ✅ 직접 확인(메뉴 구조: 공공데이터 제공신청, 분쟁조정 신청) | +| 공공데이터포털 분쟁조정 신청 | https://www.data.go.kr/tcs/dor/insertTrublMdatReqstProcssView.do | ✅ URL 확인(안내 문구까지) | +| 공공데이터 제공신청 정확한 URL | — | ❌ 미확인(자바스크립트 처리, `href="#"`만 확인) | +| 국민신문고 메인 | https://www.epeople.go.kr | ⚠️ 도메인만 확인, SPA 로 콘텐츠 미확인 | +| 국민신문고 원패스뷰(추정 URL) | https://www.epeople.go.kr/kordep/cop/onePassView.npaid | ❌ 404 | +| 정보공개포털 메인 | https://www.open.go.kr | ⚠️ 제목만 확인, 콘텐츠 미확인 | +| 정보공개포털 안내 페이지(추정) | https://www.open.go.kr/gov/govView.do | ❌ 404 | +| 정부24 정보공개청구 안내 | https://www.gov.kr/mw/AA020InfoCappView.do?CappBizCD=13110000037 | ✅ 직접 확인(청구방법, 처리기한, 수수료, 문의처) | +| 식약처 메인 | https://www.mfds.go.kr | ✅ 직접 확인(주요 메뉴 URL 목록) | +| 식약처 조직도/부서 안내 | https://www.mfds.go.kr/wpge/m_271/de010705l0001.do | ✅ 직접 확인(데이터혁신기획팀, 정보화담당관 부서 존재, 전화번호는 미기재) | +| 식약처 정보공개제도 | https://www.mfds.go.kr/wpge/m_11/de010101l0001.do | ✅ URL 확인, 내용 미확인 | +| 식약처 사전정보공개 | https://www.mfds.go.kr/wpge/m_633/de010102l0002.do | ✅ URL 확인, 내용 미확인 | +| 식약처 통합상담예약 | https://www.mfds.go.kr/usr/tCounsel_1024/list.do | ✅ URL 확인, 내용 미확인 | +| 식약처 민원편람 | https://www.mfds.go.kr/brd/m_1208/list.do | ✅ URL 확인, 내용 미확인 | +| 식약처 자주하는 질문 | https://www.mfds.go.kr/brd/m_1060/list.do | ✅ URL 확인, 내용 미확인 | +| 식약처 부서 페이지(1차 시도, 서비스 오류) | https://www.mfds.go.kr/wpge/m_39/de0304010101.do | ❌ 서비스 오류 페이지 | +| 의약품안전나라(nedrug) | https://nedrug.mfds.go.kr/index | ✅ 직접 확인(법령/자료실, 고시/공고알림, 통합검색 메뉴) | +| 식의약 데이터 포털 | https://data.mfds.go.kr | ✅ 직접 확인(메뉴 구조, 회원가입 링크) — DMF 데이터셋 존재는 미확인 | +| 식의약 데이터 포털 로그인 | https://data.mfds.go.kr/OPAAA01F01 | ✅ 로그인 화면임을 확인 | +| 공공데이터법(law.go.kr, 제27조 시도) | https://www.law.go.kr/법령/공공데이터의제공및이용활성화에관한법률/제27조 | ❌ SPA 렌더링으로 조문 텍스트 미확인 | +| 정보공개법(law.go.kr, 제11조 시도) | https://www.law.go.kr/법령/공공기관의정보공개에관한법률/제11조 | ❌ SPA 렌더링으로 조문 텍스트 미확인 | +| 민원처리법(law.go.kr 시도) | https://www.law.go.kr/법령/민원 처리에 관한 법률 | ❌ SPA 렌더링으로 조문 텍스트 미확인 | +| law.go.kr DRF Open API(비인증 시도) | https://www.law.go.kr/DRF/lawService.do?OC=test&target=law&type=HTML&LM=... | ❌ 미인증 OC 파라미터로 조문 미출력, 메인 화면으로 리다이렉트 | + +--- + +## 부록 B. 미해결 + +- [ ] 공공데이터포털 "공공데이터 제공신청"의 정확한 URL, 신청 양식 항목, 처리기한. (§3.1) +- [ ] "공공데이터 제공신청"이 신규 데이터 개방용인지, 기존 API 항목 추가에도 쓰이는지 여부. (§3.2) +- [ ] 데이터셋 페이지 "데이터 개선요청"/"오류신고 및 문의"의 정확한 제출 폼 URL 과 처리기한. 로그인 후 직접 확인 필요. (§4.1) +- [ ] 공공데이터제공분쟁조정위원회의 심의 절차, 처리기간, 결정의 구속력. (§3.4) +- [ ] 국민신문고의 민원 신청 메뉴 정확한 경로, 민원 유형(일반민원/정책제안 등)별 법정 처리기한. SPA 라서 담당자가 직접 브라우저로 열어 확인 필요. (§6.1) +- [ ] 「공공데이터의 제공 및 이용 활성화에 관한 법률」, 「공공기관의 정보공개에 관한 법률」, 「민원 처리에 관한 법률」의 정확한 조 번호와 일수. `law.go.kr` 이 자동 조회 도구로 렌더링되지 않아 원문 확인 실패 — 담당자가 직접 브라우저로 열거나, 등록된 이메일로 `law.go.kr` Open API 를 정식 신청해 재조회 필요. (§6.3, §3.1) +- [ ] 데이터혁신기획팀의 직통 전화번호 또는 이메일. 조직도 페이지에는 부서명만 있고 번호가 없어, 종합상담센터(1577-1255)를 통한 연결 시도 결과를 기록해야 한다. (§7.2) +- [ ] "허가총괄담당관"(043-719-2312, 2325)이 현재도 유효한 부서/번호인지. 현재 조직도에서는 "의약품허가총괄과"로 보이는 명칭만 확인됨 — 개편 여부 재확인 필요. (§7.1, §7.3) +- [ ] 식의약 데이터 포털(`data.mfds.go.kr`)에 DMF 관련 데이터셋이 실제로 있는지. 회원가입 후 "데이터셋 분양" 메뉴를 열어야 확인 가능. (§8.2, §8.3) +- [ ] 다른 공공 API 에서 항목 추가가 실제로 반영된 구체적 사례와 소요 기간. (§9) +- [ ] robots.txt 예외 요청의 구체적 창구(연락처)와 실제 성사 사례. (§10.1) +- [ ] 식약처 자체 정보공개 메뉴(`/wpge/m_11/de010101l0001.do`)가 `open.go.kr`/정부24 경로와 별도로 더 빠른 처리 경로인지. (§6.4) diff --git a/docs/ops/05-release-and-versioning.md b/docs/ops/05-release-and-versioning.md new file mode 100644 index 0000000..cfb9228 --- /dev/null +++ b/docs/ops/05-release-and-versioning.md @@ -0,0 +1,98 @@ +# 05. 릴리스와 버전 관리 + +> 이 문서는 **버전 번호·git 태그·릴리스 절차의 정본**이다. "몇 버전을 언제 어떻게 배포하는가"는 +> 여기서 결정한다. 배포판을 실제로 만드는 절차(원드라이브 zip 등 수동 전달)의 실측 로그는 +> `docs/HANDOFF.md`에 있다. + +## 1. 버전 번호 — Semantic Versioning + +`pyproject.toml`의 `[project].version`이 유일한 버전 정본이다. 형식은 `MAJOR.MINOR.PATCH`. + +| 자리 | 올리는 경우 | +|---|---| +| `MAJOR` | 설정 파일 스키마·CLI 인자·DB 스키마 등 하위 호환을 깨는 변경 | +| `MINOR` | 하위 호환을 유지하며 기능을 추가(새 시트, 새 알림 채널, 새 CLI 서브커맨드 등) | +| `PATCH` | 버그 수정, 문서 정리, 리팩터링 등 사용자에게 보이는 동작 변화가 없는 변경 | + +`0.y.z` 동안은(1.0.0 이전) 아직 안정 API 이전이므로 `MINOR`도 하위 호환을 깰 수 있다. + +## 2. 릴리스 절차 + +```text +1. CHANGELOG.md의 [Unreleased] 섹션을 확정 버전으로 승격 + - 헤더를 `## [X.Y.Z] - YYYY-MM-DD`로 바꾼다 + - 문서 하단 비교 링크(compare/... , releases/tag/...)를 갱신한다 +2. pyproject.toml의 version = "X.Y.Z" 를 갱신 +3. 필수 smoke 실행 (AGENTS.md §3) + .\.venv\Scripts\python.exe -m compileall -q src tests + .\.venv\Scripts\python.exe -m dmf_crawler --help + .\.venv\Scripts\python.exe -m dmf_crawler doctor --json + .\.venv\Scripts\python.exe -m pytest tests -q +4. (선택) 배포용 빌드 산출물 생성 — §3 참고 +5. 커밋: "release: vX.Y.Z" +6. 태그: git tag -a vX.Y.Z -m "vX.Y.Z" +7. 푸시: git push origin main --follow-tags +8. git.chanpaca.net 웹에서 Releases → 해당 태그로 릴리스 노트 작성 + (본문은 CHANGELOG.md의 해당 버전 섹션을 그대로 옮긴다) +``` + +태그 이름은 항상 `v` 접두어를 붙인다(`v0.1.0`, `v1.2.0`). `pyproject.toml`의 버전 문자열에는 +`v`를 붙이지 않는다. + +## 3. 빌드 산출물 (`dist/`) — `src/`와의 구분 + +이 저장소는 **소스와 빌드 산출물을 엄격히 분리**한다. + +| 디렉터리 | 내용 | git 추적 여부 | +|---|---|---| +| `src/dmf_crawler/` | 사람이 직접 편집하는 유일한 소스. 여기가 정본이다 | 추적함 | +| `dist/` | `python -m build` 등이 만들어내는 sdist(`.tar.gz`)·wheel(`.whl`) | **추적 안 함**(`.gitignore`) | +| `build/` | setuptools가 빌드 중 쓰는 임시 작업 디렉터리 | **추적 안 함** | +| `*.egg-info/` | 패키지 메타데이터 캐시 | **추적 안 함** | + +`dist/`는 **항상 `src/`에서 재생성 가능**해야 한다. `dist/` 안의 파일을 손으로 고쳐서는 안 된다 +(고칠 게 있으면 `src/`를 고치고 다시 빌드한다). 그래서 저장소에 커밋하지 않는다 — 커밋하면 +"소스와 다른 빌드 산출물이 정본 행세를 하는" 사고가 난다. + +빌드 확인 절차: + +```powershell +.\.venv\Scripts\python.exe -m pip install --upgrade build +.\.venv\Scripts\python.exe -m build # dist/dmf_crawler-X.Y.Z-py3-none-any.whl + .tar.gz 생성 +.\.venv\Scripts\python.exe -m pip install --force-reinstall dist\dmf_crawler-*.whl +.\.venv\Scripts\python.exe -m dmf_crawler --help +``` + +이 프로그램은 PyPI에 올리지 않는다(`pyproject.toml`의 `Private :: Do Not Upload` 분류자). +`dist/`의 wheel은 다른 PC에 수동 배포할 때만 쓴다. 일반 사용자 배포는 `bootstrap.cmd` + +저장소 zip 방식을 쓴다(§4). + +## 4. 최종 사용자 배포판 (zip) + +이 프로그램은 개발자가 아닌 사용자가 쓴다(`AGENTS.md`, `README.md` 참고). PyPI/wheel 설치가 +아니라 **저장소를 그대로 zip으로 압축해 전달**하는 방식을 쓴다. 실제 배포 사례와 포함/제외 +감사 체크리스트는 `docs/HANDOFF.md`의 "릴리즈 배포본 생성 완료" 절을 참고한다. + +핵심 원칙만 요약한다: + +- 포함: `bootstrap.cmd`, `README.md`, `LICENSE`, `pyproject.toml`, `src/`, `config/`, + `scripts/`, `prompts/`, `docs/`(필요한 만큼). +- 제외: `.venv/`, `data/`, `logs/`, `reports/`, `backup/`, `state/`, `tests/`, `__pycache__/`, + `*.pyc`, `*.lnk`, `config.local.toml`, `service_key*`, `*oauth-token*`. +- 압축 해제 후 `bootstrap.cmd`가 정상 동작하는지 새 폴더에서 재현 검증한다. + +## 5. 원격 저장소 + +정본 원격은 `git.chanpaca.net`(공개 저장소, Forgejo)이다. + +```powershell +git remote -v +# origin ssh://git@git.chanpaca.net:2222/yunchan/DMF_Crawler.git +``` + +이 저장소는 **공개(public)**다. 그래서 커밋 전 다음을 반드시 확인한다(AGENTS.md §6와 동일): + +- API 키·OAuth 토큰·웹훅 URL·비밀번호가 코드·설정·로그·문서 어디에도 평문으로 없는가 +- `config/config.local.toml`, `*.sqlite3`, `data/`, `logs/`, `reports/`, `backup/`, `state/`가 + `.gitignore`로 실제로 제외되는가 (`git status` 로 커밋 전 매번 확인) +- 실제 수집 데이터(엑셀·DB)가 우연히 스테이징되지 않았는가 diff --git a/docs/research/01-dmf-domain-and-sources.md b/docs/research/01-dmf-domain-and-sources.md new file mode 100644 index 0000000..e900aa4 --- /dev/null +++ b/docs/research/01-dmf-domain-and-sources.md @@ -0,0 +1,2487 @@ +# DMF 도메인 지식과 데이터 소스 정본 + +> ### ⚠️ 이 문서의 현재 지위: §0 핵심 결론 일부가 실측으로 반증됨 (부분 정정) +> +> 이 문서는 `design/00b-baseline-data-analysis.md` 의 실측(사용자가 이미 운영 중이던 `DMF_현황.xlsx` 9,084건 전수 분석, 2026-09-02) 이전에 작성되었다. +> +> **반증된 것**: §0 의 "공공데이터포털 OpenAPI 는 보조·검증용으로만 쓴다. API 단독으로는 변경·취하 탐지를 충족할 수 없다"는 결론은 실측으로 반증되었다. +> - API 전체 건수 **9,084건** vs 웹 화면 **9,840건** — **756건 차이**. 같은 날 측정이고 양쪽 최신 날짜가 2026-09-01로 동일해 시차로는 설명되지 않는다. → API 는 취하·취소 건을 제외하고 정상 건만 반환한다는 강력한 증거. +> - 등록번호 앞 8자리(최초 등록일)와 `발급일자`가 **44.5% 불일치**하며, 불일치 건은 전부 괄호가 붙은 갱신 건이다. → `발급일자`는 고정된 최초 등록일이 아니라 변경 때마다 갱신되는 **최종 갱신일**이다. +> - 등록번호는 9,084건 전부 유일(**중복 0건**) — 완전한 자연 키다. +> +> 근거: `design/00b-baseline-data-analysis.md` §4~§6. +> +> **이 문서에서 여전히 유효하고 가치가 큰 부분**: DMF 제도 개요(§2), 법적 근거 약사법·규칙·고시(§2.3), 등록번호 체계 해부(§3), 화면 컬럼과 API 필드의 대응 관계(§4.5~4.6), RA 실무자 관점(§10), 해외 소스 조사(§8), robots.txt·저작권 사실(§9). +> +> **채택하지 않는 부분**: nedrug `/pbp/CCBAC03` 화면을 주 수집원으로 삼는 결론, "절제된 수집" 크롤링 실행 정책, 화면 엔드포인트(엑셀 POST·HTML 페이지네이션) 기반 수집 설계. 이 프로젝트는 HTML 을 크롤링하지 않고 공식 Open API 만 쓴다. 근거: `design/00-DATA-SOURCE-DECISION.md`. +> +> 절별 채택 여부는 문서 끝의 [이 문서와 확정 설계의 관계](#이-문서와-확정-설계의-관계) 표를 보라. + +--- + +> **이 문서의 역할**: DMF_Crawler 프로젝트가 "무엇을(어떤 제도의, 어떤 필드를), 어디서(어떤 URL·API·파일을), 어떤 규칙으로" 수집하는지를 확정하는 단일 정본(SSOT)이며, 이 문서만 읽고 수집기·데이터 모델·리포트 스키마를 구현할 수 있어야 한다. +> +> ⚠️ 위 상태 배너 참조 — 이 문서 작성 이후 확보된 실측 증거로 §0 의 일부 결론이 정정되었다. 구현 시 `design/00-DATA-SOURCE-DECISION.md` 와 `design/00b-baseline-data-analysis.md` 를 우선 확인하라. + +--- + +## 0. 한눈에 보기 + +이 문서에서 내린 결론(구현팀은 이것만 먼저 읽어도 된다): + +- **주 데이터 소스는 의약품안전나라(nedrug.mfds.go.kr)의 `/pbp/CCBAC03` "원료의약품등록(DMF) 공고" 화면 1개로 확정한다.** JS 렌더링 불필요(Thymeleaf 서버사이드 렌더링), 순수 `GET /pbp/CCBAC03/getList` 로 목록 HTML을 받을 수 있고, `POST /pbp/CCBAC03/getExcel` 로 **전체 9,840건을 xlsx 한 방에** 받을 수 있음이 실측으로 확인되었다(2026-09-02 기준). + + > ⚠️ **채택하지 않음** — nedrug 화면을 주 수집원으로 삼는 결론은 이후 확정 설계와 어긋난다. 이 프로젝트는 HTML 크롤링을 하지 않고 공식 Open API 만 쓴다. 근거: `design/00-DATA-SOURCE-DECISION.md` + +- **공공데이터포털 OpenAPI(`MdcDmfInfoService01/getMdcDmfList01`)는 보조·검증용으로만 쓴다.** 응답 필드가 7개(등록번호·성분명·업체명·제조소명·제조소소재지·제조국가명·발급일자)뿐이라 **신규/변경/취하 탐지에 필수인 `최종변경일자`·`최종연차보고년도`·`취소/취하구분`·`취소/취하일자`·`문서번호`·`연계심사문서번호`가 없다.** 즉 API 단독으로는 이 프로젝트의 요구사항(변경·취하 탐지)을 충족할 수 없다. + + > ❌ **반증됨** — 실측으로 API 7필드만으로 신규·변경·취하 전부 탐지 가능함이 확인됐다. API 전체 건수 9,084건 대 웹 화면 9,840건(756건 차이, 같은 날 측정)은 API 가 취하·취소를 제외하고 정상 건만 반환한다는 증거이며, 등록번호 앞 8자리 대비 발급일자가 44.5% 불일치(불일치 건은 전부 괄호 붙은 갱신 건)한 것은 발급일자가 최종 갱신일로 동작함을 보여준다. 등록번호 중복도 0건(9,084건 전부 유일)이라 자연 키도 확보된다. 근거: `design/00b-baseline-data-analysis.md` §4, §5, §6 + +- **변경(diff) 탐지의 1차 키는 `등록번호(dmfPermitNo)`이며, 이 번호는 단순 문자열이 아니라 "등록수리일자-별표1 성분 일련번호-시행일 알파벳군-접수순번-동일성분 일련번호(허여서 괄호)"로 구성된 복합 의미 키다.** 파싱 규칙과 정규식을 §3에 확정했다. 단, `수6580-16-ND(20)` 같은 **신물질(신약) 계열 별도 포맷**이 공존하므로 파서는 2개 포맷을 모두 처리해야 한다. + + > ✅ **유효** — 등록번호가 자연 키라는 점과 파싱 규칙은 API 의 `dmf_permit_no` 필드에도 그대로 적용된다. 9,084건 중복 0건으로 재확인됨(`design/00b-baseline-data-analysis.md` §5.1). + +- **주간 공고 게시판(`/bbs/117`)은 2021-02-19(등록번호 bbscttNo=714, "2021년 2월 1,2주차")를 마지막으로 사실상 갱신이 끊겼다.** 따라서 "주간 공고 게시판 크롤링"이 아니라 **"공고 현황 테이블 일일 스냅샷 diff"**가 이 프로젝트의 올바른 아키텍처다. + + > ⚠️ **부분 채택하지 않음** — 게시판 갱신 중단 사실은 유효하다. 다만 "공고 현황 테이블(CCBAC03 화면)" 을 일일 스냅샷 diff 대상으로 삼는다는 결론은 채택하지 않는다. 이 프로젝트는 화면이 아니라 **API 응답을 일일 스냅샷으로 diff** 한다. 스냅샷 diff 라는 아키텍처 원리 자체는 API 로 재확인됐다. 근거: `design/00b-baseline-data-analysis.md` §6.3, `design/00-DATA-SOURCE-DECISION.md` + +- **`https://nedrug.mfds.go.kr/robots.txt` 는 `User-agent: * / Disallow: /` 로 전면 차단이다.** 반면 식약처 저작권정책은 저작권법 제24조의2에 따라 공표 저작물의 자유 이용을 허용한다. → **법적 이용은 가능하되 robots.txt를 존중하는 절제된 수집(1일 1회, 저빈도, User-Agent 명시, 엑셀 1회 다운로드)** 정책을 택한다. §9 참조. + + > ⚠️ **채택하지 않음** — robots.txt 준수를 전제로 한 HTML 크롤링 절제 정책이다. 이 프로젝트는 HTML 을 크롤링하지 않고 공식 API 만 쓰므로 이 정책은 적용 대상이 없다. robots.txt 실측 사실과 법적 검토(저작권법 제24조의2 등, §9)는 그대로 유효하며 `research/04-anti-bot-and-legal.md` 에도 보존되어 있다. 근거: `design/00-DATA-SOURCE-DECISION.md` + +- **해외 소스는 난이도 격차가 크다.** PMDA MF는 `.xlsx` 직접 다운로드가 200 OK로 성공(5,023행), EDQM CEP는 `EXPORT_WEB_CEP.txt` 전량 다운로드가 200 OK로 성공(1,459,534 bytes, 11컬럼 TSV). **FDA는 WAF(abuse-detection)로 봇 차단되어 404/403** — 브라우저 자동화 없이는 불가. + + > ✅ **유효** + +- **RA 실무 관점의 알림 축은 4개다: ① 성분(내가 다루는 API), ② 제조원(제조소명/소재지/국가), ③ 경쟁사(신청인), ④ 등록번호의 상태 변화(변경일자·연차보고·취하/취소).** 리포트 탭 설계는 이 4축을 그대로 시트로 매핑한다. + + > ✅ **유효** + +- **최종 수집 필드는 14개 원본 컬럼 + 파생 필드 9개로 확정**했다(§12). + + > ⚠️ **채택하지 않음** — 이 필드 집합은 CCBAC03 화면/엑셀 14개 컬럼 기준이다. 이 프로젝트는 API 7필드 + 파생 필드 9개를 수집 대상으로 채택하며, §12 를 전면 재작성했다. 근거: `design/00b-baseline-data-analysis.md` §3, §10 + +- 법적 근거는 **「약사법」 제31조의2 + 「의약품 등의 안전에 관한 규칙」 제15·16·17조 + 「원료의약품 등록에 관한 규정」(식약처 고시 제2024-89호, 2024.12.30. 시행)** 3단 구조이며, **공고 의무는 규칙 제16조 후단·제17조제3항 후단의 "인터넷 등으로 공고하여야 한다"** 에서 나온다. + + > ✅ **유효** + +- 처리기한은 **등록 20일(신약 원료 및 규칙 제15조제1항제1호가목 단서 해당 시 90일), 변경등록 20일(동 단서 자료 제출 시 90일)** 이다. 수수료는 등록 802,000원(전자민원)/887,000원(방문·우편), 변경등록 401,000원/443,000원. + + > ✅ **유효** + +--- + +## 1. 목차 + +1. [목차](#1-목차) +2. [DMF(원료의약품 등록) 제도 개요](#2-dmf원료의약품-등록-제도-개요) + - 2.1 [제도 정의와 도입 배경](#21-제도-정의와-도입-배경) + - 2.2 [연혁 타임라인 (원문 그대로)](#22-연혁-타임라인-원문-그대로) + - 2.3 [법적 근거 3단 구조](#23-법적-근거-3단-구조) + - 2.4 [등록대상 원료의약품](#24-등록대상-원료의약품) + - 2.5 [등록 절차·처리기한·수수료](#25-등록-절차처리기한수수료) + - 2.6 [변경등록 vs 변경보고(연차보고)](#26-변경등록-vs-변경보고연차보고) + - 2.7 [취하·취소·반려](#27-취하취소반려) + - 2.8 [공고 제도 — 이 프로젝트의 법적 원천](#28-공고-제도--이-프로젝트의-법적-원천) + - 2.9 [등록 공고 건수 통계 (연도별)](#29-등록-공고-건수-통계-연도별) +3. [등록번호 체계 완전 해부](#3-등록번호-체계-완전-해부) +4. [의약품안전나라 `/pbp/CCBAC03` — 화면 구조 조사 (수집 대상 아님)](#4-의약품안전나라-pbpccbac03--화면-구조-조사-수집-대상-아님) +5. [의약품안전나라 `/bbs/117` — 주간 공고 게시판(레거시)](#5-의약품안전나라-bbs117--주간-공고-게시판레거시) +6. [공공데이터포털 OpenAPI](#6-공공데이터포털-openapi) +7. [식의약 데이터 포털 / 기타 국내 채널](#7-식의약-데이터-포털--기타-국내-채널) +8. [해외 DMF 소스 (FDA / EDQM / PMDA)](#8-해외-dmf-소스-fda--edqm--pmda) +9. [robots.txt·저작권·이용약관·크롤링 정책](#9-robotstxt저작권이용약관크롤링-정책) +10. [제약사 RA 실무자의 모니터링 관점](#10-제약사-ra-실무자의-모니터링-관점) +11. [언론·유사 서비스가 DMF를 다루는 방식](#11-언론유사-서비스가-dmf를-다루는-방식) +12. [수집 대상 필드 확정안](#12-수집-대상-필드-확정안) +13. [부록 A. 출처 목록](#부록-a-출처-목록) +14. [부록 B. 미해결 질문 / 실측 필요 항목](#부록-b-미해결-질문--실측-필요-항목) + +--- + +## 2. DMF(원료의약품 등록) 제도 개요 + +### 2.1 제도 정의와 도입 배경 + +DMF(Drug Master File, 국내 명칭 **원료의약품 등록제도**, 영문 약칭 **KDMF**)는 완제의약품을 제조할 때 **등록된 주성분(원료의약품)만을 사용하도록 의무화**한 제도다. 원료의약품 제조원의 영업비밀(제조방법·노하우)을 완제 허가신청자에게 노출하지 않으면서도 규제기관이 품질을 심사할 수 있게 하는 구조다. + +「원료의약품 등록 제도(DMF) 해설서 제5개정판(민원인안내서)」(2020.12., 허가총괄담당관, 안내서-0225-04) Ⅰ장 원문: + +> □ 원료의약품 등록 제도 도입 +> ○ 원료의약품 품질개선 등을 통해 유통 의약품의 안전 및 품질을 확보할 수 있도록 **2002년 7월 원료의약품 등록제도(Drug Master File)를 도입** +> ○ 미국, 유럽 등과 같이 전체 유효성분을 그 대상으로 하여야 하나, 제약산업 환경, 관련 업계의 준비기간 등을 감안하여 **매년 국민 다소비 성분을 우선하여 단계적으로 대상 성분을 확대중**임 + +해설서 문의처: 허가총괄담당관, 전화 **043-719-2312, 2325**, 팩스 **043-719-2300**. + +해설서 제·개정 이력(원문 표): + +| 연번 | 제·개정번호 | 승인일자 | 주요내용 | +|---|---|---|---| +| 1 | C0-2007-2-001 | 2007.11.28 | 제정 | +| 2 | C0-2012-2-006 | 2012.10.30 | 관련 규정 개정사항 현행화, 변경등록/연차보고 대상 구분 및 제출자료 명확화 | +| 3 | 안내서-0225-01 | - | 「식약처 지침서등의 관리에 관한 규정」 개정에 따른 등록번호 일괄 정비 (규제개혁담당관실-3761호, 2017.5.16) | +| 4 | 안내서-0225-02 | 2018.12. | 관련 규정 개정사항 현행화 | +| 5 | 안내서-0225-03 | 2019.12 | 관련 규정 개정사항 현행화 / 변경등록·변경보고 대상 구분 및 제출자료 명확화 / 허여서 품목 변경관리 개선 / 미분화 원료 등록 관리 방안 | +| 6 | 안내서-0225-04 | 2020.12 | 원료-완제의약품 연계심사 절차 및 질의·응답 반영 / 「의약품동등성 확보 필요 대상 의약품 지정」(‘20.7.8.) 개정에 따른 추가된 등록대상 원료의약품 포함 / 심사시 잦은 ‘보완사항’ 관련 내용 구체 기술 / 적제개편 사항 업무처리 절차 모식도 반영 | + +해설서 목차(구현 시 참조 지점): + +``` +I. 원료의약품 등록제도 개요 ............ 1 +II. 관련 법령 ........................... 4 +III.「원료의약품 등록에 관한 규정」해설 ... 24 +IV. 관련 질의·응답 정리 ................. 64 ← 등록번호 체계(Q51)가 여기 있음 +V. 신청양식 및 자가점검표 등 ............ 89 + 첨부1 원료의약품 (변경)등록 신청서 + 첨부2 원료의약품 등록증 + 첨부3 자가 점검표 + 첨부4 신청인 체크리스트 + 첨부5 자료공유허여서 양식 + 첨부6 원료의약품 등록 업무처리흐름도 + 붙임1 변경등록 및 변경보고 사항 비교표 + 붙임2 원료-완제 연계심사를 위한 세부 운영절차 +``` + +### 2.2 연혁 타임라인 (원문 그대로) + +해설서 Ⅰ장의 연혁을 **날짜·성분 수치 그대로** 보존한다. 이 타임라인은 §3의 등록번호 알파벳군(A~K) 해석과 1:1로 대응하므로 절대 요약하지 말 것. + +| 시점 | 내용 | +|---|---| +| '02. 07. 01 | 신규 신청되는 **신약 성분** | +| '05. 09. 01 | 글리클라짓(당뇨병약) 등 **77개 성분** 추가 | +| '06. 07. 01 | **인태반 함유** 원료의약품 추가 | +| '06. 11. 14 | 돔페리돈(위장관조절제) 등 **22개 성분** 추가 (2008. 1. 1.부터 시행) | +| '07. 12. 17 | 노르플록사신 등 **14개 성분** 추가 (2009. 1. 1.부터 시행) | +| '08. 12. 31 | 세프메타졸 등 **10개 성분** 추가 (2010. 1. 1.부터 시행) | +| '10. 04. 20 | 동일 유효성분으로 **염류 및 수화물이 다른 경우에도** 등록 대상으로 하고, 설피리드 등 **18개 성분** 추가 (2011. 1. 1.부터 시행) | +| '11. 02. 10 | 나프록센 등 **67개 성분** 추가 (2013. 1. 1.부터 시행) | +| '12. 03. 30 | **원료의약품 신고제에서 등록제로 변경** | +| '12. 05. 17 | 원료의약품 신고지침 → **「원료의약품 등록에 관한 규정」으로 개정** | +| '15. 12. 28 | 한약(생약)성분 **별표1의2 추가** (2018. 1. 1.부터 시행) | +| '16. 06. 30 | 의약품동등성 확보가 필요한 의약품, 주사제의 원료의약품 등 **별표1 추가** (2017. 12. 25.부터 시행) — 고시 제2016-59호 | +| '18. 08. 23 | 주사제 원료의약품 중 **퇴장방지의약품 해당 성분 및 영양소 보급 목적 제제의 원료의약품 제외**. 항생물질제제(약효분류번호 **610**) 주사제 제조(수입)판매품목허가(신고)자는 **2020년 8월 31일까지** 별표1 개정규정에 적합하여야 함 | +| '19. 10. 02 | [별표 1] **209.** ‘의약품 동등성 확보 필요 대상의약품 지정’ 제2조 해당 원료의약품을 주성분으로 하는 의약품 추가 (퇴장방지의약품 및 동 지정 제2조 복합성분 중 별표1~별표4 비해당 비타민·무기질 제외) | +| '20. 06. 04 | 제네릭의약품 품질강화 방안의 일환으로 **완제 품질심사 시 원료-완제 연계심사**를 위한 등록대상 원료의약품 심사 절차 개선 (제네릭의약품 품질심사 절차 개선 정책 '20.5.13. 발표 연계) | +| '20. 07. 24 | 원료·완제 품질심사 연계를 위한 **세부 운영절차 마련** | + +경과조치(해설서 §제2조 해설 원문): + +> ‘원료의약품 등록에 관한 규정’(식약처 고시, 제2016-59호, 2016.6.30.) 부칙 제5조에 따라 ‘17.12.25. 이전 「의약품 등의 안전에 관한 규칙」제4조제1항제3호나목에 따른 의약품동등성 확보가 필요한 의약품의 제조판매 또는 수입품목허가(신고)를 받은 자는 다음 각 호에 따른 기한까지 별표 1의 개정규정에 적합하도록 하여야 함. +> 1. 「의약품동등성 확보 필요 대상 의약품 지정」(식약처 고시)[별표 1] ‘상용의약품’ : **2021년 12월 31일까지** +> 2. 동 [별표 2] ‘고가의약품’ : **2022년 12월 31일까지** +> 3. 동 [별표 3] ‘그 밖의 의약품 동등성 확보가 필요한 의약품’ 및 [별표 4] ‘생체를 이용하지 아니한 시험이 필요한 의약품’ : **2023년 6월 30일까지** +> +> 주1) [별표3] 그 밖의 의약품동등성 확보가 필요한 의약품 성분 추가(65. abiraterone acetate ~ 100. travoprost) — 식약처 고시 제2020-57호, 2020.7.8. 일부개정, 시행일: 신약지정 해제된 날부터 시행 + +**최근 규제완화 흐름 (2024~2025)** + +- 2024.05.09 히트뉴스 「수입 원료의약품 등록, 이제 'GMP 증명서'만 있으면 OK」: + - 수입 원료의약품 등록 시 제조소 시설 자료·**GMP 11종 자료**·현장조사를 **GMP 증명서로 대체**. 증명서는 생산국 정부기관 또는 **PIC/S 가입 기관** 발급, 제조소 정보·유효기간·GMP 적합 여부 포함 필요. + - 원료의약품 등록 기간 **기존 120일 → 20일(신약 90일)** 로 단축 예상. + - 허가·적합판정 신청 시 제출 GMP 자료 **11종 → 4종** 간소화, 나머지는 **제조소 총람(SMF)** 으로 통합. + - 총리령·고시 개정 소요 약 4~6개월, 2024년 5월 입법예고 시작 계획. +- 「원료의약품 등록에 관한 규정」 일부개정: **식약처 고시 제2024-89호, 2024.12.30.** (한국제약바이오협회 공지 2025-01-02, 식품의약품안전처 의약품정책과-12825, 담당 의약품정책과 이지은 043-719-2609). 첨부 3종(개정고시/전문/요약 안내), 전문 파일명 `「원료의약품 등록에 관한 규정」 (제2024-89호, 2024.12.30) 전문.hwpx`. +- 2025.06.26 데일리팜 「규제완화 여파...원료약 등록 1년새 237→653건」: **2025년 상반기 653건** vs **2024년 상반기 256건**(2.8배), 2024년 전체 545건, 2021년 상반기 537건(당시 역대 최고). 기사 서술 원문: "올해부턴 현장 실사가 폐지됐다. 또한 생산국 정부기관 또는 PIC/S 가입국이 발급한 GMP 증명서로 기존 자료를 대체했다." +- 연도별 DMF 건수 추이(약업신문/데일리팜 계열 보도 기준, ⚠️ 원 기사 표기가 "2019년"을 두 번 쓰는 등 부정확 — **미검증**): 2017년 347건, 2019년 516건, 2019년 571건, 2020년 714건, 2021년 967건, 2022년 671건, 2023년 487건, 2024년 545건. +- 2023.01.12 데일리팜 「필수약·공급중단 보고대상 의약품 'DMF 등록' 유예」: 국가필수의약품 및 생산·수입·공급중단 보고 대상 의약품에 대해 근거자료 마련 시 **DMF 등록 유예 절차** 가능. + +> **크롤러 설계 시사점**: 2021년(등록 의무화 완료 압박)·2025년(규제완화)처럼 **연간 건수가 2~3배 튀는 해**가 존재한다. 일일 신규 건수 이상탐지 임계값을 고정값으로 두지 말고 이동평균 대비 배수로 잡아야 한다. + +### 2.3 법적 근거 3단 구조 + +#### (1) 「약사법」 제31조의2 (원료의약품의 등록 등) — 전문 + +> ① 신약의 원료의약품 또는 식품의약품안전처장이 정하여 고시하는 원료의약품을 제조하여 판매하려는 자는 총리령으로 정하는 바에 따라 그 성분ㆍ명칭과 제조방법 등 총리령으로 정하는 사항을 식품의약품안전처장에게 등록할 수 있다. +> ② 식품의약품안전처장은 제1항에 따른 등록사항이 총리령으로 정하는 기준에 적합한지 여부를 검토하여 그 결과를 신청인에게 알리고, 그 내용을 원료의약품 등록대장에 기록하고 보관하여야 한다. **이 경우 해당 원료의약품의 성분 및 제조원 등 총리령으로 정하는 사항을 공고하여야 한다.** +> ③ 제1항 및 제2항에 따라 등록된 사항 중 총리령으로 정하는 중요한 사항을 변경하려는 자는 식품의약품안전처장에게 변경등록을 하여야 한다. 다만, 그 밖의 사항을 변경하려는 자는 보고하여야 한다. +> ④ 제1항부터 제3항까지의 규정에 따라 등록된 원료의약품은 제31조제2항에 따른 품목허가를 받거나 품목신고를 한 것으로 본다. +> ⑤ 제1항부터 제3항까지에서 규정한 사항 외에 원료의약품의 등록ㆍ변경등록 또는 변경보고, 등록된 원료의약품의 공고 등에 필요한 사항은 총리령으로 정한다. + +관련: **「약사법」 제42조(의약품등의 수입허가 등)** — 수입 원료의약품의 등록 근거(제42조제4항). **「약사법」 제2조제8호(신약 정의)**: + +> 8. "신약"이란 화학구조나 본질 조성이 전혀 새로운 신물질의약품 또는 신물질을 유효성분으로 함유한 복합제제 의약품으로서 식품의약품안전처장이 지정하는 의약품을 말한다. + +정부24 민원안내(「원료의약품(등록신청, 등록사항 변경등록신청)」, 민원코드 `CappBizCD=14700000458`) 기준 근거 법령: **약사법 제31조 제2항·제42조**, **의약품 등의 안전에 관한 규칙 제15조 제1항**, **원료의약품 등록에 관한 규정**. 처리기간 표기: **20일 / 대체절차 90일**. 신청 방법: 인터넷·방문·우편. 문의: 식품의약품안전처 **043-719-2304**. + +#### (2) 「의약품 등의 안전에 관한 규칙」 제15·16·17조 — 전문 + +**제15조(원료의약품의 등록)** + +> ① 법 제31조의2제1항 또는 제42조제4항에 따라 등록대상 원료의약품을 등록하려는 자는 별지 제16호서식의 원료의약품 등록신청서(전자문서로 된 등록신청서를 포함한다)에 다음 각 호의 자료(전자문서를 포함한다) 등을 첨부하여 식품의약품안전처장에게 제출하여야 한다. 이 경우 특히 자료의 보호가 필요한 경우에는 다음 각 호의 자료를 원료의약품 공급자가 직접 식품의약품안전처장에게 제출할 수 있다. +>  1. 원료의약품의 제조소에 관한 다음 각 목의 자료 +>   가. 법 제31조제1항에 따른 시설에 관한 자료 +>   나. 품목별로 실시상황이 별표 1의2의 원료의약품 제조 및 품질관리기준에 맞거나 이와 같은 수준 이상임을 증명하는 자료 또는 제4조제1항제4호가목에 따른 제조증명서 +>  2. 원료의약품의 성분ㆍ명칭과 제조방법에 관한 다음 각 목의 자료 +>   가. 물리화학적 특성과 안정성에 관한 자료 +>   나. 제조방법, 포장, 용기 및 취급상의 주의사항 등에 관한 자료 +>   다. 원료의약품의 시험성적서, 분석방법 및 사용된 용매 등에 관한 자료 +>   라. 시험용 원료의약품(식품의약품안전처장이 품질검사를 위하여 특별히 필요하다고 인정하는 경우에만 해당한다) +>  3.~6. 삭제 <2016. 10. 28.> +> ② 제1항에도 불구하고 **제16조제1항에 따라 공고된 제조소에서 동일한 시설을 이용하여 제조된 원료의약품을 등록하려는 경우** 등 식품의약품안전처장이 정하는 경우에는 제1항제1호의 자료를 제출하지 아니할 수 있다. <신설 2016. 10. 28.> +> ③ 식품의약품안전처장 또는 지방청장은 제1항에 따른 등록 신청이 식품의약품안전처장이 정하여 고시하는 등록기준에 적합한지를 판정하기 위하여 품질검사 또는 현장 조사를 할 수 있다. <개정 2014. 8. 21., 2016. 10. 28.> +> ④ 제1항에 따른 원료의약품을 등록하려는 자는 식품의약품안전처장이 정하여 고시하는 수수료(외국에서 현지실사를 할 필요가 있는 경우에는 이에 드는 경비를 포함한다)를 내야 한다. <개정 2016. 10. 28.> +> ⑤ 제1항 각 호에 따른 자료의 작성요령, 자료의 요건 및 면제 범위 등에 관한 세부 사항은 식품의약품안전처장이 정하여 고시한다. + +**제16조(원료의약품 등록대장과 등록증 등)** — **이 프로젝트의 데이터가 존재하는 이유** + +> 법 제31조의2제2항 및 이 규칙 제15조에 따라 식품의약품안전처장은 원료의약품 등록을 한 경우에는 원료의약품 등록대장(전자문서로 된 대장을 포함한다)에 다음 각 호의 사항을 적고, 신청인에게 별지 제17호서식의 원료의약품 등록증을 발급하여야 한다. **이 경우 식품의약품안전처장은 다음 각 호의 사항을 인터넷 등으로 공고하여야 한다.** +>  1. 원료의약품의 성분ㆍ명칭 +>  2. 등록자의 성명 +>  3. 등록번호 및 등록 연월일 +>  4. 제조소의 명칭 및 소재지 +>  5. 제15조제2항에 따라 자료를 첨부하지 않은 경우 그 사실 + +> **해석**: 화면 컬럼 `성분명`, `신청인`, `등록번호`/`최초등록일자`, `제조소명`/`제조소소재지`가 각각 제16조 제1~4호에 직결된다. 즉 **법정 공고 항목 = 우리가 수집해야 할 최소 필드**다. + +**제17조(원료의약품 등록사항의 변경등록 신청 등)** + +> ① 법 제31조의2제3항 본문에 따라 원료의약품의 등록사항 중 다음 각 호의 어느 하나에 해당하는 **중요한 사항**을 변경하려는 자는 별지 제16호서식의 원료의약품 등록사항 변경등록 신청서(전자문서로 된 신청서를 포함한다)에 원료의약품 등록증, 변경사유서(전자문서로 된 사유서를 포함한다) 및 변경 사유를 증명할 수 있는 서류(전자문서를 포함한다)를 첨부하여 식품의약품안전처장에게 제출하여야 한다. +>  1. **제조소 소재지의 변경.** 다만, 행정구역개편에 따라 소재지가 변경되는 경우는 제외한다. +>  2. 원료의약품의 제조방법 중 별표 1 제1호파목에 따른 **원료약품**(촉매 및 유기용매 등을 포함한다), **제조공정 또는 제조단위 규모의 변경.** 다만, 제조단위 규모의 변경은 **10배 이상**으로 변경하는 경우만 해당한다. +>  3. 원료의약품을 직접 담는 **용기나 포장의 재질, 저장 방법 또는 사용기간의 변경** +>  4. 원료의약품 **분석방법의 변경**(대한민국약전, 법 제52조에 따른 의약품등의 기준 또는 제4조제1항제1호나목에 따른 공정서에 실려 있지 아니한 방법으로 변경하는 경우만 해당한다) +>  5. 그 밖에 변경등록이 필요한 경우로서 식품의약품안전처장이 정하여 고시하는 등록사항의 변경 +> ② 법 제31조의2제3항 단서에 따라 원료의약품의 등록사항 중 제1항 각 호에서 규정한 사항 외의 사항을 변경하려는 자는 식품의약품안전처장이 정하는 바에 따라 원료의약품 등록증을 첨부하여 **매년 1월 31일까지** 식품의약품안전처장에게 변경사항을 보고하여야 한다. +> ③ 식품의약품안전처장은 제1항 또는 제2항에 따라 변경등록 신청 또는 변경보고를 받은 경우에는 제16조에 따른 원료의약품 등록대장 및 원료의약품 등록증에 변경사항을 적고, 원료의약품 등록증을 내주어야 한다. **이 경우 식품의약품안전처장은 변경사항을 인터넷 등에 공고하여야 한다.** +> ④ 식품의약품안전처장 또는 지방청장은 제1항에 따른 변경등록 신청이 제15조제2항에 따른 등록기준에 적합한지를 판정하기 위하여 품질검사 또는 현장 조사를 할 수 있다. <개정 2014. 8. 21.> +> ⑤ 제1항에 따라 변경등록을 신청하는 자는 식품의약품안전처장이 정하여 고시하는 수수료(외국에서 현지실사를 할 필요가 있는 경우에는 이에 드는 경비를 포함한다)를 내야 한다. + +관련 규칙 조문(연계): **제48조(제조업자 등의 준수사항) 제5호 나목** — 원료의약품은 제조방법(합성, 발효, 추출, 그 밖의 방법)별로 **별표 1의2 원료의약품 제조 및 품질관리기준(원료 GMP)** 적합 판정 후 제조한 것을 판매. **제4조(제조판매ㆍ수입품목의 허가신청)**, **제8조(허가사항 등의 변경허가 신청 등)**, **제3조(의약품 등 제조소의 시설 기준 등)**, **제48조의2제5항(제조 및 품질관리기준 적합판정서)**. + +#### (3) 「원료의약품 등록에 관한 규정」 (식약처 고시) + +| 항목 | 값 | +|---|---| +| 정확한 명칭 | **원료의약품 등록에 관한 규정** | +| 현행 고시번호 | **식품의약품안전처고시 제2024-89호** | +| 시행일 | **2024. 12. 30.** | +| 소관 부서 | 의약품정책과 (담당 이지은, 043-719-2609) | +| 국가법령정보센터 | `https://law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000252602` (본문 미노출), `https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000150569`, `https://law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000198136` | +| 이전 개정 | 제2021-8호 (시행 2021.2.15.) — 첨부파일 `원료의약품+등록에+관한+규정(제2021-8호,+2021.2.15).hwp` (43KB) / 제2016-59호 (2016.6.30.) / 제2020-57호(관련 고시 「의약품 동등성 확보 필요 대상 의약품 지정」, 2020.7.8.) | +| 고시 전문 게시글 | `https://www.mfds.go.kr/brd/m_211/view.do?seq=14870&...` — 첨부 `「원료의약품 등록에 관한 규정」 (제2024-89호, 2024.12.30) 전문.hwpx` | + +> ⚠️ **미검증**: law.go.kr 및 mfds.go.kr 게시글 페이지는 WebFetch로 **본문(조문 텍스트)이 렌더링되지 않았다.** 조문 전문은 첨부 `.hwp/.hwpx` 안에 있다. 아래 §2.4·§2.5의 조문 인용은 **해설서 제5개정판(2020.12.)에 재수록된 조문 텍스트**를 출처로 하며, 2024-89호 개정으로 달라졌을 가능성이 있다 → 부록 B 체크리스트. + +### 2.4 등록대상 원료의약품 + +「원료의약품 등록에 관한 규정」 **제2조(등록대상 원료의약품의 지정)** — 해설서 재수록 전문: + +> 「의약품 등의 안전에 관한 규칙」 제15조에 따른 등록대상 원료의약품은 다음 각 호와 같다. **다만, 희귀의약품, 유전자재조합의약품·세포배양의약품·생물학적제제·세포치료제·유전자치료제, 방사성의약품, 수출용의약품 및 약리활성이 없는 성분(부형제, 첨가제 등)은 제외한다.** +>  1. 「약사법」 제2조제8호에 따른 신약 중 **2002년 7월 1일 이후** 식품의약품안전처에 의약품 제조판매·수입품목 허가가 **신청된** 신약의 유효성분으로 사용하는 **신물질 원료의약품** +>  2. **별표 1**의 원료의약품과 그 **염류 및 수화물** +>  3. **인태반 유래 원료의약품**(최종원액 과정 의약품 포함) +>  4. **별표 1의2**의 등록 대상 **한약(생약)제제 원료의약품**과 그 혼합물(용매의 농도가 다른 의약품 포함) + +해설(원문 요지 보존): + +- 신약 중 등록 대상은 2002.7.1. 이후 **허가일자가 아닌 ‘신청일자’ 기준**. +- 최초 신물질 원료의약품은 **신약 품목허가 신청 이전에 원료 단독 등록 신청 불가**하며 신약 품목허가 신청과 **병행** 진행. +- [별표 1] **209.**: 「의약품 등의 안전에 관한 규칙」제4조제1항제3호나목에 따른 **의약품동등성 확보가 필요한 의약품**이거나 「의약품 동등성 확보 필요 대상 의약품 지정」제2조에 해당하는 원료의약품을 주성분으로 하는 의약품. 단, 퇴장방지의약품 및 동 지정 제2조 복합성분 중 별표1~별표4 해당 비타민·무기질 제외. + - 구체 범위: "‘89년 1월 1일 이후 신약을 제외한 **전문의약품**으로서 이미 제조판매(수입) 품목허가 받은 것과 성분이 동일한 **정제, 캡슐, 좌제, 산제, 과립, 점안제, 점이제, 폐에 적용하는 흡입제 또는 외용제제**에 사용하는 주성분(원료의약품)으로서 「의약품동등성확보 필요 대상 의약품지정」에 해당하는 성분(**염류, 수화물, 이성체 포함**)" +- [별표 1] **210.** 주사제 원료의약품(퇴장방지의약품 및 영양소 보급 목적 제제 원료 제외): 고시 제2016-59호(2016.6.30.) 시행일 **‘17.12.25. 이후 적용**, 고시개정일 이전 품목허가(신고)된 품목은 적용대상 아님(변경 포함). 단 항생물질제제(분류번호 **610**) 주사제는 **’20.8.31.까지** 적합. +- [별표 1] **211.** 신청사가 제조(수입)하여 등록하고자 하는 원료의약품 (**2018.1.1.부터 시행**) — §3의 J군 설명에 등장. +- 완제품 ‘원료약품 및 그 분량’에 **‘○○과립’처럼 반제품 형태**로 표기된 경우 해당 반제품은 등록 대상. +- 혼합물(주성분+주성분): 각각의 등록대상 원료의약품이 등록·공고되었다면 혼합물은 **반드시 등록대상은 아님**. 다만 혼합물로 판매하려면 혼합물로서 등록 필요. +- 혼합물(주성분+첨가제): 혼합물로서 등록 **가능**. +- 제조공정에 **미분화 공정**이 포함되고 미분화 원료로 관리하는 경우 **미분화 원료의약품으로 별도 등록 필요**(미분화 여부 **비고란 기재**). 미분화 등록 시 **입자도 규격 설정** 및 미분화 품질기준 적합 **안정성시험자료** 제출 필요. + +용어 정의(규정 제1조의2 계열, 해설서 원문): + +- **“중요공정”**: 주요 핵심물질 생성 공정 및 그 이후 공정. +- **“주요 핵심물질”**: 화학반응을 통해 화학적인 기본구조의 변화가 생긴 단계의 생성물질로서 **고체 상태로 분리 가능한 중간체**(용액 상태로 반응이 연속 진행되는 경우 제외). +- **“제조단위” 또는 “로트”**: 동일제조공정으로 제조되어 균질성을 가지는 의약품의 일정한 분량. +- **“제조번호” 또는 “로트번호”**: 일정한 제조단위분에 대하여 제조관리 및 출하에 관한 모든 사항을 확인할 수 있도록 표시된 번호(숫자·문자 또는 조합). + +> **크롤러 시사점**: 화면 컬럼 `대상의약품`의 실제 값은 **`별표1`, `신물질`** 두 가지가 실측 확인되었다. 규정 제2조 4호(별표1의2 한약(생약))와 3호(인태반)에 해당하는 값이 별도로 존재할 가능성이 높다 → 부록 B. + +### 2.5 등록 절차·처리기한·수수료 + +#### (1) 「원료의약품 등록에 관한 규정」 제5조(등록처리기준 등) — 해설서 재수록 전문 + +> ① 식품의약품안전처장은 제3조제1항에 따른 원료의약품 등록신청서를 다음 각 호에 따라 처리하여 「의약품 등의 안전에 관한 규칙」 별지 제17호 서식에 따른 원료의약품 등록증(이하 “등록증”이라 한다)을 교부하고 **식품의약품안전처 홈페이지 등에 공고하여야 하며**, 제조소(수입품목의 경우 생산국의 제조소를 말한다) 실태조사를 실시하는 때에는 **실시 20일 전까지** 이를 원료의약품 등록신청인에게 통지하여야 한다. +>  1. 「의약품 등의 안전에 관한 규칙」 제15조에 따라 원료의약품 등록신청서가 제출된 경우에는 **17주 이내(실태조사 불필요 시 13주)** 에 제출자료의 적합성을 검토하여 처리한다. +>  2. 「의약품 등의 안전에 관한 규칙」 제4조제1항제7호에 따라 의약품 제조판매·수입 품목허가를 신청한 경우에는 **17주 이내(실태조사 불필요 시 13주)** 에 처리한다. +>  2의1. 제1호 및 제2호에도 불구하고 **양도·양수로 인한 변경신청**일 경우에는 신청일로부터 **25일 이내** 처리한다. +>  3. 제1호 및 제2호의 규정에도 불구하고 **국제공인기관 등록 등** 안전성 및 품질 등이 이미 확보되었다고 식품의약품안전처장이 인정할 수 있는 경우에는 제4조의 첨부자료 구비 여부만을 검토하여 처리할 수 있다. +> ② 식품의약품안전처장은 **의약품 등의 수급상 불균형 등의 우려**가 있는 경우, 그 수급 문제 해소, 고시 시행일 등을 고려하여 **등록증 교부 및 인터넷 공고 등을 유보할 수 있다.** +> ③ 제1항에 따른 제조소 실태조사는 「의약품 등의 안전에 관한 규칙」 별표 1에 따라 실시한다. +> ④ 제조소 실태조사 등에 필요한 제반 소요비용은 「수익자부담 해외출장여비에 관한 규정」(식약처 예규)에 따라 **원료의약품 등록신청인이 부담**한다. +> ⑤ 「의약품 등의 안전에 관한 규칙」제15조에 따른 등록기준: +>  1. **제조소 관련 등록사항**: 규칙 별표 1의2 원료의약품 제조 및 품질관리기준의 적합성 (이 경우 **제조소의 명칭과 소재지 공고 시 제조소의 원료의약품 GMP 적합 평가 실시 여부를 추가하여 기재할 수 있다**) +>  2. **성분·명칭과 제조방법 관련 등록사항**: 제4조에서 정한 자료요건 및 면제범위의 적합성 +> ⑥ 「의약품 등의 안전에 관한 규칙」 제17조제1항제5호에 따른 그 밖의 변경등록이 필요한 경우는 제조방법 중 **“병원미생물의 불활화 또는 제거방법”** 을 말한다. + +> ⚠️ 제5조①의 **17주/13주**는 해설서(2020.12.) 시점 값이고, 2024~2025년 규제완화로 **20일/90일**로 바뀐 민원 처리기간과 병존한다(§(3) 참조). **크롤러가 "언제 공고에 뜨는가"를 예측할 때는 20일/90일 기준을 쓴다.** + +제5조 관련 실태조사 운영(해설서 발췌): + +- 현장 조사를 **원칙**으로 하되, 이미 적합 인정된 제조소와 **동일 제조국 동일 회사**인 경우 현장 조사 면제 가능. +- 실태조사 생략기간(의약품, 생물학적제제등·한약(생약)제제 제외): **무균제제(원료의약품) 3년 / 비무균제제(원료의약품) 5년**. 비무균은 **PIC/S 가입국 또는 EU 화이트리스트 국가 제조소** 중 요건 적합 PIC/S 국가 실사보고서 제출 시 최초 평가·생략기간 경과 모두 인정. +- 생략기간 기산: 무균·비무균별로, 원료·완제별로 각각 **의약품의 실사 최종일부터 품목허가(신고) 또는 등록 신청일(접수일자 기준)까지**. +- 실태조사 대상 예시: "**부적합, 취하(부적합 예상)**, 실사이력 대비 시설 등에 큰 변경이 있는 경우, 품목 허가심사 단계에서 제조원에 대한 제조 및 품질관리 실태 확인이 필요하다고 판단되는 경우, 신청인의 요청에 따라 실태조사의 필요성이 인정되는 경우 등" +- 제조공정이 2개 이상 제조소로 나뉜 품목: **중요공정을 실시하는 1개 대표 제조소**에 대해 현장 실태조사 실시 가능. +- 관련 지침: 「의약품등 품목별 사전 GMP 평가 운영 지침」(의약품품질과, 2016.12.), 「한약(생약)제제 품목별 사전 GMP평가 및 식물성 한약(생약) 원료의약품 등록 처리 지침」(한약정책과, 2017.04. → 2020.11.). + +#### (2) 업무처리 절차 (MFDS MaPP 지침서) + +문서: **`MFDS/MaPP : GRP-MaPP-허가업무-04(지침서-0943-03)` 「원료의약품 등록 및 변경등록 업무」 (Drug Master File(DMF) registration and post-approval registration)** — 승인일 2015, **개정일 2025. 2. 20.(6개정)**, 작성 의약품허가총괄과 주무관 김남윤 / 검토 사무관 이근아 / 승인 과장 김영주. + +정의(원문): + +- **DMF 심사부서**: 의약품심사부 또는 바이오생약심사부 내 **첨단의약품품질심사과, 생물제제과 또는 생약제제과**. +- **의약품 통합정보시스템**: 인터넷으로 민원서류·구비자료 접수하는 식약처 전자민원시스템. **의약품안전나라(https://nedrug.mfds.go.kr)(대외용)** 및 **의약품 통합정보시스템(대내용)** 포함. + +절차 단계: + +| 단계 | 내용 | +|---|---| +| 4.1 신청서 접수 | 담당자는 **매일** 「의약품 통합정보시스템」을 통해 접수 신청서·수수료 확인 | +| 4.1.2 수수료 반환 | 「의약품 등의 허가 등에 관한 수수료 규정」 제6조. **예비심사 기간 중 반려·자진취하 시 납부 수수료의 80% 반환** | +| 4.2 제출자료 요건 검토 | 「원료의약품 등록에 관한 규정」 제4조·제5조 | +| 4.2.1 검토의뢰 | 규정 제4조제1항제3호·제4호·제5호·제6호 (**신약의 원료의약품 등록에 한함**), 의약품 행정포탈 ‘나의 민원’ 창의 ‘협의’ 버튼 사용 | +| 4.3 보완 자료 요구 | 「민원 처리에 관한 법률 시행령」 제24조, 「의약품의 품목허가·신고·심사규정」 제55조 | +| 4.3.2 재보완(2차) | 1차 보완 자료 제출 시점을 보완요구기간 종료로 보고 재검토. 일부 미제출 시 **10일**을 보완기간으로 2차 보완. 전부 미제출 시 **10일** 독촉(재보완에 해당) | +| 4.3.3 보완기간 연장 | 민원인의 기간연장 요청은 **2회에 한함** | +| 4.4 자진취하 수리 | 「민원 처리에 관한 법률 시행령」 제25조제3항. 자진취하원 수리·통지 | +| 4.5 반려 | 동 시행령 제25조. 재보완 기간 내 미제출, 제출자료 부적합 시 | +| 4.6 민원처리기간 연장 | 동 시행령 제21조 | +| 4.7 (변경)등록 | 검토서 작성 → 적합 시 (변경)등록 완료 → **‘원료의약품 등록증’ 발급** | +| **4.8 원료의약품 등록 현황 공고** | **"식품의약품안전처장은 관련 규정에 따라 원료의약품 등록, 변경등록, 변경보고 현황을 우리처 홈페이지에 공고한다. * 「의약품 통합정보시스템」에서 식약처 담당자가 원료의약품 민원 처리 완료시 의약품안전나라(https://nedrug.mfds.go.kr)에 등록, 변경등록, 변경보고, 현황이 공고됨" ※ 관련 규정: 의약품 등의 안전에 관한 규칙 제16조, 제17조** | + +관련 양식(붙임): [붙임1] 원료의약품 (변경)등록 절차 흐름도, [붙임2] 등록·변경등록 신청서 양식(규칙 별지 제16호서식 <개정 2024.12.30.>), [붙임3] 등록 검토서, [붙임4] 변경등록 검토서, [붙임5] 자료 보완 요청 공문, [붙임6] 재보완 요청, [붙임7] 보완자료 독촉, [붙임8] 보완기간 연장 승인 회신, [붙임9] 민원 신청 자진취하 수리, [붙임10-1] 반려–자료미비, [붙임10-2] 반려–자료미제출, [붙임11] 민원처리기간 연장 통지서, [붙임12] 등록 공문, [붙임13] 변경등록 공문, [붙임14] 변경등록 공문–양도양수시, [붙임15] 원료의약품 등록증 양식. + +등록/변경등록 공문 문구(붙임12·13, 원문): *"등록사항은 동 법 제31조의2제2항에 따라 우리 처 홈페이지에 공고할 예정이오니 「약사법」등 관계 법규를 준수하시기 바라며, 「지방세…」"* — **면허세 납부(해당 시·군·구) → 납부영수증 제출 → 등록증 교부** 흐름이 [붙임1]에 명시. + +전자민원 접수처: **의약품 전자민원창구 `ezdrug.mfds.go.kr`** (신청서 양식 상단 안내: "의약품 전자민원창구(ezdrug.mfds.go.kr)에서도 신청할 수 있습니다.") + +#### (3) 처리기간·수수료 표 (MaPP 5장 원문) + +| 대상 | 구분 | 처리기간 | +|---|---|---| +| 원료의약품 **등록** 신청 | 기본 | **20일** | +| 원료의약품 등록 신청 | **신약의 원료의약품** / 「의약품 등의 안전에 관한 규칙」제15조제1항제1호가목 **단서**\*에 따른 등록 | **90일** | +| 원료의약품 **변경등록** 신청 | 기본(**양도양수 포함**) | **20일** | +| 원료의약품 변경등록 신청 | 동 단서\* 자료를 제출하여 변경등록하는 경우 | **90일** | + +\* 단서 = *제조판매품목이 적합판정서 사본을 제출할 수 없는 불가피한 사유가 있는 경우* + +| 종목 | 전자민원 | 방문·우편 민원 | +|---|---|---| +| 등록대상 원료의약품 **등록** 신청 | **802,000원** | **887,000원** | +| 등록대상 원료의약품 **변경등록** 신청 | **401,000원** | **443,000원** | + +### 2.6 변경등록 vs 변경보고(연차보고) + +해설서 원문: + +> 「의약품 등의 안전에 관한 규칙」제17조제1항에 따른 **중요한 변경(Major Changes)** 과 같은 조 제2항에 따른 **경미한 변경(Minor Changes)** 으로 구분하고 있으며, 중요한 변경사항은 **「변경등록」**으로, 경미한 변경사항은 **「변경보고(연차보고)」**로 갈음될 수 있음. +> - 변경등록 대상인 경우에는 **변경 등록이 완료된 후** 해당 원료의약품을 제조(수입)·판매하여야 하며, +> - 변경보고 대상인 경우에는 변경된 내용으로 제품을 제조(수입)·판매하되, **다음해 1월 말까지** 동 변경사항을 식약처장에게 보고하여야 함. +> - 변경보고의 경우 ‘원료의약품의 연차보고 간소화 방안 알림(´16.12.16, 의약품심사조정과)’에 따라 변경보고 민원 신청 시 **등록증 사본을 첨부**하고, 변경보고 완료 후 신청인이 **등록증 이면 기재 및 공문 보관**하여 관리함 (원료의약품 등록증 원본 우편 제출 불필요). +> - 변경보고 대상임에도 업체에서 신속히 변경하고자 하는 경우 **변경등록이 가능함** (예: **신청인 명칭변경, 수입자 주소변경(도로명 주소 반영 등), 단순 제조소의 명칭 변경** 등). + +기타 Q&A(해설서 Ⅳ장): + +- **Q50** 공정서(USP, EP) 각조 개정에 따른 **중금속 항 삭제**는 변경보고 대상인가? + > A. 원칙적으로 식약처장이 인정하는 공정서 개정 사항(‘중금속 시험항목’ 삭제 포함)은 **변경보고 대상에서 제외**되며, 다만 업체는 「의약품의 품목허가·신고·심사규정」(식약처 고시) [별표 1의2] 공정서 및 의약품집 범위 지정 및 「원료의약품 등록에 관한 규정」(식약처 고시) **제6조**에 따라 해당 공정서가 개정되는 경우 그 최신의 개정판을 적용하여 운영하여야 함. +- 허여서(자료공유허여서) 품목: *"허여서 품목의 변경관리 적정화를 위해 통상 최초 신청인의 변경등록 완료 시까지 미제출하는 경우 **약사감시 대상**으로 분류됨(필요시 행정처분 실시)."* + +> **크롤러 시사점**: 화면의 `최종변경일자`가 바뀌면 **변경등록**, `최종연차보고년도`가 바뀌면 **변경보고(연차보고)** 이벤트다. **두 컬럼을 서로 다른 이벤트 타입으로 분리 집계해야 한다.** 그리고 연차보고는 **매년 1월 말**에 몰리므로 1~2월 스파이크는 정상이다. + +### 2.7 취하·취소·반려 + +- **자진취하**: 민원인이 요청 → 담당자가 **자진취하원 수리·통지** (MaPP 4.4, 「민원 처리에 관한 법률 시행령」제25조제3항). 예비심사 기간 중 자진취하 시 수수료 80% 반환. +- **반려**: 재보완 기간 내 자료 미제출, 제출자료 부적합 (MaPP 4.5). +- **등록 취하 신청 시 의무**: 해설서 [첨부5] 자료공유허여서 관련 문구 — *"등록 취하 신청 시 **자료공유허여서를 제출한 모든 등록자**를 (포함) 또는 취하여부를 확인하고 그 결과를 제출할 것임을 약속합니다."* +- 화면 컬럼 **`취소/취하구분`** 의 실측 값: **`정상`**. 취하/취소 상태의 실제 코드값 문자열은 미확인 → 부록 B. + +### 2.8 공고 제도 — 이 프로젝트의 법적 원천 + +| 층위 | 근거 | 공고 의무 문구 | +|---|---|---| +| 법률 | 약사법 제31조의2제2항 후단 | "해당 원료의약품의 **성분 및 제조원 등** 총리령으로 정하는 사항을 **공고하여야 한다**" | +| 총리령 | 의약품 등의 안전에 관한 규칙 제16조 후단 | "식품의약품안전처장은 다음 각 호의 사항을 **인터넷 등으로 공고하여야 한다**" (성분·명칭 / 등록자 성명 / 등록번호 및 등록 연월일 / 제조소 명칭·소재지 / 제15조제2항 자료 미첨부 사실) | +| 총리령 | 동 규칙 제17조제3항 후단 | "**변경사항을 인터넷 등에 공고하여야 한다**" | +| 고시 | 원료의약품 등록에 관한 규정 제5조제1항 | "등록증을 교부하고 **식품의약품안전처 홈페이지 등에 공고하여야 하며**" | +| 고시 | 동 제5조제2항 | 수급 불균형 우려 시 "등록증 교부 및 **인터넷 공고 등을 유보할 수 있다**" | +| 지침 | MaPP 지침서-0943-03 §4.8 | "민원 처리 완료시 **의약품안전나라(https://nedrug.mfds.go.kr)에 등록, 변경등록, 변경보고, 현황이 공고됨**" | + +**과거 방식(2013~2021): 주간 공고문** + +- 예: **식품의약품안전처 공고 제2013-101호**, 「등록대상 원료의약품(DMF) 등록 공고(7월 둘째주)」, 게시일 **2013년 7월 8일**. + - 근거 법령 표기: 「의약품 등의 안전에 관한 규칙」 **제15조, 제17조** 및 ‘원료의약품 등록에 관한 규정’(식약처고시) **제2조**, 그리고 「의약품 등의 안전에 관한 규칙」 **제16조, 제17조제3항**. + - 본문: "등록사항을 **붙임1 및 2**(첨부파일 참조)와 같이 공고합니다" + - 신청 유형 구분: **신규등록, 변경등록, 연차보고** + - 공고 주기: **주간 단위(매주)** + - 원문 출처: `http://www.mfds.go.kr/index.do?mid=70&pageNo=1&seq=14766&cmd=v`, 첨부는 전자민원창구 `http://ezdrug.mfds.go.kr` 확인 안내. +- 게시판 `https://nedrug.mfds.go.kr/bbs/117` 의 마지막 글: **"등록대상 원료의약품(DMF) 등록 공고(2021년 2월 1,2주차, 2021.2.1.-2021.02.14)"**, 등록일 **2021-02-19**, 조회 58,193 (§5). + +**현재 방식(사실상): 상시 갱신 테이블** + +- `https://nedrug.mfds.go.kr/pbp/CCBAC03` 이 등록·변경등록·변경보고·취하 상태를 **누적 테이블**로 상시 공개. 2026-09-02 실측 총 **9,840건**, 최신 `최초등록일자` = **2026-09-01**. +- **결론: 이 프로젝트의 "신규/변경/취하 탐지"는 공고문 파싱이 아니라 CCBAC03 테이블의 일일 스냅샷 diff 로 구현한다.** + +### 2.9 등록 공고 건수 통계 (연도별) + +해설서 Ⅰ장 「원료의약품 등록 공고 현황」(**‘20.11.30. 기준, 취하 포함**). **( ) 안의 숫자는 재공고된 숫자**를 의미함. + +| 구분 | ’03 | ’04 | ’05 | ’06 | ’07 | ’08 | ’09 | ’10 | ’11 | ‘12 | ‘13 | ‘14 | ‘15 | ‘16 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 신약 원료약품 | 9 | 12 | 18 | 21 | 28 | 30 | 54 | 60 | 64 (28) | 75 (17) | 93 (27) | 97 (23) | 122 (32) | 123 (32) | +| 별도지정 고시품목 등 | - | - | 457 | 50 | 110 | 108 | 101 | 142 (3) | 844 (637) | 623 (375) | 676 (434) | 318 (204) | 312 (231) | 236 (184) | +| **합 계** | **9** | **12** | **475** | **71** | **138** | **138** | **155** | **202 (3)** | **908 (665)** | **698 (392)** | **769 (461)** | **415 (227)** | **434 (263)** | **359 (216)** | + +| 구분 | ‘17 | ‘18 | ‘19 | ‘20 | 합계 | +|---|---|---|---|---|---| +| 신약 원료약품 | 130 (35) | 130 (47) | 117 (46) | 177 (43) | **1,360 (330)** | +| 별도지정 고시품목 등 | 283 (187) | 404 (209) | 426 (266) | 515 (219) | **5,605 (2,949)** | +| **합 계** | **413 (222)** | **534 (256)** | **543 (312)** | **692 (262)** | **6,965 (3,279)** | + +> **정합성 체크**: 2020.11.30. 누계 6,965건 → 2026-09-02 CCBAC03 총 **9,840건**. 5년 9개월간 약 2,875건 증가(연 ~500건)로, §2.2의 연도별 보도 수치(2021년 967건, 2022년 671건, 2023년 487건, 2024년 545건, 2025년 상반기 653건)와 대체로 정합. **초기 백필(backfill) 시 9,840건 전량을 받아 기준선으로 삼는다.** + +--- + +## 3. 등록번호 체계 완전 해부 + +**이것이 이 프로젝트 데이터 모델의 핵심 키다.** + +### 3.1 원본 근거 (식약처 FAQ, 해설서 제5개정판 Ⅳ장 Q51) + +> **Q51.** DMF 대상원료의약품 중 공고품목현황에서 등록번호는 어떻게 부여되는지 궁금합니다. 예를 들면 **"20110531-71-B-317-05"** 에서 앞에 8자리 숫자는 수리된 날짜인 것 같은데, 등록번호의 규칙이 있는지, 어떤 기준으로 번호가 매겨지는 것인지 궁금합니다. 또한 등록번호 뒤에 괄호가 있는 부분은 무슨 의미인지 +> +> **A.** +> - **20110531** : 등록수리일자 +> - **71** : 원료의약품 등록에 관한 규정(식약처고시) **[별표1]에 따른 각 성분의 일련번호** +> - **B** : 원료의약품 등록에 관한 규정의 **부칙에 따른 시행일을 고려한 일련번호** +> - **A~E** 는 별표1 제1호 내지 제99호의 성분, **2003년 1월 1일 - 2008년 1월 1일부터 시행** +> - **F** 는 별표1 제100호 내지 제113호의 성분, **2009년 1월 1일부터 시행** +> - **G** 는 별표1 제114호 내지 123호의 성분, **2010년 1월 1일부터 시행** +> - **H** 는 별표1 제124호 내지 141호의 성분, **2011년 1월 1일부터 시행** +> - **I** 는 별표1 제142호 내지 208호의 성분, **2013년 1월 1일부터 시행** +> - **K** 는 별표1의2 제1호 내지 19호의 성분, **2018년 1월 1일부터 시행** +> - **J** 는 별표1 **209호**의 「의약품동등성 확보 필요 대상 의약품 지정」성분 및 **210호**의 주사제 원료의약품(**2017년 12월 25일부터 시행**), **211호**의 신청사가 제조(수입)하여 등록하고자 하는 원료의약품(**2018년 1월 1일부터 시행**) +> - **317** : **동일 알파벳 군에서 접수된 성분 순으로 부여한 일련번호** +> - **05** : **동일 성분에 대하여 부여한 일련번호**(단, 일련번호 **‘J’는 제외**) +> - **괄호** : 20110531-71-B-317-05**(1~9)** 에 해당하는 **동일제조소, 동일성분에 대하여 허여서로 등록된 순으로 부여한 일련번호** + +### 3.2 자리별 해부표 + +| 위치 | 토큰 | 예시(`20110531-71-B-317-05(1)`) | 의미 | 타입 | 값 범위 / 규칙 | 파생 활용 | +|---|---|---|---|---|---|---| +| 1 | `accept_date` | `20110531` | **등록수리일자** (YYYYMMDD) | date | `(19\|20)\d{6}` | 등록 접수 시점 시계열 분석. `최초등록일자` 컬럼과 대개 일치하나 재공고 시 불일치 가능 | +| 2 | `ingr_no` | `71` | 「원료의약품 등록에 관한 규정」 **[별표1] 성분 일련번호** | int | 1~211+ (별표1의2는 K군에서 1~19) | **성분 마스터 조인 키.** 동일 성분의 모든 등록건을 묶는 축 | +| 3 | `group` | `B` | **부칙 시행일 군(群)** 알파벳 | char | `A`~`K` (관측: A,B,C,D,E,F,G,H,I,J,K) | 등록 의무 시행 시기 코호트 분류 | +| 4 | `serial` | `317` | 동일 알파벳 군 내 **접수 순번** | int | 1~4자리+ | 군 내 누적 접수 순서 | +| 5 | `sub` | `05` | **동일 성분 내 일련번호** (J군은 없음) | int? | 1~2자리, **J군 미부여** | 같은 성분에 대한 몇 번째 등록인지 = **경쟁 등록 수** | +| 6 | `grant` | `(1)` | **허여서(자료공유허여서) 등록 순번** — 동일제조소·동일성분 | int? | `(1)`~`(9)` | 허여서 기반 파생 등록 식별. **원 등록번호와 묶어야 함** | + +**군(group) → 별표 호수 → 시행일 매핑표** (파서에 상수 테이블로 내장) + +| 군 | 별표 | 호수 범위 | 시행일 | +|---|---|---|---| +| A~E | 별표1 | 제1호 ~ 제99호 | 2003.1.1. ~ 2008.1.1. | +| F | 별표1 | 제100호 ~ 제113호 | 2009.1.1. | +| G | 별표1 | 제114호 ~ 제123호 | 2010.1.1. | +| H | 별표1 | 제124호 ~ 제141호 | 2011.1.1. | +| I | 별표1 | 제142호 ~ 제208호 | 2013.1.1. | +| J | 별표1 | 제209호(의약품동등성 확보 필요 대상), 제210호(주사제 원료), 제211호(신청사 제조·수입 등록) | 209·210호: 2017.12.25. / 211호: 2018.1.1. | +| K | 별표1의2 | 제1호 ~ 제19호 (한약(생약)제제) | 2018.1.1. | + +> **주의**: 알파벳 순서가 시행일 순서와 **완전히 일치하지 않는다.** `I`(2013) → `J`(2017/2018) → `K`(2018)이지만, J와 K는 별표가 다르다(J=별표1, K=별표1의2). **정렬 키로 알파벳을 그대로 쓰지 말고 매핑 테이블의 시행일을 쓸 것.** + +### 3.3 실측 등록번호 샘플 (2026-09-02 CCBAC03 1페이지) + +| 등록번호 | 대상의약품 | 성분명 | 신청인 | 제조소명 | 제조국가 | 최초등록일자 | 파싱 결과 | +|---|---|---|---|---|---|---|---| +| `20260901-209-J-2270` | 별표1 | 탐스로신염산염 | 주식회사지맥스파마켐 | Hema Pharmaceuticals Pvt. Ltd. | 인도 | 2026-09-01 | date=2026-09-01, ingr=209, group=J, serial=2270, sub=없음(J군 규칙 일치 ✅) | +| `20260901-209-J-2268` | 별표1 | 미분화부데소니드 | ㈜하이플 | Avik Pharmaceutical Limited | 인도 | 2026-09-01 | 동일 패턴. 성분명에 **‘미분화’ 접두어** 존재(§2.4 미분화 등록 규정과 일치) | +| `수6580-16-ND(20)` | **신물질** | 프레가발린 | 엠피크코리아(주) | — | — | — | **별도 포맷.** §3.5 참조 | +| `20121228-168-I-169-04` | (공공데이터포털 API 샘플값) | 포르모테롤푸마르산염수화물 | (주)대웅제약 | SICOR SOCIETA'ITALIANA CORTICOSTEROIDO S.R.L. , [미분화공정 제조소] Micro-Macinazione SA. | 이탈리아,스위스 | 2015-02-26(발급일자) | date=2012-12-28, ingr=168, group=I, serial=169, sub=04 | +| `20110531-71-B-317-05` | (FAQ 예시) | — | — | — | — | — | date=2011-05-31, ingr=71, group=B, serial=317, sub=05 | + +> **중요 관측 1**: `20121228-168-I-169-04` 건은 **제조소명·제조소소재지·제조국가가 콤마로 다중 값**이다 (`SICOR ... , [미분화공정 제조소] Micro-Macinazione SA.` / `Rho(MI) - Via Terrazzano, 77, Italy , 6995 Madonna del Piano, Switzerland` / `이탈리아,스위스`). **제조소는 1:N 이다.** 화면 HTML에서도 `` 안에 중첩 `
...
` 로 여러 행이 들어간다. → **정규화 테이블(dmf_site) 분리 필요.** +> +> **중요 관측 2**: 등록수리일자(등록번호 접두 8자리)와 `최초등록일자` 컬럼이 같은 날인 신규 건이 다수지만(`20260901-...` ↔ `2026-09-01`), API 샘플처럼 **발급일자 2015-02-26 vs 등록번호 20121228** 로 크게 벌어지는 경우가 있다. **두 날짜를 반드시 별도 필드로 저장할 것.** + +### 3.4 파싱 정규식 (확정안) + +```python +# -*- coding: utf-8 -*- +"""DMF 등록번호 파서 — dmf_crawler/domain/permit_no.py""" +from __future__ import annotations + +import re +from dataclasses import dataclass, asdict +from datetime import date +from typing import Optional + +# 포맷 A: 일반(별표1 / 별표1의2) 등록번호 +# 20110531-71-B-317-05 (기본) +# 20110531-71-B-317-05(1) (허여서 파생) +# 20260901-209-J-2270 (J군: sub 없음) +RE_STANDARD = re.compile( + r"^(?P(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01]))" + r"-(?P\d{1,4})" + r"-(?P[A-Z])" + r"-(?P\d{1,6})" + r"(?:-(?P\d{1,3}))?" + r"(?:\((?P\d{1,3})\))?$" +) + +# 포맷 B: 신물질(신약 원료) 계열 — 예: 수6580-16-ND(20) +# 접두 한글 1자(수/제 등) + 숫자 + '-' + 숫자 + '-ND' + 선택 괄호 숫자 +RE_NEW_SUBSTANCE = re.compile( + r"^(?P[가-힣]{1,2})(?P\d{3,7})" + r"-(?P\d{1,3})" + r"-(?PND)" + r"(?:\((?P\d{1,3})\))?$" +) + +GROUP_TABLE = { + "A": ("별표1", "제1호~제99호", "2003-01-01"), + "B": ("별표1", "제1호~제99호", "2003-01-01"), + "C": ("별표1", "제1호~제99호", "2003-01-01"), + "D": ("별표1", "제1호~제99호", "2003-01-01"), + "E": ("별표1", "제1호~제99호", "2008-01-01"), + "F": ("별표1", "제100호~제113호", "2009-01-01"), + "G": ("별표1", "제114호~제123호", "2010-01-01"), + "H": ("별표1", "제124호~제141호", "2011-01-01"), + "I": ("별표1", "제142호~제208호", "2013-01-01"), + "J": ("별표1", "제209호·제210호·제211호", "2017-12-25"), + "K": ("별표1의2", "제1호~제19호", "2018-01-01"), +} + + +@dataclass +class PermitNo: + raw: str + fmt: str # "standard" | "new_substance" | "unknown" + accept_date: Optional[date] = None + ingr_no: Optional[int] = None + group: Optional[str] = None + group_table: Optional[str] = None # 별표1 / 별표1의2 + group_range: Optional[str] = None + group_effective_from: Optional[str] = None + serial: Optional[int] = None + sub: Optional[int] = None + grant: Optional[int] = None # 허여서 괄호 번호 + is_grant_derived: bool = False + base_key: Optional[str] = None # 괄호 제거한 기준 등록번호 + ingr_group_key: Optional[str] = None # 성분 코호트 키 "71-B" + + def to_dict(self) -> dict: + d = asdict(self) + if self.accept_date is not None: + d["accept_date"] = self.accept_date.isoformat() + return d + + +def parse_permit_no(raw: str) -> PermitNo: + """DMF 등록번호를 구조화한다. 어떤 입력이든 예외를 던지지 않는다.""" + s = (raw or "").strip() + # 전각 괄호/하이픈, 비단절 공백 정규화 + s = (s.replace("(", "(").replace(")", ")") + .replace("-", "-").replace("–", "-").replace("—", "-") + .replace(" ", " ").replace(" ", "")) + + m = RE_STANDARD.match(s) + if m: + g = m.groupdict() + d = g["accept_date"] + table, rng, eff = GROUP_TABLE.get(g["group"], (None, None, None)) + grant = int(g["grant"]) if g["grant"] else None + base = s.split("(")[0] + return PermitNo( + raw=raw, + fmt="standard", + accept_date=date(int(d[0:4]), int(d[4:6]), int(d[6:8])), + ingr_no=int(g["ingr_no"]), + group=g["group"], + group_table=table, + group_range=rng, + group_effective_from=eff, + serial=int(g["serial"]), + sub=int(g["sub"]) if g["sub"] else None, + grant=grant, + is_grant_derived=grant is not None, + base_key=base, + ingr_group_key=f'{g["ingr_no"]}-{g["group"]}', + ) + + m = RE_NEW_SUBSTANCE.match(s) + if m: + g = m.groupdict() + seq = int(g["seq"]) if g["seq"] else None + return PermitNo( + raw=raw, + fmt="new_substance", + serial=int(g["doc_no"]), + sub=int(g["year_seq"]), + grant=seq, + base_key=s.split("(")[0], + ) + + return PermitNo(raw=raw, fmt="unknown", base_key=s or None) + + +if __name__ == "__main__": + samples = [ + "20110531-71-B-317-05", + "20110531-71-B-317-05(1)", + "20260901-209-J-2270", + "20260901-209-J-2268", + "20121228-168-I-169-04", + "수6580-16-ND(20)", + "이상한값", + ] + for x in samples: + print(x, "->", parse_permit_no(x).to_dict()) +``` + +### 3.5 신물질(신약) 포맷 `수6580-16-ND(20)` + +- 실측 1건: `수6580-16-ND(20)` / 대상의약품 = **신물질** / 성분명 = 프레가발린 / 신청인 = 엠피크코리아(주). +- 구조 추정(⚠️ **미검증** — FAQ Q51은 별표1 포맷만 설명): `수` = 수입 문서 접두, `6580` = 문서/접수번호, `16` = 연도 또는 연차 일련번호, `ND` = New Drug(신약/신물질), `(20)` = 허여서/파생 순번. +- **파서는 이 포맷을 절대 표준 포맷으로 강제 파싱하지 말고 `fmt="new_substance"` 로 분기**한다. `대상의약품 == "신물질"` 인 행은 대체로 이 포맷일 것으로 예상하되, 전수 검증은 부록 B 항목. + +### 3.6 등록번호 기반 파생 지표 (리포트 시트에 직결) + +| 파생 지표 | 계산식 | RA 실무 의미 | +|---|---|---| +| `ingr_group_key` = `{ingr_no}-{group}` | 등록번호 2·3토큰 | **같은 성분의 모든 등록건** 묶음 → "이 성분에 몇 개 제조원이 붙었나" | +| `sub` 최댓값 | 동일 `ingr_group_key` 내 max(sub) | 해당 성분의 **누적 등록 경쟁 강도** | +| `is_grant_derived` | 괄호 존재 여부 | **허여서(자료공유) 파생 등록** — 원 등록자와 묶어 보여야 오해가 없다 | +| `group_effective_from` | GROUP_TABLE | 등록 의무 코호트 — "언제부터 의무였던 성분인가" | +| `accept_date` vs `최초등록일자` gap | 일수 차 | 심사 소요기간 추정(단, 재공고 건은 왜곡) | + +--- + +## 4. 의약품안전나라 `/pbp/CCBAC03` — 화면 구조 조사 (수집 대상 아님) + +> ⚠️ 이 절은 수집 설계가 아니라 **화면 컬럼과 API 필드의 대응 관계를 파악하기 위한 조사 기록**이다. 이 프로젝트는 이 화면을 수집하지 않는다. 공식 Open API(§6)만 수집하며, HTML 크롤링(엑셀 POST 포함)은 하지 않는다. 근거: `design/00-DATA-SOURCE-DECISION.md`. 이 절에서 지금도 유효한 것은 §4.5(정렬 키=서버 필드명)와 §4.6(컬럼 목록)의 **화면 컬럼 ↔ 서버 필드명 대응관계**이며, 나머지(§4.2~4.4, §4.7~4.11)는 참고용 조사 기록으로만 보존한다. + +### 4.1 페이지 식별 + +| 항목 | 값 | +|---|---| +| URL | `https://nedrug.mfds.go.kr/pbp/CCBAC03` | +| `` | `의약품안전나라 > 의약품등 정보 > 의약품 및 화장품 품목정보 > 원료의약품등록(DMF) 공고 ` | +| 보조 title | `현재메뉴 > 3차메뉴 > 2차메뉴 > 1차메뉴 > 식품의약품안전처 의약품통합정보시스템2` | +| 메뉴 경로 | 의약품등 정보 → 의약품 및 화장품 품목정보 → **원료의약품등록(DMF) 공고** | +| 총 건수 (2026-09-02 실측) | **9,840건** (`총 9,840건` / `총 9840건`) | +| 페이지 수 | `totalPages` = **984** (limit=10) / **197** (limit=50) | +| 렌더링 | **Thymeleaf 서버사이드 렌더링** — JS 실행 불필요. HTML 소스에 `th:onclick`, `th:text` 주석이 그대로 남아 있음 | +| 페이지 크기(HTML) | 1페이지 limit=10 → **400,143 bytes** / limit=50 → **501,975 bytes** | + +### 4.2 HTTP 응답 헤더 (실측) + +``` +HTTP/1.1 200 OK +Date: Wed, 02 Sep 2026 12:57:38 GMT +Server: Apache +Allow: GET, POST, OPTIONS +Strict-Transport-Security: max-age=63072000 +Expires: 0 +Cache-Control: no-cache, no-store, max-age=0, must-revalidate +Access-Control-Allow-Headers: Origin, Content-Type, content-type, Content-Style-Type, Accept, Authorization,DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,If-Modified-Since, x-requested-with, Content-Security-Policy, X-UA-Compatible, X-Content-Type-Options, X-FRAME-OPTIONS, Cache-Control, Pragma +X-XSS-Protection: 1; mode=block +Pragma: no-cache +Access-Control-Allow-Origin: *.mfds.go.kr +Strict-Transport-Security: max-age=31536000 ; includeSubDomains +X-Content-Type-Options: nosniff +Content-Language: ko-KR +Access-Control-Allow-Methods: GET, POST, OPTIONS +Set-Cookie: key=value; SameSite=Lax;Secure;;HttpOnly;Secure +Set-Cookie: JSESSIONID=fM1_XKbx-MbQK_vNXBB34GnMHg_cxN9doYh7Pw34.ext21; path=/; secure; HttpOnly; Max-Age=14400; Expires=Wed, 02-Sep-2026 16:57:49 GMT;HttpOnly;Secure +Set-Cookie: elevisor_for_j2ee_uid=g7t8mq4p6at05; path=/; Max-Age=31536000; Expires=Thu, 02-Sep-2027 12:57:49 GMT;HttpOnly;Secure +Transfer-Encoding: chunked +Content-Type: text/html;charset=UTF-8 +``` + +> **엔지니어링 포인트** +> - `Allow: GET, POST, OPTIONS` — **GET·POST 둘 다 허용**. 목록은 GET, 엑셀은 POST. +> - `JSESSIONID` (Max-Age=14400 = 4시간) + `elevisor_for_j2ee_uid` (1년) 쿠키 발급. **`requests.Session()` 으로 쿠키를 물고 다녀야 엑셀 POST가 안정적이다.** 서버 앞단에 **Elevisor for J2EE**(WAS 모니터링/보안 솔루션)가 있다. +> - `Cache-Control: no-cache, no-store` — 조건부 요청(ETag/If-Modified-Since) 최적화 불가. **매일 전량 재수집이 전제.** +> - `Access-Control-Allow-Origin: *.mfds.go.kr` — 브라우저 fetch로는 교차출처 불가. 서버사이드(파이썬) 수집이 정답. + +### 4.3 검색 form 구조 (원문 HTML 속성 그대로) + +```html +<!-- 상단 통합검색(무관) --> +<form method="get" action="/search"> + <input type="text" style="width:200px;" name="keyword" id="keywordTop" + placeholder="검색어로 메뉴·정보 검색 가능" value="" + alt="통합검색 검색어 입력창" title="통합검색 검색어 입력창" /> +</form> + +<!-- ★ 실제 목록 검색 form --> +<form id="searchForm" action="/pbp/CCBAC03/getList" method="get" onsubmit="return goSearch()"> + <input type="hidden" name="totalPages" id="totalPages" value="984" /> + <input type="hidden" name="page" id="page" value="1" /> + <input type="hidden" name="limit" id="limit" value="10" /> + <input type="hidden" name="sort" id="sort" value="" /> + <input type="hidden" name="sortOrder" id="sortOrder" value="" /> + <input type="hidden" name="searchYn" id="searchYn" value="" /> + <input type="hidden" name="searchGbn" value="entpName"/> + <input type="hidden" name="searchDmfPermitNo" value="dmfPermitNo"/> + <input type="hidden" name="searchIngrKorName" value="ingrKorName"/> + + <!-- 발급일자(필수) --> + <label for="date_trMinDate">발급일자</label> + <input ... name="dmfAcceptDateStart" id="date_trMinDate" value="" + title="발급일자 시작일 (ex:YYYYMMDD)" + pattern="^(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[0-1])" /> + <input ... name="dmfAcceptDateEnd" id="date_trMaxDate" value="" + title="발급일자 종료일 (ex:YYYYMMDD)" + pattern="^(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[0-1])" /> + + <!-- 검색구분 select (Thymeleaf 원문) --> + <option th:text="|성분명|" th:value="'ingrCode'" th:selected="'ingrCode' eq ${isSelected}"></option> + <option th:text="|등록번호|" th:value="'acceptNo'" th:selected="'acceptNo' eq ${isSelected}"></option> + + <label for="title">업소명</label> + <input type="text" name="searchItem" id="title" title="업소명" /> + + <!-- [220418][ygha] 조회 조건(등록번호, 성분명) 추가 Start --> + <label for="title">등록번호</label> + <input type="text" name="searchDmfPermitNo" id="title" title="등록번호" /> + <label for="title">성분명</label> + <input type="text" name="searchIngrKorName" id="title" title="성분명" /> + <!-- [220418][ygha] 조회 조건(등록번호, 성분명) 추가 End --> +</form> +``` + +**검색 파라미터 확정표** + +| 파라미터명 | 화면 라벨 | 타입 | 필수 | 값/형식 | 비고 | +|---|---|---|---|---|---| +| `page` | 페이지 번호 | int | ○ | 1 ~ `totalPages` | `getList(page_no)` 가 세팅 | +| `limit` | 페이지 크기 | int | ○ | **10 / 20 / 30 / 40 / 50** (UI 제공값) | `changePageSize()` 로 변경. **10000 같은 큰 값도 서버가 수용**(엑셀 POST에서 확인) | +| `sort` | 정렬 컬럼 | string | ✕ | §4.5 정렬키 목록 | 빈 문자열이 기본 | +| `sortOrder` | 정렬 방향 | string | ✕ | `true`(ASC) / `false`(DESC) — **JS가 boolean 문자열을 넣는다** | `sort(sort, order)` 참조 | +| `searchYn` | 검색 여부 | string | ✕ | 최초 진입 시 `isFirst` 값, 이후 `''` | | +| `totalPages` | 총 페이지 | int | ✕ | 서버가 다시 계산하므로 무의미 | hidden 유지용 | +| `dmfAcceptDateStart` | 발급일자 시작 | date | **○(JS 검증)** | `YYYY-MM-DD` | 비면 `alert("시작 검색일(발급일자)을 입력하세요")` | +| `dmfAcceptDateEnd` | 발급일자 종료 | date | **○(JS 검증)** | `YYYY-MM-DD` | 비면 `alert("종료 검색일(발급일자)을 입력하세요")`, start > end 면 거부 | +| `searchItem` | 업소명 | string | ✕ | 부분일치 추정 | hidden `searchGbn=entpName` 과 짝 | +| `searchDmfPermitNo` | 등록번호 | string | ✕ | 부분일치 추정 | **동명 hidden(`value="dmfPermitNo"`) 이 함께 전송되어 파라미터가 2개 중복됨** ⚠️ | +| `searchIngrKorName` | 성분명 | string | ✕ | 부분일치 추정 | **동명 hidden(`value="ingrKorName"`) 중복** ⚠️ | +| `searchGbn` | 검색구분(hidden) | string | ✕ | `entpName` 고정 | | + +> ⚠️ **중대한 함정**: `searchDmfPermitNo` / `searchIngrKorName` 은 **hidden 과 text input 이 같은 name 을 공유**한다. 브라우저 submit 시 `searchDmfPermitNo=dmfPermitNo&searchDmfPermitNo=<사용자입력>` 처럼 **값이 2개** 전송된다. 파이썬에서 재현하려면 `params=[("searchDmfPermitNo","dmfPermitNo"), ("searchDmfPermitNo", 사용자값), ...]` 형태의 **튜플 리스트**를 써야 브라우저와 동일해진다. dict 로 넘기면 값 1개만 가고 서버 동작이 달라질 수 있다. +> +> 우리 프로젝트는 **전량 수집**이 목표이므로 검색어 파라미터를 아예 비우고 날짜 범위만 넓게 주는 방식을 쓴다 → 이 함정을 회피한다. + +### 4.4 JavaScript 함수 (원문) + +```javascript +function goSearch() { + /*날짜 검증 1. 시작일자 유무, 2. 종료일자 유무*/ + var date_trMinDate = $('#date_trMinDate').val(); + var date_trMaxDate = $('#date_trMaxDate').val(); + + if(date_trMinDate == ""){ + alert("시작 검색일(발급일자)을 입력하세요"); + $('#date_trMinDate').focus(); + return false; + } + if(date_trMaxDate == ""){ + alert("종료 검색일(발급일자)을 입력하세요"); + $('#date_trMaxDate').focus(); + return false; + } + if(date_trMinDate > date_trMaxDate){ + alert("시작 검색일은 종료 검색일 보다 늦을 수 없습니다"); + $('#date_trMinDate').focus(); + return false; + } + + if (isFirst) { + $('#searchForm #page').val(1); + $('#searchForm #sort').val(''); + $('#searchForm #sortOrder').val(''); + } + $('#searchForm #searchYn').val(isFirst ? isFirst : ''); + + return true; +} + +function getList(page_no){ + if (page_no) { + $('#searchForm #page').val(page_no); + isFirst = false; + } + // (이하 submit) +} + +function changePageSize(page_size) { + $('#searchForm #limit').val(page_size); + $('#searchForm').submit(); +} + +function resetForm() { + $("input[type=text]").val(''); + dateSetting(fixStartDay); +} + +function sort(sort, order) { + if (0 == $("#searchForm #totalPages").val()) { + return false; + } + if (sort) { + $("#searchForm #sort").val(sort); + if (order === 'DESC') { + $("#searchForm #sortOrder").val(false); + } else { + $("#searchForm #sortOrder").val(true); + } + } + getList(1); +} + +function getExcelFile() { + $.download('/pbp/CCBAC03/getExcel', 'searchForm', 'post'); +} +``` + +페이지 내 정의된 전체 함수 목록(참고): `changePageSize`, `confirmDelete`, `dateSetting`, `findSessionTimer`, `fn_inoAgentYn`, `fn_message`, `fn_setAgreeYn`, `getExcelFile`, `getList`, `goSearch`, `goWindowToUrl`, `handleAnyIdAuthResponse`, `init_sessionTimer`, `lpad`, `makeInput`, `redirectToAuthPage`, `removeAccountItemById`, `requestRestLogin`, `resetForm`, `selectAnyidAccount`, `sendAnyIdLinkReq`, `sendDataToParent`, `setAnyIdNotLinked`, `showAnyIdAccountModal`, `sort`, `tempGoPCPage`, `timer_SessionTimer`. + +`$.download` 는 커스텀 jQuery 플러그인이며 **`/resources/js/common-pbp.js`** 에 정의되어 있다(동적 form 생성 후 POST submit 방식). + +### 4.5 정렬 키 = 서버 필드명 (컬럼 ↔ 내부 필드 매핑의 결정적 증거) + +HTML `onclick="javascript:sort('<key>', 'DESC'); return false;"` 에서 추출한 전체 정렬 키: + +`noticeCode`, `dmfPermitNo`, `ingrKorName`, `entpName`, `mnfctrName`, `mnfctrPlace`, `manufCountryCodeNm`, `strDmfPermitDate`, `dmfVersion`, `yrycReportYYear`, `yrycReportNDate`, `cancelCodeNm`, `cancelDate`, `cntcJdgmnNo` + +HTML 주석으로 남은 **폐기된 정렬 헤더**(과거 컬럼명 증거): + +```html +<!--<th scope="col" th:replace="common/modules/etc :: tableSortHeader3(columnName='발급일자', columnKey='strDmfPermitDate')" />--> +<!--<th scope="col" th:replace="common/modules/etc :: tableSortHeader3(columnName='업소명', columnKey='entpName')" />--> +``` + +→ **`strDmfPermitDate` 는 원래 ‘발급일자’였고 현재 ‘최초등록일자’로 라벨만 바뀌었다.** 공공데이터포털 API 의 `DMF_PERMIT_DATE(발급일자)` 와 같은 값이다. 마찬가지로 **`entpName` 은 원래 ‘업소명’, 현재 ‘신청인’** 이다. + +접근성 요약 문구(테이블 caption 대체 텍스트, 원문): + +> `의약품 등 심사결과정보 공개 테이블 :: 순번, 대상의약품, 등록번호, 성분명, 신청인, 제조소명, 제조소소재지, 제조국가, 최초등록일자, 최종변경일자, 최종연차보고년도, 문서번호, 취소/취하구분, 취소/취하일자` + +### 4.6 컬럼 목록 (화면 15개 / 엑셀 14개) + +| # | 화면 헤더 | 엑셀 헤더 | 서버 필드(정렬키) | 실측 예시값 | +|---|---|---|---|---| +| 1 | 순번 | *(없음)* | — | `1` | +| 2 | 대상의약품 | 대상의약품 | `noticeCode` ⚠️추정 | `별표1`, `신물질` | +| 3 | 등록번호 | 등록번호 | `dmfPermitNo` | `20260901-209-J-2270` | +| 4 | 성분명 | 성분명 | `ingrKorName` | `탐스로신염산염`, `미분화부데소니드`, `프레가발린` | +| 5 | 신청인 | 신청인 | `entpName` | `주식회사지맥스파마켐`, `㈜하이플`, `엠피크코리아(주)` | +| 6 | 제조소명 | 제조소명 | `mnfctrName` | `Hema Pharmaceuticals Pvt. Ltd.` | +| 7 | 제조소소재지 | 제조소소재지 | `mnfctrPlace` | `Plot No. 6201/A & B, G.I.D.C., Opp. EWAC Alloys, Ciyt – Ankleshwar – 393 002, Dist.- Bharuch, Gujarat State, India` | +| 8 | 제조국가 | 제조국가 | `manufCountryCodeNm` | `인도` | +| 9 | 최초등록일자 | 최초등록일자 | `strDmfPermitDate` | `2026-09-01` | +| 10 | 최종변경일자 | 최종변경일자 | `yrycReportNDate` ⚠️추정 | (공란) | +| 11 | 최종연차보고년도 | 최종연차보고년도 | `yrycReportYYear` | (공란) | +| 12 | 취소/취하구분 | 취소/취하구분 | `cancelCodeNm` | `정상` | +| 13 | 취소/취하일자 | 취소/취하일자 | `cancelDate` | (공란) | +| 14 | 문서번호 | 문서번호 | `dmfVersion` | `v0.0.0/2026` | +| 15 | 연계심사문서번호 | 연계심사문서번호 | `cntcJdgmnNo` | (공란) | + +> ⚠️ `noticeCode ↔ 대상의약품`, `yrycReportNDate ↔ 최종변경일자` 는 **정렬키 개수(14) 대 컬럼 수(14, 순번 제외) 매칭으로 추론**한 것이다. 실측 검증 필요 → 부록 B. +> +> **`문서번호 = v0.0.0/2026`** 는 버전 문자열 형태다(`dmfVersion`). 재공고/개정 시 이 값이 올라갈 가능성이 크므로 **diff 감시 대상**으로 반드시 포함한다. + +### 4.7 목록 행 HTML 원문 (파서 작성용) + +```html +<tr ><!--th:onclick="|javascript:location.href='@{'/pbp/CCBAC03/getItem?'+${T(gov.mfds.ndii.dsaw.cmn.PbpUtils).mapToHref(criteria)} + (dmfSeq=${item.dmfSeq})}'|" class="btn_base" style="cursor:pointer;">--> + <td class="pc-tr">1</td> + <td><span class="s-th">대상의약품</span><span>별표1</span></td> + <td class="al_c tb_horizontal"> + <span class="s-th">등록번호</span> + <span>20260901-209-J-2270</span> + </td> + <td><span class="s-th">성분명</span><span>탐스로신염산염</span></td> + <td><span class="s-th">신청인</span><span>주식회사지맥스파마켐</span></td> + <!--<td><span class="s-th">발급일자</span><span th:text="${item.strDmfPermitDate}"></span></td>--> + <!--<td><span class="s-th">업소명</span><span th:text="${item.entpName}"></span></td>--> + <td><span class="s-th">제조소명</span><span><table><tr style="border: none;"><td style="border: none;">Hema Pharmaceuticals Pvt. Ltd.</td></tr></table></span></td> + <td><span class="s-th">제조소소재지</span><span><table><tr style="border: none;"><td style="border: none;">Plot No. 6201/A & B, G.I.D.C., Opp. EWAC Alloys, Ciyt – Ankleshwar – 393 002, Dist.- Bharuch, Gujarat State, India</td></tr></table></span></td> + <td><span class="s-th">제조국가</span><span> <table><tr style="border: none;"><td style="border: none;">인도</td></tr></table></span></td> + <td><span class="s-th">최초등록일자</span><span>2026-09-01</span></td> + <td><span class="s-th">최종변경일자</span><span></span></td> + <td><span class="s-th">최종연차보고년도</span><span></span></td> + <td><span class="s-th">취소/취하구분</span><span>정상</span></td> + <td><span class="s-th">취소/취하일자</span><span></span></td> + <td><span class="s-th">문서번호</span><span>v0.0.0/2026</span></td> + <td><span class="s-th">연계심사문서번호</span><span></span></td> +</tr> +``` + +파싱 규칙: + +1. `<span class="s-th">` 는 **모바일용 라벨**이다 → 텍스트 추출 시 **반드시 제거**. 그렇지 않으면 값 앞에 "대상의약품"이 붙는다. +2. 제조소명/제조소소재지/제조국가는 **`<table><tr><td>` 중첩**이며 다중 제조소 시 `<tr>` 이 여러 개가 된다 → **리스트로 수집** 후 단순 조인이 아니라 **정규화 테이블 저장**. +3. `&`, `–`(en dash), `’`(전각 어포스트로피) 등 HTML 엔티티·유니코드 문장부호가 그대로 온다 → `html.unescape()` + NFKC 정규화 권장(단, **원문 보존 컬럼도 함께 저장**). +4. 상세 링크는 **주석 처리되어 비활성**이다: `/pbp/CCBAC03/getItem?<criteria>&dmfSeq=${item.dmfSeq}`. 즉 **현재 UI에서 상세 페이지로 갈 수 없다.** 서버 라우트 `/pbp/CCBAC03/getItem` 자체는 살아 있을 가능성이 있으나 `dmfSeq` 값이 HTML에 노출되지 않아 **직접 접근 불가** → 부록 B. (Thymeleaf 표현식에서 서버 클래스명 `gov.mfds.ndii.dsaw.cmn.PbpUtils` 가 노출된다 — 시스템 패키지 `gov.mfds.ndii.dsaw`.) +5. 성분명 셀 안에는 별도 팝업 링크가 붙는 행도 존재: `onclick="javascript:pbpPopup('/pbp/CCBBB01T/getItemDetail?itemSeq=200500904', '의약품상세정보', 1200, 0, 0, 0); return false;"` → **완제의약품 상세 팝업**. `itemSeq` 는 품목기준코드. + +### 4.8 목록 GET 호출 (실측 성공) + +```bash +curl -sS -L --max-time 60 \ + -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" \ + -o p2.html -w "%{http_code} %{size_download}\n" \ + "https://nedrug.mfds.go.kr/pbp/CCBAC03/getList?page=2&limit=50" +# => 200 501975 (행 51,52,53... 총 50행 확인) +``` + +날짜 필터 GET 도 200 OK 이며 `총 9,840건` / `총 9840건` / `name="totalPages" id="totalPages" value="197"` 을 응답에 포함(= limit 50 기준 197페이지). + +> **결론**: 날짜 필터를 넓게 주어도 **전체 9,840건이 그대로 잡힌다.** 즉 `dmfAcceptDateStart=2002-01-01`, `dmfAcceptDateEnd=<오늘>` 이면 전량이다. + +### 4.9 엑셀 다운로드 (★ 핵심 수집 경로) + +**버튼 HTML** + +```html +<button type="button" class="btn_small btn_icon" title="엑셀다운로드" onclick="getExcelFile()"> + <span class="icon_xls">엑셀다운로드</span> +</button> +``` +(버튼 라벨: `총 9,840건 엑셀다운로드`) + +**엔드포인트**: `POST https://nedrug.mfds.go.kr/pbp/CCBAC03/getExcel` (form-urlencoded, `searchForm` 전체 필드 전송) + +**응답 헤더 (실측)** + +``` +HTTP/1.1 200 OK +Date: Wed, 02 Sep 2026 12:59:37 GMT +Server: Apache +Allow: GET, POST, OPTIONS +Content-Transfer-Encoding: binary +Cache-Control: no-cache, no-store, max-age=0, must-revalidate +Content-Disposition: attachment; filename="의약품등심사결과공개.xlsx"; +X-Content-Type-Options: nosniff +Content-Length: 3575 +Set-Cookie: fileDownloadToken=True;HttpOnly;Secure +Content-Type: application/download; UTF-8; charset=UTF-8 +``` + +**두 번의 실측 결과 비교** + +| 시도 | 조건 | 응답 크기 | 시트 행 수 | 해석 | +|---|---|---|---|---| +| 1차 | 날짜 파라미터 없이 최소 필드만 POST | **3,575 bytes** | **1행(헤더만)** | **날짜 범위를 안 주면 빈 파일이 온다** | +| 2차 | 날짜 범위 + `limit=10000` POST | **1,379,451 bytes** | **9,841행 (헤더 1 + 데이터 9,840)** | **전량 수신 성공** | + +파일 시그니처: `504b 0304 1400 0808` = `PK..` (정상 xlsx/zip). 시트명 `Sheet0`. + +**엑셀 헤더 행(원문 XML)** + +```xml +<row r="1"> +<c r="A1" t="inlineStr"><is><t>대상의약품</t></is></c> +<c r="B1" t="inlineStr"><is><t>등록번호</t></is></c> +<c r="C1" t="inlineStr"><is><t>성분명</t></is></c> +<c r="D1" t="inlineStr"><is><t>신청인</t></is></c> +<c r="E1" t="inlineStr"><is><t>제조소명</t></is></c> +<c r="F1" t="inlineStr"><is><t>제조소소재지</t></is></c> +<c r="G1" t="inlineStr"><is><t>제조국가</t></is></c> +<c r="H1" t="inlineStr"><is><t>최초등록일자</t></is></c> +<c r="I1" t="inlineStr"><is><t>최종변경일자</t></is></c> +<c r="J1" t="inlineStr"><is><t>최종연차보고년도</t></is></c> +<c r="K1" t="inlineStr"><is><t>취소/취하구분</t></is></c> +<c r="L1" t="inlineStr"><is><t>취소/취하일자</t></is></c> +<c r="M1" t="inlineStr"><is><t>문서번호</t></is></c> +<c r="N1" t="inlineStr"><is><t>연계심사문서번호</t></is></c> +</row> +<row r="2"> +<c r="A2" t="inlineStr"><is><t>별표1</t></is></c> +<c r="B2" t="inlineStr"><is><t>20260901-209-J-2270</t></is></c> +<c r="C2" t="inlineStr"><is><t>탐스로신염산염</t></is></c> +<c r="D2" t="inlineStr"><is><t>주식회사지맥스파마켐</t></is></c> +<c r="E2" t="inlineStr"><is><t>Hema Pharmaceuticals Pvt. Ltd.</t></is></c> +<c r="F2" t="inlineStr"><is><t>Plot No. 6201/A & B, G.I.D.C., Opp. EWAC Alloys, Ciyt – Ankleshwar – 393 002, Dist.- Bharuch, Gujarat State, India</t></is></c> +<c r="G2" t="inlineStr"><is><t>인도</t></is></c> +... +</row> +``` + +zip 내부 구조: `['[Content_Types].xml', '_rels/.rels', 'docProps/app.xml', 'docProps/core.xml', 'xl/sharedStrings.xml', 'xl/styles.xml', 'xl/workbook.xml', 'xl/_rels/workbook.xml.rels', 'xl/worksheets/sheet1.xml']`, `sheet1.xml` 크기 **7,804,898 bytes**. + +시트 XML 선두(원문): + +```xml +<?xml version="1.0" encoding="UTF-8"?> +<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><dimension ref="A1"/><sheetViews><sheetView workbookViewId="0" tabSelected="true"/></sheetViews><sheetFormatPr defaultRowHeight="15.0"/><sheetData> +``` + +> ### ⚠️⚠️ 최중요 함정: openpyxl 이 이 파일을 0행으로 읽는다 +> +> 실측에서 **openpyxl 로 열면 `total rows: 1`** 이 나왔고, 경고도 떴다: +> ``` +> C:\...\openpyxl\styles\stylesheet.py:237: UserWarning: Workbook contains no default style, apply openpyxl's default +> warn("Workbook contains no default style, apply openpyxl's default") +> ``` +> 원인은 시트 XML 의 **`<dimension ref="A1"/>`** — 서버가 생성한 xlsx 가 시트 차원을 A1 하나로만 기록해 두었다. openpyxl 은 이 `dimension` 을 신뢰하므로 **데이터 9,840행을 통째로 놓친다.** +> +> **해결책 3가지 (권장 순서)** +> 1. `zipfile` 로 `xl/worksheets/sheet1.xml` 을 직접 열어 `<row>` 를 정규식/`iterparse` 로 파싱 (실측으로 **9,841행** 정확히 나옴) +> 2. `openpyxl.load_workbook(f, read_only=False)` 로 열고 `ws.reset_dimensions()` 후 `ws.iter_rows()` +> 3. `pandas.read_excel(..., engine="calamine")` (python-calamine) — dimension 무시 +> +> 아래 §4.10 코드는 1번을 채택했다. + +### 4.10 완결 수집 코드 (복붙 동작 수준) + +```python +# -*- coding: utf-8 -*- +""" +dmf_crawler/sources/nedrug_ccbac03.py +의약품안전나라 원료의약품등록(DMF) 공고 전량 수집기. + +- 1차: POST /pbp/CCBAC03/getExcel 로 xlsx 전량 다운로드 +- 2차(폴백): GET /pbp/CCBAC03/getList 페이지네이션 HTML 파싱 +""" +from __future__ import annotations + +import datetime as dt +import html as _html +import io +import re +import time +import zipfile +from pathlib import Path +from typing import Any, Dict, Iterator, List, Optional + +import requests +from bs4 import BeautifulSoup # pip install beautifulsoup4 lxml + +BASE = "https://nedrug.mfds.go.kr" +LIST_URL = f"{BASE}/pbp/CCBAC03" +GETLIST_URL = f"{BASE}/pbp/CCBAC03/getList" +GETEXCEL_URL = f"{BASE}/pbp/CCBAC03/getExcel" + +UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + +COLUMNS = [ + "대상의약품", "등록번호", "성분명", "신청인", "제조소명", "제조소소재지", + "제조국가", "최초등록일자", "최종변경일자", "최종연차보고년도", + "취소/취하구분", "취소/취하일자", "문서번호", "연계심사문서번호", +] + +FIELD_MAP = { + "대상의약품": "target_drug", + "등록번호": "permit_no", + "성분명": "ingredient_ko", + "신청인": "applicant", + "제조소명": "site_name", + "제조소소재지": "site_address", + "제조국가": "site_country", + "최초등록일자": "first_permit_date", + "최종변경일자": "last_change_date", + "최종연차보고년도": "last_annual_report_year", + "취소/취하구분": "cancel_type", + "취소/취하일자": "cancel_date", + "문서번호": "doc_no", + "연계심사문서번호": "linked_review_doc_no", +} + +EARLIEST = "2002-01-01" # DMF 제도 도입(2002.7.) 이전까지 여유 있게 + + +def new_session() -> requests.Session: + s = requests.Session() + s.headers.update({ + "User-Agent": UA, + "Accept-Language": "ko-KR,ko;q=0.9", + "Referer": LIST_URL, + }) + # JSESSIONID / elevisor_for_j2ee_uid 쿠키 획득 (엑셀 POST 안정화에 필요) + s.get(LIST_URL, timeout=60) + return s + + +def _search_form(start: str, end: str, limit: int, page: int = 1) -> List[tuple]: + """브라우저가 실제로 보내는 form 필드를 그대로 재현(동명 hidden 중복 포함).""" + return [ + ("totalPages", "0"), + ("page", str(page)), + ("limit", str(limit)), + ("sort", ""), + ("sortOrder", ""), + ("searchYn", "Y"), + ("searchGbn", "entpName"), + ("searchDmfPermitNo", "dmfPermitNo"), + ("searchIngrKorName", "ingrKorName"), + ("dmfAcceptDateStart", start), + ("dmfAcceptDateEnd", end), + ("searchItem", ""), + ] + + +def download_excel(session: requests.Session, + start: str = EARLIEST, + end: Optional[str] = None, + limit: int = 100000, + out_path: Optional[Path] = None) -> bytes: + """전량 xlsx 를 받는다. 날짜 범위를 반드시 채워야 빈 파일이 아니다.""" + end = end or dt.date.today().isoformat() + resp = session.post( + GETEXCEL_URL, + data=_search_form(start, end, limit), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=300, + ) + resp.raise_for_status() + body = resp.content + if not body.startswith(b"PK"): + raise RuntimeError(f"xlsx 가 아님: {body[:64]!r}") + if len(body) < 10_000: + raise RuntimeError( + f"엑셀이 비어 있음({len(body)} bytes). 날짜 파라미터를 확인하라.") + if out_path: + out_path.write_bytes(body) + return body + + +_ROW_RE = re.compile(rb"<row[^>]*>(.*?)</row>", re.S) +_CELL_RE = re.compile(rb"<c[^>]*>(.*?)</c>", re.S) +_TEXT_RE = re.compile(rb"<t[^>]*>(.*?)</t>", re.S) + + +def parse_excel_rows(xlsx_bytes: bytes) -> Iterator[Dict[str, Any]]: + """openpyxl 이 <dimension ref="A1"/> 때문에 0행으로 읽는 문제를 우회한다.""" + with zipfile.ZipFile(io.BytesIO(xlsx_bytes)) as z: + sheets = [n for n in z.namelist() + if n.startswith("xl/worksheets/") and n.endswith(".xml")] + if not sheets: + raise RuntimeError("worksheet xml 없음") + raw = z.read(sorted(sheets)[0]) + + header: Optional[List[str]] = None + for m in _ROW_RE.finditer(raw): + cells: List[str] = [] + for c in _CELL_RE.finditer(m.group(1)): + texts = _TEXT_RE.findall(c.group(1)) + val = "".join(t.decode("utf-8") for t in texts) + cells.append(_html.unescape(val).strip()) + if header is None: + header = cells + continue + cells += [""] * (len(header) - len(cells)) + row_ko = dict(zip(header, cells)) + yield {FIELD_MAP.get(k, k): v for k, v in row_ko.items()} + + +def fetch_list_page(session: requests.Session, page: int, limit: int = 50, + start: str = EARLIEST, end: Optional[str] = None) -> str: + end = end or dt.date.today().isoformat() + r = session.get(GETLIST_URL, params=_search_form(start, end, limit, page), + timeout=60) + r.raise_for_status() + r.encoding = "utf-8" + return r.text + + +def parse_list_html(html_text: str) -> List[Dict[str, Any]]: + """HTML 폴백 파서. <span class="s-th"> 라벨 제거 + 중첩 table 리스트화.""" + soup = BeautifulSoup(html_text, "lxml") + out: List[Dict[str, Any]] = [] + for tr in soup.select("table tbody tr"): + tds = tr.find_all("td", recursive=False) + if len(tds) < 15: + continue + vals: List[Any] = [] + for td in tds: + for lab in td.select("span.s-th"): + lab.decompose() + inner = td.find("table") + if inner: + vals.append([c.get_text(strip=True) + for c in inner.select("td") if c.get_text(strip=True)]) + else: + vals.append(td.get_text(" ", strip=True)) + rec = dict(zip(["순번"] + COLUMNS, vals)) + rec.pop("순번", None) + out.append({FIELD_MAP.get(k, k): v for k, v in rec.items()}) + return out + + +def total_count(html_text: str) -> Optional[int]: + m = re.search(r"총\s*([\d,]+)\s*건", html_text) + return int(m.group(1).replace(",", "")) if m else None + + +def crawl_all_via_html(session: requests.Session, limit: int = 50, + sleep_sec: float = 1.5) -> List[Dict[str, Any]]: + first = fetch_list_page(session, 1, limit) + m = re.search(r'name="totalPages"\s+id="totalPages"\s+value="(\d+)"', first) + total_pages = int(m.group(1)) if m else 1 + rows = parse_list_html(first) + for p in range(2, total_pages + 1): + time.sleep(sleep_sec) # 서버 배려: 1일 1회 배치이므로 넉넉히 + rows += parse_list_html(fetch_list_page(session, p, limit)) + return rows + + +if __name__ == "__main__": + s = new_session() + blob = download_excel(s, out_path=Path("dmf_full.xlsx")) + records = list(parse_excel_rows(blob)) + print(f"수집 {len(records):,}건") + print(records[0]) +``` + +### 4.11 관련 nedrug 라우트 (같은 시스템 내 형제 화면) + +| 경로 | 화면 | +|---|---| +| `/pbp/CCBAC03` | 원료의약품등록(DMF) 공고 ★ | +| `/pbp/CCBAC03/getList` | 목록 (GET) | +| `/pbp/CCBAC03/getExcel` | 엑셀 (POST) | +| `/pbp/CCBAC03/getItem?...&dmfSeq=` | 상세 (주석 처리 = 비활성) | +| `/pbp/CCBBB01T/getItemDetail?itemSeq=200500904` | 의약품 상세정보 팝업 | +| `/pbp/CCBAE01` | 품목허가현황 | +| `/pbp/CCBBA01` | 업체정보 | +| `/pbp/CCBDB01` | 기능성화장품제품정보(심사) | +| `/pbp/CCBAQ03/getItem?bbsYn=Y&bbscttNo=3898&orderNo=` | (구)변경지시 | +| `/CCBAR01F012/getList/getInfo` | (미확인 라우트, 검색결과에만 등장) | +| `/searchDrug?...` | 의약품등 정보검색 | +| `/search?keyword=...` | 통합검색 | +| `/cntnts/80` | 공공데이터 개요 | +| `/cntnts/77` | eCTD 이용안내 | +| `/cntnts/236` | 전자결제 및 수수료안내 | +| `/bbs/117` | 원료의약품등록(DMF) 정보 게시판 ★ | +| `/bbs/119/968/` | 보도자료 | +| `/safetyuseinfo` | 안전사용정보 | +| `/eng/index` | MFDS Drug Safety Korea (영문) | +| `/join` | 회원가입 | +| `/PPL0000` | (미확인) | +| `/resources/js/common-pbp.js` | `$.download` 플러그인 정의 | + +소셜 공유 버튼이 노출하는 정식 URL(정본 확인용): `https://nedrug.mfds.go.kr/pbp/CCBAC03` +푸터: `Copyright ⓒ Ministry of Food and Drug Safety. All Rights Reserved.` / 저작권정책 링크 `https://www.mfds.go.kr/wpge/m_34/de010803l001.do` + +--- + +## 5. 의약품안전나라 `/bbs/117` — 주간 공고 게시판(레거시) + +### 5.1 개요 + +| 항목 | 값 | +|---|---| +| URL | `https://nedrug.mfds.go.kr/bbs/117` | +| `<title>` | `의약품안전나라 > 의약품등 정보 > 의약품 및 화장품 품목정보 > 원료의약품등록(DMF) 정보 ` | +| 총 페이지 | `totalPages` = **71** (limit=10) → 약 **705건** | +| 목록 컬럼 | **연번 / 제목 / 조회건수 / 등록자 / 등록일자** | +| **마지막 게시글** | **"등록대상 원료의약품(DMF) 등록 공고(2021년 2월 1,2주차, 2021.2.1.-2021.02.14)"**, `bbscttNo=714`, 등록일 **2021-02-19**, 조회 **58,193**, 등록자 마스킹(`○**`) | +| 상태 | **2021-02 이후 갱신 중단(사실상 아카이브)** | + +최근 게시글 5건(실측): + +| 연번 | bbscttNo | 제목 | 등록일자 | 조회 | +|---|---|---|---|---| +| 1 | 714 | 등록대상 원료의약품(DMF) 등록 공고(2021년 2월 1,2주차, 2021.2.1.-2021.02.14) | 2021-02-19 | 58,193 | +| 2 | 713 | 등록대상 원료의약품(DMF) 등록 공고(2021년 1월 4주차, 2021.1.25.-2021.01.31) | 2021-02-05 | 3,489 | +| 3 | 712 | 등록대상 원료의약품(DMF) 등록 공고(2021년 1월 3주차, 2021.1.18.-2021.01.24) | 2021-01-28 | 1,762 | +| 4 | 711 | 등록대상 원료의약품(DMF) 등록 공고(2021년 1월 2주차) | 2021-01-21 | — | +| 5 | 710 | 등록대상 원료의약품(DMF) 등록 공고(2020년 12월 4주차) | 2021-01-08 | — | + +### 5.2 form / JS 구조 (원문) + +```html +<form id="searchForm" onsubmit="return goSearch()"> + <input type="hidden" name="totalPages" id="totalPages" value="71" /> + <input type="hidden" name="page" id="page" value="1" /> + <input type="hidden" name="searchYn" id="searchYn" value="" /> + <input type="hidden" name="limit" id="limit" value="10" /> + <input type="text" class="wd40" name="title" id="title" title="제목" th:value="${param.title}" /> + <input type="text" class="wd80" name="title" id="title" title="제목" value="" /> +</form> +``` + +```javascript +function list(page_no) { + if(page_no){ + $("#searchForm #page").val(page_no); + isFirst = false; + } + $("#searchForm").attr("action", "/bbs/"+bbsNo).submit(); +} + +function moveDetail(bbsNo, bbscttNo){ + window.location.href = '/bbs/'+bbsNo+'/'+bbscttNo+'/'; +} + +function resetForm() { + $('input[type=text]').val(''); + $('input[type=date]').val(''); + $('[name=ctgryNo] option:eq(0)').prop('selected', 'selected'); +} + +function fileAddPopup(){ + window.open("/bbs/1/filePopup","","width=600, height=300, resizable=no, scrollbars=no, status=no;"); +} + +function listBoard(){ + window.location.href = referrer; +} +``` + +**URL 패턴 확정** + +- 목록: `GET https://nedrug.mfds.go.kr/bbs/117?page={n}&limit={10|20|30|40|50}&title={검색어}` +- **상세: `GET https://nedrug.mfds.go.kr/bbs/117/{bbscttNo}/`** — 예 `https://nedrug.mfds.go.kr/bbs/117/714/` +- 검색 파라미터: `title`(제목, ⚠️ **동명 input 2개 중복**), `page`, `limit`, `searchYn`, `ctgryNo`(카테고리 select) +- 페이지네이션: `onclick="javascript:list(n)"`, 처음/이전/다음/마지막 아이콘 +- 목록 행 앵커 원문 예: `<a onclick="javascript:moveDetail(117,714)" href="#" title="등록대상 원료의약품(DMF) 등록 공고(2021년 2월 1,2주차, 2021.2.1.-2021.02.14) 새창 새창으로 열립니다.">` + +첨부파일: 실측한 목록 HTML에는 첨부 정보가 없으며, **첨부는 상세 페이지에서 `downFile(docId)` 로 내려받는 구조**로 보인다(주석 처리된 `onKeyDown(docId)` 함수 존재). 과거 공고문 첨부는 **붙임1·붙임2** 형태(hwp/pdf 추정) → 부록 B. + +### 5.3 프로젝트 결론 + +- **일일 크롤링 대상에서 제외한다.** 2021-02 이후 신규 글이 없다. +- 단, **역사 데이터 백필 용도로 1회성 아카이브 크롤링은 가치가 있다**(약 705건 × 상세/첨부). 주차별 신규/변경/연차보고 구분이 명시된 원 공고문을 확보하면 §2.9 통계와 교차검증 가능. +- 유사 아카이브: **식품의약품안전평가원 KDMF 게시판** `https://www.nifds.go.kr/brd/m_87/list.do` — "등록대상 원료의약품(DMF) 등록 공고" 게시, 실측 **총 694건 / 70페이지**, 최신 글이 **2020년 9~11월**, 주간 단위, 담당 허가총괄과. 역시 갱신 중단 상태. +- 또 다른 미러: **KHIDI 제약글로벌정보센터** `https://www.khidi.or.kr/board/view?...menuId=MENU01872...` — 「등록대상 원료의약품(DMF) 등록 공고(7월 둘째주)」(2013-07-08, 공고 제2013-101호) 등 과거 공고를 재게시. + +--- + +## 6. 공공데이터포털 OpenAPI + +### 6.1 메타데이터 + +| 항목 | 값 | +|---|---| +| API 명 | **식품의약품안전처_원료의약품등록(DMF)현황** | +| 데이터셋 페이지 | `https://www.data.go.kr/data/15057075/openapi.do` | +| 제공기관 | **식품의약품안전처** | +| 서비스 URL(엔드포인트) | **`https://apis.data.go.kr/1471000/MdcDmfInfoService01`** (HTTP 표기도 통용: `http://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01`) | +| 오퍼레이션 | **`getMdcDmfList01`** | +| 전체 호출 URL | `https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01` | +| 기관 코드 | `1471000` (식약처) | +| 인증 방식 | **serviceKey (URL Encode)** — 공공데이터포털 발급 인증키 | +| 응답 형식 | **XML (기본) / JSON** — `type` 파라미터 | +| 트래픽 제한 | **개발계정 10,000건/일**, 운영계정은 활용사례 등록 후 증설 신청 가능 | +| 비용 | **무료** | +| 이용허락범위 | **"이용허락범위 제한 없음"** | +| 최종 수정일 | **2025-09-19** | +| 데이터 갱신주기 | 문서에 명시 없음 ⚠️ | + +용도 설명(포털 원문 요지): "등록번호, 성분명, 업체명, 제조소명, 제조소 소재지, 제조국가명, 발급일자 등을 제공하여 특정 원료의약품이 어떤 성분으로 구성되고 어느 업체·제조소에서 생산되어 언제 등록되었는지 객관적으로 확인할 수 있다. 제약사·연구자는 제품 개발 시 **원료 사용 가능성 및 원료 제공처 파악**에 활용할 수 있다." + +### 6.2 요청 파라미터 (포털 명세 원문 파싱) + +| 항목명 | 파라미터 | 크기 | 필수 | 샘플 | 설명 | +|---|---|---|---|---|---| +| 업체명 | `entp_name` | 200 | 옵 | 업체명 | 업체명 | +| 성분명 | `ingr_kor_name` | 3000 | 옵 | 성분명 | 성분명 | +| 페이지 번호 | `pageNo` | 5 | 옵 | 1 | 페이지번호 | +| 한 페이지 결과 수 | `numOfRows` | 3 | 옵 | 3 | 한 페이지 결과 수 | +| 인증키 | `serviceKey` | 100 | **필** | (키) | 인증키 (URL Encode) — "공공데이터포털에서 발급받은 인증키" | +| 데이터포맷 | `type` | 4 | 옵 | xml | 응답데이터 형식(xml/json) default : xml | + +> ⚠️ `numOfRows` 크기가 **3자리**로 명시되어 있다 → **최대 999건/페이지**로 추정. 전량(9,840건) 수집 시 최소 10회 이상 페이징 필요. + +### 6.3 응답 필드 (포털 명세 원문 파싱) + +**공통 헤더/바디** + +| 항목명 | 필드 | 크기 | 필수 | 샘플 | 설명 | +|---|---|---|---|---|---| +| 결과코드 | `resultCode` | 4 | 필 | `00` | 결과코드 | +| 결과메시지 | `resultMsg` | 50 | 필 | `NORMAL SERVICE.` | 결과메시지 | +| 한 페이지 결과 수 | `numOfRows` | 3 | 옵 | 3 | | +| 페이지 번호 | `pageNo` | 5 | 옵 | 1 | | +| 전체 결과 수 | `totalCount` | 7 | 옵 | 1 | | + +**데이터 항목 (7개)** + +| 항목명 | 필드 | 크기 | 필수 | 샘플 값 (원문 그대로) | +|---|---|---|---|---| +| 등록번호 | **`DMF_PERMIT_NO`** | 200 | 옵 | `20121228-168-I-169-04` | +| 성분명 | **`INGR_KOR_NAME`** | 3000 | 옵 | `포르모테롤푸마르산염수화물` | +| 업체명 | **`ENTP_NAME`** | 200 | 옵 | `(주)대웅제약` | +| 제조소명 | **`MNFCTR_NAME`** | 150 | 옵 | `SICOR SOCIETA'ITALIANA CORTICOSTER OIDO S.R.L. ,[미분화공정 제조소] Micro-Macinazione SA.` | +| 제조소 소재지 | **`MNFCTR_PLACE`** | 2000 | 옵 | `Rho(MI) - Via Terrazzano, 77, Italy ,6995 Madonna del Piano, Switzerland` | +| 제조국가명 | **`MANUF_COUNTRY_CODE_NM`** | 1000 | 옵 | `이탈리아,스위스` | +| 발급일자 | **`DMF_PERMIT_DATE`** | 30 | 옵 | `2015-02-26` | + +> **결정적 한계**: 이 7개 필드에는 **`최종변경일자`, `최종연차보고년도`, `취소/취하구분`, `취소/취하일자`, `문서번호`, `연계심사문서번호`, `대상의약품`이 전부 없다.** 이 프로젝트의 요구사항인 **변경·취하 탐지가 API 단독으로는 불가능**하다. +> +> 또한 `MNFCTR_NAME` 크기가 **150** 인데 샘플 값 자체가 이미 100자에 육박한다 → 다중 제조소 건에서 **잘림(truncation) 위험**이 있다. CCBAC03 화면/엑셀은 잘리지 않는다(제조소소재지가 2000 크기인 API 대비 화면 값이 더 길다). + +### 6.4 에러 코드 전표 (포털 명세 원문) + +| 에러명 | 코드 | 메시지 | +|---|---|---| +| `APPLICATION_ERROR` | 01 | GW 내부 처리 중 예기치 않은 오류가 발생했습니다. 잠시 후 다시 호출하고, 문제가 반복되면 활용지원센터로 문의해 주세요. | +| `HTTP_ERROR` | 04 | 허용되지 않은 HTTP 요청이거나 기관 API 응답 처리에 실패했습니다. 요청 방식과 호출 URL을 확인해 주세요. | +| `SERVICETIMEOUT_ERROR` | 05 | 기관 API 또는 GW 연계 서비스와의 연결에 실패했거나 응답 대기시간을 초과했습니다. 잠시 후 다시 호출해 주세요. | +| `INVALID_REQUEST_PARAMETER_ERROR` | 10 | 요청 파라미터의 값이나 형식이 올바르지 않습니다. API 명세에서 파라미터 이름, 형식 및 허용값을 확인해 주세요. | +| `NO_OPENAPI_SERVICE_ERROR` | 12 | 요청한 오픈API 서비스가 존재하지 않거나 폐기되었습니다. 호출 URL에 오타가 없는지 확인해 주세요. | +| `SERVICE_KEY_IS_NULL` | 20 | 요청에 API 인증키가 포함되지 않았습니다. 공공데이터포털에서 발급받은 인증키를 요청 파라미터에 포함해 주세요. | +| `PERMISSION_DENIED` | 20 | GW 접근 권한 검사에서 요청이 거부되었습니다. 해당 API의 활용신청 및 접근 권한을 확인해 주세요. | +| `SERVICE_ACCESS_DENIED_ERROR` | 20 | 해당 API 서비스에 대한 이용 권한이 확인되지 않습니다. 활용신청 여부와 승인 또는 일시중지 상태를 확인해 주세요. | +| `LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR` | 22 | API 서비스의 일일 호출 허용량을 초과했습니다. 호출량이 초기화된 이후 다시 이용하거나 트래픽 증설을 신청해 주세요. | +| `LIMITED_NUMBER_OF_SERVICE_REQUESTS_PER_SECOND_EXCEEDS_ERROR` | 23 | 짧은 시간에 많은 요청이 발생하여 초당 호출 허용량을 초과했습니다. 잠시 후 다시 호출해 주세요. | +| `BLACKLIST_IP_ACCESS_ERROR` | 29 | 차단된 IP에서 호출한 요청입니다. 호출 서버의 IP를 확인하고, 차단 해제가 필요한 경우 활용지원센터로 문의해 주세요. | +| `SERVICE_KEY_IS_NOT_REGISTERED_ERROR` | 30 | 등록되지 않은 API 인증키입니다. 인증키가 정확한지와 해당 서비스의 활용신청이 정상적으로 완료되었는지 확인해 주세요. | +| `DEADLINE_HAS_EXPIRED_ERROR` | 31 | API 인증키의 사용 기한이 만료되었습니다. 공공데이터포털에서 이용 기간을 확인하거나 갱신해 주세요. | + +> **운영 시사점**: 코드 **22(일일 한도)**, **23(초당 한도)**, **29(IP 차단)**, **31(키 만료)** 는 반드시 별도 알림으로 처리해야 한다. 특히 **31(키 만료)** 는 조용히 실패하므로 Windows 알림 트리거 대상. + +### 6.5 호출 코드 (복붙 동작 수준) + +```python +# -*- coding: utf-8 -*- +""" +dmf_crawler/sources/datago_dmf_api.py +공공데이터포털 식품의약품안전처_원료의약품등록(DMF)현황 API 클라이언트. +CCBAC03 크롤링 결과의 교차검증(sanity check)용. +""" +from __future__ import annotations + +import os +import time +from typing import Any, Dict, Iterator, List, Optional + +import requests + +ENDPOINT = "https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01" + +# data.go.kr 은 Decoding/Encoding 두 종류의 키를 준다. +# requests 가 자동 인코딩하므로 **Decoding 키**를 그대로 params 에 넣는 것이 안전하다. +SERVICE_KEY = os.environ.get("DATAGO_SERVICE_KEY", "") + +FATAL_CODES = {"20", "29", "30", "31"} # 재시도 무의미 +RETRY_CODES = {"01", "04", "05", "22", "23"} + + +class DataGoError(RuntimeError): + def __init__(self, code: str, msg: str): + super().__init__(f"[{code}] {msg}") + self.code, self.msg = code, msg + + +def fetch_page(page_no: int = 1, num_of_rows: int = 100, + entp_name: Optional[str] = None, + ingr_kor_name: Optional[str] = None, + session: Optional[requests.Session] = None, + timeout: int = 30) -> Dict[str, Any]: + if not SERVICE_KEY: + raise RuntimeError("환경변수 DATAGO_SERVICE_KEY 가 비어 있다.") + s = session or requests.Session() + params = { + "serviceKey": SERVICE_KEY, + "pageNo": page_no, + "numOfRows": num_of_rows, + "type": "json", + } + if entp_name: + params["entp_name"] = entp_name + if ingr_kor_name: + params["ingr_kor_name"] = ingr_kor_name + + r = s.get(ENDPOINT, params=params, timeout=timeout) + r.raise_for_status() + # 오류 시 XML 로 떨어지는 경우가 있어 방어적으로 처리 + ctype = r.headers.get("Content-Type", "") + if "json" not in ctype and not r.text.lstrip().startswith("{"): + raise DataGoError("XML", r.text[:500]) + data = r.json() + body = data.get("body", data) + header = data.get("header", {}) + code = str(header.get("resultCode", "00")) + if code not in ("00", "0"): + raise DataGoError(code, str(header.get("resultMsg", ""))) + return body + + +def iter_all(num_of_rows: int = 100, sleep_sec: float = 0.4, + max_retry: int = 3) -> Iterator[Dict[str, Any]]: + s = requests.Session() + page = 1 + total: Optional[int] = None + seen = 0 + while True: + for attempt in range(max_retry): + try: + body = fetch_page(page, num_of_rows, session=s) + break + except DataGoError as e: + if e.code in FATAL_CODES or attempt == max_retry - 1: + raise + time.sleep(2 ** attempt) + else: # pragma: no cover + raise RuntimeError("unreachable") + + if total is None: + total = int(body.get("totalCount", 0)) + items: List[Dict[str, Any]] = body.get("items", []) or [] + if isinstance(items, dict): # 단건일 때 dict 로 오는 케이스 + items = [items] + if not items: + return + for it in items: + seen += 1 + yield it + if total and seen >= total: + return + page += 1 + time.sleep(sleep_sec) + + +if __name__ == "__main__": + rows = list(iter_all(num_of_rows=100)) + print(f"API 수집 {len(rows):,}건") + print(rows[0]) +``` + +### 6.6 프로젝트 내 위치 + +| 용도 | 채택 여부 | +|---|---| +| 주 수집원 | ✕ (필드 부족) | +| **총건수 교차검증** (CCBAC03 `총 N건` vs API `totalCount`) | ○ | +| **CCBAC03 장애 시 최소 폴백** (신규 등록 감지만) | ○ | +| 성분명·업체명 단건 조회 API 제공 | ○ (부가 기능) | +| 제조소 문자열 정본 | ✕ (150자 제한 truncation 위험) | + +--- + +## 7. 식의약 데이터 포털 / 기타 국내 채널 + +| 채널 | URL | 성격 | 확인 상태 | +|---|---|---|---| +| 식의약 데이터 포털 | `https://data.mfds.go.kr/` | 식약처 자체 공공데이터 포털 | 미확인 | +| 공공데이터 상세 | `https://data.mfds.go.kr/OPCAA01F01` | 데이터셋 상세 | **WebFetch 미수행(사용자 승인 거부)** ⚠️ | +| 공공데이터 검색(의약품 분류) | `https://data.mfds.go.kr/OPCAA01F01/search?selectedTab=tab1&taskDivsCd=2&taskDivsDtlCd=3&srchSrvcKorNm=&btnSearch=` | 분류 검색 (`taskDivsCd`/`taskDivsDtlCd` 파라미터) | 미확인 | +| 공공데이터 검색(다른 분류) | `https://data.mfds.go.kr/OPCAA01F01/search?selectedTab=tab1&taskDivsCd=3&taskDivsDtlCd=7&rchSrvcKorNm=&btnSearch=` | 동일 | 미확인 | +| 의약품안전나라 공공데이터 개요 | `https://nedrug.mfds.go.kr/cntnts/80` | 개방 정책 | ✅ 확인 | +| 식약처 공공자료 개방 | `https://www.mfds.go.kr/usr/opendata_13/list.do` | 파일데이터 목록 | 미확인 | +| 공공데이터포털 검색 | `https://www.data.go.kr/tcs/dss/selectDataSetList.do?keyword="식품의약품안전처"&recmSe=N` | 식약처 데이터셋 전체 | 미확인 | +| (참고) 낱알식별 API | `https://www.data.go.kr/data/15057639/openapi.do?recommendDataYn=Y` | 다른 식약처 API 예시 | — | +| (참고) 건강기능식품 영양DB | `https://www.data.go.kr/data/15085712/openapi.do` | 다른 식약처 API 예시 | — | +| (참고) 구 데이터셋 URL 형식 | `https://www.data.go.kr/dataset/15020627/openapi.do` | 레거시 URL 패턴 | — | + +**`https://nedrug.mfds.go.kr/cntnts/80` 확인 내용 (원문)** + +- 개방 취지: "개인ㆍ기업 및 단체 등이 식품의약품안전처의 의약품 공공데이터를 편리하고 손쉽게 활용하여 대국민서비스를 제공할 수 있도록" +- 제공 형식: **파일 데이터(CSV, EXCEL) 및 OpenAPI** +- 공공데이터 정의: "공공기관이 법령 등에서 정하는 목적을 위하여 생성 또는 취득하여 관리하고 있는" 정보로, 재활용 가능한 형태로 제공 +- **이용 조건 (3항, 원문 그대로)** + 1. **상업적, 비영리적 이용 모두 가능** + 2. **출처 표시 필수** + 3. **내용에 대한 임의 가공 금지** +- 공공누리 유형: 페이지에 명시 없음 → 별도 확인 필요 ⚠️ +- 연락처: 민원업무 **1577-1255**, 기술지원 **1544-9563** (평일 9:00~18:00) + +> ⚠️ **"내용에 대한 임의 가공 금지"** 조항은 이 프로젝트에 직접 영향이 있다. **원본 값을 변형해 재배포하지 말 것.** 리포트에서는 원문 컬럼을 그대로 보존하고, 파생 지표는 **별도 컬럼/시트**로 분리하며, **출처(식품의약품안전처 의약품안전나라, 조회일자)를 모든 시트에 명기**한다. + +기타 국내 참조 채널: + +- 식약처 「원료의약품 등록 제도(DMF) 해설서 제3개정판(민원인 안내서) 개정(안) 의견조회」: `https://www.mfds.go.kr/brd/m_74/view.do?seq=43076&...` +- 한국제약바이오협회(KPBMA) 해설서 제5개정판 다운로드: `https://www.kpbma.or.kr/api/info/law/policy/download/211364` (**PDF 118페이지, 약 1.5MB** — 실측 다운로드 성공, `%PDF-1.4`) +- KPBMA 「원료의약품 등록에 관한 규정」 일부개정 알림: `https://www.kpbma.or.kr/info/law/statute/select/210036` +- 식약처 「원료등록(DMF) 기간 대폭 단축!」 보도 첨부: `https://www.mfds.go.kr/brd/m_99/down.do?brd_id=ntc0021&seq=48245&data_tp=A&file_seq=2` +- 식약처 「붙임1. 개별 항목 검증 룰」: `https://www.mfds.go.kr/brd/m_1060/down.do?brd_id=data0011&seq=14313&data_tp=A&file_seq=2` +- 대한화장품협회(KCIA) 「2021년 자주하는 질문집(의약품 분야)」: `https://kcia.or.kr/inc/down.php?dir=BOARD&file_name=202111_163704850587622_2.pdf&rename=붙임_2021년+자주하는+질문집(의약품+분야).pdf` +- KDI 경제정보센터: 「원료의약품 등록제도(DMF), 궁금하면 클릭하세요!」(`num=196213`, **2019.12.27.**, 식약처 융복합혁신제품지원단 허가총괄팀), 「원료의약품 등록 제도(DMF) 꼼꼼히 알기!」(`num=209012`, **2020.12.29.**, 식약처 허가총괄담당관, 1페이지 HWP), 「식약처 공문서, '의약품안전나라'로 쉽고 편하게 받으세요!」(`num=230838`) +- 정부24 민원안내: `https://www.gov.kr/mw/AA020InfoCappView.do?HighCtgCD=A09006&CappBizCD=14700000458&tp_seq=` +- 관세무역개발원 「원료의약품 등록 기간 '120일→20일'로 대폭 단축」: `https://www.kctdi.or.kr/kctdi/research/research11.do?mode=view&articleNo=3707&...` + +**2019.12.27. KDI 자료 요지(원문)** — 「원료의약품 등록에 관한 규정」 10월 개정 반영: +- 변경등록 및 변경보고 기준을 명확히 함 +- **외국 공정서(USP, EP) 개정에 근거한 중금속 시험항목 삭제** +- **미분화 원료의약품 관리기준 신규 설정** +- **화학의약품 및 한약(생약)제제 해설서 통합** + +--- + +## 8. 해외 DMF 소스 (FDA / EDQM / PMDA) + +### 8.1 요약 비교표 + +| 항목 | 🇺🇸 **FDA DMF** | 🇪🇺 **EDQM CEP** | 🇯🇵 **PMDA MF (原薬等登録原簿)** | 🇰🇷 **MFDS KDMF** (참고) | +|---|---|---|---|---| +| 공식 명칭 | Drug Master Files (DMFs) | Certificates of Suitability (CEP/COS) | 原薬等登録原簿(マスターファイル, MF) | 원료의약품 등록(DMF) | +| 목록 페이지 | `https://www.fda.gov/drugs/drug-master-files-dmfs/list-drug-master-files-dmfs` | `https://extranet.edqm.eu/publications/recherches_CEP.shtml` | `https://www.pmda.go.jp/review-services/drug-reviews/master-files/0008.html` | `https://nedrug.mfds.go.kr/pbp/CCBAC03` | +| 대량 다운로드 | **Excel(.xlsx) 분기별** | **`https://extranet.edqm.eu/4DLink1/4DCGI/EXPORT_WEB_CEP.txt`** (TSV) | **`https://www.pmda.go.jp/files/000240884.xlsx`** (418KB) / PDF `000240885.pdf` (1.39MB) | `POST /pbp/CCBAC03/getExcel` | +| 갱신 주기 | **분기(quarterly)** | 상시(웹DB) / 다운로드 파일은 즉시 생성 | **월 2회(15일경·월말 목표)** | 상시(민원 처리 완료 시 즉시) | +| 실측 접근 결과 | ❌ **404/403 — WAF 차단** (`https://www.fda.gov/apology_objects/abuse-detection-apology.html` 로 리다이렉트) | ✅ **200 OK, 1,459,534 bytes** | ✅ **200 OK, 428,927 bytes, 5,023행** | ✅ 200 OK, 9,841행 | +| 크롤링 난이도 | **상** (봇 차단, 브라우저 자동화/수동 다운로드 필요) | **하** (단일 URL GET) | **하** (단일 URL GET, 단 파일명이 갱신마다 변경) | **중** (세션 쿠키 + POST + xlsx 파싱 함정) | +| 로그인 | 불필요 | 불필요(공개 DB) | 불필요 | 불필요 | + +### 8.2 🇺🇸 FDA Drug Master Files + +**DMF 유형 (Wikipedia 확인)** + +| 유형 | 내용 (원문) | +|---|---| +| **Type I** | Manufacturing Site, Facilities, Operating Procedures and Personnel | +| **Type II** | Drug Substance, Drug Substance Intermediate, and Material Used in Their Preparation, or Drug Product | +| **Type III** | Packaging Material | +| **Type IV** | Excipient, Colorant, Flavor, Essence, or Material Used in Their Preparation | +| **Type V** | FDA Accepted Reference Information | + +**목록 파일 메타데이터 (검색 스니펫 기준, ⚠️ 페이지 직접 확인 실패)** + +> "The list of DMFs, which is updated **quarterly**, contains DMFs received by **December 31, 2025**, for which acknowledgment letters were sent before **January 22, 2026**. The list is **current through DMF 043437**, with changes to the DMF activity status, DMF type, holder name and subject (title) made since the last update of **September 30, 2025**." + +**컬럼 구조** (John Snow Labs 재배포 데이터셋 기준, coverage **1939–2022**, **quarterly updates**): + +| # | 컬럼 | 설명 | +|---|---|---| +| 1 | `DMF_Id` | Unique identifier number for each filing | +| 2 | `Is_DMF_Status_Active` | Boolean — DMF 유효/폐쇄 여부 (원 FDA 파일은 `A`/`I` 코드) | +| 3 | `Type_of_DMF` | 분류(Type I~V) | +| 4 | `Submission_Date` | 제출일 | +| 5 | `DMF_Holder_Name` | 제출 기관/개인 | +| 6 | `DMF_Subject` | DMF 대상 설명 | + +**접근 실패 로그 (실측)** + +``` +GET https://www.fda.gov/drugs/forms-submission-requirements/drug-master-files-dmfs -> 404 +GET https://www.fda.gov/drugs/drug-master-files-dmfs/list-drug-master-files-dmfs -> 404 +GET https://cacmap.fda.gov/drugs/gdufa-ii-drug-master-files-dmfs/list-drug-master-files-dmfs -> 403 +curl -A "Mozilla/5.0 ... Chrome/120 Safari/537.36" https://www.fda.gov/... -> 404, 최종 URL https://www.fda.gov/apology_objects/abuse-detection-apology.html (본문 10 bytes) +``` + +> **결론**: FDA 는 **Akamai 계열 봇 탐지(abuse-detection)** 로 curl/requests 를 차단한다. 필요 시 **Playwright/Chrome 자동화 또는 수동 분기별 다운로드**로만 확보 가능. **본 프로젝트 v1 범위에서는 제외**하고, v2 확장 항목으로 둔다. + +기타 FDA 관련 URL(참조): `https://www.fda.gov/media/186970/download`, `https://www.fda.gov/drugs/developmentapprovalprocess/formssubmissionrequirements/drugmasterfilesdmfs/default.htm`, 상용 재배포처 `https://www.pharmacompass.com/us-drug-master-files-dmfs` (403), `https://dmf-list.backgroundscheck.info/`, `https://fdapals.com/services/dmf-fda-guidance/`, 2006년 가이드 PDF `https://www.gmp-navigator.com/files/guidemgr/Drug_Master_Files_Current_Information_2006.pdf`, Duke 도서관 블로그 `https://mclibrary.duke.edu/about/blog/2014-12-04/fda-drug-master-files` (404). + +### 8.3 🇪🇺 EDQM CEP (Certificates of Suitability) + +**검색 화면**: `https://extranet.edqm.eu/publications/recherches_CEP.shtml` — `<title>Certificates catalogue`, **200 OK, 27,941 bytes** + +**검색 폼 필드(원문 HTML)** + +```html + + + + + + + + + + +Download CEP data file +``` + +**전량 다운로드 (실측 성공)** + +```bash +curl -sS -L --max-time 120 -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" \ + -D edqm_export_headers.txt -o edqm_export.txt \ + -w "%{http_code} %{size_download} %{content_type}\n" \ + "https://extranet.edqm.eu/4DLink1/4DCGI/EXPORT_WEB_CEP.txt" +# => 200 1459534 application/download +``` + +응답 헤더: `Content-Type: application/download`, `Content-Length: 1459534`. 파일은 **BOM(``) 포함 UTF-8, 탭 구분(TSV)**. + +**컬럼 (11개, 원문 헤더 그대로)** + +``` +Monograph Number Substance Type CEP Certificate (CEP) Holder Holder SPOR ORG-ID / SPOR LOC-ID Certificate (CEP) Number Issue Date CEP Status CEP Renewal due End date CEP Closure Date of last Procedure +``` + +**샘플 행 2건 (원문 그대로)** + +``` +0 (6,7)-3-hydroxymethyl-7-(z-2-methoxyimino-2-(fur-2-yl)acetamido)ceph-3-em-4-carb, (640/2 sodium) TSE Glaxo Wellcome London GB R0-CEP 2000-275 - Rev 01 21/11/2001 Withdrawn by Holder 03/08/2006 03/08/2006 +0 1,2-dihydrotriamcinolone TSE Pharmacia & Upjohn Company Kalamazoo US R0-CEP 2001-231 - Rev 00 09/04/2002 Expired 09/04/2007 15/06/2007 +``` + +관측 사항: +- 날짜 포맷 **`DD/MM/YYYY`** (한국 소스의 `YYYY-MM-DD` 와 다름 → 파서 분기 필요) +- `Status CEP` 값: `Withdrawn by Holder`, `Expired` 등 — **취하/만료 상태가 명시된다.** 한국 `취소/취하구분` 과 대응 가능. +- CEP 번호 형식: `R0-CEP 2000-275 - Rev 01` (Rev 리비전 포함) +- `Type CEP`: `TSE`(TSE risk), 그 외 화학/허브 유형 존재 +- 신규 DB 기능 안내(EDQM 공지, 403으로 본문 미확인): "public CEP database ... includes additional columns showing **SPOR ORG and LOC-ID** information for the holder ... as well as the **renewal date** for the CEP (where not yet renewed) and the **closure date of the last procedure**. Users can view and print a **tabulated history** of a selected CEP (revision type, outcome, closure date)." + +EDQM 관련 URL: `https://www.edqm.eu/en/databases` (403), `https://www.edqm.eu/en/certification`, `https://www.edqm.eu/en/what-is-the-cep-2.0`, `https://www.edqm.eu/en/certification-policy-documents-guidelines`, `https://www.edqm.eu/en/actions-on-ceps`, `https://www.edqm.eu/en/knowledge-database`, 공지 `https://www.edqm.eu/en/-/deployment-of-a-new-edqm-cep-database-feature-full-download-of-cep-data` (403), FAQ `https://faq.edqm.eu/pages/viewpage.action?pageId=1377010` (미확인), 사용자 가이드 `PA_PH_CEP (23) 56, May 2024` (Scribd 사본 `https://www.scribd.com/document/849026918/User-Guide-for-Certification-on-Line-Database-PA-PH-CEP-23-56-May-2024`), 재배포처 `https://www.pharmacompass.com/certificates-of-suitability-products-cep`. + +> **주의**: `www.edqm.eu` 는 **403(봇 차단)** 이지만 `extranet.edqm.eu` 는 열린다. **수집은 extranet 만 사용.** + +### 8.4 🇯🇵 PMDA MF (原薬等登録原簿) + +**공시 페이지**: `https://www.pmda.go.jp/review-services/drug-reviews/master-files/0008.html` (「原薬等登録原簿(MF)の公示について」) + +- 법적 근거: **医薬品医療機器等法(薬事法) 第80条の6 第3項** +- 갱신 주기: **月2回(15日ごろ及び月末を目途)**, 最新のMF番号順に掲載 +- 현황 시점(실측): **As of August 15, 2026** / 시트 셀 원문 `2026年8月15日 時点` +- 파일: + - **Excel**: `https://www.pmda.go.jp/files/000240884.xlsx` (418KB) — 실측 **200 OK, 428,927 bytes, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`** + - **PDF**: `https://www.pmda.go.jp/files/000240885.pdf` (1.39MB) +- 시트명: **`2026.8.15公示`** (갱신마다 시트명·파일명이 바뀐다 ⚠️) +- 총 행 수(실측): **5,023행** +- 2019년 1월 이후: 등록자 요청 시 **原薬等国内管理人(국내관리인) 정보(명칭·주소)** 선택 게재 +- 2011년 3월 31일 등록 정리분(경과조치)에 대한 이력도 별도 게재 +- 국내관리인 관련 3종 서식 제공: 신규 게재 의뢰서 / 변경 신고(공개판) / 변경 신고(비공개판) + +**컬럼 (9개, 원문 그대로)** + +``` +MF登録番号 | 初回登録年月日 | 最新登録年月日 | 登録区分 | 登録品目名 | 登録者氏名 | 登録者住所 | 原薬等国内管理人の名称 | 原薬等国内管理人の住所 +``` + +**샘플 행 4건 (원문 그대로)** + +``` +['308MF10111', '2026/08/05', '2026/08/05', '医薬品等原薬', 'ラサギリンメシル酸塩「立山」', '立山化成株式会社', '富山県射水市大江1133番地', None, None] +['308MF10110', '2026/08/05', '2026/08/05', '医薬品等原薬', 'トファシチニブクエン酸塩「立山」', '立山化成株式会社', '富山県射水市大江1133番地', None, None] +['308MF10109', '2026/08/05', '2026/08/05', '医薬品等原薬', 'リオシグアト', 'Zhejiang Hengkang Pharmaceutical Co.,Ltd.', 'No.11 Chengen Road, Pubagang Town, Sanmen, Zh', None, None] +['308MF10108', '2026/08/05', '2026/08/05', '医薬品等原薬', 'リオシグアト 製造専用', '白鳥製薬株式会社', '千葉県習志野市津田沼6-11-24', None, None] +``` + +관측 사항: +- MF 번호 형식: **`308MF10111`** (앞 3자리 + `MF` + 5자리 일련번호) +- 날짜 포맷 **`YYYY/MM/DD`** +- 登録区分 값: `医薬品等原薬` +- **전각 문자(Zhejiang,  )** 가 대량 사용됨 → **NFKC 정규화 필수** +- `登録者住所` 가 **중간에서 잘려 있다**(`... Sanmen, Zh`) → 원본 자체가 truncated +- **1행 상단에 병합 셀 형태의 시점 표기**가 있어 헤더가 2행부터 시작한다: 1행 `[None×8, '2026年8月15日 時点']`, 2행이 실제 헤더 + +**한글 콘솔 인코딩 함정 (실측 에러)** + +``` +UnicodeEncodeError: 'cp949' codec can't encode character '録' in position 5: illegal multibyte sequence +``` +→ Windows 기본 콘솔(cp949)이 일부 한자(`録` 등)를 출력하지 못한다. **`PYTHONIOENCODING=utf-8` 환경변수 설정 또는 `sys.stdout.reconfigure(encoding="utf-8")` 필수.** 이 프로젝트 전체(Windows 11 + 스케줄러)에 해당하는 문제이므로 부트스트랩 스크립트에 반영할 것. + +PMDA 관련 URL: 개요 `https://www.pmda.go.jp/review-services/drug-reviews/master-files/0007.html`, 등록정리 신고 `https://www.pmda.go.jp/review-services/drug-reviews/master-files/0003.html`, 영문 Q&A/가이드 `https://www.pmda.go.jp/files/000280630.pdf`, 최신 유의사항 `https://www.pmda.go.jp/files/000237522.pdf`, MF관리실 자료 `https://www.pmda.go.jp/files/000227202.pdf`, 일본제네릭제약협회 `https://www.jga.gr.jp/jgapedia/deals/_19341.html`, 일본제약공업협회 신뢰성보증 자료 `https://www.jpma.or.jp/information/quality/jirei/bbh7c90000000jbr-att/2023-3.pdf`. + +MF 제도 요지(0007.html 확인): 국내외 原薬等製造業者가 제조방법·제조관리·품질관리 정보를 등록하여 **"原薬等製造業者のノウハウが保護されます"**. **임의 등록(voluntary)**, 대상은 의약품·의료기기·재생의료등제품·체외진단용의약품의 원료. **외국 제조업자는 原薬等国内管理人 선임 및 일본어 서류 제출 필수.** + +### 8.5 해외 소스 다운로드 코드 + +```python +# -*- coding: utf-8 -*- +"""dmf_crawler/sources/overseas.py — EDQM CEP / PMDA MF 다운로더""" +from __future__ import annotations + +import csv +import io +import re +import sys +import unicodedata +import zipfile +from pathlib import Path +from typing import Dict, Iterator, List + +import requests + +if sys.platform == "win32": # cp949 콘솔 대응 + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" + +EDQM_EXPORT = "https://extranet.edqm.eu/4DLink1/4DCGI/EXPORT_WEB_CEP.txt" +PMDA_PAGE = "https://www.pmda.go.jp/review-services/drug-reviews/master-files/0008.html" + + +def fetch_edqm_cep(out: Path | None = None) -> List[Dict[str, str]]: + r = requests.get(EDQM_EXPORT, headers={"User-Agent": UA}, timeout=180) + r.raise_for_status() + text = r.content.decode("utf-8-sig") # BOM 제거 + if out: + out.write_text(text, encoding="utf-8") + return list(csv.DictReader(io.StringIO(text), delimiter="\t")) + + +def find_pmda_xlsx_url() -> str: + """공시 페이지에서 최신 xlsx 링크를 찾는다(파일명이 갱신마다 바뀌므로).""" + r = requests.get(PMDA_PAGE, headers={"User-Agent": UA}, timeout=60) + r.raise_for_status() + m = re.search(r'href="(/files/\d+\.xlsx)"', r.text) + if not m: + raise RuntimeError("PMDA xlsx 링크를 찾지 못했다") + return "https://www.pmda.go.jp" + m.group(1) + + +_ROW_RE = re.compile(rb"]*>(.*?)", re.S) +_CELL_RE = re.compile(rb"]*>(.*?)", re.S) +_TEXT_RE = re.compile(rb"]*>(.*?)", re.S) + + +def fetch_pmda_mf(out: Path | None = None) -> Iterator[List[str]]: + url = find_pmda_xlsx_url() + r = requests.get(url, headers={"User-Agent": UA}, timeout=180) + r.raise_for_status() + if out: + out.write_bytes(r.content) + with zipfile.ZipFile(io.BytesIO(r.content)) as z: + # sharedStrings 를 쓰는 정상 xlsx 이므로 openpyxl 도 동작하지만, + # 의존성을 줄이려 XML 을 직접 훑는다. + shared: List[str] = [] + if "xl/sharedStrings.xml" in z.namelist(): + raw = z.read("xl/sharedStrings.xml") + shared = [t.decode("utf-8") for t in _TEXT_RE.findall(raw)] + sheet = sorted(n for n in z.namelist() + if n.startswith("xl/worksheets/") and n.endswith(".xml"))[0] + data = z.read(sheet) + + for m in _ROW_RE.finditer(data): + cells: List[str] = [] + for c in _CELL_RE.finditer(m.group(1)): + chunk = c.group(1) + if b't="s"' in c.group(0): + idx = re.search(rb"(\d+)", chunk) + cells.append(shared[int(idx.group(1))] if idx else "") + else: + texts = _TEXT_RE.findall(chunk) + if texts: + cells.append("".join(t.decode("utf-8") for t in texts)) + else: + v = re.search(rb"(.*?)", chunk, re.S) + cells.append(v.group(1).decode("utf-8") if v else "") + # 전각 -> 반각 정규화 (PMDA 데이터는 전각이 많다) + yield [unicodedata.normalize("NFKC", x).strip() for x in cells] + + +if __name__ == "__main__": + ceps = fetch_edqm_cep(Path("edqm_cep.txt")) + print(f"EDQM CEP {len(ceps):,}건 / 컬럼 {list(ceps[0].keys())}") + rows = list(fetch_pmda_mf(Path("pmda_mf.xlsx"))) + print(f"PMDA MF {len(rows):,}행 / 헤더 {rows[1]}") +``` + +### 8.6 v1 범위 결정 + +| 소스 | v1 (매일 06:00) | v2 (확장) | +|---|---|---| +| MFDS CCBAC03 | ✅ 매일 | — | +| data.go.kr API | ✅ 매일(교차검증만) | — | +| PMDA MF | ⬜ **주 1회** (월 2회 갱신이므로) | 성분 매칭 리포트 | +| EDQM CEP | ⬜ **주 1회** | CEP 보유 성분 대조 | +| FDA DMF | ❌ (WAF 차단) | Playwright 분기 1회 | + +--- + +## 9. robots.txt·저작권·이용약관·크롤링 정책 + +### 9.1 robots.txt (실측 원문) + +```bash +curl -sS -L --max-time 30 https://nedrug.mfds.go.kr/robots.txt +``` + +``` +User-agent: * +Disallow: / +``` + +**전면 차단**이다. `Allow` 도 `Sitemap` 도 없다. + +### 9.2 식약처 저작권정책 (`https://www.mfds.go.kr/wpge/m_34/de010803l001.do`) + +확인 내용: + +- **공공저작물 자유이용**: 저작권법 **제24조의2** — *"국가 또는 지방자치단체가 업무상 작성하여 공표한 저작물...은 허락 없이 이용할 수 있다"* +- **예외 4가지 (자유이용 제외)** + 1. 국가안전보장에 관련되는 정보 + 2. 개인의 사생활 또는 사업상 비밀 + 3. 다른 법률에 따라 보호되는 정보 + 4. 한국저작권위원회에 등록되어 국유/공유재산으로 관리되는 저작물 +- **출처 표시 의무**: 저작권법 **제37조**에 따라 출처 명시, 원저작자명이 표시된 저작물은 그 이름도 표기 +- **공공누리 미적용 콘텐츠**: 사전에 담당부서와 협의 필요 +- 제3자 지식재산권 존중, 보호 저작물의 무단 상업적 이용·변경 금지 +- 푸터 표기: `Copyright ⓒ Ministry of Food and Drug Safety. All Rights Reserved.` + +### 9.3 의약품안전나라 공공데이터 이용 조건 (`/cntnts/80`) + +1. 상업적, 비영리적 이용 모두 가능 +2. **출처 표시 필수** +3. **내용에 대한 임의 가공 금지** + +### 9.4 공공누리(KOGL) 유형 + +- 공공누리(Korea Open Government License) 자체는 **제1~4유형** 체계이지만, **본 프로젝트가 확인한 어느 페이지에도 DMF 공고 데이터에 대한 명시적 공공누리 유형 표기가 없었다.** ⚠️ 부록 B 확인 항목. +- 참고: `https://en.wikipedia.org/wiki/Korea_Open_Government_License`, `https://en.wikipedia.org/wiki/Copyright_law_of_South_Korea` +- 공공데이터포털 API(15057075)는 **"이용허락범위 제한 없음"** 으로 명시되어 있어 API 경로가 법적으로 가장 명확하다. + +### 9.5 이용약관 관련 확인 사항 + +검색으로 확인된 nedrug 이용약관 취지: "제공 정보가 허위이거나 허위로 의심할 만한 상당한 이유가 있으면 서비스 이용을 제한할 수 있으며, 이용자는 서비스를 통해 게시·전송한 정보에 대해 모든 책임을 진다." **크롤링·자동수집을 직접 금지하는 조항은 확인되지 않았다.** ⚠️ 전문 미확인 → 부록 B. + +### 9.6 이 프로젝트의 크롤링 정책 (확정) + +robots.txt 전면 차단 vs 법적 자유이용이라는 긴장 관계에서, **"법적으로는 허용되나 기술적 예의를 최대한 지킨다"** 는 원칙을 채택한다. + +| 정책 | 값 | 근거 | +|---|---|---| +| 수집 빈도 | **1일 1회, 06:00 KST** | 데이터가 민원 처리 시점에만 갱신되므로 일 1회로 충분 | +| 1회 요청 수 | **엑셀 POST 1회 (+ 세션 확보 GET 1회) = 총 2회** | HTML 페이지네이션(197~984회)을 피한다 | +| HTML 폴백 시 | 페이지 간 **`sleep 1.5초` 이상**, `limit=50` | 서버 부하 최소화 | +| User-Agent | 실제 브라우저 UA + (선택) 연락처 주석 | 위장이 아니라 호환성 목적 | +| 동시성 | **1 (직렬)** | 절대 병렬 요청 금지 | +| 재시도 | 지수 백오프, 최대 3회, 총 대기 ≤ 30초 | | +| 실패 시 | **API 폴백 → 그래도 실패면 Windows 알림 + 전날 스냅샷 유지** | | +| 데이터 재배포 | **원문 그대로 보존 + 출처·조회일자 명기**, 임의 가공 결과는 별도 시트 | `/cntnts/80` 3항 | +| 출처 표기 문구 | `출처: 식품의약품안전처 의약품안전나라(nedrug.mfds.go.kr) 원료의약품등록(DMF) 공고, 조회일 YYYY-MM-DD` | 저작권법 제37조 | +| 개인정보 | 신청인·제조소는 **법인 정보**이나, 게시판 등록자명(`○**`)처럼 마스킹된 값은 **수집·저장 금지** | | +| 로그 보존 | 요청 URL·응답코드·바이트수·소요시간만. 응답 본문 원본은 **일 1개 스냅샷만 보관, 90일 롤링** | | + +> **경고**: robots.txt 를 무시하는 결정이므로, **대량·고빈도 요청은 절대 금지**다. 위 정책(일 2회 요청)을 벗어나는 순간 IP 차단 및 법적 리스크가 현실화된다. 개발 중 반복 테스트는 **로컬에 저장한 스냅샷 파일**로 수행할 것. + +--- + +## 10. 제약사 RA 실무자의 모니터링 관점 + +### 10.1 왜 RA가 DMF 공고를 매일 보는가 + +공공데이터포털의 활용 목적 서술(원문): *"제약사 및 연구자 등은 DMF 현황 파악으로 제품 개발 시 **원료 사용 가능성 및 원료 제공처 파악** 등에 활용할 수 있다."* + +RA(Regulatory Affairs) 실무 관점(검색 확인 원문): *"제품에 대한 허가정보 변경은 RA부서에서 빈번히 일어나는 업무 중 하나이며, **등록된 제조원이 매각되어 새로운 제조원으로 변경해야 하는 상황**이 발생할 수 있다."* RA 업무에는 **인허가 관련 법령 및 규정 모니터링**이 포함된다. + +채용 공고 예시(직무 실체 확인): `https://www.bzpp.co.kr/biz/businessDetailView/BR250423A00015` — 「[중견사]원료 의약품 RA (허가신청 및 등록)/차장-부장급」. 실무 교육: `https://comento.kr/edu/learn/연구개발/인허가개발-G676` 「제약 RA 현직자 실무 체험: 신약 허가 준비, 의약품 품목 갱신 등」. + +> ⚠️ 검색 결과에서 **"경쟁사 모니터링"에 대한 구체적 실무 문헌은 확인되지 않았다.** 아래 4축 중 ③은 데이터 구조상 자명한 활용이지만, 업계 관행 근거는 **미검증**이다. + +### 10.2 모니터링 4축 → 리포트 시트 매핑 + +| 축 | 감시 대상 필드 | RA가 알고 싶은 것 | 이벤트 정의 | 대응 리포트 시트 | +|---|---|---|---|---| +| **① 성분(API)** | `성분명`, 등록번호의 `ingr_no`+`group` | "내가 담당하는 성분에 새 등록이 떴나?" / "이 성분 소싱 대안이 늘었나?" | 관심 성분 워치리스트에 신규 행 발생 | `내_관심성분` | +| **② 제조원(Site)** | `제조소명`, `제조소소재지`, `제조국가` | "우리 제조원이 다른 회사 DMF에도 올라갔나?" / "제조원이 매각·이전됐나?" | 동일 `permit_no` 의 site_* 값 변경, 또는 특정 제조소명이 새 등록에 등장 | `제조원_변동`, `국가별_동향` | +| **③ 경쟁사(신청인)** | `신청인` | "경쟁사가 어떤 원료를 확보하고 있나?" | 워치리스트 신청인의 신규/변경 발생 | `경쟁사_동향` | +| **④ 등록 상태 변화** | `최종변경일자`, `최종연차보고년도`, `취소/취하구분`, `취소/취하일자`, `문서번호` | "내가 쓰는 원료가 취하됐나?"(**공급중단 리스크**) / "변경등록이 걸렸나?" | 상태 필드 값 변경 | `변경`, `취하_취소`(★최우선 알림) | + +### 10.3 이벤트 타입 확정 (diff 엔진 사양) + +전일 스냅샷 `S(t-1)` 과 금일 스냅샷 `S(t)` 를 `permit_no`(정규화된 `base_key` 아님, **원문 등록번호**)를 키로 비교한다. + +| 이벤트 | 판정 조건 | 심각도 | 알림 | +|---|---|---|---| +| `NEW` | `permit_no ∈ S(t) \ S(t-1)` | info | 관심 성분/경쟁사 매칭 시 | +| `WITHDRAWN` | `취소/취하구분` 이 `정상` → 다른 값으로 변경, 또는 `취소/취하일자` 신규 부여 | **critical** | 항상 | +| `CHANGED_REG` | `최종변경일자` 값 변경 | warn | 항상 | +| `ANNUAL_REPORT` | `최종연차보고년도` 값 변경 | info | 집계만 | +| `SITE_CHANGED` | `제조소명`/`제조소소재지`/`제조국가` 값 변경 | **critical** | 항상 (규칙 제17조제1항제1호 = 중요 변경) | +| `APPLICANT_CHANGED` | `신청인` 값 변경 | warn | 양도양수 가능성 (처리기한 25일 조항 참조) | +| `DOC_VERSION_CHANGED` | `문서번호`(`dmfVersion`) 값 변경 | info | 재공고 추적 | +| `LINKED_REVIEW_SET` | `연계심사문서번호` 신규 부여 | info | 원료-완제 연계심사 진행 신호 | +| `DISAPPEARED` | `permit_no ∈ S(t-1) \ S(t)` | **critical** | 데이터 삭제 or 크롤링 실패 → **먼저 총건수 검증** | + +> **필수 안전장치**: `DISAPPEARED` 가 다수 발생하면 대개 **크롤링 실패(빈 엑셀 3,575 bytes)** 다. `총 N건` 값이 전일 대비 **±5% 이상 급감**하면 diff 를 중단하고 전일 스냅샷을 유지한 채 Windows 알림을 띄운다. + +### 10.4 파생 분석 지표 (리포트 부가 시트) + +| 지표 | 정의 | 활용 | +|---|---|---| +| 성분별 등록 제조원 수 | `성분명` 별 distinct(`제조소명`) | 소싱 대안 폭 = 공급 안정성 | +| 성분별 신청인 수 | `성분명` 별 distinct(`신청인`) | 시장 경쟁 강도 | +| 국가별 점유 | `제조국가` 집계 (다중 국가는 분해 후 집계) | **인도·중국 의존도** — 지정학 리스크 | +| 신청인 Top-N | `신청인` 별 등록 건수 | 원료 상사·수입사 랭킹 | +| 제조소 Top-N | `제조소명` 별 등록 건수 | 글로벌 API 제조사 랭킹 | +| 월별 신규 추이 | `최초등록일자` 월별 count | §2.9 통계와 연속성 유지 | +| 알파벳군(A~K) 구성 | 등록번호 `group` 집계 | 제도 확대 코호트 분석 | +| 취하율 | `취소/취하구분 != 정상` 비율 | 제도 건전성 | +| 허여서 파생 비율 | `is_grant_derived == True` 비율 | 자료공유 관행 규모 | +| 심사 소요일 | `최초등록일자 − accept_date(등록번호 접두)` | 처리기한(20/90일) 준수 실태 | + +### 10.5 RA 워치리스트 설정 파일 (권장 스키마) + +```yaml +# config/watchlist.yaml +watchlist: + ingredients: # ① 성분 축 + - 탐스로신염산염 + - 프레가발린 + - 부데소니드 # '미분화부데소니드' 도 부분일치로 잡히게 할 것 + ingredient_match: contains # contains | exact | regex + + sites: # ② 제조원 축 + - Hema Pharmaceuticals + - Avik Pharmaceutical + site_match: contains + + applicants: # ③ 경쟁사 축 + - 주식회사지맥스파마켐 + - 엠피크코리아 + applicant_match: contains + + countries: # 국가 관심 축 + - 인도 + - 중국 + +alerts: + always_critical: # ④ 상태 변화는 워치리스트와 무관하게 항상 알림 + - WITHDRAWN + - SITE_CHANGED + - DISAPPEARED + daily_digest: + - NEW + - CHANGED_REG + - ANNUAL_REPORT + - APPLICANT_CHANGED + - DOC_VERSION_CHANGED + - LINKED_REVIEW_SET +``` + +### 10.6 도메인 특이 규칙 (오탐 방지) + +1. **‘미분화’ 접두어**: `미분화부데소니드` 처럼 미분화 원료는 **별도 등록**이므로 `부데소니드` 와 다른 행이다. 성분 매칭은 **부분일치 + 미분화 플래그 별도 표시**. +2. **염류·수화물**: `탐스로신염산염`, `포르모테롤푸마르산염수화물` 처럼 염/수화물이 성분명에 붙는다. 규정 제2조 2호가 "별표1의 원료의약품과 그 **염류 및 수화물**"이므로 **모두 등록 대상**. 성분 정규화 시 염 접미어(염산염/푸마르산염/수화물/메실산염 등) 사전을 별도 관리. +3. **다중 제조소**: 콤마 구분 다중 값이며 `[미분화공정 제조소]` 같은 **역할 표기 접두어**가 붙는다. 단순 split(",") 하면 주소 안의 콤마(`Rho(MI) - Via Terrazzano, 77, Italy`)와 충돌 → **`" ,"`(공백+콤마) 또는 화면 HTML 의 중첩 `` 기준 분해**가 안전. +4. **회사명 표기 흔들림**: `㈜하이플` vs `(주)하이플`, `주식회사지맥스파마켐` vs `(주)지맥스파마켐`. NFKC 정규화 + `주식회사|㈜|(주)|㈔` 접두/접미 제거 후 **정규화 키** 생성 필수. +5. **연차보고 1월 스파이크**: 규칙 제17조제2항이 **매년 1월 31일까지** 보고이므로 1~2월 `ANNUAL_REPORT` 폭증은 정상. +6. **재공고(재등록)**: §2.9 통계에서 괄호로 표기된 "재공고" 건이 전체의 47%(3,279/6,965)에 달한다. **같은 성분·제조소가 반복 등장하는 것은 이상이 아니다.** +7. **공고 유보**: 규정 제5조제2항에 따라 수급 불균형 시 **공고가 유보**될 수 있다 → 특정 성분이 공고에 안 뜬다고 미등록으로 단정 금지. + +--- + +## 11. 언론·유사 서비스가 DMF를 다루는 방식 + +| 매체/서비스 | URL | 다루는 방식 | +|---|---|---| +| **데일리팜** | `https://www.dailypharm.com/Users/News/NewsView.html?ID=324241` / 모바일 `http://m.dailypharm.com/newsView.html?ID=295979` | **제도 변화에 따른 시간대별 추이 분석**. 「규제완화 여파...원료약 등록 1년새 237→653건」(2025-06-26)에서 연도별 변동 패턴(2021년 정점 → 감소 → 2025년 반등)을 추적. 「필수약·공급중단 보고대상 의약품 'DMF 등록' 유예」(2023-01-12)처럼 **규제 완화·산업 지원 정책 프레임**으로 보도 | +| **히트뉴스** | `https://www.hitnews.co.kr/news/articleView.html?idxno=54607` (2024-05-09) / `?idxno=16548` (2020-04-26) | **정책 변화 해설**. GMP 증명서 대체·처리기한 단축 등 제도 개편을 업계 영향 관점에서 심층 보도. 2020년 기사는 의약품안전나라 개편(‘e약은要’, 메일링 구독, 공급중단 예측 AI, **전자허가증 전환 — 완제 2020년, 원료 2021년, 나머지 2022년**)을 다룸 | +| **약업신문 / 약사공론** | `https://www.kpanews.co.kr/article/show.asp?idx=211278&category=C` | 반기·연간 등록 건수 집계 기사 (2022년 상반기 653건 = 전년 동기 256건 대비 2.8배 등) | +| **KHIDI 제약글로벌정보센터** | `https://www.khidi.or.kr/board/view?...menuId=MENU01872...` | **법령 및 고시 > 의약품 인허가정보** 게시판에 공고 원문·지침 재게시. 「등록대상 원료의약품(DMF) 등록 공고(7월 둘째주)」(`no1=912&linkId=26604564`), 「[지침]원료의약품 등록(DMF) 처리 절차」(2017-03-30, 식품의약품안전평가원 바이오생약심사부, `linkId=26605812`), 「원료의약품 등록에 관한 규정」(2021-02-24, 제2021-8호, `no1=3182&linkId=48852840`) | +| **PharmaCompass** | `https://www.pharmacompass.com/us-drug-master-files-dmfs`, `https://www.pharmacompass.com/certificates-of-suitability-products-cep` | FDA DMF / EDQM CEP 를 **상용 재가공 DB**로 제공 (403으로 상세 미확인) | +| **John Snow Labs** | `https://www.johnsnowlabs.com/marketplace/fda-drug-master-files-directory/` | FDA DMF 목록을 **정제 데이터셋 상품**으로 판매 (컬럼 6개, 분기 갱신, 1939–2022) | +| **dmf-list.backgroundscheck.info** | `https://dmf-list.backgroundscheck.info/` | FDA 분기 스프레드시트 비공식 미러 | + +**시사점**: 국내에는 **"매일 DMF 신규/변경/취하를 자동 탐지해 알려주는 서비스가 없다."** 언론은 반기/연간 집계 기사만 내고, 정부는 상시 테이블만 던져둔다. **이 프로젝트의 존재 이유가 바로 그 공백이다.** + +--- + +## 12. 수집 대상 필드 확정안 + +> ⚠️ **전면 재작성됨 (2026-09-02)**. 이 절은 원래 CCBAC03 화면/엑셀 14개 컬럼을 기준으로 작성되어 있었으나, `design/00b-baseline-data-analysis.md` 의 실측(기존 `DMF_현황.xlsx` 9,084건 전수 분석)에 따라 **공식 Open API 7필드 + 파생 필드 9개** 기준으로 다시 썼다. 이 프로젝트는 화면을 수집하지 않는다(§4 참조). 원본 14컬럼 버전은 git 이력에 보존된다. + +### 12.1 확정 필드표 (원본 7 + 파생 9 = 16) + +| # | 필드명(영문 snake_case) | 한글명 | 출처 | 타입 | 널 허용 | 정규화 규칙 | 예시 | +|---|---|---|---|---|---|---|---| +| 1 | `dmf_permit_no` | 등록번호 | API (`DMF_PERMIT_NO`) | string | 아니오 (**PK**) | 원문 그대로 저장. 자연 키, 9,084건 중복 0건 실측(00b §5.1). §3 파서로 파생 필드를 뽑되 **파싱 실패해도 이 필드는 그대로 유지**(00b D7) | `20260901-209-J-2270` | +| 2 | `ingr_kor_name` | 성분명 | API (`INGR_KOR_NAME`) | string | 아니오 | 원문 보존. 그룹핑용 정규화는 파생 필드 #15 에서 별도 수행(21그룹 표기 흔들림 실측, 00b §8.2) | `탐스로신염산염` | +| 3 | `entp_name` | 업체명(신청인) | API (`ENTP_NAME`) | string | 아니오 | 원문 보존. 정규화는 파생 필드 #16 에서 별도 수행(2그룹 표기 흔들림 실측, 00b §8.3) | `(주)삼오제약` | +| 4 | `mnfctr_name` | 제조소명 | API (`MNFCTR_NAME`) | string | 아니오 | 원문 그대로 보존. **콤마로 단순 분할 금지**(제조소명 자체에 `Co., Ltd.` 콤마 포함) — 다중 제조소 분해는 신뢰할 수 없어 하지 않는다. 연속 공백·마침표 중복 등 원본 오류 42건 실측(00b §8.4)이지만 원문은 고치지 않고 그대로 저장 | `BDR LIFESCIENCES PVT. LTD..` | +| 5 | `mnfctr_place` | 제조소소재지 | API (`MNFCTR_PLACE`) | string | 아니오 | 원문 보존. 표시 폭만 제한(목록 시트는 앞 60자, 상세는 전체) — 최대 473자 실측(00b §8.6, D10) | 최대 473자 문자열 | +| 6 | `manuf_country_code_nm` | 제조국가명 | API (`MANUF_COUNTRY_CODE_NM`) | string | **예** (5건 결측 실측, 00b §7.1) | 원문 그대로 보존. 정규화된 리스트는 파생 필드 #14 에서 별도 생성 — 같은 국가 반복 표기 496건 실측(00b §8.1) | `중국,중국` | +| 7 | `dmf_permit_date` | 발급일자 | API (`DMF_PERMIT_DATE`) | date (`YYYY-MM-DD`) | 아니오 | 파싱 실패 0건, 전량 일관 포맷 확인(00b §8.5). **고정된 최초 등록일이 아니라 변경마다 갱신되는 최종 갱신일**로 동작(00b §6) — 변경 탐지의 1차 신호 | `2026-08-18` | +| 8 | `first_collected_date` | 최초수집일 | 파생(시스템) | date | 아니오 | 이 시스템이 해당 등록번호를 **처음 관측한 날**. 기존 프로토타입 xlsx 의 동일 컬럼 설계를 계승(00b §3, D9) | `2026-09-02` | +| 9 | `last_change_detected_date` | 최종변경감지일 | 파생 | date | 예 (변경 이력 없으면 null) | `dmf_permit_date` 이동 또는 성분명·업체명·제조소명·소재지·국가·발급일자 등 6개 필드 diff 발생 시 그 날짜로 갱신(00b §6.3, D3) | `2026-09-02` | +| 10 | `status` | 상태 | 파생 | enum(`신규`/`정상`/`변경`/`취하`) | 아니오 | `신규`=오늘 API 응답에 처음 출현, `변경`=`dmf_permit_date` 이동 또는 필드 diff 발생, `취하`=어제 있던 등록번호가 오늘 API 응답에서 소멸, `정상`=그 외(00b §4, §6.3, D2·D3) | `변경` | +| 11 | `change_sequence` | 변경차수 | 파생 | int | 아니오(기본 0) | `status` 가 `변경`으로 판정될 때마다 +1 되는 누적 카운터 | `3` | +| 12 | `initial_registration_date` | 최초등록일 | 파생(`dmf_permit_no` 파싱) | date | 예 (파싱 실패 시 null) | 표준 포맷 등록번호 앞 8자리(`YYYYMMDD`)를 §3 파서로 파싱. `dmf_permit_date` 와 다를 수 있다 — 44.5% 불일치 실측, 전부 괄호 붙은 갱신 건(00b §6.1) | `2005-08-31` (등록번호 `20050831-33-A-81-08(18)`에서) | +| 13 | `target_drug_category` | 대상의약품구분 | 파생(`dmf_permit_no` 포맷) | enum(`별표1`/`신물질`/`기타`) | 예 | `수` 접두어 포맷 = 신물질(1,882건 실측, 00b §5.2), 표준 `YYYYMMDD-n-A-n-n` 포맷 = 별표1, 그 외 포맷(455건, "기타")은 판별 불가로 null 또는 `기타` | `신물질` | +| 14 | `country_list_normalized` | 정규화된 국가 리스트 | 파생(`manuf_country_code_nm`) | list\ | 예 (원본 결측 5건) | 콤마로 분해 → 공백 제거 → 중복 제거 → 정렬 → 재결합. 496건 중복 표기(`중국,중국` 등) 해소, 정규화 후 고유 국가 49개(00b §8.1, D5) | `["중국"]` (원문 `중국,중국`에서) | +| 15 | `ingredient_key_normalized` | 정규화된 성분명 키 | 파생(`ingr_kor_name`) | string | 아니오 | 공백·중점(`·`)·하이픈·괄호 제거 후 소문자화하여 그룹 키 생성. 21그룹 표기 흔들림 해소(00b §8.2, D5). 원문(#2)은 그대로 보존(D6) | `다비가트란에텍실레이트메실산염` | +| 16 | `entity_key_normalized` | 정규화된 업체명 키 | 파생(`entp_name`) | string | 아니오 | `㈜`→`(주)`, `주식회사`→`(주)` 치환 후 공백·마침표 제거 및 소문자화. 2그룹 표기 흔들림 해소(00b §8.3, D5). 원문(#3)은 그대로 보존(D6) | `(주)삼진제약` | + +> **원문 보존 원칙(D6)**: 정규화 값(#14~#16)은 항상 별도 컬럼이며, 원본 값(#2, #3, #6)을 덮어쓰지 않는다. `/cntnts/80`(§7·§9.3)의 "내용에 대한 임의 가공 금지" 조항과도 일치한다. +> +> **등록번호 파싱은 §3 을 그대로 쓴다.** `dmf_permit_no` 는 API 응답이지만 값 자체는 화면 컬럼 `등록번호`와 동일하므로, §3 에서 확정한 정규식·`GROUP_TABLE`·`parse_permit_no()` 구현이 그대로 적용된다(포맷 4종: 표준 41.5%, 표준+괄호 32.7%, 신물질 20.7%, 기타 5.0% — 00b §5.2). + +### 12.2 §6 응답 필드와의 매핑 확인 + +API 원본 필드명(대문자 스네이크, §6.3)과 이 절의 `snake_case` 필드명은 단순 소문자 변환 관계다. + +| API 필드 | 이 절 필드명 | +|---|---| +| `DMF_PERMIT_NO` | `dmf_permit_no` | +| `INGR_KOR_NAME` | `ingr_kor_name` | +| `ENTP_NAME` | `entp_name` | +| `MNFCTR_NAME` | `mnfctr_name` | +| `MNFCTR_PLACE` | `mnfctr_place` | +| `MANUF_COUNTRY_CODE_NM` | `manuf_country_code_nm` | +| `DMF_PERMIT_DATE` | `dmf_permit_date` | + +### 12.3 저장 스키마 (SQLite/DuckDB 권장) + +```sql +-- 1) 일일 스냅샷(API 원본 7필드, 임의 가공 금지 원칙 준수) +CREATE TABLE IF NOT EXISTS dmf_snapshot ( + snapshot_date TEXT NOT NULL, -- YYYY-MM-DD + dmf_permit_no TEXT NOT NULL, -- PK 구성요소, 9,084건 중복 0 실측 + ingr_kor_name TEXT NOT NULL, + entp_name TEXT NOT NULL, + mnfctr_name TEXT NOT NULL, + mnfctr_place TEXT NOT NULL, + manuf_country_code_nm TEXT, -- 5건 결측 실측 + dmf_permit_date TEXT NOT NULL, + row_hash TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'datago_api', + PRIMARY KEY (snapshot_date, dmf_permit_no) +); +CREATE INDEX IF NOT EXISTS ix_snap_permit ON dmf_snapshot(dmf_permit_no); +CREATE INDEX IF NOT EXISTS ix_snap_ingr ON dmf_snapshot(ingr_kor_name); + +-- 2) 파생/이력 필드(등록번호별 최신 상태) +CREATE TABLE IF NOT EXISTS dmf_derived ( + dmf_permit_no TEXT PRIMARY KEY, + first_collected_date TEXT NOT NULL, + last_change_detected_date TEXT, + status TEXT NOT NULL, -- 신규/정상/변경/취하 + change_sequence INTEGER NOT NULL DEFAULT 0, + initial_registration_date TEXT, -- 파싱 실패 시 NULL + target_drug_category TEXT, -- 별표1/신물질/기타/NULL + ingredient_key_normalized TEXT NOT NULL, + entity_key_normalized TEXT NOT NULL +); + +-- 3) 정규화된 국가 리스트(1:N) +CREATE TABLE IF NOT EXISTS dmf_country ( + dmf_permit_no TEXT NOT NULL, + country TEXT NOT NULL, + PRIMARY KEY (dmf_permit_no, country) +); + +-- 4) 등록번호 파싱 결과(§3 파서 그대로 적용) +CREATE TABLE IF NOT EXISTS dmf_permit_parsed ( + dmf_permit_no TEXT PRIMARY KEY, + permit_fmt TEXT NOT NULL, -- standard/new_substance/unknown + accept_date TEXT, + ingr_no INTEGER, + permit_group TEXT, + permit_serial INTEGER, + permit_sub INTEGER, + grant_seq INTEGER, + base_key TEXT +); + +-- 5) 변경 이벤트 +CREATE TABLE IF NOT EXISTS dmf_event ( + event_date TEXT NOT NULL, + dmf_permit_no TEXT NOT NULL, + event_type TEXT NOT NULL, -- NEW/CHANGED/WITHDRAWN + field TEXT, + old_value TEXT, + new_value TEXT, + PRIMARY KEY (event_date, dmf_permit_no, event_type, field) +); + +-- 6) 수집 실행 로그(운영 모니터링/알림용, design/00-DATA-SOURCE-DECISION.md §7.2 안전장치 연동) +CREATE TABLE IF NOT EXISTS crawl_run ( + run_id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + finished_at TEXT, + http_status INTEGER, + row_count INTEGER, + total_count INTEGER, -- API 의 totalCount + ok INTEGER NOT NULL, -- 0/1 + error TEXT +); +``` + +### 12.4 xlsx 리포트 시트 구성 + +00b 문서가 확인한 **기존 프로토타입의 3시트 구조(`전체`/`신규`/`갱신이력`)를 계승·확장**한다(00b §2, §10 D8). + +| 시트 | 내용 | 원천 | 비고 | +|---|---|---|---| +| `전체` | 당일 API 전량 스냅샷(원본 7필드 + 파생 9필드) | `dmf_snapshot` + `dmf_derived` | 기존 프로토타입 시트 계승. 소재지는 표시 폭 제한(D10) | +| `신규` | 당일 `status = 신규` 행 | `dmf_derived` | 기존 프로토타입 시트 계승 | +| `갱신이력` | 실행일·누적건수·신규건수 로그 | `crawl_run` | 기존 프로토타입 시트 계승. 변경건수·취하건수·오류 로그 컬럼 추가 검토(00b 부록 B) | +| `변경` | 당일 `status = 변경` 행 (old→new 나란히) | `dmf_event` | 신설 | +| `취하` | 당일 `status = 취하` 행 (★ 조건부 서식 빨강) | `dmf_derived` | 신설. §10.3 의 `WITHDRAWN` 이벤트에 대응 | +| `대시보드` | 국가 분포(인도·중국 61.5% 집중, 00b §7.3), 상위 성분·업체, 연도별 추이(발급일자=갱신연도 기준임을 명시, 00b §7.2, D11) | 집계 | 신설. 00b §9 "디자인 예쁘게" 요구 대응 | +| `국가별_집계` | `country_list_normalized` 기준 피벗 | `dmf_country` | 신설. 국가 코드 매핑 테이블 별도 관리(00b D12) | +| `성분별_집계` / `업체별_집계` | 정규화 키(#15/#16) 기준 랭킹 | `dmf_derived` | 신설 | +| `출처_고지` | 출처·조회일자·이용조건(출처표시 필수, 임의가공 금지) 문구 | 고정 텍스트 | 계승 | + +> 시트별 상세 서식(틀 고정·자동 필터·조건부 서식·열 너비 상한)은 00b §9 의 실측 결함(freeze_panes 없음, 자동필터 없음, D열 너비 255.6) 을 근거로 `design/03-xlsx-report-spec.md` 에서 확정한다. + +--- + +## 부록 A. 출처 목록 + +**확인여부 범례**: ✅ = WebFetch/curl 로 실제 내용 확인 / ⚠️ = 접근했으나 본문 미노출(첨부·JS·바이너리) / ❌ = 404/403/차단 / 🔍 = 검색 결과에만 등장, 미열람 / 🚫 = 도구 사용 거부로 미수행 + +### A.1 한국 — 의약품안전나라 (nedrug.mfds.go.kr) + +| 제목 | URL | 확인 | +|---|---|---| +| 원료의약품등록(DMF) 공고 (★주 수집원) | `https://nedrug.mfds.go.kr/pbp/CCBAC03` | ✅ | +| 동 목록 GET | `https://nedrug.mfds.go.kr/pbp/CCBAC03/getList` | ✅ | +| 동 엑셀 POST | `https://nedrug.mfds.go.kr/pbp/CCBAC03/getExcel` | ✅ | +| 동 상세(비활성) | `https://nedrug.mfds.go.kr/pbp/CCBAC03/getItem?` | ⚠️ | +| 원료의약품등록(DMF) 정보 게시판 | `https://nedrug.mfds.go.kr/bbs/117` | ✅ | +| robots.txt | `https://nedrug.mfds.go.kr/robots.txt` | ✅ | +| 공공데이터 개요 | `https://nedrug.mfds.go.kr/cntnts/80` | ✅ | +| 의약품 상세정보 팝업 | `https://nedrug.mfds.go.kr/pbp/CCBBB01T/getItemDetail?itemSeq=200500904` | 🔍 | +| 품목허가현황 | `https://nedrug.mfds.go.kr/pbp/CCBAE01` | 🔍 | +| 업체정보 | `https://nedrug.mfds.go.kr/pbp/CCBBA01` | 🔍 | +| 기능성화장품제품정보(심사) | `https://nedrug.mfds.go.kr/pbp/CCBDB01` | 🔍 | +| (구)변경지시 | `https://nedrug.mfds.go.kr/pbp/CCBAQ03/getItem?bbsYn=Y&bbscttNo=3898&orderNo=` | 🔍 | +| eCTD 이용안내 | `https://nedrug.mfds.go.kr/cntnts/77` | 🔍 | +| 전자결제 및 수수료안내 | `https://nedrug.mfds.go.kr/cntnts/236` | 🔍 | +| 보도자료 | `https://nedrug.mfds.go.kr/bbs/119/968/` | 🔍 | +| 의약품등 정보검색 | `https://nedrug.mfds.go.kr/searchDrug?amp%3BsortOrder=false&%3BsearchYn=true&%3Bpage=1&%3BsearchDivision=detail&%3BitemName=%ED%94%84%EB%9E%9C%EB%93%9C%EB%A6%AC&%3BindutyClassCode=B0&%3BsearchConEe=AND&%3BsearchConUd=AND&%3BsearchConNb=AND` | 🔍 | +| 통합검색 | `https://nedrug.mfds.go.kr/search?keyword=...` | 🔍 | +| 홈 | `https://nedrug.mfds.go.kr/` | 🔍 | +| index | `https://nedrug.mfds.go.kr/index` | 🔍 | +| 회원가입 | `https://nedrug.mfds.go.kr/join` | 🔍 | +| 안전사용정보 | `https://nedrug.mfds.go.kr/safetyuseinfo` | 🔍 | +| 영문 사이트 | `https://nedrug.mfds.go.kr/eng/index` | 🔍 | +| (미확인 라우트) | `https://nedrug.mfds.go.kr/CCBAR01F012/getList/getInfo` | 🔍 | +| (미확인 라우트) | `https://nedrug.mfds.go.kr/PPL0000` | 🔍 | +| `$.download` 플러그인 | `https://nedrug.mfds.go.kr/resources/js/common-pbp.js` | 🔍 | + +### A.2 한국 — 식약처 본청 / 법령 / 정부 + +| 제목 | URL | 확인 | +|---|---|---| +| 식약처 저작권정책 | `https://www.mfds.go.kr/wpge/m_34/de010803l001.do` | ✅ | +| 「원료의약품 등록에 관한 규정」 고시전문 (제2024-89호) | `https://www.mfds.go.kr/brd/m_211/view.do?seq=14870&srchFr=&srchTo=&srchWord=&srchTp=&itm_seq_1=0&itm_seq_2=0&multi_itm_seq=0&company_cd=&company_nm=&Data_stts=A&page=1` | ✅ | +| 「원료의약품 등록에 관한 규정」 (구 게시글) | `https://www.mfds.go.kr/brd/m_211/view.do?seq=14569&...&page=1` | 🔍 | +| DMF 해설서 제3개정판 의견조회 | `https://www.mfds.go.kr/brd/m_74/view.do?seq=43076&...&page=1` | 🔍 | +| 동(다른 page 파라미터) | `https://mfds.go.kr/brd/m_74/view.do?seq=43076&...&page=66` | 🔍 | +| 「원료등록(DMF) 기간 대폭 단축!」 첨부 | `https://www.mfds.go.kr/brd/m_99/down.do?brd_id=ntc0021&seq=48245&data_tp=A&file_seq=2` | 🔍 | +| 「붙임1. 개별 항목 검증 룰」 | `https://www.mfds.go.kr/brd/m_1060/down.do?brd_id=data0011&seq=14313&data_tp=A&file_seq=2` | 🔍 | +| 식약처 공공자료 개방 | `https://www.mfds.go.kr/usr/opendata_13/list.do` | 🔍 | +| 식약처 홈 | `https://www.mfds.go.kr/` | 🔍 | +| 구 공고 원문 URL (2013) | `http://www.mfds.go.kr/index.do?mid=70&pageNo=1&seq=14766&cmd=v` | 🔍 | +| 의약품 전자민원창구 | `http://ezdrug.mfds.go.kr` | 🔍 | +| 국가법령정보센터 — 원료의약품 등록에 관한 규정 | `https://www.law.go.kr/%ED%96%89%EC%A0%95%EA%B7%9C%EC%B9%99/%EC%9B%90%EB%A3%8C%EC%9D%98%EC%95%BD%ED%92%88%EB%93%B1%EB%A1%9D%EC%97%90%EA%B4%80%ED%95%9C%EA%B7%9C%EC%A0%95` | ⚠️ | +| 동 (admRulSeq=2100000252602) | `https://law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000252602` | ⚠️ | +| 동 (admRulInfoP) | `https://www.law.go.kr/LSW/admRulInfoP.do?admRulSeq=2100000252602` | ⚠️ | +| 동 (admRulSeq=2100000150569) | `https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000150569` | 🔍 | +| 동 (admRulSeq=2100000198136) | `https://law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000198136` | 🔍 | +| 동 조문 팝업 | `https://www.law.go.kr/conAdmrulByLsPop.do?lsiSeq=217283&joNo=0031&joBrNo=02&datClsCd=010102&dguBun=DEG&lnkText=%EC%8B%9D%ED%92%88%EC%9D%98%EC%95%BD%ED%92%88%EC%95%88%EC%A0%84%EC%B2%98%EC%9E%A5%EC%9D%B4+%EC%A0%95%ED%95%98%EC%97%AC+%EA%B3%A0%EC%8B%9C%ED%95%98%EB%8A%94&admRulPttninfSeq=1526` | 🔍 | +| 정부24 — 원료의약품(등록신청, 등록사항 변경등록신청) | `https://www.gov.kr/mw/AA020InfoCappView.do?HighCtgCD=A09006&CappBizCD=14700000458&tp_seq=` | ✅ | +| 식품의약품안전평가원 KDMF | `https://www.nifds.go.kr/brd/m_87/list.do` | ✅ | +| 식품의약품안전평가원 홈 | `https://www.nifds.go.kr/` | 🔍 | + +### A.3 한국 — 공공데이터 + +| 제목 | URL | 확인 | +|---|---|---| +| 식품의약품안전처_원료의약품등록(DMF)현황 | `https://www.data.go.kr/data/15057075/openapi.do` | ✅ | +| 동 API 엔드포인트(서비스) | `https://apis.data.go.kr/1471000/MdcDmfInfoService01` | ✅(명세) | +| 동 오퍼레이션 | `http://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01` | ✅(명세) | +| 식의약 데이터 포털 | `https://data.mfds.go.kr/` | 🔍 | +| 공공데이터 상세 | `https://data.mfds.go.kr/OPCAA01F01` | 🚫 | +| 공공데이터 검색 (taskDivsCd=2) | `https://data.mfds.go.kr/OPCAA01F01/search?selectedTab=tab1&taskDivsCd=2&taskDivsDtlCd=3&srchSrvcKorNm=&btnSearch=` | 🔍 | +| 공공데이터 검색 (taskDivsCd=3) | `https://data.mfds.go.kr/OPCAA01F01/search?selectedTab=tab1&taskDivsCd=3&taskDivsDtlCd=7&rchSrvcKorNm=&btnSearch=` | 🔍 | +| 공공데이터포털 식약처 데이터 목록 | `https://www.data.go.kr/tcs/dss/selectDataSetList.do?keyword=%22%EC%8B%9D%ED%92%88%EC%9D%98%EC%95%BD%ED%92%88%EC%95%88%EC%A0%84%EC%B2%98%22&recmSe=N` | 🔍 | +| (참고) 의약품 낱알식별 정보 | `https://www.data.go.kr/data/15057639/openapi.do?recommendDataYn=Y` | 🔍 | +| (참고) 건강기능식품 영양DB | `https://www.data.go.kr/data/15085712/openapi.do` | 🔍 | +| (참고) 구 데이터셋 URL 형식 | `https://www.data.go.kr/dataset/15020627/openapi.do` | 🔍 | + +### A.4 한국 — 협회·연구기관·언론 + +| 제목 | URL | 확인 | +|---|---|---| +| KPBMA — DMF 해설서 제5개정판 (PDF 118p) | `https://www.kpbma.or.kr/api/info/law/policy/download/211364` | ✅(바이너리 추출) | +| KPBMA — 「원료의약품 등록에 관한 규정」 일부개정 알림 | `https://www.kpbma.or.kr/info/law/statute/select/210036` | ✅ | +| KHIDI — 등록대상 원료의약품(DMF) 등록 공고(7월 둘째주) | `https://www.khidi.or.kr/board/view?pageNum=48&rowCnt=10&menuId=MENU01872&maxIndex=00487793189998&minIndex=00487441479998&schType=0&schText=&categoryId=&continent=&country=&upDown=0&boardStyle=&no1=912&linkId=26604564` | ✅ | +| KHIDI — [지침]원료의약품 등록(DMF) 처리 절차 | `https://www.khidi.or.kr/board/view?pageNum=1&rowCnt=10&menuId=MENU01872&maxIndex=99999999999999&minIndex=99999999999999&schType=0&upDown=0&no1=0&linkId=26605812` | ⚠️ | +| KHIDI — 원료의약품 등록에 관한 규정 (제2021-8호) | `https://www.khidi.or.kr/board/view?pageNum=28&rowCnt=10&menuId=MENU01872&maxIndex=&minIndex=&schType=0&schText=&categoryId=&continent=&country=&upDown=0&boardStyle=&no1=3182&linkId=48852840` | ✅ | +| KDI — 원료의약품 등록 제도(DMF) 꼼꼼히 알기! | `https://eiec.kdi.re.kr/policy/materialView.do?datecount=&num=209012&pg=&pp=20&recommend=&topic=L` | ✅ | +| KDI — 원료의약품 등록제도(DMF), 궁금하면 클릭하세요! | `https://eiec.kdi.re.kr/policy/materialView.do?num=196213&topic=L&pp=20` | ✅ | +| KDI — 식약처 공문서, '의약품안전나라'로 쉽고 편하게 받으세요! | `https://eiec.kdi.re.kr/policy/materialView.do?num=230838` | 🔍 | +| 관세무역개발원 — 원료의약품 등록 기간 '120일→20일' | `https://www.kctdi.or.kr/kctdi/research/research11.do?mode=view&articleNo=3707&title=%EC%9B%90%EB%A3%8C%EC%9D%98%EC%95%BD%ED%92%88+%EB%93%B1%EB%A1%9D+%EA%B8%B0%EA%B0%84+%27120%EC%9D%BC%E2%86%9220%EC%9D%BC%27%EB%A1%9C+%EB%8C%80%ED%8F%AD+%EB%8B%A8%EC%B6%95` | 🔍 | +| KCIA — 2021년 자주하는 질문집(의약품 분야) PDF | `https://kcia.or.kr/inc/down.php?dir=BOARD&file_name=202111_163704850587622_2.pdf&rename=%EB%B6%99%EC%9E%84_2021%EB%85%84+%EC%9E%90%EC%A3%BC%ED%95%98%EB%8A%94+%EC%A7%88%EB%AC%B8%EC%A7%91(%EC%9D%98%EC%95%BD%ED%92%88+%EB%B6%84%EC%95%BC).pdf` | 🔍 | +| 데일리팜 — 규제완화 여파...원료약 등록 1년새 237→653건 | `https://www.dailypharm.com/Users/News/NewsView.html?ID=324241` | ✅ | +| 데일리팜(모바일) — 필수약·공급중단 보고대상 의약품 'DMF 등록' 유예 | `http://m.dailypharm.com/newsView.html?ID=295979` | ✅ | +| 히트뉴스 — 수입 원료의약품 등록, 이제 'GMP 증명서'만 있으면 OK | `https://www.hitnews.co.kr/news/articleView.html?idxno=54607` | ✅ | +| 히트뉴스 — "국민·업자 원하는 대로…" 의약품안전나라, 탈바꿈 | `https://www.hitnews.co.kr/news/articleView.html?idxno=16548` | ✅ | +| 약사공론 — 약가제도 개편 무엇이 바뀌고, 어떻게 준비해야 하나 | `https://www.kpanews.co.kr/article/show.asp?idx=211278&category=C` | 🔍 | +| 코멘토 — 제약 RA 현직자 실무 체험 | `https://comento.kr/edu/learn/%EC%97%B0%EA%B5%AC%EA%B0%9C%EB%B0%9C/%EC%9D%B8%ED%97%88%EA%B0%80%EA%B0%9C%EB%B0%9C-G676` | 🔍 | +| 비즈니스피플 — [중견사]원료 의약품 RA 채용 | `https://www.bzpp.co.kr/biz/businessDetailView/BR250423A00015` | 🔍 | +| 질병관리청 — 약품/식품정보 | `https://health.kdca.go.kr/healthinfo/biz/health/gnrlzHealthInfo/healthInfo/medcinFoodInfoMain.do` | 🔍 | + +### A.5 미국 (FDA) + +| 제목 | URL | 확인 | +|---|---|---| +| Drug Master Files (DMFs) | `https://www.fda.gov/drugs/forms-submission-requirements/drug-master-files-dmfs` | ❌ 404 | +| List of Drug Master Files (DMFs) | `https://www.fda.gov/drugs/drug-master-files-dmfs/list-drug-master-files-dmfs` | ❌ 404 | +| Drug Master Files (DMFs) — cacmap 미러 | `https://cacmap.fda.gov/drugs/forms-submission-requirements/drug-master-files-dmfs` | 🔍 | +| List of DMFs — cacmap 미러 | `https://cacmap.fda.gov/drugs/gdufa-ii-drug-master-files-dmfs/list-drug-master-files-dmfs` | ❌ 403 | +| (레거시 URL) | `https://www.fda.gov/drugs/developmentapprovalprocess/formssubmissionrequirements/drugmasterfilesdmfs/default.htm` | 🔍 | +| FDA media download | `https://www.fda.gov/media/186970/download` | 🔍 | +| 봇 차단 안내 페이지 | `https://www.fda.gov/apology_objects/abuse-detection-apology.html` | ✅(차단 확인) | +| John Snow Labs — FDA DMF Directory | `https://www.johnsnowlabs.com/marketplace/fda-drug-master-files-directory/` | ✅ | +| PharmaCompass — US DMF Database | `https://www.pharmacompass.com/us-drug-master-files-dmfs` | ❌ 403 | +| dmf-list 미러 | `https://dmf-list.backgroundscheck.info/` | 🔍 | +| FDApals — DMF 서비스 | `https://fdapals.com/services/dmf-fda-guidance/` | 🔍 | +| GMP Navigator — Drug Master Files (2006) PDF | `https://www.gmp-navigator.com/files/guidemgr/Drug_Master_Files_Current_Information_2006.pdf` | 🔍 | +| Duke Medical Library 블로그 | `https://mclibrary.duke.edu/about/blog/2014-12-04/fda-drug-master-files` | ❌ 404 | +| Wikipedia — Drug Master File | `https://en.wikipedia.org/wiki/Drug_Master_File` | ✅ | +| Scribd — Drug Master File 프레젠테이션 | `https://www.scribd.com/presentation/672546507/Drug-Master-File` | 🔍 | + +### A.6 유럽 (EDQM) + +| 제목 | URL | 확인 | +|---|---|---| +| EDQM Certification Database (Certificates catalogue) | `https://extranet.edqm.eu/publications/recherches_CEP.shtml` | ✅ | +| **CEP 전량 다운로드 (TSV)** | `https://extranet.edqm.eu/4DLink1/4DCGI/EXPORT_WEB_CEP.txt` | ✅ | +| Databases | `https://www.edqm.eu/en/databases` | ❌ 403 | +| Deployment of a new EDQM CEP database feature: full download of CEP data | `https://www.edqm.eu/en/-/deployment-of-a-new-edqm-cep-database-feature-full-download-of-cep-data` | ❌ 403 | +| Certification of Suitability (CEP) | `https://www.edqm.eu/en/certification` | 🔍 | +| What is the CEP 2.0? | `https://www.edqm.eu/en/what-is-the-cep-2.0` | 🔍 | +| Certification Policy Documents & Guidelines | `https://www.edqm.eu/en/certification-policy-documents-guidelines` | 🔍 | +| Actions on CEPs | `https://www.edqm.eu/en/actions-on-ceps` | 🔍 | +| KNOWLEDGE Database | `https://www.edqm.eu/en/knowledge-database` | 🔍 | +| FAQ — How do I know if I have the latest valid version of a CEP? | `https://faq.edqm.eu/pages/viewpage.action?pageId=1377010` | 🚫 | +| User Guide for Certification on-Line Database (PA_PH_CEP (23) 56, May 2024) | `https://www.scribd.com/document/849026918/User-Guide-for-Certification-on-Line-Database-PA-PH-CEP-23-56-May-2024` | 🔍 | +| PharmaCompass — CEP/COS Database | `https://www.pharmacompass.com/certificates-of-suitability-products-cep` | 🔍 | +| LinkedIn — EDQM CEP Database 소개 | `https://www.linkedin.com/posts/regulatory-affairs-insights_regulatoryaffairs-edqm-cep-activity-7375459258387042304-Xp8S` | 🔍 | +| Wikipedia — EDQM | `https://en.wikipedia.org/wiki/European_Directorate_for_the_Quality_of_Medicines_%26_HealthCare` | 🔍 | + +### A.7 일본 (PMDA) + +| 제목 | URL | 확인 | +|---|---|---| +| 原薬等登録原簿(MF)の公示について | `https://www.pmda.go.jp/review-services/drug-reviews/master-files/0008.html` | ✅ | +| **MF 공시 Excel** | `https://www.pmda.go.jp/files/000240884.xlsx` | ✅ | +| MF 공시 PDF | `https://www.pmda.go.jp/files/000240885.pdf` | 🔍 | +| 原薬等登録原簿(MF) 개요 | `https://www.pmda.go.jp/review-services/drug-reviews/master-files/0007.html` | ✅ | +| MF의 登録整理届書について | `https://www.pmda.go.jp/review-services/drug-reviews/master-files/0003.html` | 🔍 | +| 영문 Q&A/실시 가이드 | `https://www.pmda.go.jp/files/000280630.pdf` | 🔍 | +| MF 신청·수속 최신 유의사항 | `https://www.pmda.go.jp/files/000237522.pdf` | 🔍 | +| 医薬品基準課MF管理室 자료 | `https://www.pmda.go.jp/files/000227202.pdf` | 🔍 | +| 日本ジェネリック製薬協会 — MF登録について | `https://www.jga.gr.jp/jgapedia/deals/_19341.html` | 🔍 | +| 日本製薬工業協会 — MF 신뢰성보증 | `https://www.jpma.or.jp/information/quality/jirei/bbh7c90000000jbr-att/2023-3.pdf` | 🔍 | +| Wikipedia — PMDA | `https://en.wikipedia.org/wiki/Pharmaceuticals_and_Medical_Devices_Agency` | 🔍 | + +### A.8 참고·법제 일반 / 검색 노이즈 + +| 제목 | URL | 확인 | 비고 | +|---|---|---|---| +| Wikipedia — Korea Open Government License | `https://en.wikipedia.org/wiki/Korea_Open_Government_License` | 🔍 | 공공누리 | +| Wikipedia — Copyright law of South Korea | `https://en.wikipedia.org/wiki/Copyright_law_of_South_Korea` | 🔍 | | +| Wikipedia — Ministry of Food and Drug Safety | `https://en.wikipedia.org/wiki/Ministry_of_Food_and_Drug_Safety` | 🔍 | | +| Wikipedia — Approved Drug Products with Therapeutic Equivalence Evaluations | `https://en.wikipedia.org/wiki/Approved_Drug_Products_with_Therapeutic_Equivalence_Evaluations` | 🔍 | Orange Book | +| Wikipedia — DMF (동음이의) | `https://en.wikipedia.org/wiki/DMF` | 🔍 | 노이즈 | +| Wikipedia — Distribution Media Format | `https://en.wikipedia.org/wiki/Distribution_Media_Format` | 🔍 | 노이즈 | +| Wikipedia — Dimethylformamide | `https://en.wikipedia.org/wiki/Dimethylformamide` | 🔍 | 노이즈 | +| Wikipedia — 3,5-Difluoromethcathinone | `https://en.wikipedia.org/wiki/3,5-Difluoromethcathinone` | 🔍 | 노이즈 | +| Wikipedia — α-Difluoromethyl-DOPA | `https://en.wikipedia.org/wiki/%CE%91-Difluoromethyl-DOPA` | 🔍 | 노이즈 | +| Wikipedia — Non-racemic MDMA | `https://en.wikipedia.org/wiki/Non-racemic_MDMA` | 🔍 | 노이즈 | +| Wikipedia — NED-19 | `https://en.wikipedia.org/wiki/NED-19` | 🔍 | 노이즈 | +| Wikipedia — Urimalsaem | `https://en.wikipedia.org/wiki/Urimalsaem` | 🔍 | 노이즈 | +| ResearchGate — DMF-scMT-seq | `https://www.researchgate.net/publication/380826072_DMF-scMT-seq_linking_methylome_and_transcriptome_within_single_cells_with_digital_microfluidics` | 🔍 | 노이즈(약어 충돌) | + +**총 보존 URL 수: 100개** (중복 제거 기준) + +--- + +## 부록 B. 미해결 질문 / 실측 필요 항목 + +### B.1 데이터 스키마 확정을 위한 필수 실측 + +- [ ] **`취소/취하구분(cancelCodeNm)` 의 전체 코드값 집합** — `정상` 외에 어떤 문자열이 오는가? (`취하`, `취소`, `자진취하`, `직권취소` …) → 전량 엑셀에서 `SELECT DISTINCT` 수행 +- [ ] **`대상의약품(noticeCode)` 의 전체 값 집합** — `별표1`, `신물질` 외에 `별표1의2`(한약(생약)), `인태반` 값이 존재하는가? +- [ ] **`최종변경일자` ↔ `yrycReportNDate` 매핑 검증** — 정렬키를 실제로 눌러 응답 순서가 바뀌는지 확인 (`sort=yrycReportNDate&sortOrder=false`) +- [ ] **`대상의약품` ↔ `noticeCode` 매핑 검증** — 동일 방법 +- [ ] **`문서번호(dmfVersion)` 의 값 패턴** — `v0.0.0/2026` 외 형식(`v1.0.0/2025` 등)이 존재하는가? 변경 시 실제로 증가하는가? +- [ ] **`연계심사문서번호(cntcJdgmnNo)` 가 채워진 행의 비율과 형식** +- [ ] **신물질 등록번호 포맷의 전수 조사** — `수6580-16-ND(20)` 외 접두어(`제`, `허` 등)와 변형이 몇 종인가? `대상의약품 == '신물질'` 인 행 전량 추출해 정규식 커버리지 확인 +- [ ] **등록번호 알파벳군의 실제 관측 집합** — A~K 전부 존재하는가? 문서에 없는 군(예: `L`)이 있는가? +- [ ] **다중 제조소 행의 구분자 규칙** — 화면 HTML 중첩 `` 개수와 엑셀 셀 내 콤마 개수가 일치하는가? (엑셀은 `,` 로 병합되어 주소 내부 콤마와 충돌할 수 있음) + +### B.2 수집 파이프라인 검증 + +- [ ] **`getExcel` POST 의 최소 필수 파라미터 집합 확정** — 원 실측에서 사용한 정확한 POST body 가 로그에서 잘렸다. `dmfAcceptDateStart`/`End` 만으로 충분한지, `limit` 값이 결과 건수에 영향을 주는지 A/B 검증 +- [ ] `limit=10000` 이 아니라 `limit=100000` 이어도 서버가 전량을 반환하는가? 상한선은? +- [ ] **세션 쿠키 없이 `getExcel` POST 가 성공하는가?** (`fileDownloadToken` 쿠키의 역할) +- [ ] **`/pbp/CCBAC03/getItem?dmfSeq=N` 라우트가 실제로 살아 있는가?** 살아 있다면 상세 페이지에 목록에 없는 추가 필드(자료 미첨부 사실 = 규칙 제16조 5호 등)가 있는가? +- [ ] `sort`/`sortOrder` 파라미터로 **`최초등록일자 DESC` 정렬 후 상위 N건만 받는 경량 모드**가 가능한가? (일일 신규 감지용 최적화) +- [ ] 엑셀 `Sheet0` 의 `sharedStrings.xml` 사용 여부 — 실측에서는 `inlineStr` 만 관측됨. 데이터가 커지면 sharedStrings 로 바뀔 가능성 대비 +- [ ] **공공데이터포털 API `numOfRows` 상한** (명세상 3자리 = 999?) 및 실제 `totalCount` 가 9,840과 일치하는지 + +### B.3 법령·제도 확인 + +- [ ] **「원료의약품 등록에 관한 규정」 제2024-89호 전문(.hwpx) 다운로드 후 조문 확인** — 본 문서 §2.4·§2.5 의 조문 인용은 2020년 해설서 재수록본이므로 현행성 검증 필요 +- [ ] **제5조 처리기한**: 해설서의 `17주/13주` vs MaPP의 `20일/90일` — 현행 고시의 실제 문구는? +- [ ] **등록 취하·취소에 관한 명시 조문** — 규칙/고시 어디에도 "취소" 사유 조항을 확인하지 못했다. 약사법 제31조의2에는 취소 규정이 없다. 화면 컬럼 `취소/취하구분`의 법적 근거는? +- [ ] **[별표1] 성분 목록 전체(1~211호) 확보** — 등록번호 `ingr_no` 를 성분명으로 역매핑하려면 필수. 현행 고시 별표1 파싱 필요 +- [ ] **[별표1의2] 한약(생약) 성분 목록(1~19호) 확보** +- [ ] 「원료의약품 등록(DMF) 처리 절차」 지침(2017-03-30) HWP 첨부 확보 — 등록번호 부여 방식의 공식 절차 문서 +- [ ] **DMF 해설서 최신판(제6개정판 이상)이 존재하는가?** 현재 확보본은 제5개정판(2020.12.) +- [ ] MaPP 지침서-0943-03 (2025.2.20. 6개정) 원본 PDF 출처 URL 확보 (현재는 텍스트만 보유) + +### B.4 정책·법적 리스크 + +- [ ] **nedrug.mfds.go.kr 이용약관 전문 확인** — 자동수집 금지 조항 유무 +- [ ] **DMF 공고 데이터의 공공누리 유형(1~4유형) 명시 확인** — `/cntnts/80` 에 유형 표기가 없었다 +- [ ] **`robots.txt` 전면 차단과 공공데이터 개방 정책의 충돌**에 대해 식약처 문의(민원 1577-1255 / 기술지원 1544-9563) 후 공식 답변 확보 권장 +- [ ] "내용에 대한 임의 가공 금지" 조항의 해석 범위 — 파생 지표(집계·랭킹) 생성이 '가공'에 해당하는가? + +### B.5 해외 소스 + +- [ ] **FDA DMF 목록 xlsx 의 정확한 다운로드 URL** — WAF 우회(Playwright) 후 확인 필요. 검색 스니펫상 "current through DMF 043437", "last update September 30, 2025", "DMFs received by December 31, 2025" +- [ ] FDA 목록의 **원본 컬럼명** (John Snow Labs 재가공본이 아닌 FDA 원본 헤더) +- [ ] FDA DMF **status 코드 `A`/`I`** 의 정확한 값 표기 +- [ ] **PMDA xlsx 파일명 변경 규칙** — `000240884.xlsx` 는 공시 회차마다 바뀐다. 페이지 파싱으로 최신 링크를 추출하는 로직(§8.5 `find_pmda_xlsx_url`)의 안정성 검증 +- [ ] PMDA `登録区分` 의 전체 값 집합 (`医薬品等原薬` 외 医療機器·再生医療等製品·体外診断用医薬品 관련 값) +- [ ] EDQM `Status CEP` 의 전체 값 집합 (`Withdrawn by Holder`, `Expired` 외 `Valid`, `Suspended` 등) +- [ ] EDQM `Type CEP` 의 전체 값 집합 (`TSE` 외 `Chemical purity`, `Herbal` 등) +- [ ] EDQM 다운로드 파일의 **총 행 수** (1,459,534 bytes 인데 행 수 미집계) + +### B.6 리포트/운영 + +- [ ] 워치리스트 초기값을 누가 어떻게 채울 것인가 (사용자 입력 UI vs YAML 직접 편집) +- [ ] Windows 11 기본 콘솔 cp949 문제 → `PYTHONIOENCODING=utf-8` 을 스케줄러 작업에 확실히 주입하는 방법 검증 +- [ ] 전일 스냅샷이 없는 최초 실행일(cold start) 처리 — 전량을 `NEW` 로 볼 것인가, 이벤트 없이 기준선만 세울 것인가 (**후자 권장**) +- [ ] 9,840행 × 28컬럼 xlsx 리포트의 생성 시간·파일 크기 목표치 설정 + +--- + +## 이 문서와 확정 설계의 관계 + +`design/00-DATA-SOURCE-DECISION.md`(API 채택 결정)와 `design/00b-baseline-data-analysis.md`(기존 xlsx 실측)에 따라, 이 문서 각 절의 지위를 정리한다. + +| 절 | 상태 | 비고 | +|---|---|---| +| 0. 한눈에 보기 | ⚠️ 부분 반증 / 부분 미채택 | 불릿별 정정 표시 참조. "API 단독 불충분" 결론은 ❌ 반증, "CCBAC03 주 수집원"·"절제된 수집 정책"·"14+9 필드 확정"은 ⚠️ 미채택, 나머지는 ✅ 유효 | +| 2. DMF 제도 개요 (2.1~2.9) | ✅ 유효 | 법령·연혁·처리기한·통계는 데이터 소스와 무관하게 그대로 유효 | +| 3. 등록번호 체계 완전 해부 | ✅ 유효 | 파싱 규칙·정규식·`GROUP_TABLE` 이 API 의 `dmf_permit_no` 필드에도 그대로 적용됨(§12.1) | +| 4. CCBAC03 화면 구조 조사 | ⚠️ 참고자료로 격하 (수집 대상 아님) | §4.5·§4.6 의 화면 컬럼↔서버 필드 대응관계만 유효. 엑셀 POST/HTML 파싱 코드(§4.9·§4.10)는 구현 대상 아님 | +| 5. `/bbs/117` 주간 공고 게시판 | ✅ 유효(참고, 원문 결론 그대로) | 갱신 중단 사실과 "일일 크롤링 대상에서 제외"라는 원 결론이 그대로 유지됨. 애초에 화면 자체를 수집하지 않으므로 이 결론과 상충하지 않음 | +| 6. 공공데이터포털 OpenAPI | ⚠️ 지위 격상 — 명세는 유효, "보조·검증용" 결론만 반증 | §6.1~6.5 의 API 명세·파라미터·응답 필드·에러코드는 그대로 유효하고 지금은 **유일 수집원**이다. §6.6 "주 수집원 ✕" 표는 반증됨(design/00b §6) | +| 7. 식의약 데이터 포털/기타 국내 채널 | ✅ 유효 | | +| 8. 해외 DMF 소스 (FDA/EDQM/PMDA) | ✅ 유효 | v1 범위 결정(§8.6)도 그대로 유효 | +| 9. robots.txt·저작권·이용약관 | ✅ 유효(사실) / ⚠️ 미채택(실행 정책) | 실측 사실과 법적 검토(저작권법 제24조의2 등)는 유효. §9.6 "절제된 수집" 실행 정책은 HTML 을 크롤링하지 않으므로 적용 대상이 없음 | +| 10. 제약사 RA 실무자의 모니터링 관점 | ✅ 유효 | 4축 구조와 이벤트 타입 정의는 유효하나, 이벤트 판정 조건(§10.3)의 일부(`SITE_CHANGED`·`APPLICANT_CHANGED`·`DOC_VERSION_CHANGED`·`LINKED_REVIEW_SET` 등 화면 전용 필드 기반)는 §12 의 API 기반 `status` 판정으로 대체됨 | +| 11. 언론·유사 서비스가 DMF를 다루는 방식 | ✅ 유효 | | +| 12. 수집 대상 필드 확정안 | ❌ 전면 재작성됨 | CCBAC03 14컬럼 기준에서 API 7필드 + 파생 9필드 기준으로 교체(design/00b 근거) | +| 부록 A. 출처 목록 | ✅ 유효 | 조사에 사용된 URL 전량 보존 | +| 부록 B. 미해결 질문 / 실측 필요 항목 | ⚠️ 부분 해소 | B.1 의 "등록번호 유일성", "발급일자 성격" 은 design/00b 로 해소됨. B.2(`getExcel` 파라미터 등 화면 수집 관련)는 미채택 경로라 더 이상 우선순위 아님 | + +**미작성 절 확인**: §1 목차가 예고한 §2~§12·부록 A·부록 B 전 절이 본문에 실제로 존재함을 확인했다(2026-09-02 재확인). 누락된 절은 없다. + +--- + +> **문서 이력**: v1.0 (2026-09-02 작성). 원천 raw dump: `agent-aefc6c5fd48ce96ef.md` (1,755줄) + 동 세션 수집 아티팩트(`ccbac03.html` 400,143 bytes, `bbs117.html` 382,506 bytes, `datago.html` 204,048 bytes, `dmf_excel2.xlsx` 1,379,451 bytes, `edqm_export.txt` 1,459,534 bytes, `pmda_mf.xlsx` 428,927 bytes, `haeseolseo.txt` 262,458 bytes = DMF 해설서 제5개정판 PDF 118p 추출, `mfds_change.txt` 53,007 bytes = MaPP 지침서-0943-03 추출). v1.1 (2026-09-02 정정) — `design/00b-baseline-data-analysis.md` 실측 반영, §0 반증/미채택 표시, §4 제목 변경, §12 전면 재작성, 관계 표 추가. diff --git a/docs/research/02-benchmark-github-projects.md b/docs/research/02-benchmark-github-projects.md new file mode 100644 index 0000000..65f166e --- /dev/null +++ b/docs/research/02-benchmark-github-projects.md @@ -0,0 +1,3003 @@ +# 유사 프로젝트 벤치마킹과 채택 구조 + +> **이 문서의 역할**: DMF_Crawler 를 스크래치에서 만들기 전에, 실제로 존재가 확인된 GitHub 저장소 37개 이상을 범주별로 해부하여 "무엇을 베끼고 무엇을 버릴지"를 확정하고, 그 근거 위에서 최종 디렉터리 구조와 모듈 경계(fetch / parse / diff / store / report / notify / orchestrate)를 SSOT 로 못박는 문서다. + +--- + +## 0. 한눈에 보기 + +이 문서에서 내린 결론(상세 근거는 각 섹션에): + +- **한국 식약처(MFDS) DMF 전용 오픈소스는 존재하지 않는다.** 30회 이상의 검색과 68회의 실제 페이지 fetch 로도 `nedrug`/`MFDS`/`DMF` 를 대상으로 한 크롤러 저장소를 하나도 찾지 못했다. 가장 근접한 것이 `Q00/data.go.kr-crawling`(★4, DUR 품목정보 API → xlsx)뿐이다. **즉 fetch/parse 계층은 우리가 직접 쓴다. 베낄 것은 "구조"이지 "코드"가 아니다.** +- **1차 데이터 소스는 스크래핑이 아니라 공공 API 로 확정한다.** `식품의약품안전처_원료의약품등록(DMF)현황` OpenAPI 엔드포인트 `https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01` 가 `DMF_PERMIT_NO`, `INGR_KOR_NAME`, `ENTP_NAME`, `MNFCTR_NAME`, `MNFCTR_PLACE`, `MANUF_COUNTRY_CODE_NM`, `DMF_PERMIT_DATE` 를 JSON/XML 로 준다. 공고 게시판(`nedrug.mfds.go.kr/bbs/117`, 총 710건)은 API 가 놓치는 "변경/취하" 서사를 보완하는 2차 소스다. +- **아키텍처의 정본 레퍼런스는 `mrueda/nomenclator-delta`다.** 스페인 보건부 의약품 목록의 월간 델타를 추적하는 프로젝트로, `수집(collection) → 정규화(normalization) → 디핑(diffing) → 검증(validation)` 이라는 4단계 모듈 분리와 `data/`(스냅샷 + 변경 이력) 레이아웃이 우리 요구사항과 1:1 로 대응한다. **모듈 경계는 이걸 그대로 채택한다.** +- **범용 변경감지 도구(changedetection.io ★33.5k, urlwatch ★3.1k, huginn ★49.9k)는 "도입"하지 않고 "설계 개념만" 가져온다.** 이들은 텍스트 블록 diff 도구지 레코드 키 기반 diff 도구가 아니다. 우리에게 필요한 것은 `DMF_PERMIT_NO` 를 키로 한 added/removed/changed 판정이고, 그 형태는 `larsyencken/csvdiff`(★131, `_index`/`added`/`removed`/`changed` JSON 구조)가 정답이다. 다만 csvdiff 는 2021-02-18 아카이브되었으므로 **JSON 스키마만 채택하고 구현은 자체 작성**한다. +- **운영 방식은 "Windows Task Scheduler(schtasks) + 상주 워치독" 2단 구조로 간다.** `michalzobec/autorunsalerts` 가 검증한 패턴 — SYSTEM 컨텍스트 스캔 태스크와 사용자 컨텍스트 토스트 태스크를 분리 — 를 그대로 채택한다. 토스트는 SYSTEM/서비스 세션에서 뜨지 않기 때문이다(BurntToast 문서가 명시). +- **서비스화는 WinSW(★14.3k, `v2.12.0` 안정)를 1순위로 한다.** `` + `1 hour` + `Automatic` + `` 이 XML 한 장에 선언적으로 들어가고 .NET 외 런타임 의존이 없다. NSSM(★1.2k, v2.24 / 2014-08-31)은 대안, pywin32 서비스는 기각(복구 액션이 트리거되지 않는 알려진 결함 — pywin32 issue #1563). +- **AI CLI headless 호출 규약은 `claude -p` / `gemini -p` / `codex exec` 3종의 공통 패턴을 `agy -p` 로 사상한다**: (1) 프롬프트를 파일로 빼서 `-p "$(cat prompt.txt)"`, (2) `--output-format json` + JSON Schema 로 구조화 출력 강제, (3) stdout 을 파일로 리다이렉트 후 파싱, (4) 종료코드로 분기. `agy` 자체의 플래그는 raw dump 에서 확인되지 않았으므로 **⚠️ 미검증**이며 부트스트랩 단계에서 `agy --help` 로 실측해야 한다. +- **리포트는 xlsxwriter 로 만들고, 탭 간 연동은 `write_url(row, col, 'internal:Sheet2!A1')` 로 구현한다.** openpyxl 대신 xlsxwriter 를 쓰는 이유는 `add_table()` / `conditional_format()` / 내부 하이퍼링크가 한 API 로 깔끔하게 나오기 때문이다(`Bwhiz/Auto-Excel-Reports` 는 openpyxl 예시이나 서식 품질 요구가 우리보다 낮다). +- **알림은 Apprise(★17.2k) 한 겹으로 추상화한다.** `windows://`(pywin32 필요, 250자 제한, 같은 PC 한정), `tgram://bottoken/ChatID`, `slack://`, `mailto://` 를 URL 문자열 하나로 갈아끼울 수 있다. 다만 리치 토스트(버튼/이미지)는 `win11toast`(★333) 로 직접 호출하는 이중 경로를 둔다. +- **최종 디렉터리 구조는 `src/dmf_crawler/{fetch,parse,diff,store,report,notify,orchestrate}` + `data/{raw,snapshots,history}` + `ops/{winsw,tasks,watchdog}` + `config/sources.yaml` 이다.** 각 경로의 출처는 §10 에 저장소별로 명기했다. + +--- + +## 1. 목차 + +- [0. 한눈에 보기](#0-한눈에-보기) +- [1. 목차](#1-목차) +- [2. 조사 방법과 신뢰도 표기 규칙](#2-조사-방법과-신뢰도-표기-규칙) +- [3. 전체 저장소 인덱스 (실존 확인 37건)](#3-전체-저장소-인덱스-실존-확인-37건) +- [4. (a) 한국 식약처 / 공공데이터 크롤러](#4-a-한국-식약처--공공데이터-크롤러) +- [5. (b) FDA / openFDA / 규제 데이터](#5-b-fda--openfda--규제-데이터) +- [6. (c) 규제 변경 감지 · 인텔리전스](#6-c-규제-변경-감지--인텔리전스) +- [7. (d) 범용 변경 감지 도구](#7-d-범용-변경-감지-도구) +- [8. (e) 크롤링 → 엑셀/시트 리포트 파이프라인](#8-e-크롤링--엑셀시트-리포트-파이프라인) +- [9. (f) AI CLI headless 자동화](#9-f-ai-cli-headless-자동화) +- [10. (g) Windows 서비스화 · 워치독 · 토스트](#10-g-windows-서비스화--워치독--토스트) +- [11. (h) awesome 리스트 및 기타 참고](#11-h-awesome-리스트-및-기타-참고) +- [12. 채택 결정 표 (채택 / 부분채택 / 기각)](#12-채택-결정-표-채택--부분채택--기각) +- [13. 최종 디렉터리 구조 제안](#13-최종-디렉터리-구조-제안) +- [14. 모듈 경계 제안과 입출력 계약](#14-모듈-경계-제안과-입출력-계약) +- [15. 데이터 소스 실측 정보 (DMF API / 공고 게시판)](#15-데이터-소스-실측-정보-dmf-api--공고-게시판) +- [부록 A. 출처 목록](#부록-a-출처-목록) +- [부록 B. 미해결 질문 / 실측 필요 항목](#부록-b-미해결-질문--실측-필요-항목) + +--- + +## 2. 조사 방법과 신뢰도 표기 규칙 + +원본 리서치는 WebSearch 33회 + WebFetch 68회로 수행되었다(WebSearch 예산 200/200 소진으로 #31, #32, #33 검색은 미수행). 본 문서는 그 raw dump 를 손실 없이 정제한 것이다. + +| 표기 | 의미 | +|---|---| +| **[F#n]** | raw dump 의 `[FETCH #n]` — 해당 페이지를 실제로 열어 확인함 (verified_by_fetch=true) | +| **[S#n]** | raw dump 의 `[SEARCH #n]` — 검색 결과 링크로만 등장. 페이지를 직접 열지 않음 | +| **⚠️ 미검증** | 존재/수치를 직접 확인하지 못함. 삭제하지 않고 남기되 구현 전 실측 필요 | + +스타 수·커밋 수는 **2026-09-02 조사 시점 기준**이며, GitHub 페이지에 "last commit date" 가 텍스트로 노출되지 않은 경우 커밋 총수로 대체 기록했다(원 dump 가 그렇게 기록했으므로 그대로 보존). + +--- + +## 3. 전체 저장소 인덱스 (실존 확인 37건) + +아래는 **WebFetch 로 저장소 페이지를 실제로 열어 확인한** 항목이다. 범주 기호는 원 조사 항목 (a)~(h) 를 따른다. + +| # | 저장소 | URL | ★ | Fork | 언어 | 최근 활동 | 무엇을 하는가 | **이 프로젝트에서 정확히 무엇을 베낄 것인가** | +|---|---|---|---|---|---|---|---|---| +| 1 | `Q00/data.go.kr-crawling` | https://github.com/Q00/data.go.kr-crawling | 4 | 0 | Python | development 브랜치 55 commits | 건강정보·의약품 크롤링, gevent 멀티스레딩 표방 | `config.py.example` 로 API 키를 코드 밖으로 빼는 패턴, `column.py` 로 응답 필드명↔한글명 매핑을 **생성해서 파일로 떨구는** 아이디어, `page = int(totalCount/100) + 1` 페이지네이션 공식 | +| 2 | `jjscan/data.go.kr-1` | https://github.com/jjscan/data.go.kr-1 | 0 | 0 | R | 미표기 | data.go.kr MFDS `DURPrdlstInfoService`/`getUsjntTabooInfoList` 수집 | **실패 사례 카탈로그**: 연결 실패→폴링 재시도, `totalCount == 0` 로 빈 응답 판정, 351,010건 단일스레드 17시간 병목. 우리 재시도/공백판정 로직의 근거 | +| 3 | `WooilJeong/PublicDataReader` | https://github.com/WooilJeong/PublicDataReader | 597 | 113 | Python | 168 commits | 공공데이터포털/KOSIS/ECOS 등 조회 파이썬 라이브러리 | 공공 API 래퍼의 **패키지 레이아웃과 provider 별 모듈 분리**. 단 식약처/의약품 커버리지는 **없음**(문서 명시) → 의존하지 않고 구조만 참고 | +| 4 | `NomaDamas/k-skill` | https://github.com/NomaDamas/k-skill | 7.4k | — | — | — | 한국 특화 스킬 모음. `docs/features/mfds-food-safety.md` + `scripts/mfds_food_safety.py` | **API 키를 사용자 머신이 아니라 프록시 서버 환경변수(`DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY`)에 두는 분리 원칙**. 우리는 로컬 단독이므로 `.env` 로 대체하되 "키를 코드/리포지토리에 절대 넣지 않는다"는 규범만 채택 | +| 5 | `FDA/openfda` | https://github.com/FDA/openfda | 705 | 166 | Python | — | FDA 공식. Luigi 파이프라인으로 공개 데이터셋 → JSON → Elasticsearch | **`openfda/` 패키지 + `schemas/` + `config/` + `scripts/` 4분할 레이아웃**. 데이터셋별 파이프라인 파일 분리(NSDE, CAERS, Substance, Device Clearance, Device PMA, Device Event) | +| 6 | `jbremz/FDA-Analysis` | https://github.com/jbremz/FDA-Analysis | 5 | 2 | Python | — | (은퇴한) Drugs@FDA 사이트를 Scrapy 로 22,000+ 제품 스크래핑 후 pandas 분석 | **"스파이더 디렉터리 / 원시 CSV / 분석 스크립트 / 노트북" 4분할**. 우리 `data/raw` ↔ `notebooks/` 분리의 근거 | +| 7 | `logiover/fda-data-scraper` | https://github.com/logiover/fda-data-scraper | 0 | 0 | 미표기 | 1 commit | openFDA 9개 데이터셋 → JSON/CSV/XLSX/JSONL/XML/HTML | **출력 포맷을 하나의 CLI 인자로 스위칭하는 설계**. 우리도 `--format xlsx|csv|json` 을 단일 report 모듈에서 처리 | +| 8 | `coderxio/OpenFDA` | https://github.com/coderxio/OpenFDA | 3 | 3 | Python | — | openFDA drug NDC 데이터셋을 DB 에 적재, CherryPy 로 서빙 | `docker-compose.yml` + `docker-compose.override.yml` 로 **최초적재/운영 구성 분리**. 우리는 Docker 미사용이나 "초기 백필 실행"과 "일일 증분 실행"을 다른 엔트리포인트로 분리하는 개념을 채택 | +| 9 | `Tanguy9862/AI-Powered-FDA-Drug-Scraper` | https://github.com/Tanguy9862/AI-Powered-FDA-Drug-Scraper | 3 | 0 | Python | — | Drugs.com 신약 승인 페이지 스크래핑 → LangChain+GPT-4o-mini 분류 (1,770건) | **`scraper.py` / `classification.py` / `utils.py` 3파일 분리** — 수집과 LLM 후처리를 절대 한 파일에 섞지 않는다. 회사명 표기 정규화(약 1000→700종)의 필요성 근거 | +| 10 | `anton-semerenko/pharma-radar` | https://github.com/anton-semerenko/pharma-radar | 0 | 0 | Python | 2 commits | Claude(Opus급) 에이전트가 매일 06:00 규제/경쟁 인텔리전스 브리핑 생성 → Telegraph + Telegram | **가장 유사한 프로젝트.** ① `prompts/system_prompt.md` 로 에이전트 방법론을 파일로 분리 ② `config/sources.yaml` 로 소스 계층·루브릭 정의 ③ `src/deliver.py` 는 표준 라이브러리만 사용 ④ **"≥2개 독립 출처 또는 1개 공식 규제 1차 출처"라는 검증 정책** ⑤ 매일 06:00 스케줄 — 우리 요구사항과 동일 | +| 11 | `mrueda/nomenclator-delta` | https://github.com/mrueda/nomenclator-delta | 1 | 0 | Python | Updated Aug 9, 2026 | 스페인 보건부 Nomenclátor 의약품 목록의 **월간 델타**(추가/삭제/변경) 비교 | **아키텍처 정본.** `src/nomenclator_delta/`(collection·normalization·diffing·validation), `data/`(스냅샷+변경이력), `site/`(정적 앱), `docs-site/`, `tests/`. CLI 는 `python3 -m nomenclator_delta validate data` / `python3 -m nomenclator_delta dist` 형태의 서브커맨드 | +| 12 | `Mzands2622/Zanalytix` | https://github.com/Mzands2622/Zanalytix | 0 | 0 | Python | Updated Mar 9, 2026 / 1 commit | 60+ 제약사 파이프라인 페이지 스크래핑 → GPT-4o 로 old vs new 비교, 우선순위 1~5 부여 | **① 소스별 파서 모듈을 1파일 1소스로 쪼개는 규칙(`{company}_pipeline.py`, `fetch_{company}_html()` / `process_{company}_html()` 시그니처 통일) ② 날짜 스냅샷을 JSON 으로 보관 ③ 변경건에 우선순위 점수를 매겨 알림 대상 선별** | +| 13 | `suriyadeepan/WebScraping-for-Healthcare` | https://github.com/suriyadeepan/WebScraping-for-Healthcare | 8 | 1 | Python | 44 commits | DrugBank·ClinicalTrials.gov·EMC(SMPC/PIL)·HPRA·MHRA 등 규제/의약 데이터 수집 | `phscrape` 패키지 안에서 소스별 모듈이 **동일한 `fetch()` / `crawl_k()` 인터페이스**를 노출하는 규약 | +| 14 | `dgtlmoon/changedetection.io` | https://github.com/dgtlmoon/changedetection.io | 33.5k | 2.0k | Python | 2,448 commits / release `0.55.8` (13 Jul 09:26) | 웹페이지 변경 감지·알림 SaaS/셀프호스트 | **개념만**: CSS/XPath/JSONPath/jq 로 감시 범위를 좁히는 필터 체인, word/line/character 3단계 diff 시각화, 타임존 인식 스케줄(요일·시간 제한), Apprise 알림, `{{diff}}`/`{{diff_added}}`/`{{diff_removed}}` 템플릿 토큰 | +| 15 | `thp/urlwatch` | https://github.com/thp/urlwatch | 3.1k | 354 | Python | 974 commits / **릴리스 없음** | URL·셸 명령 출력의 변경을 감시하고 unified diff 로 통지 | **`urls.yaml` 잡 정의 스키마**(url/name/method/data/headers/cookies/encoding/filter/ignore_connection_errors), `job_defaults` 로 공통 설정 상속, 28종 내장 필터 체인 개념, `diff_filter` 로 diff 결과 자체를 후처리하는 발상 | +| 16 | `huginn/huginn` | https://github.com/huginn/huginn | 49.9k | 4.3k | Ruby | 4,134 commits | 에이전트가 웹을 읽고 이벤트를 만들어 유향 그래프로 전파 | `WebsiteAgent` 의 **`mode: all / on_change / merge`** 3분류 — 우리 diff 모듈의 출력 모드와 정확히 대응. 기본 스케줄 `every_12h`. `extract` 설정이 선언적 추출 스펙을 데이터로 표현 | +| 17 | `larsyencken/csvdiff` | https://github.com/larsyencken/csvdiff | 131 | 31 | Python | **2021-02-18 아카이브** | 두 CSV 를 키 기준으로 비교해 added/removed/changed 산출 | **JSON 출력 스키마를 그대로 채택**: `_index`(키 컬럼 배열), `added`, `removed`, `changed`(키별 field-level from/to). CLI 옵션 `--style=summary\|pretty`, `--output`, `--ignore-columns`, `--significance` 도 우리 CLI 에 이식 | +| 18 | `ecprice/newsdiffs` | https://github.com/ecprice/newsdiffs | 506 | 136 | Python (Django) | — | 뉴스 기사 변경 이력 추적 프레임워크 | `parsers/` 아래 `BaseParser` 상속 사이트별 서브클래스 구조, 진행 로그와 에러 로그를 **분리** 기록(`/tmp/newsdiffs_logging` per-run vs `/tmp/newsdiffs/logging_errs` cumulative) | +| 19 | `simonw/git-scraper-template` | https://github.com/simonw/git-scraper-template | 132 | 10 | — | — | GitHub Actions 로 URL 을 주기적으로 받아 변경 시 커밋(Git scraping) | **"스냅샷을 버전관리에 커밋해 변경 이력 자체를 만든다"는 발상**. 우리는 GitHub Actions 대신 로컬 Task Scheduler + 로컬 git 리포로 `data/snapshots` 를 커밋 | +| 20 | `Bwhiz/Auto-Excel-Reports` | https://github.com/Bwhiz/Auto-Excel-Reports | 1 | 0 | Python | — | openpyxl 로 엑셀 리포트 생성 + GitHub Actions cron + SMTP 발송 | `report_script.py`(생성) / `auto_mail.py`(배포) **분리**, cron `0 0 * * *`, 자격증명은 GitHub Secrets → 우리는 `.env` | +| 21 | `HasData/playwright-scraping` | https://github.com/HasData/playwright-scraping | 15 | 4 | Python/Node.js | 5 commits | Playwright 스크래핑 레시피 모음 | **디렉터리 분류 자체가 체크리스트**: `basics/ scraping/ selectors/ interactions/ save_data/ auth/ browser/ errors/ debug/`. 특히 `errors/`(재시도·타임아웃)와 `debug/`(video/trace 녹화)를 우리 fetch 모듈 설계 항목으로 채택 | +| 22 | `jshchnz/claude-code-scheduler` | https://github.com/jshchnz/claude-code-scheduler | 510 | 37 | TypeScript | — | AI CLI 를 OS 네이티브 스케줄러에 등록해 `claude -p` 자동 실행 | **① OS별 스케줄러 어댑터 분리(`src/schedulers/base.ts`, `darwin.ts`, `linux.ts`, `windows.ts`, `index.ts`) ② 스케줄 정의를 JSON 파일로(`.claude/schedules.json`) ③ 로그를 태스크 ID별 파일로(`~/.claude/logs/.log`)**. Windows 는 Task Scheduler 사용 | +| 23 | `addyosmani/gemini-cli-tips` | https://github.com/addyosmani/gemini-cli-tips | 2.4k | 105 | — | — | Gemini CLI 팁 모음 | headless `gemini -p "..."`, stdin 파이프, `--format=json`, `GEMINI_SYSTEM_MD` 환경변수로 시스템 프롬프트 교체 — **agy 의 동등 플래그를 찾을 때의 탐색 체크리스트** | +| 24 | `winsw/winsw` | https://github.com/winsw/winsw | 14.3k | 1.7k | C# | v3 브랜치 841 commits / 최신 `v3.0.0-alpha.11`(29 Jan 02:20), 안정 `v2.12.0`(28 Jan 16:22) | 임의 실행파일을 Windows 서비스로 감싸는 래퍼 | **XML 한 장으로 서비스 정의**: ``, ``, ``, `1 hour`, `Automatic`, `true`, ``, ``, ``, ``, `` | +| 25 | `kirillkovalenko/nssm` | https://github.com/kirillkovalenko/nssm | 1.2k | 169 | C++ | v2.24 (2014-08-31) | NSSM — 애플리케이션을 NT 서비스로 실행, 실패 시 재시작 | WinSW 대안. CLI 로 `AppDirectory`/`AppParameters`/`AppStdout`/`AppStderr`/`AppThrottle`/`AppRestartDelay`/`AppRotateFiles`/`Start` 설정 (§10.2 에 전체 명령 보존) | +| 26 | `larsekje/PythonWindowsServices` | https://github.com/larsekje/PythonWindowsServices | 1 | 0 | Python | — | NSSM 으로 파이썬 스크립트를 서비스로 돌리는 PoC | `/logs`, `/scripts`, `/windows_service` 3분할. **"NSSM 은 로그 파일을 자동 생성하지 않으므로 사전 생성 필요"**라는 실전 함정 | +| 27 | `HaroldMills/Python-Windows-Service-Example` | https://github.com/HaroldMills/Python-Windows-Service-Example | 21 | 11 | Python | — | pywin32 + PyInstaller 로 파이썬 서비스 빌드 | 반면교사. `example_service.exe install` / `start` 를 **관리자 권한 프롬프트에서** 실행해야 함. PyInstaller 가 Python 3.5 까지만 지원한다는 오래된 주석(2016) → 이 경로는 기각 근거 | +| 28 | `mhammond/pywin32` (issue #1563) | https://github.com/mhammond/pywin32/issues/1563 | — | — | Python | — | 서비스 크래시 시 Windows 복구 액션이 트리거되지 않는 문제 | **pywin32 서비스 기각의 결정적 근거**: `SvcRun()` 이 예외를 던지거나 `sys.exit()` 해도 pywin32 정리 코드가 `SERVICE_STOPPED` 를 보고해버려 복구 액션이 발동하지 않는다. 우회책이 `os.kill(os.getpid(), signal.SIGABRT)` 수준 | +| 29 | `Windos/BurntToast` | https://github.com/Windos/BurntToast | 1.7k | 126 | PowerShell | v1.1.0 | Windows 10/Server 2019+ 토스트 알림 PowerShell 모듈 | `Install-Module -Name BurntToast`, `New-BurntToastNotification -Text ...`, `New-BTButton`, `-AppLogo`. **핵심 제약: SYSTEM/서비스 계정에서는 데스크톱 세션 요구 때문에 동작 제한** → 워치독 2단 분리의 근거 | +| 30 | `michalzobec/autorunsalerts` | https://github.com/michalzobec/autorunsalerts | 0 | — | PowerShell | — | autoruns 설정 변경을 감지해 토스트로 알림 | **운영 패턴 정본.** ① `AutorunsAlert`(SYSTEM, 60분마다): 스캔→`state.json` 과 비교→`audit.log` 기록 ② `AutorunsAlertToast`(사용자 컨텍스트, 15분마다): 플래그 확인 후 토스트. 파일 구성 `autorunsalert.ps1`/`autorunstoast.ps1`/`configuration.json`/`state.json`/`audit.log`/`install.ps1`/`uninstall.ps1` | +| 31 | `DatGuy1/Windows-Toasts` | https://github.com/DatGuy1/Windows-Toasts | 142 | 9 | Python | — | WinRT 기반 파이썬 토스트 | `python -m pip install windows-toasts`. `Toast()`, `WindowsToaster('Python')`, `text_fields`, `on_activated` 콜백. duration 이 short/long 만 지원(pywin32 대비 제약) | +| 32 | `GitHub30/win11toast` | https://github.com/GitHub30/win11toast | 333 | 24 | Python | — | Windows 10/11 토스트 (WinRT) | `pip install win11toast`. `toast()`, `notify()`(논블로킹), `toast_async()`, `buttons=[...]`, `on_click='https://...'`, `image=`, `duration='long'`. **함정: 스크립트 실행 시 현재 디렉터리가 `C:\Windows\system32` 이므로 `os.chdir()` 필요** | +| 33 | `ysfchn/toasted` | https://github.com/ysfchn/toasted | 31 | 2 | Python | — | 리치 토스트(이미지/select/input/progress) | `Progress(value="{value}", status="...")` 로 **진행률 토스트** — 백필 실행처럼 오래 걸리는 작업의 진행 표시에 사용 가능 | +| 34 | `caronc/apprise` | https://github.com/caronc/apprise | 17.2k | 652 | Python | 1,178 commits | 100+ 알림 서비스를 URL 문자열 하나로 통합 | `pip install apprise`; `apobj.add('...')` / `apobj.notify(body=, title=)`. URL: `windows://`(pywin32 필요, `?duration=5`, 250자 제한, 타 PC 전송 불가), `slack://TokenA/TokenB/TokenC/Channel`, `tgram://bottoken/ChatID`, `mailto://`/`mailtos://` | +| 35 | `786raees/task-scheduler-python` | https://github.com/786raees/task-scheduler-python | 2 | 0 | Python | 2 commits | `win32com.client` 로 Windows Task Scheduler 제어 | `create_task()`(실행경로/인자/ISO 8601 트리거), `get_all_tasks()`, `toggle_task()`, `run_task()`, `delete_task()` — **설치 스크립트에서 schtasks 문자열 조립 대신 COM 으로 다루는 대안** | +| 36 | `lorien/awesome-web-scraping` | https://github.com/lorien/awesome-web-scraping | 8.1k | 934 | — | 640 commits | 스크래핑 라이브러리/도구/API 큐레이션 | `python.md` / `javascript.md` / `php.md` / `ruby.md` / `golang.md` / `cli.md` / `manuals.md` 언어별 분할. **변경감지·스케줄링·엑셀 섹션은 없음**(확인함) → 라이브러리 선정 참고용으로만 | +| 37 | `testing-in-production/gemini-jobs` | https://github.com/testing-in-production/gemini-jobs | — | — | — | — | (블로그가 언급한 cron+Gemini CLI 예제 저장소) | **HTTP 404 — 존재하지 않음.** ⚠️ 미검증. 참조 금지 | + +### 3.1 검색 결과에만 등장한 저장소 (⚠️ 미검증 — 페이지를 직접 열지 않음) + +버리지 않고 남긴다. 필요 시 실측 후 승격. + +| 저장소 | URL | 범주 | 검색에서 파악된 내용 | +|---|---|---|---| +| `DarpitPatel/OpenFDA` | https://github.com/DarpitPatel/OpenFDA | (b) | openFDA API 를 파이썬으로 스크래핑해 txt + CSV 출력 [S#3] | +| `shaayohn/fda-drug-aproval-data-scraping` | https://github.com/shaayohn/fda-drug-aproval-data-scraping | (b) | Drugs@FDA 에서 특정 기준의 승인 상세를 가져옴 [S#6] | +| `tsbischof/fda` | https://github.com/tsbischof/fda | (b) | FDA 510(k) 및 관련 문서 스크래퍼·집계, predicate device 분석 [S#19] | +| `sheetalkalburgi/web-scraping` | https://github.com/sheetalkalburgi/web-scraping | (b)(c) | BeautifulSoup 로 FDA + Health Canada 사이트 스크래핑 [S#19] | +| `Norbaeocystin/FDA` | https://github.com/Norbaeocystin/FDA | (b) | FDA 의약품 승인 데이터 스크래핑·분석 [S#19] | +| `vshah1016/pharma_scraper` | https://github.com/vshah1016/pharma_scraper | (b) | Biopharmcatalyst PDUFA 캘린더 → CSV [S#19][S#27] | +| `arpitamangal/pharma-scrape-and-analysis` | https://github.com/arpitamangal/pharma-scrape-and-analysis | (b) | 대체 브랜드 식별, FDA 40개 카테고리 분류 [S#8] | +| `rOpenHealth/openfda` | https://github.com/rOpenHealth/openfda | (b) | **R 패키지**. jsonlite/magrittr 로 openFDA 접근 [S#23] | +| `roivant/openfda` | https://github.com/roivant/openfda | (b) | openFDA 관련 포크 [S#23] | +| `betagouv/api-medicaments` | https://github.com/betagouv/api-medicaments | (a 유사) | 프랑스 ANSM 공식 의약품 공개 DB API [S#13] | +| `kawsarlog/AmerisourceBergen` | https://github.com/kawsarlog/AmerisourceBergen | (b) | ★4, Python, Updated Aug 5, 2023. AmerisourceBergen 제품 가격 추출 자동화 [F#19] | +| `MohammedAhmed-01/DataDoseProject` | https://github.com/MohammedAhmed-01/DataDoseProject | (b) | ★0, Jupyter Notebook, Updated Mar 22, 2026. 성분 검증 + OpenFDA 라벨 보강 + DDI 탐지 파이프라인 [F#19] | +| `Dagiayy/kara-medical-telegram-data-platform` | https://github.com/Dagiayy/kara-medical-telegram-data-platform | (e) | ★0, Python, Updated Aug 23, 2026. Telegram 스크래핑 + PostgreSQL + **Dagster 오케스트레이션** [F#19] | +| `bdmorris238/pharmaco-database-project` | https://github.com/bdmorris238/pharmaco-database-project | (b) | ★0, PLpgSQL, Updated Aug 15, 2025. 제약 시장 분석용 관계형/DW 설계 [F#19] | +| `khushihajiyani-dotcom/drug-spending-analysis` | https://github.com/khushihajiyani-dotcom/drug-spending-analysis | (b) | ★0, SQLite, Updated May 3, 2026. 캐나다 주별 약품 지출 분석 2020-2024 [F#19] | +| `Tanguy9862/new-drug-approvals-dashboard` | https://github.com/Tanguy9862/new-drug-approvals-dashboard | (e) | 위 #9 의 자매 프로젝트. Dash 로 실시간 대시보드 [F#42] | +| `patrickloeber/llm-data-scrapers` | https://github.com/patrickloeber/llm-data-scrapers | (h) | LLM 용 데이터 수집 오픈소스 도구 목록 [S#18] | +| `ManiMozaffar/linkedIn-scraper` | https://github.com/ManiMozaffar/linkedIn-scraper | (e) | Playwright + FastAPI, 결과를 DB 와 Telegram 채널로 [S#17] | +| `dineshk-qa/playwright.slack.reporter` | https://github.com/dineshk-qa/playwright.slack.reporter | (e) | Playwright 결과를 Slack 웹훅으로 리포팅 [S#17] | +| `god233012yamil/Excel-Automation-Using-Python` | https://github.com/god233012yamil/Excel-Automation-Using-Python | (e) | openpyxl 엑셀 자동화 예제 모음 [S#7] | +| `prabudevarajan/Task-Reminder-Automation-Python-Excel-CSV-Email-Alerts` | https://github.com/prabudevarajan/Task-Reminder-Automation-Python-Excel-CSV-Email-Alerts | (e) | Tkinter UI + Excel/CSV 저장 + 15/7/3/1일 전 이메일 리마인더 + daily scheduler + 로깅 [S#7] | +| `jithurjacob/Windows-10-Toast-Notifications` | https://github.com/jithurjacob/Windows-10-Toast-Notifications | (g) | `win10toast` 원본. 커스텀 아이콘, threaded 알림 [S#9] | +| `jacobcolbert/Windows-10-Toast-Notifications` | https://github.com/jacobcolbert/Windows-10-Toast-Notifications | (g) | 위 포크 [S#9] | +| `NakedPowerShell/BurntToast` | https://github.com/NakedPowerShell/BurntToast | (g) | BurntToast 포크 [S#25] | +| `Badgerati/Hook` | https://github.com/Badgerati/Hook | (g) | PowerShell 모듈. 서비스 상태 감시 후 BurntToast 팝업 [S#25] | +| `mattwolfe/changedetection` | https://github.com/mattwolfe/changedetection | (d) | changedetection.io 포크 [S#5] | +| `huginn/huginn_agent` | https://github.com/huginn/huginn_agent | (d) | Huginn 에이전트를 Gem 으로 만드는 베이스 [S#12] | +| `SublimeText/Pywin32` | https://github.com/SublimeText/Pywin32 | (g) | pywin32 번들 [S#16] | +| `WinSW-Windows` (org) | https://github.com/WinSW-Windows | (g) | WinSW 관련 조직 계정 [S#15] | +| `google-gemini/gemini-cli` Discussion #3215 | https://github.com/google-gemini/gemini-cli/discussions/3215 | (f) | "Headless execution" 논의 스레드 [S#20] | +| `realpython/list-of-python-api-wrappers` | https://github.com/realpython/list-of-python-api-wrappers | (h) | 파이썬 API 래퍼 목록 [S#23] | +| `noirquant/awesome-web-scraping` | https://github.com/noirquant/awesome-web-scraping | (h) | lorien 포크 [S#8] | +| `jjwangnlp/awesome-web-scraping` | https://github.com/jjwangnlp/awesome-web-scraping | (h) | lorien 포크 [S#8] | +| `luminati-io/Awesome-Web-Scraping` | https://github.com/luminati-io/Awesome-Web-Scraping | (h) | HTTP 라이브러리·브라우저 자동화·프록시 서비스 포함 목록 [S#8] | +| `spinov001-art/awesome-web-scraping-2026` | https://github.com/spinov001-art/awesome-web-scraping-2026 | (h) | 130+ 도구, Python/JS/Go/Rust, 안티디텍션·프록시·클라우드, 주간 갱신 [S#8] | +| `duyet/awesome-web-scraper` | https://github.com/duyet/awesome-web-scraper | (h) | 스크래퍼/크롤러 모음 [S#8] | +| `firecrawl/firecrawl` | https://github.com/firecrawl/firecrawl/releases | (d) | 릴리스 페이지가 검색에 노출 [S#24] | +| `diakes/coupang_crawler_python` | https://github.com/diakes/coupang_crawler_python | (a) | 쿠팡 크롤러 — 무관 [S#1] | +| `FareedKhan-dev/best-llm-finder-pipeline` | https://github.com/FareedKhan-dev/best-llm-finder-pipeline | (c) | Agentic RAG / 멀티에이전트 파이프라인 [S#18] | +| `architkaila/Fine-Tuning-LLMs-for-Medical-Entity-Extraction` | https://github.com/architkaila/Fine-Tuning-LLMs-for-Medical-Entity-Extraction | (c) | Llama2/StableLM PEFT·LoRA 로 약물명·부작용 추출 [S#18] | +| `guilopgar/Medication-Detection-LLM` | https://github.com/guilopgar/Medication-Detection-LLM | (c) | 소셜미디어 텍스트에서 약물 언급 탐지 [S#18] | +| `FDA` (org) | https://github.com/FDA | (b) | FDA 공식 GitHub 조직 [S#4] | + +### 3.2 Gist (⚠️ 코드 조각, 저장소 아님) + +| Gist | URL | 내용 | +|---|---|---| +| `drmalex07/10554232` | https://gist.github.com/drmalex07/10554232 | pywin32 서비스 예제. `class HelloWorldSvc(win32serviceutil.ServiceFramework)`, `_svc_name_`, `_svc_display_name_`, `SvcStop`, `SvcDoRun`, `win32event.CreateEvent`, `win32serviceutil.HandleCommandLine` [F#40] | +| `seanherron/5997278` | https://gist.github.com/seanherron/5997278 | drugs@fda scraper [S#3][S#19] | +| `nmpowell/dc8e7187948788c5c126f01755252164` | https://gist.github.com/nmpowell/dc8e7187948788c5c126f01755252164 | 기존 Windows Task Scheduler 태스크와 상호작용하는 파이썬 스크립트 [S#29] | +| `hygull/32a742339a416dcfa2990504c848c1a9` | https://gist.github.com/hygull/32a742339a416dcfa2990504c848c1a9 | Windows 10 토스트 생성 [S#9] | +| `HainanZhao/92b43e68850189bfee8f39a2c2581ca6` | https://gist.github.com/HainanZhao/92b43e68850189bfee8f39a2c2581ca6 | "Gemini CLI Job" 으로 반복 작업 자동화 [S#20] | + +--- + +## 4. (a) 한국 식약처 / 공공데이터 크롤러 + +### 4.1 결론: **DMF 전용 오픈소스는 없다** + +WebSearch #1, #2, #11, #13, #21, #22, #30 을 통해 다음 질의를 던졌으나 `nedrug`/`MFDS`/DMF 전용 크롤러 저장소는 **하나도 나오지 않았다**: + +- `github nedrug 식약처 크롤링 의약품 crawler python` +- `github 공공데이터포털 식약처 의약품 API python wrapper mfds` +- `github 원료의약품 DMF 등록 식약처 크롤러 파이썬` +- `github 의약품안전나라 e약은요 API 파이썬 오픈API 크롤링 selenium 의약품 허가` +- `"nedrug.mfds.go.kr" github python selenium requests 크롤링 프로젝트` +- `github Korea MFDS drug approval scraper "nedrug" OR "mfds" python` +- `github 공공데이터포털 data.go.kr 파이썬 라이브러리 PublicDataReader 식약처 의약품` + +검색 엔진이 반환한 것은 대부분 식약처 공식 포털 페이지와 블로그 튜토리얼이었다. 따라서 **fetch/parse 계층은 우리가 최초 구현자**라고 전제하고 설계한다. + +### 4.2 `Q00/data.go.kr-crawling` [F#1][F#62][F#64] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/Q00/data.go.kr-crawling | +| 설명 | "건강정보, 의약품 크롤링, 멀티쓰레딩 gevent" | +| ★ / Fork | 4 / 0 | +| 언어 | Python | +| 커밋 | development 브랜치 55 commits | +| 토픽 | crawling, gevent, Python, python-lock, threading | + +파일 구조: + +``` +apis/ +async_data_crawler.py +go_data_crwaler.py # 오타 그대로 (crwaler) +column.py +url.py +config.py.example +requirements.txt +README.md +.gitignore +``` + +**`go_data_crwaler.py` 실측 내용 [F#64]** — 호출하는 data.go.kr MFDS 엔드포인트: + +- `getDurPrdlstInfoList` (메인 품목 목록) +- `getSeobangjeongPartitnAtentInfoList` +- `getEfcyDplctInfoList` +- `getOdsnAtentInfoList` +- `getMdctnPdAtentInfoList` +- `getCpctyAtentInfoList` +- `getPwnmTabooInfoList` +- `getSpcifyAgrdeTabooInfoList` +- `getUsjntTabooInfoList` + +핵심 코드 라인(원문 인용): + +- 인증: `config.go_data_api_key` +- 파라미터 조립: `params.update({'typeName' : column.typeName[addUrl]})` +- 페이지네이션: `params_str2 += '&pageNo=' + str(i+1)` +- 총 페이지 계산: `page = int(totalCount/100) + 1` +- 출력: `wb.save(column.typeName[addUrl]+'.xlsx')` + +**중요한 반증**: 저장소 설명과 토픽은 gevent 를 내세우지만, 실제 `go_data_crwaler.py` 에는 **gevent 사용이 없고 동기 `requests.get()` 만 쓴다**. 저장소 설명을 믿지 말고 코드를 읽어야 한다는 교훈. + +**`url.py` 실측 내용 [F#62]** — 이 파일은 크롤링이 아니라 **API 명세 스크래핑**을 한다: + +- 대상: `https://www.data.go.kr/pubn/lab/gui/IrosDevGuide/selectReqResPrmList.do` +- POST payload: `publicDataDetailPk: "uddi:9a60503c-b31c-4879-9028-a4250f0f6998"`, `paramtrSe: "2"`, `oprtinSeqNo: 15920` +- 응답 JSON 의 `RESULT_RE_LIST` 를 순회(인덱스 8은 건너뜀)하며 파라미터명과 한글명을 추출 +- 결과를 **`column.py` 파일로 써낸다** +- import: gevent, base64, requests, BeautifulSoup, json + +> **베낄 것**: 공공데이터포털의 "요청/응답 파라미터 목록" 화면을 긁어 **필드 사전을 코드로 자동 생성**하는 발상. DMF API 의 영문 필드명(`DMF_PERMIT_NO` 등)을 한글 헤더로 바꿔야 하는 우리 리포트 요구와 정확히 맞물린다. → `config/field_map.yaml` 을 손으로 쓰되, 생성 스크립트를 `scripts/gen_field_map.py` 로 둔다. + +### 4.3 `jjscan/data.go.kr-1` [F#46] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/jjscan/data.go.kr-1 | +| ★ / Fork | 0 / 0 | +| 언어 | **R** | +| 파일 | `DURPrdlstInfoService.R`, `README.md` | +| 대상 API | `DURPrdlstInfoService` / `getUsjntTabooInfoList` (병용금기정보조회) | + +응답 구조 실측: `numOfRows: 100`, `pageNo: 895`(예), `totalCount: 351010`. XML 응답을 `xmlSApply` / `xpathSApply` 로 파싱 후 CSV 로 내보냄. HTTP 는 `httr` 패키지. + +README 가 기록한 **구현 난제 4가지**(우리가 그대로 대비해야 할 항목): + +1. **연결 실패** — 폴링(재시도) 메커니즘으로 해결 +2. **널 데이터 처리** — `totalCount == 0` 검증으로 탐지 +3. **R 세션 크래시** — 간헐적, 미해결 +4. **성능 병목** — 단일 스레드로 약 350,000건에 **17시간**. Rmpi 로 MPI 병렬화하여 해결 + +> **베낄 것**: (1) 재시도 정책의 필요성, (2) `totalCount == 0` 을 "데이터 없음"의 공식 판정 기준으로 삼기, (3) 전체 백필은 반드시 병렬/증분으로 설계 — DMF 현황 전량 백필 시에도 동일한 함정이 있다. + +### 4.4 `WooilJeong/PublicDataReader` [F#37] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/WooilJeong/PublicDataReader | +| 설명 | "공공 데이터 조회를 위한 오픈소스 파이썬 라이브러리" | +| ★ / Fork | 597 / 113 | +| 언어 | Python | +| 커밋 | 168 | +| 설치 | `pip install PublicDataReader --upgrade` | + +지원 provider: FRED, 공공데이터포털(국토교통부 실거래가/건축물대장/건축인허가/주택인허가/토지임야/토지소유, 소상공인시장진흥공단 상권정보, 한국자산관리공사 공매물건, 국세청 사업자등록 확인, 한국부동산원), KOSIS, ECOS, 서울시 교통, V-World, KB부동산. + +**결정적 사실: 식약처/MFDS/의약품 커버리지가 없다**(문서에 명시). 따라서 **의존성으로 채택하지 않는다.** 다만 "provider 별 모듈 + 공통 조회 인터페이스" 라는 패키지 레이아웃은 우리 `fetch/` 의 소스별 어댑터 설계 참고가 된다. + +### 4.5 `NomaDamas/k-skill` — `docs/features/mfds-food-safety.md` [F#36] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/NomaDamas/k-skill/blob/main/docs/features/mfds-food-safety.md | +| 부모 저장소 ★ | 7.4k | +| 문서 주제 | "식품 안전 체크 가이드" | +| 스크립트 경로 | `scripts/mfds_food_safety.py` | +| 참조 데이터 소스 | ① 공공데이터포털 "부적합 식품" 엔드포인트 ② 식품안전나라 회수·판매중지 API | +| API 키 처리 | `DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY` 를 **"프록시 운영 서버" 환경변수**에 두고, 사용자는 `k-skill-proxy` 의 `/v1/mfds/food-safety/search` 로 접근 | +| 안전 규범 | "이 helper 는 **직접 진단**을 하지 않는다" — 자동 판정보다 전문가 상담 우선 | + +> **베낄 것**: ① 키를 사용자 머신 코드에 박지 않는 분리 원칙(우리는 로컬 단독이므로 `.env` + `.gitignore`) ② **"자동화가 판정하지 않고 근거만 제시한다"는 안전 규범** — DMF 변경 탐지 결과도 "규제 판단"이 아니라 "차이 보고"로 문구를 통일한다. + +### 4.6 이 범주에서 확인되지 않은 것 (⚠️ 미검증) + +- `nedrug.mfds.go.kr` 를 WebFetch 로 열었을 때 `https://nedrug.mfds.go.kr/searchDmf` 는 **에러 페이지**("The requested page cannot be found")를 반환했다 [F#63]. DMF 검색 화면의 실제 URL·파라미터는 브라우저로 실측해야 한다. +- data.go.kr 의 DMF OpenAPI 는 페이지 자체는 열렸다 [F#29]. §15 참조. + +--- + +## 5. (b) FDA / openFDA / 규제 데이터 + +한국 DMF 에 직접 쓸 코드는 없지만, **"규제 데이터셋을 주기적으로 받아 정규화하고 저장한다"** 는 문제를 가장 오래 푼 집단이 여기다. 레이아웃과 파이프라인 분할을 여기서 가져온다. + +### 5.1 `FDA/openfda` (공식) [F#2] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/FDA/openfda | +| 설명 | "openFDA is a research project to provide open APIs, raw data downloads, documentation and examples, and a developer community for an important collection of FDA public datasets." | +| ★ / Fork | **705 / 166** | +| 언어 | Python | +| 최상위 디렉터리 | `api/faers`, `config`, `dependencies`, `openfda`, `schemas`, `scripts` + `Dockerfile`, `docker-compose.yml`, `requirements.txt`, `setup.py` | +| 파이프라인 기술 | **Luigi** — "Python pipelines written with Luigi for processing public FDA data sets (drugs, foods, medical devices, and other) into a JSON format that can be loaded into Elasticsearch." | +| 데이터셋 | NSDE, CAERS, Substance Data, Device Clearance, Device PMA, Device Event 파이프라인 명시 | +| 실행 | `docker-compose up` → Elasticsearch + API 컨테이너(포트 8000). "the API container starts right away, it will not serve any data until some or all of the pipelines above have finished running." | +| 전제 조건 | Elasticsearch 7, Python 3.10, Node 16+ | + +> **베낄 것** +> - `config/`(설정) · `schemas/`(데이터 계약) · `scripts/`(운영 스크립트) · `/`(코드) **4분할**. 우리 트리에 그대로 반영한다. +> - `schemas/` 를 별도 최상위로 두는 것 — 응답 스키마와 스냅샷 스키마를 코드와 분리해 버전 관리하면, 식약처가 필드를 바꿨을 때 diff 로 즉시 감지된다. +> - "데이터 적재 전에는 API 가 아무것도 서빙하지 않는다"는 명시 — 우리도 스냅샷이 2개 미만이면 리포트를 만들지 않고 "기준선 수립" 상태로 종료해야 한다. +> +> **기각할 것**: Luigi / Elasticsearch / Docker. 단일 Windows PC 에 하루 1회 소량 데이터. 오버엔지니어링이다. + +### 5.2 `jbremz/FDA-Analysis` [F#3] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/jbremz/FDA-Analysis | +| 설명 | "Scraping and analysis of the (now retired) Drugs@FDA site - with scrapy and pandas" | +| ★ / Fork | 5 / 2 | +| 언어 | Python | +| 파일 | `FDA Spider/`(Scrapy 프로젝트, 메인 스파이더는 `FDASpider`), `masterDrugList2.csv`(원시 산출물), `FDA_Data_Analysis.py`(pandas 분석), `Drugs@FDA Analysis.ipynb`(결론 노트북), `.ipynb_checkpoints/` | +| 규모 | "over 22,000 different products" — British Medical Journal 의뢰로 데이터 품질·누락 보고 평가 | + +> **베낄 것**: **"스파이더 / 원시 CSV / 분석 스크립트 / 결론 노트북" 4단 분리.** 특히 원시 산출물을 저장소에 남겨 재현 가능하게 한 점. 우리는 `data/raw/YYYY-MM-DD/` 에 원시 응답을 그대로 남긴다. +> +> **경고 신호**: 대상 사이트(Drugs@FDA 구 버전)가 **은퇴하면서 이 프로젝트도 죽었다.** 우리도 nedrug 게시판 구조 변경에 대비해 파서를 소스별로 격리하고, 파싱 실패를 즉시 알림으로 올려야 한다. + +### 5.3 `logiover/fda-data-scraper` [F#4] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/logiover/fda-data-scraper | +| 설명 | "FDA data scraper — openFDA drug/device/food recalls, adverse events & 510(k) clearances as JSON/CSV" | +| ★ / Fork | 0 / 0 (master 1 commit) | +| 라이선스 | MIT | +| 파일 | `/examples`(CLI, API, JavaScript, Python 사용 예), `.gitignore`, `LICENSE`, `README.md` | + +지원 9개 데이터셋: ① Drug recalls (enforcement) ② Drug adverse events (20M+ records) ③ Drug labels (258K records) ④ Device recalls ⑤ Device adverse events (24M+ records) ⑥ Device 510(k) clearances ⑦ Food recalls ⑧ Food adverse events ⑨ Animal & veterinary adverse events + +출력 포맷: **"JSON, CSV, Excel (XLSX), JSONL, XML or HTML"** + +자동화 기능: 일일 스케줄 실행, 완료 웹훅, Google Sheets/Excel 연동, 클라우드 스토리지 내보내기(S3, GCS), "Zapier, Make, n8n or Pipedream" 호환. 실행 경로는 Apify Console / Apify CLI / API(curl) / apify-client(JS·Python) 4가지. + +> **베낄 것**: **출력 포맷을 데이터 파이프라인이 아니라 최종 어댑터에서 스위칭**하는 설계. `report` 모듈이 동일한 `DiffResult` 를 받아 xlsx/csv/json 중 하나로 렌더링한다. +> +> **기각할 것**: Apify 플랫폼 의존. 로컬 실행이 요구사항이다. + +### 5.4 `coderxio/OpenFDA` [F#18] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/coderxio/OpenFDA | +| 설명 | "Python scripts for capturing OpenFDA data in a database." | +| ★ / Fork | 3 / 3 | +| 언어 | Python | +| 파일 | `openfda/`, `.gitignore`, `README.md`, `docker-compose.override.yml`, `docker-compose.yml` | +| 데이터 | drug NDC 데이터셋 (`https://open.fda.gov/apis/drug/ndc/download/`) 을 압축 해제 후 `drug-ndc.json` 으로 이름 바꿔 `./data/` 에 배치 | +| 실행 (venv) | venv 생성 → requirements 설치 → `python app/load_data.py` → `python app/serve_data.py` | +| 실행 (Docker) | "First run to load DB: `docker-compose up --build`" → 운영은 별도 compose 파일 → 정리는 `docker-compose down -v` | +| 서버 | CherryPy | + +> **베낄 것**: **`load_data`(적재) 와 `serve_data`(제공) 의 엔트리포인트 분리**, 그리고 "최초 실행"과 "이후 실행"이 다른 명령이라는 명시. 우리 CLI 에서 `dmf backfill` 과 `dmf daily` 로 구현한다. 또한 원시 다운로드물을 `./data/` 에 고정 파일명으로 두는 규칙. + +### 5.5 `Tanguy9862/AI-Powered-FDA-Drug-Scraper` [F#42] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/Tanguy9862/AI-Powered-FDA-Drug-Scraper | +| 설명 | "Python-based web scraper leveraging generative AI with LangChain and GPT-4o-mini to extract and classify FDA drug approval data" | +| ★ / Fork | 3 / 0 | +| 언어 | Python | +| 파일 | `new_drug_approvals_scraper/`(패키지), `img_readme/`, `scraper.py`, `classification.py`, `utils.py`, `__init__.py`, `requirements.txt`, `setup.py`, `.gitignore`, `LICENSE`, `README.md` | +| 대상 | https://www.drugs.com/newdrugs.html | +| 규모 | "over 1,770 records" | +| 정규화 | 회사명 표기 변형을 접미사·약어·협업 서술 표준화로 **약 1000종 → 700종**으로 축소 | +| 자매 | https://github.com/Tanguy9862/new-drug-approvals-dashboard (Dash 대시보드) | + +> **베낄 것** +> - **`scraper.py` / `classification.py` / `utils.py` 분리** — 수집(결정론)과 LLM 분류(비결정론)를 절대 같은 파일에 두지 않는다. 우리 구조에서는 `fetch/`(결정론) 와 `enrich/`(agy 호출) 로 대응. +> - **업체명 정규화가 필수 작업이라는 실증.** DMF `ENTP_NAME`/`MNFCTR_NAME` 도 "(주)"·"주식회사"·영문/한글 혼용 때문에 같은 문제를 겪는다. `parse/normalize.py` 에 정규화 규칙 테이블을 둔다. + +### 5.6 FDA DMF 목록 자체에 대해 확인된 것 / 확인 실패한 것 + +- FDA 는 DMF 목록을 **분기별**로 갱신한다. 검색 결과가 인용한 문장: "The list of DMFs, which is updated quarterly, contains DMFs received by June 30, 2026, for which acknowledgment letters were sent before July 19, 2026." [S#6] +- 컬럼(검색 결과 기준, ⚠️ 미검증): DMF 번호, submitter, file type, acknowledgment date, review division [S#27] +- **`https://www.fda.gov/drugs/drug-master-files-dmfs/list-drug-master-files-dmfs` 와 `https://www.fda.gov/drugs/drug-master-files-dmfs` 는 둘 다 WebFetch 에서 HTTP 404 를 반환했다** [F#48][F#53]. 실제 xls/xlsx/zip 다운로드 URL 은 확인하지 못했다. ⚠️ 미검증. +- 이 프로젝트는 **한국 DMF** 가 대상이므로 FDA DMF 는 범위 밖이다. 다만 "분기 갱신 스프레드시트를 받아 이전 분기와 비교" 라는 문제 형태는 우리와 동형이므로 참고로만 남긴다. + +--- + +## 6. (c) 규제 변경 감지 · 인텔리전스 + +이 범주가 **우리 프로젝트와 문제 정의가 가장 가깝다**. 세 저장소가 각각 다른 축을 보여준다. + +### 6.1 `anton-semerenko/pharma-radar` — 에이전트 파이프라인의 정본 [F#6] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/anton-semerenko/pharma-radar | +| 설명 | "Autonomous LLM agent delivering a daily, source-verified pharma regulatory & competitive intelligence briefing to Telegram." | +| ★ / Fork | 0 / 0 | +| 언어 | Python | +| 커밋 | main 브랜치 2 commits | +| 라이선스 | MIT | + +디렉터리 구조 (원문 그대로): + +``` +├── prompts/system_prompt.md (agent methodology) +├── config/sources.yaml (source hierarchy & rubrics) +├── src/deliver.py (Telegraph + Telegram publishing) +└── examples/sample_digest.md (sample output) +``` + +**감시 소스** +- 규제기관: FDA, EMA, WHO, 우크라이나 당국(МОЗ, ДЕЦ, Держлікслужба) +- 업계지: Endpoints News, FiercePharma, STAT News +- 기업 IR 페이지 및 등록부 + +**에이전트 로직 (원문 인용)**: Claude(Opus급)를 agentic loop 로 사용하여 "retrieves broadly, **verifies every item against ≥2 independent sources or one official regulatory primary**, attributes every number, and states what each development _means_." + +**검증 정책**: 2개 독립 출처 또는 1개 공식 규제 1차 출처가 있어야 항목에 포함. 단일 2차 출처는 불충분으로 간주. + +**출력 구조**: 4개 루브릭 — Regulatory & Approvals / Clinical & Pipeline / Market·Access & Ukraine / Competitive & Corporate — 에 Forward Agenda 와 Signals to Watch 섹션 추가. 각 항목은 4~7문장 + 출처 명시. + +**배포·스케줄**: 롤링 Telegraph 페이지에 게시 + Telegram 푸시. **매일 06:00 스케줄드 태스크로 무인 실행.** 배포 계층은 **파이썬 표준 라이브러리만** 사용. + +> **베낄 것 (5가지, 전부 채택)** +> 1. **`prompts/` 를 최상위 디렉터리로.** 에이전트 프롬프트는 코드가 아니라 자산이다. `prompts/dmf_summarize.md`, `prompts/dmf_classify.md` 로 분리하고 버전 관리한다. +> 2. **`config/sources.yaml` 로 소스 계층·루브릭을 데이터로 선언.** 소스를 추가할 때 코드를 고치지 않는다. +> 3. **배포(notify) 계층은 의존성 최소화.** pharma-radar 는 표준 라이브러리만 썼다. 우리는 Apprise 하나만 허용하고 그 외 SDK 는 금지한다. +> 4. **검증 정책을 문서로 명시.** 우리 버전: "DMF 변경 건은 ① 공공 API 응답과 ② 공고 게시판 중 최소 1개의 공식 1차 출처로 뒷받침되어야 리포트에 '확정' 으로 표기한다. 한쪽만 있으면 '관찰 중(pending)' 으로 표기한다." +> 5. **매일 06:00 + 무인 + 스케줄드 태스크** — 우리 요구사항과 동일한 운영 형태가 실제로 굴러가고 있다는 존재 증명. +> +> **주의**: ★0 / 2 commits 로 성숙도는 매우 낮다. **구조만 참고하고 코드 재사용은 하지 않는다.** + +### 6.2 `mrueda/nomenclator-delta` — 모듈 경계의 정본 [F#30] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/mrueda/nomenclator-delta | +| 설명 | "Compare monthly changes in medicines and health products from the Ministerio de Sanidad's Nomenclátor de Facturación." | +| ★ / Fork | **1 / 0** | +| 언어 | Python | +| 최근 갱신 | Updated Aug 9, 2026 [F#19] | +| 라이선스 | MIT | + +디렉터리 구조 (원문 그대로): + +``` +src/nomenclator_delta/ (collection, normalization, diffing, validation) +data/ (snapshots and change history) +site/ (Spanish static application) +docs-site/ (documentation and Pages build) +tests/ (unit and integration tests) +``` + +**데이터 소스**: 스페인 보건부 "Nomenclátor de facturación" — 국가보건시스템 의약품 공개 DB + +**델타 계산**: "compares consecutive monthly releases" 하고 "finds changes by Código Nacional, medicine name, active ingredient, or laboratory." 즉 **국가코드(고유 키) + 3개 부가 축**으로 변경을 찾는다. added/removed/changed 를 식별한다고 README 가 기술. + +**출력**: 백엔드 없는 정적 브라우저 앱 (GitHub Pages 호스팅) + +**운영**: "a monthly update runbook" 문서가 존재 — 유지보수자가 정기 릴리스를 다루는 절차서 + +**CLI**: +```bash +python3 -m nomenclator_delta validate data +python3 -m nomenclator_delta dist +``` + +> **베낄 것 (이 프로젝트의 골격)** +> - **`collection / normalization / diffing / validation` 4단 모듈 분리를 그대로 채택.** 우리 명칭으로는 `fetch / parse(normalize) / diff / validate`. +> - **`data/` 에 "스냅샷"과 "변경 이력"을 함께 둔다.** 스냅샷은 시점의 진실, 변경 이력은 시점 간의 진실. 둘 다 보존해야 재계산이 가능하다. +> - **`validate` 를 별도 CLI 서브커맨드로.** 파이프라인을 돌리기 전에 데이터 무결성을 먼저 검사한다. 우리는 `dmf validate data` 로 동일하게 만든다. +> - **`docs-site/`(문서) 와 `site/`(산출물 뷰어) 를 분리.** 우리는 산출물이 xlsx 이므로 `site/` 대신 `reports/` 를 둔다. +> - **runbook 문서를 리포지토리 안에.** `docs/ops/runbook.md` 로 채택. +> - **`python3 -m ` 형태의 진입점.** Windows 에서도 `python -m dmf_crawler daily` 가 배치 파일보다 견고하다. + +### 6.3 `Mzands2622/Zanalytix` — LLM 기반 변경 감지의 실물 [F#31] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/Mzands2622/Zanalytix | +| 설명 | "AI-powered pipeline for scraping and tracking pharmaceutical clinical pipeline data with LLM-based change detection." | +| ★ / Fork | 0 / 0 | +| 언어 | Python | +| 커밋 | main 브랜치 1 commit / Updated Mar 9, 2026 | + +**파일 구조**: 60+ 개의 회사별 파서 모듈(`abbvie_pipeline.py`, `pfizer_pipeline.py` …) + 코어: +- `db.py` (DB 관리) +- `function_app.py`, `master_scheduler.py` (API 계층 / 스케줄러) +- `login.py`, `sign_up.py` (인증) +- `notifications.py` (알림) +- `cleanup_text.py`, `treatment_visualizer.py` (유틸) + +**수집 흐름 (원문)**: "HTML Scraping (Zyte API) --> 60+ Company Parsers (BeautifulSoup)" → 표준화 파이프라인. 각 회사 모듈은 **두 함수를 반드시 구현**: +- `fetch_{company}_html()` — 파이프라인 페이지 획득 +- `process_{company}_html()` — 구조화 객체로 파싱 + +**LLM 변경 감지 (원문)**: "GPT-4o compares old vs new treatment data, assigns priority (1-5), categorizes changes" — 비교 큐를 통해 수행. 날짜별 스냅샷을 JSON 으로 유지해 시계열 분석 가능. + +**저장 구조**: `Revised_MasterTable` 이 treatment 를 키로, `Treatment_Data` 에 타임스탬프 스냅샷을 담는다. 별도 `Stream` 테이블이 변경 이벤트별 GPT-4o 응답과 메타데이터를 추적. + +**스케줄·알림**: "Calendar-based scraping schedules with recurrence rules and auto-extension" 이 Azure Functions 를 트리거. 매칭된 변경은 사용자 저장 선호에 따라 "Twilio SMS/Calls, Email" 로 라우팅. + +> **베낄 것 (3가지)** +> 1. **소스별 파서 모듈의 함수 시그니처를 강제 통일** (`fetch_*` / `process_*`). 우리는 `fetch(source_id) -> RawPayload`, `parse(RawPayload) -> list[DmfRecord]` 프로토콜로 명문화한다. +> 2. **변경 이벤트에 우선순위 점수(1~5)를 부여**하고, 그 점수로 알림 대상/채널을 결정. DMF 에서는 예: 신규 등록=3, 등록 취하=5, 제조소 주소 변경=2, 오탈자 수정=1. +> 3. **원본 스냅샷 테이블과 "변경 이벤트 + LLM 응답" 테이블을 분리**. 우리 SQLite 스키마에 `snapshots` / `changes` / `agent_runs` 3테이블로 반영. +> +> **기각할 것**: Azure Functions, Zyte API, Twilio, 로그인/회원가입. 로컬 단일 사용자에겐 전부 불필요. + +### 6.4 `suriyadeepan/WebScraping-for-Healthcare` [F#22] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/suriyadeepan/WebScraping-for-Healthcare | +| 설명 | "Scraping the internet for extracting healthcare and pharma data." | +| ★ / Fork | 8 / 1 | +| 언어 | Python | +| 커밋 | main 브랜치 44 commits | +| 라이선스 | GPL-3.0 | + +구조: + +``` +├── data/ +├── mhra/ +├── notebooks/ +├── phscrape/ +├── twitter/ +├── .gitignore +├── LICENSE (GPL-3.0) +├── README.md +├── requirements.txt +└── tests.py +``` + +수집 대상: Drug Bank, ClinicalTrials.gov, COVID-19 API, Twitter(#remdesivir), EMC(Electronic Medicines Compendium — SMPC/PIL 추출), HPRA(아일랜드), MHRA(영국). + +`phscrape` 모듈이 노출하는 함수: `drugbank.fetch()`, `clinicaltrials.fetch()`, `emc.crawl_k()`, `hpra.crawl_k()`. + +> **베낄 것**: **소스별 서브모듈이 동일 인터페이스(`fetch()` / `crawl_k()`)를 노출**하는 규약. 위 Zanalytix 와 동일한 결론에 독립적으로 도달했다는 점이 이 패턴의 타당성을 보강한다. +> +> **주의**: GPL-3.0 이므로 **코드를 복사하면 안 된다.** 설계만 참고. + +### 6.5 상업 도구 및 업계 동향 (배경, 코드 없음) + +- Clarivate 의 Biopharma Regulatory Compliance 서비스 [S#4] +- Vistaar 의 Regulatory Intelligence Database 소개 [S#4] +- IntuitionLabs: "AI and the Future of Regulatory Affairs in the U.S. Pharmaceutical Industry", "Open Source Pharma: Tools & Trends in Drug Development" [S#4] +- **2026년 1월, FDA 와 EMA 가 공동으로 "Guiding Principles for Good AI Practice in Drug Development" 를 발표** — 규제 당국이 새로운 도구 패러다임에 개방적임을 시사 [S#4]. ⚠️ 미검증(검색 요약문 기준) + +--- + +## 7. (d) 범용 변경 감지 도구 + +### 7.0 이 범주에 대한 총평 — **도입이 아니라 설계 차용** + +| 도구 | 감지 단위 | 우리 요구와의 불일치 | +|---|---|---| +| changedetection.io | 페이지 텍스트 블록 | DMF 는 **레코드 집합**이다. "이 페이지의 3줄이 바뀜"이 아니라 "등록번호 XXX 가 신규/변경/취하"를 알아야 한다 | +| urlwatch | URL·명령 출력의 unified diff | 위와 동일. 또한 xlsx 리포트 생성 기능이 없다 | +| huginn | 이벤트 그래프 | Ruby/Rails + DB. Windows 단일 PC 에 얹기엔 과중 | +| csvdiff | **레코드 키 기반 added/removed/changed** | ✅ 맞음. 단 2021 아카이브 | + +**결정: 우리 diff 모듈은 csvdiff 의 출력 스키마를 채택하고, 필터/알림/스케줄 개념은 urlwatch·changedetection.io 에서 가져오되, 도구 자체는 도입하지 않는다.** + +### 7.1 `dgtlmoon/changedetection.io` [F#7][F#55][F#61] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/dgtlmoon/changedetection.io | +| 설명 | "Best and simplest tool for website change detection, web page monitoring, and website change alerts. Perfect for tracking content changes, price drops, restock alerts, and website defacement monitoring—all for free or enjoy our SaaS plan!" | +| ★ / Fork | **33.5k / 2.0k** | +| 언어 | Python | +| 커밋 | master 2,448 commits | +| 최신 릴리스 | **`0.55.8`** (13 Jul 09:26) | + +**콘텐츠 필터·감지** +- CSS Selectors, XPath (1.0 & 2.0) +- JSONPath, jq 필터 (API 모니터링용) +- HTML 페이지 내 embedded JSON 추출 +- Visual Selector 도구 + +**모니터링** +- **word / line / character 레벨 diff 시각화** +- 인터랙티브 브라우저 스텝 (로그인, 폼 채우기, 버튼 클릭) +- PDF 변경 추적 +- 커스터마이즈 가능한 체크 간격 + +**알림**: Apprise 통합 — Discord, Email, Slack, Telegram, webhooks 등 90+ 서비스, Jinja2 템플릿 + +**스케줄**: 타임존 인식, 요일·시간 제한 지원 + +**고급**: LiteLLM 연동 AI 변경 감지·요약, REST API, Chrome 확장, 프록시(Bright Data 포함) + +**설치** +```bash +# Docker Compose +docker compose up -d + +# Docker standalone +docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io + +# pip +pip3 install changedetection.io +changedetection.io -d /path/to/empty/data/dir -p 5000 +``` +접속: `http://127.0.0.1:5000` + +**REST API (x-api-key 헤더)** [F#27] — Settings > API 에서 키 발급 + +```bash +# 감시 목록 +curl -X GET "http://localhost:5000/api/v1/watch" \ + -H "x-api-key: YOUR_API_KEY" + +# 감시 생성 +curl -X POST "http://localhost:5000/api/v1/watch" \ + -H "x-api-key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://example.com", + "title": "My Monitor", + "time_between_check": {"hours": 1} + }' + +# 단건 조회 +curl -X GET "http://localhost:5000/api/v1/watch/{uuid}" \ + -H "x-api-key: YOUR_API_KEY" + +# 수정 +curl -X PUT "http://localhost:5000/api/v1/watch/{uuid}" \ + -H "x-api-key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"notification_muted": true}' + +# 삭제 +curl -X DELETE "http://localhost:5000/api/v1/watch/{uuid}" \ + -H "x-api-key: YOUR_API_KEY" + +# 이력 +curl -X GET "http://localhost:5000/api/v1/watch/{uuid}/history" \ + -H "x-api-key: YOUR_API_KEY" + +# 최신 스냅샷 +curl -X GET "http://localhost:5000/api/v1/watch/{uuid}/history/latest" \ + -H "x-api-key: YOUR_API_KEY" + +# 이전↔최신 비교 +curl -X GET "http://localhost:5000/api/v1/watch/{uuid}/difference/previous/latest?format=htmlcolor" \ + -H "x-api-key: YOUR_API_KEY" + +# 전역 알림 URL 등록 +curl -X POST "http://localhost:5000/api/v1/notifications" \ + -H "x-api-key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"notification_urls": ["mailto:admin@example.com"]}' +``` + +추가 동작: 강제 재확인은 GET watch 엔드포인트에 `?recheck=1`, 일시정지/음소거는 `?paused=paused` / `?muted=muted`. 연결 URL 형식은 로컬 `http://localhost:5000/api/v1/`, 호스티드 `https:///api/v1/`. + +**알림 템플릿 토큰** [F#55] (정확히 인용): + +``` +{{base_url}} +{{current_snapshot}} +{{diff}} +{{diff_full}} +{{diff_added}} +{{diff_removed}} +{{watch_url}} +{{triggered_text}} +``` + +예시 본문: +```json +{ + 'myKey': 1234, + 'url': '{{watch_url|tojson}}' +} +``` + +주의: `{{current_snapshot}}`, `{{diff}}`, `{{diff_full}}` 은 길어서 서비스 길이 제한(Discord 2,000자)을 넘길 수 있다. "your notification body contains at least something" 을 보장할 것. + +> **베낄 것** +> - **`{{diff_added}}` / `{{diff_removed}}` 로 알림 본문을 조립하는 토큰 설계** → 우리 notify 모듈의 템플릿 변수명을 이것과 동일하게 맞춘다(`added`, `removed`, `changed`, `report_path`, `run_date`). +> - **알림 본문 길이 상한을 서비스별로 강제**하는 규칙. Apprise `windows://` 는 250자 제한이므로 토스트에는 요약 카운트만, 상세는 xlsx 링크로. +> - **타임존 인식 + 요일/시간 제한 스케줄** 개념. 우리는 매일 06:00 고정이지만, 공휴일 스킵 옵션을 `config/schedule.yaml` 로 열어둔다. +> - 강제 재확인(`?recheck=1`) 에 대응하는 수동 실행 커맨드 `dmf daily --force`. + +### 7.2 `thp/urlwatch` [F#5][F#26][F#50][F#60] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/thp/urlwatch | +| 설명 | "Watch (parts of) webpages and get notified when something changes via e-mail, on your phone or via other means. Highly configurable." | +| ★ / Fork | **3.1k / 354** | +| 언어 | Python | +| 커밋 | master 974 commits | +| 릴리스 | **"There aren't any releases here"** — 공식 릴리스 없음 [F#60] | +| 문서 | https://urlwatch.readthedocs.io/ | +| 홈페이지 | https://thp.io/2008/urlwatch/ | +| 토픽 | automation, monitor, python, webpage | + +**URL 잡 형식** [F#26]: +```yaml +name: "urlwatch homepage" +url: "https://thp.io/2008/urlwatch/" +``` + +URL 잡 옵션: `url`(필수), `name`, `method`(기본 GET), `data`(POST/PUT 페이로드), `headers`, `cookies`, `encoding`, `filter`, `ignore_connection_errors`. + +**셸 잡 형식**: +```yaml +name: "What is in my Home Directory?" +command: "ls -al ~" +``` + +**설정 저장/실행**: 잡은 `urls.yaml` 에 저장하고 `urlwatch --edit` 로 편집. `urlwatch --list` 로 인덱스 번호와 함께 목록 표시. 각 잡은 `---` 만 있는 줄로 구분. 메인 설정 파일에 `job_defaults` 섹션을 두어 전 잡에 공통 설정 적용 가능(키 반복 제거). + +**내장 필터 28종** [F#50] (정확히 인용): + +``` +beautify, css, csv2text, element-by-class, element-by-id, element-by-style, +element-by-tag, format-json, grep, grepi, hexdump, html2text, pdf2text, +pretty-xml, ical2text, ocr, re.sub, re.findall, reverse, sha1sum, shellpipe, +sort, remove-duplicate-lines, strip, striplines, xpath, jq +``` + +필터 체인 예시: +```yaml +url: https://example.net/css.html +filter: + - css: ul#groceries > li.unchecked + - html2text +``` + +diff 관련: `diff_filter` 는 "applied to the diff result before reporting the changes" 이며, `--test-diff-filter` 로 캐시된 과거 데이터로 테스트 가능. `diff_tool` 은 Filters 페이지에 문서화되어 있지 않음(⚠️ 미검증 — Configuration 페이지 확인 필요). + +> **베낄 것** +> - **`config/sources.yaml` 의 스키마를 urlwatch 잡 스키마에 맞춘다**: `name`, `url`, `method`, `headers`, `params`, `encoding`, `ignore_connection_errors`, `filter`(체인). +> - **`job_defaults` 상속 개념** — 타임아웃·User-Agent·재시도 횟수를 소스마다 반복하지 않는다. +> - **필터를 이름 붙은 체인으로 선언**하고 코드가 아니라 설정으로 조합. 우리는 `strip`, `sort`, `re.sub`, `jq` 정도만 있으면 충분하다. +> - **`ignore_connection_errors`** — nedrug 게시판이 일시 장애일 때 파이프라인 전체를 죽이지 않는 플래그. 채택. +> - **`--test-diff-filter` 처럼 과거 캐시로 diff 로직을 테스트하는 서브커맨드** → `dmf diff --replay 2026-09-01 2026-09-02`. +> +> **경고**: 릴리스가 없는 저장소다. 의존성으로 채택하지 않는다. + +### 7.3 `huginn/huginn` [F#8][F#65][F#68] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/huginn/huginn | +| 설명 | "Create agents that monitor and act on your behalf. Your agents are standing by!" | +| ★ / Fork | **49.9k / 4.3k** | +| 언어 | Ruby (Rails) | +| 커밋 | master 4,134 commits | +| 라이선스 | MIT | + +에이전트 타입(확인된 것): WeatherAgent, WebsiteAgent, EmailAgent, TwitterAgent, 그리고 HipChat / FTP / IMAP / Jabber / JIRA / MQTT 커넥터, JavaScript 실행 에이전트, 위치 추적. + +동작 원리: "Huginn's Agents create and consume events, propagating them along a directed graph." "send digest email with things that you care about at specific times during the day" + +**`WebsiteAgent` 실측** [F#68]: +- 정의: "The Website Agent scrapes a website, XML document, or JSON feed and creates Events based on the results." +- **mode 옵션 3종**: + - `all` — 모든 추출 결과에 대해 이벤트 생성 + - `on_change` — 이전 결과와 다를 때만 이벤트 생성 + - `merge` — 기존 페이로드를 유지하며 새 값으로 갱신 +- **기본 스케줄: `every_12h`** +- extract 설정 예시: +```json +"extract": { + "url": { "css": "#comic img", "value": "@src" }, + "title": { "css": "#comic img", "value": "@alt" }, + "hovertext": { "css": "#comic img", "value": "@title" } +} +``` +CSS selector, XPath, JSON path, regex 를 문서 타입에 따라 지원. + +**주의**: Huginn wiki 의 Agent-Types 페이지는 로딩 오류로 `ChangeDetectorAgent`, `DeDuplicationAgent`, `DigestAgent`, `EmailDigestAgent`, `SchedulerAgent`, `ShellCommandAgent` 의 설명을 확보하지 못했다 [F#65]. ⚠️ 미검증. + +> **베낄 것** +> - **`mode: all / on_change / merge` 3분류를 diff 모듈의 출력 모드로 그대로 채택.** +> - `all` = 전체 스냅샷 시트 +> - `on_change` = 변경 건만 담은 시트 (일일 리포트의 메인) +> - `merge` = 마스터 테이블 갱신 (누적 현황 시트) +> - **`extract` 를 선언적 데이터로 표현**하는 방식 — 게시판 파싱 규칙을 `config/sources.yaml` 안에 `{"필드": {"css": "...", "value": "..."}}` 형태로 넣는다. +> +> **기각**: Ruby/Rails 스택 전체. + +### 7.4 `larsyencken/csvdiff` — diff 출력 스키마의 정본 [F#32] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/larsyencken/csvdiff | +| 설명 | "Generate a diff between two tabular datasets expressed in CSV files." | +| ★ / Fork | **131 / 31** | +| 언어 | Python | +| 상태 | **2021년 2월 18일 아카이브 (read-only, 유지보수 종료)** | +| 라이선스 | BSD-3-Clause | + +CLI: +```bash +csvdiff --style=summary KEY file1.csv file2.csv +csvdiff --style=summary id a.csv b.csv +``` +옵션: `--style`(summary, pretty), `--output`(JSON 출력 파일), `--ignore-columns`(제외 컬럼 콤마 목록), `--significance`(수치 비교 정밀도, 음수는 자릿수) + +**JSON 출력 구조** (이것을 채택한다): +- `_index` — 키 컬럼 배열 +- `added` — 새 행 전체 필드 +- `removed` — 삭제된 행 전체 필드 +- `changed` — 변경 행. 키별로 필드 레벨 "from/to" + +Python API: +```python +import csvdiff +patch = csvdiff.diff_files('a.csv', 'b.csv', ['id']) +patch = csvdiff.diff_records(records_a, records_b, ['id']) +``` +patch 적용 메서드도 제공. + +> **채택 결정**: **JSON 스키마와 CLI 옵션 이름은 채택, 라이브러리는 미채택(아카이브).** 우리 `diff/` 모듈이 동일 구조를 만들어낸다: +> ```json +> { +> "_index": ["DMF_PERMIT_NO"], +> "added": [ { "DMF_PERMIT_NO": "...", "INGR_KOR_NAME": "...", ... } ], +> "removed": [ { "DMF_PERMIT_NO": "...", ... } ], +> "changed": { +> "20250001": { +> "MNFCTR_PLACE": { "from": "구주소", "to": "신주소" } +> } +> } +> } +> ``` +> `--ignore-columns` 는 필수다 — 조회수·수집시각 같은 노이즈 필드를 diff 에서 빼야 오탐이 사라진다. + +### 7.5 `ecprice/newsdiffs` [F#43] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/ecprice/newsdiffs | +| 설명 | "A website and framework that tracks changes in online news articles over time." | +| ★ / Fork | **506 / 136** | +| 언어 | Python (Django) | + +아키텍처: +- **스냅샷 저장**: 기사별 git 저장소가 아니라 **디렉터리 기반**. 셋업 시 생성되는 `articles` 디렉터리에 저장 +- **diff 생성**: 스크래퍼가 주기적으로 실행되어 버전을 캡처하고 스냅샷 간 변경 탐지 +- **스케줄링**: cron 또는 루프 + ```bash + while true; do python website/manage.py scraper; sleep 60m; done + ``` +- **파서 프레임워크**: `parsers/` 디렉터리에 모듈식, `BaseParser` 를 상속한 사이트별 서브클래스 +- **로깅**: 진행 로그 `/tmp/newsdiffs_logging`(per-run), 에러 로그 `/tmp/newsdiffs/logging_errs`(cumulative) + +> **베낄 것**: **per-run 로그와 누적 에러 로그의 분리.** 우리는 `logs/runs/YYYY-MM-DD.log`(실행별) 와 `logs/errors.log`(누적)로 구현한다. 워치독이 감시할 대상은 후자다. 또한 `BaseParser` 상속 구조 — 소스가 늘어날 때 파서만 추가하면 되는 형태. + +### 7.6 `simonw/git-scraper-template` [F#47] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/simonw/git-scraper-template | +| 설명 | "Template repository for setting up a new Git scraper using GitHub Actions." | +| ★ / Fork | **132 / 10** | + +동작: GitHub Actions 가 `./download.sh`(curl) 로 URL 내용을 받아 변경이 있으면 저장소에 커밋. 기본 스케줄 24시간마다. 파일: `.github/workflows/scrape.yml`, `README.md`, `download.sh`, `scrape.sh`(템플릿 생성 시 만들어짐). 파이썬 스크래퍼는 `scrape.yml` 의 주석 블록을 풀고 `requirements.txt` 를 두면 지원. + +관련 개념 [S#24]: "Git scrapers can grab data periodically, commit it to a repository if changed, creating a commit log of changes to information over time." Simon Willison 의 `git-history` 도구가 이렇게 수집한 데이터를 분석하는 데 쓰인다. + +> **베낄 것**: **`data/snapshots/` 를 로컬 git 리포지토리로 만들어 매일 커밋한다.** 이러면 (1) 변경 이력이 공짜로 생기고 (2) `git diff` 로 언제든 재검증 가능하며 (3) 스냅샷 파일이 무한히 쌓이지 않는다. 원격 push 는 하지 않는다(사내 데이터). + +--- + +## 8. (e) 크롤링 → 엑셀/시트 리포트 파이프라인 + +### 8.1 `Bwhiz/Auto-Excel-Reports` [F#16] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/Bwhiz/Auto-Excel-Reports | +| 설명 | "Scripts and workflows to automate the generation and distribution of Excel reports using Python's openpyxl library" + GitHub Actions 로 "scheduled and event-triggered report generation and email distribution" | +| ★ / Fork | 1 / 0 | +| 언어 | Python | +| 파일 | `.github/workflows/`, `assets/`, `report_script.py`, `auto_mail.py`, `requirements.txt`, `.gitignore`, `README.md` | + +핵심: +1. **리포트 생성**: openpyxl 로 워크북 생성·서식 +2. **스케줄**: GitHub Actions cron — 예시 워크플로가 `schedule: - cron: '0 0 * * *'` (매일 실행) +3. **배포**: `auto_mail.py` 가 SMTP 로 발송, 발신자 자격증명·수신자 주소는 환경변수 +4. **보안**: GitHub Secrets 로 "securely handle sensitive information like email credentials" + +> **베낄 것**: **`report_script.py`(생성)와 `auto_mail.py`(배포) 파일 분리** — 리포트를 만들 수 있는데 배포에서 실패하는 경우와, 애초에 리포트를 못 만드는 경우를 로그에서 구분할 수 있어야 한다. 우리는 `report/` 와 `notify/` 로 모듈 분리. +> **기각**: GitHub Actions(로컬 실행 요구), SMTP(1차 채널은 Windows 토스트). + +### 8.2 XlsxWriter — 탭 연동 리포트의 실제 API [F#58] + +우리 요구사항 "**탭(시트)별로 연동된 보기 좋은 xlsx**" 를 만족시키는 정확한 API 는 다음과 같다. (출처: https://xlsxwriter.readthedocs.io/worksheet.html) + +**내부 하이퍼링크 — `write_url()`** +```python +write_url(row, col, url[, cell_format[, string[, tip]]]) +``` +```python +# 현재 워크시트의 셀로 링크 +worksheet.write_url('A1', 'internal:Sheet2!A1') + +# 다른 워크시트의 셀로 링크 +worksheet.write_url('A2', 'internal:Sheet2!A1:B2') + +# 시트명에 공백이 있으면 작은따옴표로 감싼다 +worksheet.write_url('A3', "internal:'Sales Data'!A1") +``` + +**표 — `add_table()`** +```python +add_table(first_row, first_col, last_row, last_col, options) +``` +```python +worksheet.add_table('B3:F7', { ... }) +# 또는 행-열 표기 +worksheet.add_table(2, 1, 6, 5, { ... }) +``` + +**컬럼 너비 — `set_column()`** +```python +set_column(first_col, last_col, width, cell_format, options) +``` +```python +worksheet.set_column(0, 0, 20) # A열 너비 20 +worksheet.set_column(1, 3, 30) # B-D열 너비 30 +worksheet.set_column('E:E', 20) # E열 너비 20 +worksheet.set_column('F:H', 30) # F-H열 너비 30 +``` + +**조건부 서식 — `conditional_format()`** +```python +conditional_format(first_row, first_col, last_row, last_col, options) +``` +```python +worksheet.conditional_format('B3:K12', {'type': 'cell', + 'criteria': '>=', + 'value': 50, + 'format': format1}) +``` + +**확인 실패**: `autofilter()` 와 `freeze_panes()` 는 위 fetch 에서 문서 내용을 확보하지 못했다. ⚠️ 미검증 — 다만 XlsxWriter 에 두 메서드가 존재한다는 것은 널리 알려져 있으므로 구현 시 문서 재확인 필요. + +> **채택**: 리포트 엔진은 **XlsxWriter**. 근거는 위 4개 API 가 한 라이브러리에서 나오고, 특히 `write_url('internal:...')` 이 "탭 간 연동"의 유일한 정공법이기 때문이다. +> **리포트 시트 설계(초안)** +> | 시트명 | 내용 | 연동 | +> |---|---|---| +> | `요약` | 실행일자, 신규/변경/취하 건수, 각 카운트 셀이 해당 시트로 internal 링크 | → 신규/변경/취하 | +> | `신규` | `added` 레코드. `add_table` + `autofilter` | 각 행의 등록번호 → `전체현황` 해당 행 | +> | `변경` | `changed` 레코드. from/to 2열 병기, 조건부 서식으로 변경 필드 강조 | 동일 | +> | `취하` | `removed` 레코드 | 동일 | +> | `전체현황` | 최신 스냅샷 전량(merge 모드) | — | +> | `실행로그` | 소스별 HTTP 상태, 건수, 소요시간, 에러 | — | + +### 8.3 `HasData/playwright-scraping` [F#41] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/HasData/playwright-scraping | +| 설명 | "Web scraping and browser automation using Playwright in both Python and Node.js. It includes scripts for common tasks such as scraping data, interacting with web elements, handling authentication, and managing errors." | +| ★ / Fork | **15 / 4** | +| 커밋 | main 5 commits | + +`Python/` 과 `NodeJS/` 가 동일한 하위 구조: +``` +basics/ (브라우저 실행, headless 모드, 멀티 탭) +scraping/ (텍스트, 링크, 이미지, Shadow DOM, 대기) +selectors/ (CSS, XPath, role 기반, text 기반) +interactions/ (클릭, 폼, 드롭다운, 페이지네이션, 스크롤) +save_data/ (JSON, CSV, PDF, 다운로드, 스크린샷) +auth/ (basic auth, 쿠키) +browser/ (user agent, 프록시, 디바이스 에뮬레이션) +errors/ (재시도 로직, 타임아웃 처리) +debug/ (video/trace 녹화, 일시정지, 콘솔 검사) +``` +시연 대상: Amazon, WooCommerce 상품 데이터, 요소 선택, 폼 상호작용, 페이지네이션, 무한 스크롤. + +> **베낄 것**: 이 **디렉터리 목록을 fetch 모듈의 요구사항 체크리스트로 사용**한다. 특히 +> - `errors/` — 재시도·타임아웃은 처음부터 설계에 넣는다 +> - `debug/` — **실패 시 trace/스크린샷 자동 저장**. nedrug 게시판 구조가 바뀌었을 때 원인 파악의 유일한 단서가 된다. `data/debug/YYYY-MM-DD/` 에 저장. +> - `browser/` — User-Agent 설정. 공공기관 사이트는 기본 UA 를 차단하는 경우가 있다. +> +> **의사결정**: DMF **API 호출은 `requests` 로 충분**하다. Playwright 는 **nedrug 게시판(`/bbs/117`) 파싱에만** 조건부로 도입한다(정적 HTML 이면 requests + BeautifulSoup 로 끝낸다). ⚠️ 미검증 — 게시판이 JS 렌더링인지 실측 필요. + +### 8.4 검색 결과에만 있는 파이프라인 예시들 [S#7][S#17][S#29] + +| 프로젝트 | 요점 | +|---|---| +| `god233012yamil/Excel-Automation-Using-Python` | openpyxl 로 엑셀 읽기/쓰기/수정 예제 모음 | +| `prabudevarajan/Task-Reminder-Automation-...` | pandas + openpyxl + `schedule` 라이브러리. Excel/CSV 에 태스크 저장, 15/7/3/1일 전 이메일, **daily scheduler + 로깅** | +| `ManiMozaffar/linkedIn-scraper` | Playwright 봇 + FastAPI. 결과를 DB 와 **Telegram 채널**에 저장 | +| `dineshk-qa/playwright.slack.reporter` | Playwright 결과(통과/실패/flaky 수)를 Slack 웹훅으로 | +| `nmpowell` gist | 기존 Windows Task Scheduler 태스크와 상호작용하는 파이썬 스크립트 | + +**`schtasks` 로 일일 태스크를 만드는 표준 구문** [S#29]: +``` +schtasks /create /tn "Task Name" /tr path_to_bat_file/run.bat /sc DAILY /st 16:00 +``` +일반적 접근: 파이썬을 호출하는 배치 파일을 만들고 그것을 Task Scheduler 에 등록. + +> **채택**: 우리 06:00 실행 등록 명령의 기본형은 다음과 같다(⚠️ 실측 필요 — 계정/권한/`/ru` `/rl` 옵션). +> ``` +> schtasks /create /tn "DMF_Crawler_Daily" /tr "D:\workspace\DMF_Crawler\ops\tasks\run_daily.cmd" /sc DAILY /st 06:00 /rl HIGHEST /f +> ``` + +### 8.5 대안 경로: `786raees/task-scheduler-python` [F#45] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/786raees/task-scheduler-python | +| 설명 | "The Task Scheduler Python project provides a convenient way to interact with the Windows Task Scheduler using Python and the `win32com.client` library." | +| ★ / Fork | 2 / 0 | +| 언어 | Python | +| 커밋 | main 2 commits | +| 파일 | `.vscode/`, `app/`, `.gitignore`, `LICENSE`(MIT), `README.md`, `main.py`, `requirements.txt` | +| 의존성 | `pip install pywin32` | + +`TaskScheduler` 클래스 메서드: +- `create_task()` — 실행 경로, 인자, **ISO 8601 트리거 시각** +- `get_all_tasks()` — 태스크 열거 +- `toggle_task()` — 활성/비활성 토글 +- `run_task()` — 즉시 실행 +- `delete_task()` — 삭제 + +> **부분채택**: 설치/제거 스크립트에서 `schtasks` 문자열을 조립하는 대신 COM 으로 다루면 **태스크 존재 여부 확인·재등록·상태 조회**가 훨씬 안정적이다. 우리 `ops/tasks/install_tasks.py` 의 구현 방식으로 이 API 형태를 참고한다. 단 코드 복사는 하지 않는다(★2, 2 commits). + +--- + +## 9. (f) AI CLI headless 자동화 + +> **중요한 전제 정정**: 이 프로젝트가 실제로 사용할 CLI 는 **Google Antigravity CLI (`agy`)** 이며 headless 모드는 `agy -p` 다. 그러나 raw dump 는 `agy` 를 조사하지 않았다 — `claude -p`, `gemini -p`, `codex exec` 세 가지만 조사되었다. 따라서 **아래 내용은 "agy 에 사상해야 할 공통 규약"으로 읽어야 하며, `agy` 의 실제 플래그는 전부 ⚠️ 미검증이다.** + +### 9.1 `claude -p` (Claude Code headless) — 가장 상세히 문서화된 레퍼런스 [F#25] + +출처: https://code.claude.com/docs/en/headless + +**기본 사용** +```bash +claude -p "Find and fix the bug in auth.py" --allowedTools "Read,Edit,Bash" +claude -p "What does the auth module do?" +``` +- `-p` (= `--print`) 로 비대화형 실행 +- **종료 코드**: 성공 0, 실패 시 0 이 아닌 값. 잘못된 플래그는 실행 전 stderr 로 보고. 실행 중 발생한 실패(예: 인증 누락)는 결과로 stdout 에 출력 +- `-p` 와 자주 조합: `--continue`, `--allowedTools`, `--output-format` +- `--bg` 는 거부됨. `--cloud` + 태스크 설명도 거부. `--cloud` + 세션 ID + `-p` 는 클라우드 세션에 메시지를 큐잉하고 종료 + +**`--bare` 모드 (CI/스크립트 권장)** +```bash +claude --bare -p "Summarize README.md" --allowedTools "Read" +``` +- hooks, skills, custom commands, subagents, plugins, MCP servers, auto memory, CLAUDE.md 자동 탐색을 **건너뛴다** → 모든 머신에서 동일 결과 +- bare 모드에서는 OAuth 자격증명/시스템 키체인을 읽지 않는다. `ANTHROPIC_API_KEY` 를 환경변수로 설정하거나 `--settings` JSON 에 `apiKeyHelper` 제공 +- bare 모드 기본 도구: Bash, file read, file edit +- 컨텍스트 주입 플래그: + +| 로드할 것 | 플래그 | +|---|---| +| 시스템 프롬프트 추가 | `--append-system-prompt`, `--append-system-prompt-file` | +| 설정 | `--settings ` | +| MCP 서버 | `--mcp-config ` | +| 커스텀 에이전트 | `--agents ` | +| 플러그인 | `--plugin-dir `, `--plugin-url ` | + +- 문서 주석: "`--bare` is the recommended mode for scripted and SDK calls, and will become the default for `-p` in a future release." +- **경고**: `--bare` 없이는 신뢰하지 않은 폴더에서도 프로젝트 `.claude/settings.json` 의 hooks 를 실행하고 `.mcp.json` 의 서버에 연결한다. `-p` 세션은 워크스페이스 신뢰 대화상자도, 서버별 승인 프롬프트도 표시하지 않는다. + +**stdin 파이프** +```bash +cat build-error.txt | claude -p 'concisely explain the root cause of this build error' > output.txt +``` +- 파이프 stdin 상한 **10MB**. 초과 시 명확한 에러와 함께 비정상 종료. 더 큰 입력은 파일로 쓰고 경로를 프롬프트에 참조 +- stdin 을 읽을 수 없으면 stderr 에 경고 후 커맨드라인 프롬프트로 계속. **v2.1.211 이전에는 Windows 에서 읽을 수 없는 stdin 이 세션을 크래시시키거나 출력 없이 조용히 종료시켰다** + +**빌드 스크립트 통합 예 (Windows 이식성 고려한 이스케이프)** +```json +{ + "scripts": { + "lint:claude": "git diff main | claude -p \"you are a typo linter. for each typo in this diff, report filename:line on one line and the issue on the next. return nothing else.\"" + } +} +``` + +**구조화 출력 — 이 프로젝트의 핵심 규약** +- `--output-format` 값: `text`(기본), `json`(result·session ID·metadata 포함), `stream-json`(개행 구분 JSON 스트리밍) +```bash +claude -p "Summarize this project" --output-format json +``` +```bash +claude -p "Extract the main function names from auth.py" \ + --output-format json \ + --json-schema '{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}},"required":["functions"]}' +``` +- 구조화 결과는 응답의 **`structured_output` 필드**에 담긴다. 텍스트 결과는 `result` 필드 +- 스키마가 유효하지 않으면 `Error: --json-schema is not a valid JSON Schema` + 검증기 진단 출력. `format` 키워드(예: `"format": "email"`)는 허용되지만 **주석으로만 취급하고 강제하지 않는다**. v2.1.205 이전에는 잘못된 스키마를 조용히 무시하고 비구조화 텍스트를 반환했다 +- `--output-format json` 사용 시 응답 페이로드에 `total_cost_usd` 와 모델별 비용 내역 포함(클라이언트 측 추정치) + +jq 로 파싱: +```bash +# 텍스트 결과 추출 +claude -p "Summarize this project" --output-format json | jq -r '.result' + +# 구조화 출력 추출 +claude -p "Extract function names from auth.py" \ + --output-format json \ + --json-schema '{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}},"required":["functions"]}' \ + | jq '.structured_output' +``` + +**스트리밍** +```bash +claude -p "Explain recursion" --output-format stream-json --verbose --include-partial-messages +``` +```bash +claude -p "Write a poem" --output-format stream-json --verbose --include-partial-messages | \ + jq -rj 'select(.type == "stream_event" and .event.delta.type? == "text_delta") | .event.delta.text' +``` +- 스트림 마지막 줄은 최종 응답 텍스트·비용·세션 메타데이터를 담은 `result` 메시지 +- 소비자가 느리게 읽으면 큐 배출까지 대기, 최대 30초(v2.1.214 이전엔 약 2초라 대용량 응답 끝이 잘렸다) +- 서브에이전트 메시지는 `parent_tool_use_id` 로 구분(메인은 `null`). `--forward-subagent-text` 또는 `CLAUDE_CODE_FORWARD_SUBAGENT_TEXT` 로 서브에이전트 텍스트·사고 블록도 방출(v2.1.211+) + +**프로세스 수명** +- 백그라운드 Bash 태스크는 최종 결과 반환 + stdin 닫힘 **약 5초 후** 종료. v2.1.163 이전에는 종료하지 않는 백그라운드 프로세스가 `claude -p` 를 무한정 붙잡았다 +- 백그라운드 서브에이전트/워크플로는 5초 유예에서 면제되어 완료까지 대기. v2.1.182 부터 연속 유휴 대기 **최대 10분**으로 상한. `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` 로 조정, `0` 이면 무제한 +- **SIGTERM 으로 중단하면 종료 코드 143.** 진행 중이던 턴은 미완료로 남고 결과가 기록되지 않는다. 턴을 끝내려면 SIGINT 를 보내거나 SDK `interrupt()` 호출. SIGTERM 시 실행 중 Bash 명령의 프로세스 트리를 종료하고 `SessionEnd` 훅을 실행 후 종료 + +> **agy 로 사상할 규약 (⚠️ 플래그명은 실측 필요)** +> | 개념 | claude | gemini | codex | **agy (미검증)** | +> |---|---|---|---|---| +> | 비대화형 프롬프트 | `-p` / `--print` | `-p` | `codex exec` | `agy -p` | +> | JSON 출력 | `--output-format json` | `--format=json` | (문서 미확인) | `?` | +> | 스키마 강제 | `--json-schema` | 없음 | 없음 | `?` | +> | 권한 우회 | `--dangerously-skip-permissions` | — | `--ask-for-approval never` | `?` | +> | 쓰기 허용 | `--allowedTools` | — | `--sandbox workspace-write` | `?` | +> | 전자동 | — | — | `--full-auto` | `?` | +> | 시스템 프롬프트 | `--append-system-prompt-file` | `GEMINI_SYSTEM_MD` 환경변수 | — | `?` | +> | 컨텍스트 최소화 | `--bare` | — | `--ephemeral` | `?` | +> +> **반드시 지킬 것**: (1) 프롬프트는 파일에 두고 `$(cat ...)` 로 주입, (2) 출력은 파일로 리다이렉트 후 파싱, (3) 종료 코드로 성공/실패 분기, (4) **타임아웃을 반드시 걸 것**, (5) 실패해도 파이프라인 전체가 죽지 않게 — AI 요약은 **선택적 보강**이지 필수 경로가 아니다. + +### 9.2 실전 호출 예시 — drew.tech [F#21] + +출처: https://drew.tech/posts/claude-code-as-a-cron-job + +```sh +claude \ + --dangerously-skip-permissions \ + --output-format json \ + --json-schema "$(cat /tmp/schema.json)" \ + -p "$(cat /tmp/prompt.txt)" \ + > /tmp/output.json +``` + +| 플래그 | 용도 | +|---|---| +| `--dangerously-skip-permissions` | 권한 프롬프트 우회 | +| `--output-format json` | 구조화 JSON 반환 | +| `--json-schema` | 제공된 스키마로 출력 검증 | +| `-p` | 파일 또는 stdin 에서 프롬프트 수용 | + +출력 처리: stdout 을 `/tmp/output.json` 으로 리다이렉트 → `sandbox.readFileToBuffer()` 로 읽기 → JSON 파싱 후 `structured_output` 필드 추출 → Zod 스키마로 검증. + +함정: **"snapshots preserve auth state"** — MCP·통합은 자동화 시작 전에 스냅샷에 미리 설정해두어야 한다. PATH/환경변수 이슈는 이 글에서 언급되지 않았다. + +> **이 스니펫이 우리 `orchestrate` 모듈의 원형이다.** 4개 요소 — 스키마 파일, 프롬프트 파일, 출력 파일, 권한 우회 플래그 — 를 그대로 `ops/agent/run_agy.cmd` 에 옮긴다. + +### 9.3 `jshchnz/claude-code-scheduler` [F#9][F#39][F#44][F#57][F#66] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/jshchnz/claude-code-scheduler | +| 설명 | "Put Claude on autopilot. Schedule code reviews, security audits, and anything else - Claude Code runs them automatically, even while you sleep." | +| ★ / Fork | **510 / 37** | +| 언어 | TypeScript | +| 요구사항 | "Claude Code v1.0.33+" | + +파일 구조: +``` +.claude-plugin/ 플러그인 설정 +commands/ CLI 명령 구현 +src/ 소스 +skills/scheduler/ 스케줄러 스킬 모듈 +examples/ 사용 예시 +dist/ 빌드 산출물 +package.json, tsconfig.json, vitest.config.ts +``` + +`src/` 하위 [F#57]: +``` +__tests__/ cron/ history/ logs/ schedulers/ utils/ vcs/ +config.ts index.ts types.ts +``` + +`src/schedulers/` 하위 [F#66] (정확히 인용): +``` +base.ts +darwin.ts +index.ts +linux.ts +windows.ts +``` + +**동작**: 태스크를 OS 네이티브 스케줄러에 등록하고, 예정 시각에 `claude -p "your command"` 를 실행. 출력은 `~/.claude/logs/.log` 에 로깅. + +**OS 지원**: "macOS (launchd), Linux (crontab), Windows (Task Scheduler)" + +**설정 형식**: `.claude/schedules.json`(프로젝트) 또는 `~/.claude/schedules.json`(전역). 속성: `id`, `name`, `trigger`(cron 표현식), `execution`(command, timeout, skipPermissions), 선택적 `worktree`. + +**지원 플래그**: `--dangerously-skip-permissions`(파일 편집·명령 실행 자율 수행). `--output-format` 은 README 에 명시되지 않음. + +**실제 스케줄 JSON 전문** [F#44] (`examples/daily-review.json`, 원문 그대로): + +```json +{ + "version": 1, + "tasks": [ + { + "id": "daily-code-review", + "name": "Daily Code Review", + "description": "Review commits from the previous day for code quality and potential issues", + "enabled": true, + "trigger": { + "type": "cron", + "expression": "0 9 * * 1-5", + "timezone": "local" + }, + "execution": { + "command": "Review all commits from yesterday. Check for: 1) Code quality issues, 2) Security vulnerabilities, 3) Performance concerns, 4) Missing tests. Summarize findings and suggest improvements.", + "workingDirectory": ".", + "timeout": 300 + }, + "tags": ["code-quality", "daily"], + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:00:00.000Z" + } + ], + "settings": { + "defaultTimezone": "local", + "logRetentionDays": 30, + "maxExecutionHistory": 100 + } +} +``` +`examples/` 에는 `daily-review.json` 과 `weekly-audit.json` 두 파일이 있다 [F#39]. + +Windows 에서 스케줄된 Claude 태스크 확인 [S#28]: +``` +schtasks /query /tn "ClaudeSchedule*" +``` +플러그인은 태스크 생성 시 **래퍼(wrapper) 셸 스크립트**를 생성해 Claude Code 를 프롬프트와 함께 호출하고, 그 래퍼를 Task Scheduler 에 등록한다. Task Scheduler 는 시스템 레벨 프로세스이므로 앱을 열지 않아도 실행된다. + +> **베낄 것 (3가지, 전부 채택)** +> 1. **`src/schedulers/{base,darwin,linux,windows}` 어댑터 분리** → 우리는 Windows 만 필요하지만 `ops/tasks/` 에 동일한 인터페이스를 두어 나중에 서버 이관이 가능하게 한다. +> 2. **스케줄 정의를 JSON/YAML 파일로.** 위 JSON 의 필드 구성(`id`, `name`, `enabled`, `trigger.{type,expression,timezone}`, `execution.{command,workingDirectory,timeout}`, `tags`, `settings.{defaultTimezone,logRetentionDays,maxExecutionHistory}`)을 거의 그대로 `config/schedule.yaml` 로 옮긴다. 특히 **`timeout`(초)과 `logRetentionDays`** 는 필수다. +> 3. **태스크 ID 별 로그 파일**(`logs/.log`). 우리는 `logs/runs/-YYYYMMDD.log`. + +### 9.4 Gemini CLI [F#35][S#20] + +`addyosmani/gemini-cli-tips` (★2.4k / Fork 105): + +```bash +# 원샷 +gemini -p "Your prompt here" + +# stdin 파이프 +echo "Count to 10" | gemini +``` +- "output a single response and exit" — 대화형 REPL 진입 없음 +- `--format=json` 으로 프로그램적 소비. "parse the JSON to get the answer or any tool actions details" +- 시스템 프롬프트 교체: +```bash +export GEMINI_SYSTEM_MD="/path/to/custom_system.md" +``` +- 위치: "It transforms Gemini CLI from an interactive assistant into a **backend service** or utility that other programs can call." +- 관련 문서/논의: https://github.com/google-gemini/gemini-cli/discussions/3215 (Headless execution), https://geminicli.com/docs/issue-and-pr-automation/ + +**주의**: 블로그가 소개한 예제 저장소 `github.com/testing-in-production/gemini-jobs` 는 **404** 다 [F#17]. + +### 9.5 Codex CLI [S#26] + +- `codex exec` 는 대화형 TUI 없이 실행 — CI/CD, Git hooks, cron job, 스크립트 자동화용 +- 단일 에이전트 세션을 시작해 태스크를 완료까지 실행, 진행 상황은 stderr 로 스트리밍, 최종 에이전트 메시지는 stdout 으로, 그리고 종료. **승인 프롬프트 없음** +- 기본 샌드박스는 **read-only**. 파일 수정이 필요하면 `--sandbox workspace-write`, 무인 실행은 `--ask-for-approval never` +- GitHub Actions 예시: `codex exec --full-auto` 를 `cron: '0 8 * * 1-5'` 스케줄로 +- 배치 패턴: bash 루프로 `codex exec --full-auto --ephemeral` 를 태스크 목록마다 별도 세션으로 실행 + +> **agy 에 대한 시사점**: 세 CLI 모두 **"기본은 안전(읽기 전용/승인 요구), 자동화는 명시적 플래그로 해제"** 구조다. `agy` 도 동일할 가능성이 높으므로, 부트스트랩 시 `agy --help` 로 (a) 비대화형 프롬프트 플래그, (b) 쓰기 권한 플래그, (c) 승인 우회 플래그, (d) JSON 출력 플래그 4가지를 반드시 식별해 `docs/research/` 에 기록해야 한다. + +### 9.6 Claude Code Desktop 스케줄드 태스크 — 우리가 쓰지 않을 경로 [F#49] + +출처: https://code.claude.com/docs/en/desktop-scheduled-tasks + +세 가지 스케줄링 옵션 비교(문서 표 원문): + +| | Cloud (routines) | Desktop | `/loop` | +|---|---|---|---| +| 실행 위치 | 클라우드, 기본 Anthropic 관리 | 사용자 머신 | 사용자 머신 | +| 머신 켜짐 필요 | No | **Yes** | Yes | +| 열린 세션 필요 | No | No | **Yes** | +| 재시작 후 지속 | Yes | Yes | 만료 전이면 `--resume` 시 복원 | +| 로컬 파일 접근 | No (fresh clone) | **Yes** | Yes | +| MCP 서버 | 태스크별 커넥터 | 설정 파일 + 커넥터 | 세션 상속 | +| 권한 프롬프트 | No (자율 실행) | 태스크별 설정 가능 | 세션 상속 | +| 스케줄 커스터마이즈 | CLI `/schedule` | Yes | Yes | +| 최소 간격 | **1시간** | 1분 | 1분 | + +주요 제약: +- **로컬 태스크는 앱이 열려 있고 컴퓨터가 깨어 있을 때만 발화한다.** 컴퓨터가 자면 실행은 스킵 +- Settings → Desktop app → General 의 **Keep computer awake** 로 유휴 절전 방지 가능. 노트북 뚜껑을 닫으면 여전히 잠듦 +- **놓친 실행**: 앱 시작/기기 깨어남 시 지난 7일 내 놓친 실행을 확인해 **가장 최근에 놓친 1회만** 캐치업 실행하고 나머지는 폐기. "A task scheduled for 9am might run at 11pm if your computer was asleep all day." +- 태스크마다 몇 분의 결정론적 지연 오프셋이 붙어 API 트래픽을 분산 +- 프롬프트 파일: `~/.claude/scheduled-tasks//SKILL.md` (YAML frontmatter 로 `name`/`description`, 본문이 프롬프트). 스케줄·폴더·모델·활성 상태는 이 파일에 없음 +- 태스크가 실행 중 `update_scheduled_task` MCP 도구로 자기 스케줄/프롬프트를 수정 가능 + +> **기각 결정**: Desktop 스케줄드 태스크는 **앱이 열려 있어야 한다**는 치명적 제약 때문에 "재부팅 후에도 자동 복구되는 서비스" 요구를 만족하지 못한다. **Windows Task Scheduler + 워치독**으로 간다. +> **다만 채택할 개념 2가지**: ① **놓친 실행 캐치업 로직** — 06:00 에 PC 가 꺼져 있었다면 부팅 후 1회만 따라잡는다(중복 실행 금지). ② **프롬프트를 YAML frontmatter + 본문 마크다운 파일로 관리**. + +--- + +## 10. (g) Windows 서비스화 · 워치독 · 토스트 + +### 10.1 `winsw/winsw` — 서비스화 1순위 [F#10][F#23][F#59] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/winsw/winsw | +| 설명 | "A wrapper executable that can run any executable as a Windows service, in a permissive license." | +| ★ / Fork | **14.3k / 1.7k** | +| 언어 | C# | +| 커밋 | v3 브랜치 841 commits | +| 최신 릴리스 | **`v3.0.0-alpha.11`** — "29 Jan 02:20" | +| 최신 안정 | **`v2.12.0`** — "28 Jan 16:22" | +| 배포 | GitHub Releases + NuGet/Maven(2.x) | + +**설치 절차** (README 원문 요약): WinSW.exe 또는 WinSW.zip 을 받아 → `myapp.xml` 작성 → `winsw install myapp.xml` → `winsw start myapp.xml` → `winsw status myapp.xml`. 또는 WinSW.exe 를 `myapp.exe` 로 리네임하고 `myapp.xml` 을 나란히 두면 자동 발견된다. + +**XML 설정 요소 전문** [F#23]: + +```xml + + + + + + +1 hour + + +Automatic + + +true + + + + + +catalina.sh +jpda run +catalina.sh +stop + + + + + +C:\\application + + +idle + + + + DomainName\\UserName + Pa55w0rd + true + +``` +설정 XML 은 `%Name%` 형태의 환경변수 확장을 지원한다. + +샘플 파일: https://github.com/winsw/winsw/blob/v3/samples/minimal.xml (필수 옵션만), https://github.com/winsw/winsw/blob/v3/samples/complete.xml (전체 옵션) + +> **채택 (1순위)** — 근거: +> - `` 를 **여러 개 나열**해 1차/2차 재시도 지연을 다르게 줄 수 있다. 요구사항 "서비스가 죽으면 복구" 를 선언적으로 만족. +> - `1 hour` 로 "1시간 정상 동작하면 실패 카운트 리셋" — 무한 재시작 루프 방지. +> - `` 로 로그 로테이션 내장. +> - `Automatic` + `true` 로 **재부팅 후 자동 복구** 요구를 만족. 지연 시작은 부팅 직후 네트워크 미준비 상태를 회피. +> - 파이썬 런타임 의존이 없다(C# 단일 exe). +> +> **우리 XML 초안** (`ops/winsw/dmf-watchdog.xml`): +> ```xml +> +> DMFCrawlerWatchdog +> DMF Crawler Watchdog +> DMF_Crawler 일일 파이프라인 감시 및 복구 안내 +> D:\workspace\DMF_Crawler\.venv\Scripts\python.exe +> -m dmf_crawler watchdog +> D:\workspace\DMF_Crawler +> Automatic +> true +> +> +> +> 1 hour +> +> +> belownormal +> +> ``` +> ⚠️ 미검증 — ``, ``, ``, `` vs `` 의 정확한 요구 여부는 `samples/minimal.xml` 로 실측 필요. + +### 10.2 NSSM — 서비스화 2순위 [F#24][F#51][F#14] + +`kirillkovalenko/nssm` (★1.2k / Fork 169, C++, 버전 **2.24 / 2014-08-31**). README 는 `http://nssm.cc/` 를 공식 문서로 지목하며, 이 저장소는 **커뮤니티 포크로 보인다**(공식 소스 미러 여부 불확실 — ⚠️ 미검증). 핵심 문장: NSSM "can start any application as an NT service and will restart the service if it fails for any reason." + +**CLI 사용법 전문** [F#24] (출처: https://nssm.cc/usage): + +``` +nssm install [] +``` + +``` +nssm set Application C:\path\to\app.exe +nssm set AppDirectory C:\startup\directory +nssm set AppParameters argument1 argument2 + +nssm set AppStdout C:\path\to\output.log +nssm set AppStderr C:\path\to\error.log + +nssm set Start SERVICE_AUTO_START +nssm set Start SERVICE_DELAYED_AUTO_START +``` + +**종료 액션** (애플리케이션 종료 시 반응): `Restart`(자동 재실행) / `Ignore`(중지 상태 유지) / `Exit`(서비스 중지). 레지스트리 경로: `HKLM\System\CurrentControlSet\Services\\Parameters\AppExit` + +**재시작 스로틀링** (CPU 루프 방지 — 임계 밀리초 내 종료 시 재시작 지연): +``` +nssm set AppThrottle 1500 +``` + +**재시작 지연** (재시작 간 강제 간격): +``` +nssm set AppRestartDelay 3000 +``` + +**로그 로테이션**: +``` +nssm set AppRotateFiles 1 +``` + +**제거**: +``` +nssm remove confirm +``` + +**전체 구성 예시**: +``` +nssm install MyService "C:\Program Files\app.exe" +nssm set MyService AppDirectory C:\Program Files +nssm set MyService AppParameters --config settings.ini +nssm set MyService AppStdout C:\logs\output.log +nssm set MyService AppStderr C:\logs\error.log +nssm set MyService Start SERVICE_AUTO_START +nssm set MyService AppThrottle 2000 +nssm set MyService AppRestartDelay 5000 +``` + +**`larsekje/PythonWindowsServices` 의 실전 지식** [F#14] (★1 / Fork 0, Python. 파일: `/logs`, `/scripts`, `/windows_service`, `.gitignore`, `readme.md`, `requirements.txt`): +``` +nssm install "SERVICE_NAME" "PATH_TO_PYTHON.exe" "PATH_TO_PYTHON_SCRIPT.py" +nssm start SERVICE_NAME +``` +요구사항: NSSM 이 시스템 PATH 에 있을 것, requirements.txt 로 파이썬 환경 구성, 그리고 **"NSSM 이 로그 파일을 자동 생성하지 않으므로 로그 파일을 미리 만들어 둘 것"**. + +> **부분채택 (폴백)**: WinSW 설치가 실패하거나 .NET 런타임 문제가 생기면 NSSM 으로 전환한다. `AppThrottle`(1500~2000ms) + `AppRestartDelay`(3000~5000ms) 조합이 WinSW 의 `onfailure delay` 와 등가다. **버전이 2014년이라는 점이 감점 요인.** + +### 10.3 pywin32 서비스 — **기각** [F#15][F#40][F#52][S#16] + +`HaroldMills/Python-Windows-Service-Example` (★21 / Fork 11, Python, MIT). 파일: `.gitignore`, `LICENSE`, `README.md`, `example_service.py`, `example_service.spec`. +- 의존성: "The service should be built in a Python environment that includes the `pywin32` and `pyinstaller` packages." +- 빌드: `pyinstaller example_service.spec` (저장소 루트에서) +- 설치: `build\example_service` 에서 `example_service.exe install` → `example_service.exe start` +- **"The commands must be issued from a command prompt that was run as administrator."** +- 제거: `example_service.exe stop` → `example_service.exe remove` (관리자 권한) +- **치명적 주석**: "as of this writing (2016-03-37), PyInstaller supports Python versions only through 3.5" + +`drmalex07/10554232` gist 의 표준 골격 [F#40]: +```python +class HelloWorldSvc (win32serviceutil.ServiceFramework): + _svc_name_ = "HelloWorld-Service" + _svc_display_name_ = "HelloWorld Service" + + def __init__(self, args): + win32serviceutil.ServiceFramework.__init__(self, args) + self.stop_event = win32event.CreateEvent(None, 0, 0, None) + socket.setdefaulttimeout(60) + self.stop_requested = False +``` +`SvcStop` 은 `self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)` 후 정지 플래그를 세운다. `SvcDoRun` 이 `self.main()` 을 호출하고, 메인 루프는 `if self.stop_requested:` 로 탈출한다. +```python +if __name__ == '__main__': + win32serviceutil.HandleCommandLine(HelloWorldSvc) +``` +스레드 코멘트: 이 기본 패턴은 타임아웃 에러를 만날 수 있으며, 개선판은 `len(sys.argv)` 로 커맨드라인 설치와 실제 서비스 실행을 구분한다. + +**기각의 결정적 근거 — pywin32 issue #1563** [F#52]: +> 요청 내용: `win32serviceutil.ServiceFramework` 서비스가 Windows 복구 액션(자동 재시작 등)을 트리거하는 방식으로 종료되기를 원한다. 복구 액션은 서비스가 `SERVICE_STOPPED` 를 보고하지 않고 끝날 때 발동한다. +> 문제: **`SvcRun()` 이 예외를 던지거나 `sys.exit()` 를 호출하면, pywin32 의 정리 코드가 자동으로 서비스 상태를 `SERVICE_STOPPED` 로 설정해버려 복구 액션이 트리거되지 않는다.** +> 현재 우회책: `os.kill(os.getpid(), signal.SIGABRT)` — 보고자 본인도 "극단적 조치"라고 인정. +> 요청: `SvcRun()` 이 자동 정리(특히 `SetServiceStatus()` 호출)를 건너뛰도록 플래그를 걸 수 있게 해달라. + +관련 [S#16]: 크래시한 서비스의 재시작은 `ChangeServiceConfig2` 로 실패 액션 딕셔너리를 설정해 구성할 수 있다. `win32service` 모듈로 SCM 에 연결해 서비스를 열거하고 상태(Running/Stopped/Paused)를 확인하며 시작·중지·일시정지·재시작할 수 있다. + +> **결정: pywin32 로 서비스를 직접 구현하지 않는다.** 서비스 껍데기는 WinSW/NSSM 에 맡기고, pywin32 는 **워치독이 서비스 상태를 조회할 때만**(`win32service` 열거/상태 조회) 사용한다. + +### 10.4 `michalzobec/autorunsalerts` — 운영 패턴 정본 [F#34] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/michalzobec/autorunsalerts | +| 설명 | "Simple toast notifications for changes to autoruns configurations on windows" | +| ★ | 0 | +| 언어 | PowerShell | + +**2개 스케줄드 태스크 구조**: + +1. **`AutorunsAlert` (SYSTEM 컨텍스트, 60분마다)** — `autorunsc.exe` 로 현재 상태 스캔 → `state.json` 의 이전 기준선과 비교 → 탐지된 모든 변경을 `audit.log` 에 기록 +2. **`AutorunsAlertToast` (사용자 컨텍스트, 15분마다)** — 스캐너가 세운 알림 플래그를 확인하고 토스트를 띄우며 `audit.log` 로 연결 + +파일: +- `autorunsalert.ps1` — 메인 스캔 스크립트 +- `autorunstoast.ps1` — 토스트 전달 스크립트 +- `configuration.json` — 공유 설정 변수 +- `state.json` — 비교용 이전 스캔 기준선 +- `audit.log` — 변경 이력 및 조사 기록 +- `install.ps1` / `uninstall.ps1` — 설치/제거 + +원문 결론: "This separation ensures alerts appear in the user's active session rather than the system session where detection occurs." + +> **채택 (핵심 운영 패턴)**. 우리 대응: +> +> | autorunsalerts | DMF_Crawler | +> |---|---| +> | `AutorunsAlert` (SYSTEM, 60분) | `DMF_Crawler_Daily` (Task Scheduler, 매일 06:00) — 수집·diff·리포트 생성 | +> | `AutorunsAlertToast` (User, 15분) | `DMF_Crawler_Notify` (User 컨텍스트, 15분) — `state/notify_queue.json` 확인 후 토스트 | +> | `state.json` | `data/state.json` (마지막 성공 실행 시각, 마지막 스냅샷 해시) | +> | `audit.log` | `logs/audit.log` (누적 변경 이력) | +> | `configuration.json` | `config/settings.yaml` | +> | `install.ps1` / `uninstall.ps1` | `ops/tasks/install.ps1` / `uninstall.ps1` | +> +> **이 2단 분리가 필수인 이유**: 아래 10.5 참조. + +### 10.5 `Windos/BurntToast` — 그리고 SYSTEM 컨텍스트 제약 [F#33][S#25] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/Windos/BurntToast | +| 설명 | "PowerShell Module for displaying Toast Notifications on Windows 10 and Windows Server 2019 and above." | +| ★ / Fork | **1.7k / 126** | +| 언어 | PowerShell | +| 최신 릴리스 | **v1.1.0** — Urgent 스위치(긴급 알림), 버튼 색상 커스터마이즈 추가 | +| 라이선스 | MIT | + +```powershell +Install-Module -Name BurntToast +New-BurntToastNotification -Text "제목", "본문" +New-BTButton # 인터랙티브 버튼 +# -AppLogo 로 앱 브랜딩 +``` + +**결정적 제약 (원문)**: "The module targets user-context notifications and has limitations when running from SYSTEM or service accounts due to the Windows notification framework's desktop session requirements." + +관련 사례 [S#25]: +- `Badgerati/Hook` — 서비스 상태를 감시하다 중지되면 BurntToast 팝업(MongoDB 서비스 예시) +- Windows 업데이트 알림 패턴 (cyberdrain.com), 재부팅 알림 가이드 (dearing.dev), PDQ 블로그 + +> **채택**: 워치독의 사용자 알림은 **BurntToast(PowerShell) 또는 win11toast(Python)** 로 하되, **반드시 사용자 컨텍스트 태스크에서 실행**한다. 서비스(SYSTEM)에서 직접 토스트를 띄우려는 시도는 하지 않는다. + +### 10.6 파이썬 토스트 라이브러리 3종 비교 + +| 라이브러리 | ★ | Fork | 설치 | 기반 | 특징 | 함정 | +|---|---|---|---|---|---|---| +| `GitHub30/win11toast` [F#12][F#67] | **333** | 24 | `pip install win11toast` | WinRT | `toast()`, `notify()`(논블로킹), `toast_async()`, `buttons`, `on_click`, `image`, `duration` | **실행 시 CWD 가 `C:\Windows\system32` 이므로 `os.chdir()` 필요.** `app_id` 파라미터는 문서에 없음 | +| `DatGuy1/Windows-Toasts` [F#11] | 142 | 9 | `python -m pip install windows-toasts` | WinRT (pywin32 아님) | `Toast()`, `WindowsToaster()`, `text_fields`, `on_activated` | duration 이 short/long 만 (pywin32 대비 제약) | +| `ysfchn/toasted` [F#20] | 31 | 2 | `python -m pip install toasted` | WinRT | **Windows 가 제공하는 모든 요소 지원** — 이미지, select, input, progress | Python 버전 요구사항 미문서화 | + +**win11toast 코드** [F#12][F#67]: +```python +from win11toast import toast +toast('Hello Python🐍') +toast('Hello Python', 'Click to open url', on_click='https://www.python.org') +toast('Hello', 'Click a button', buttons=['Approve', 'Dismiss', 'Other']) +toast('Hello', 'Hello from Python', image='https://example.com/image.png') +toast('Hello Python🐍', duration='long') +``` +```python +from win11toast import notify +notify('Hello Python', 'Click to open url', on_click='https://www.python.org') + +from win11toast import toast_async +async def main(): + await toast_async('Hello Python', 'Click to open url', + on_click='https://www.python.org') +``` +버튼은 프로토콜 활성화 지원: `{'activationType': 'protocol', 'arguments': 'https://google.com', 'content': 'Open Google'}` → 클릭 시 `{'arguments': 'https://google.com', 'user_input': {}}` 반환. 라이선스 MIT. 선행 프로젝트로 winsdk_toast, Windows-Toasts, MarcAlx/notification.py 를 인정. + +**Windows-Toasts 코드** [F#11]: +```python +from windows_toasts import Toast, WindowsToaster +toaster = WindowsToaster('Python') +newToast = Toast() +newToast.text_fields = ['Hello, world!'] +newToast.on_activated = lambda _: print('Toast clicked!') +toaster.show_toast(newToast) +``` + +**toasted 코드** [F#20]: +```python +from toasted import Toast, Progress, Text +import asyncio + +async def main(): + toast = Toast() + toast.elements = [ + Text("File downloader"), + Progress(value="{value}", status="Downloading files...") + ] + await toast.show(dict(value=75/100)) +``` + +`win10toast` (jithurjacob/Windows-10-Toast-Notifications) [S#9] — pip 설치 가능, 커스텀 아이콘·스레드 알림 지원. 가장 널리 쓰이지만 오래된 라이브러리. + +> **채택**: **`win11toast`** 를 1순위(별 수 가장 많고 `on_click` 으로 xlsx 파일 열기 연결 가능), `toasted` 를 백필 진행률 표시용 2순위. **`os.chdir()` 함정은 워치독 스크립트 첫 줄에 반드시 반영.** + +### 10.7 Apprise — 알림 추상화 계층 [F#38][F#56] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/caronc/apprise | +| 설명 | "Push Notifications that work with just about every platform!" | +| ★ / Fork | **17.2k / 652** | +| 언어 | Python | +| 커밋 | master 1,178 commits | +| 설치 | `pip install apprise` | + +```python +import apprise +apobj = apprise.Apprise() +apobj.add('mailto://myuserid:mypass@gmail.com') +apobj.notify(body='notification text', title='my title') +``` + +URL 형식: + +| 서비스 | URL | +|---|---| +| Windows Toast | `windows://` | +| Slack | `slack://TokenA/TokenB/TokenC/Channel` | +| Telegram | `tgram://bottoken/ChatID` | +| Email | `mailto://userid:pass@domain.com`, `mailtos://`(보안) | + +**`windows://` 상세** [F#56]: +- 필요 의존성: `pip install pywin32` +- 파라미터: `duration` — "Optionally set the duration of the popup message in seconds. By default this value is set to `12`". 예: `windows://?duration=5` +- **제약: "this notification can not be sent from one PC to another."** — 같은 시스템에만 전송 가능 +- 메시지 사양: 아이콘 지원, 텍스트 포맷, **메시지당 최대 250자** + +> **채택**: 알림 채널을 `config/settings.yaml` 의 URL 문자열 리스트로 선언하고 Apprise 로 일괄 전송한다. 100+ 서비스를 코드 수정 없이 갈아끼울 수 있다. +> ```yaml +> notify: +> urls: +> - "windows://?duration=8" +> # - "tgram:///" +> # - "mailto://user:pass@company.co.kr" +> max_body_chars: 250 # windows:// 제약에 맞춤 +> ``` +> **단, 리치 토스트(버튼/이미지/클릭 시 xlsx 열기)는 Apprise 로 불가능**하므로 `notify/toast.py` 에서 `win11toast` 를 직접 호출하는 이중 경로를 유지한다. + +### 10.8 Windows 워치독 최종 설계 (종합) + +``` +[Task Scheduler] + ├─ DMF_Crawler_Daily 매일 06:00, 최고 권한 + │ → run_daily.cmd → python -m dmf_crawler daily + │ 성공: state.json 갱신 + notify_queue.json 에 요약 push + │ 실패: logs/errors.log 기록 + notify_queue.json 에 실패 push + │ + ├─ DMF_Crawler_Notify 15분마다, 사용자 컨텍스트(로그온 시) + │ → python -m dmf_crawler notify-drain + │ notify_queue.json 을 비우며 win11toast 로 표시 + │ + └─ DMF_Crawler_Catchup 로그온 시 1회 + → python -m dmf_crawler daily --catchup + state.json 의 마지막 성공일이 오늘 이전이면 1회만 따라잡기 + +[WinSW 서비스: DMFCrawlerWatchdog] (선택, 상시 감시가 필요할 때) + → python -m dmf_crawler watchdog + · Task Scheduler 태스크 3개의 존재/활성 상태를 주기 확인 (win32com/win32service) + · 마지막 성공 실행이 26시간을 넘으면 notify_queue.json 에 경보 push + · 자기 자신이 죽으면 WinSW 가 복구 +``` + +**왜 서비스와 스케줄드 태스크를 둘 다 쓰는가**: 스케줄드 태스크는 "정시에 한 번 도는 일"에 최적이고, 서비스는 "죽었는지 지켜보는 일"에 최적이다. autorunsalerts 가 검증한 SYSTEM/User 분리에, WinSW 의 `onfailure` 자동 복구를 얹은 형태다. + +--- + +## 11. (h) awesome 리스트 및 기타 참고 + +### 11.1 `lorien/awesome-web-scraping` [F#13] + +| 항목 | 값 | +|---|---| +| URL | https://github.com/lorien/awesome-web-scraping | +| 설명 | "List of libraries, tools and APIs for web scraping and data processing." | +| ★ / Fork | **8.1k / 934** | +| 커밋 | master 640 commits | + +구조: +``` +python.md Python 패키지 +javascript.md JavaScript 패키지 +php.md PHP 패키지 +ruby.md Ruby 패키지 +golang.md Go 패키지 +cli.md 커맨드라인 도구 +manuals.md 교육 자료·서적 +``` +README 링크: https://github.com/lorien/awesome-web-scraping/blob/master/README.md + +**직접 확인한 사실**: 이 저장소에는 **변경 감지·스케줄링·엑셀/리포팅 섹션이 없다.** 큐레이션 목록이지 모니터링 도구가 아니다. 캡차 해결 서비스, 프록시 마켓플레이스 참조, Telegram 커뮤니티 링크, `CONTRIBUTING.md` 를 포함. + +> **활용 범위**: `python.md` 를 라이브러리 선정 시 참고. 그 이상은 없다. + +### 11.2 awesome 파생 리스트 (⚠️ 미검증) + +| 저장소 | 요점 | +|---|---| +| `noirquant/awesome-web-scraping` | lorien 포크 | +| `jjwangnlp/awesome-web-scraping` | lorien 포크 | +| `luminati-io/Awesome-Web-Scraping` | HTTP 라이브러리·브라우저 자동화·프록시 서비스 포함 | +| `spinov001-art/awesome-web-scraping-2026` | "130+ web scraping tools — Python, JavaScript, Go, Rust. Anti-detection, proxies, cloud platforms. Updated weekly. Includes free API alternatives." | +| `duyet/awesome-web-scraper` | "A collection of awesome web scaper, crawler." | +| `patrickloeber/llm-data-scrapers` | "A list of useful Open Source tools and scrapers to gather data for LLMs" | +| `realpython/list-of-python-api-wrappers` | 파이썬 API 래퍼 목록 | + +**awesome-pharma-data 같은 제약 전용 awesome 리스트는 존재가 확인되지 않았다.** 대신 GitHub Topics 페이지가 그 역할을 한다: + +| Topic | URL | +|---|---| +| `pharmaceutical-data` | https://github.com/topics/pharmaceutical-data (7개 공개 저장소 — [F#19] 에서 전량 열거) | +| `pharmaceuticals` | https://github.com/topics/pharmaceuticals?l=python | +| `pharma` | https://github.com/topics/pharma?o=desc&s=updated | +| `fda` | https://github.com/topics/fda | +| `open-fda` | https://github.com/topics/open-fda | +| `openfda` | https://github.com/topics/openfda?l=r&o=desc&s=updated | +| `openpyxl` | https://github.com/topics/openpyxl?o=asc&s=forks | +| `openpyxl-python` | https://github.com/topics/openpyxl-python | +| `excelwriter` | https://github.com/topics/excelwriter?l=python | +| `python-excel` | https://github.com/topics/python-excel | +| `xlsxwriter` | https://github.com/topics/xlsxwriter?l=python | +| `scheduled-tasks` | https://github.com/topics/scheduled-tasks?l=python&o=desc&s=updated | +| `task-scheduler` | https://github.com/topics/task-scheduler?l=powershell | +| `playwright-python` | https://github.com/topics/playwright-python?o=asc&s=updated | +| `playwright` | https://github.com/topics/playwright?l=python | +| `python-scraper` | https://github.com/topics/python-scraper | +| `huginn` | https://github.com/topics/huginn?o=asc&s=stars | +| `llm-pipeline` | https://github.com/topics/llm-pipeline | + +> **활용**: `pharmaceutical-data` 토픽에는 저장소가 **7개뿐**이다(2026-09 기준). 이 분야에 오픈소스가 거의 없다는 사실 자체가, 우리가 직접 만들어야 한다는 결론을 강화한다. + +### 11.3 벤더/블로그 자료 (코드 없음, 배경 지식) + +| 자료 | URL | 요점 | +|---|---|---| +| PageCrawl.io: 오픈소스 변경감지 도구 비교 | https://pagecrawl.io/blog/open-source-website-change-detection-tools | 도구를 **목적형 모니터(changedetection.io, urlwatch)** 와 **범용 자동화 플랫폼(Huginn, n8n)** 으로 이분. 선택 기준: JavaScript 렌더링, 노이즈 필터링, 알림 폭, 실패 가시성, 유지보수 부담 | +| GIGAZINE: changedetection.io 리뷰 | https://gigazine.net/gsc_news/en/20260517-changedetection-io | 셀프호스트 모니터링 도구 리뷰 | +| alternativeto: urlwatch / changedetection.io 대안 | https://alternativeto.net/software/urlwatch , https://alternativeto.net/software/changedetection-io/ | Huginn 이 changedetection.io 의 최선 대안으로 언급 | +| Simon Willison: Git scraping | https://simonwillison.net/2020/Oct/9/git-scraping/ | "track changes over time by scraping to a Git repository". `git-history` 도구 | +| Oxylabs / Flipnode / JC Chouinard / Biztory / Oreate AI | (부록 A 참조) | Python 스크래퍼 + Windows Task Scheduler 자동화 튜토리얼 | +| PDQ: BurntToast | https://www.pdq.com/blog/display-toast-notifications-with-powershell-burnt-toast-module/ | "perfect for script completion alerts, Pomodoro timers, or nudging users to reboot" | +| Infonautics / usro.net / ehmiiz.se | (부록 A 참조) | WinSW 로 프로그램을 서비스화하는 단계별 가이드, PowerShell 스크립트 서비스화 | +| Codex Knowledge Base (danielvaughan.com) 4편 | (부록 A 참조) | `codex exec` 헤드리스·배치·CI·스케줄드 에이전트 | +| SmartScope / MindStudio / wmedia.es / hidekazu-konishi / StackNotice / Usagebar / DevShelfHub / HeyClaude / LikeOne / BuildThisNow | (부록 A 참조) | Claude Code 헤드리스·cron 가이드 다수 | +| Level Up Coding: Claude Code Routines | https://levelup.gitconnected.com/claude-code-routines-the-cron-replacement-i-didnt-know-i-needed-6f53cf476577 | Routines 를 cron 대체로 | +| MCP Market: Windows Task Scheduler Skill | https://mcpmarket.com/tools/skills/windows-task-scheduler | Claude Code 로 Windows Task Scheduler 잡을 만들고 관리하는 스킬 | +| Apify 스크래퍼들 | https://apify.com/labrat011/fda-orange-book-scraper/api , https://apify.com/fortuitous_pirate/openfda-scraper/api/python , https://apify.com/benthepythondev/openfda-drug-intelligence/api/python | FDA Orange Book / openFDA 상용 스크래퍼. Drugs@FDA 28,000+ 신청(NDA/ANDA/BLA) | +| John Snow Labs / PharmaCompass / pharmaexcipients / fdapals / dmf-list.backgroundscheck.info | (부록 A 참조) | FDA DMF 디렉터리 상용 데이터 | +| 한국어 크롤링 블로그 | velog `naverPillCrawling`, samslow.github.io 식품안전나라 크롤링 가이드, velog 공공데이터 포털API사용하기 | Selenium/BeautifulSoup 기반 국내 의약품 크롤링 사례 | + +### 11.4 라이선스 주의 표 + +코드를 참고할 때 반드시 확인해야 할 항목이다. + +| 저장소 | 라이선스 | 코드 복사 가능? | +|---|---|---| +| `anton-semerenko/pharma-radar` | MIT | 가능(출처 표기) | +| `mrueda/nomenclator-delta` | MIT | 가능(출처 표기) | +| `logiover/fda-data-scraper` | MIT | 가능 | +| `huginn/huginn` | MIT | 가능 | +| `GitHub30/win11toast` | MIT | 가능 | +| `Windos/BurntToast` | MIT | 가능 | +| `HaroldMills/Python-Windows-Service-Example` | MIT | 가능 | +| `786raees/task-scheduler-python` | MIT | 가능 | +| `larsyencken/csvdiff` | BSD-3-Clause | 가능(고지 유지) | +| **`suriyadeepan/WebScraping-for-Healthcare`** | **GPL-3.0** | **불가 — 설계만 참고** | +| 나머지 | 미확인 | ⚠️ 복사 전 확인 필수 | + +--- + +## 12. 채택 결정 표 (채택 / 부분채택 / 기각) + +### 12.1 저장소·도구별 결정 + +| 후보 | 결정 | 이유 (1~2문장) | +|---|---|---| +| `mrueda/nomenclator-delta` | **채택 (구조 정본)** | collection/normalization/diffing/validation 4단 분리와 `data/`(스냅샷+이력) 레이아웃이 우리 문제와 1:1 대응한다. CLI 서브커맨드 형태(`python -m pkg validate data`)까지 그대로 가져온다. | +| `anton-semerenko/pharma-radar` | **채택 (에이전트 계층 정본)** | `prompts/` + `config/sources.yaml` + 의존성 최소 `deliver` 계층, 그리고 "≥2 독립 출처 또는 1 공식 1차 출처" 검증 정책. 매일 06:00 무인 실행이라는 동일한 운영 형태의 존재 증명. | +| `larsyencken/csvdiff` | **부분채택 (스키마만)** | `_index`/`added`/`removed`/`changed` JSON 구조와 `--ignore-columns`/`--significance` 옵션은 우리 diff 요구에 정확히 맞다. 단 2021-02-18 아카이브라 **라이브러리 의존은 금지**하고 자체 구현한다. | +| `huginn/huginn` | **부분채택 (개념만)** | `mode: all/on_change/merge` 3분류를 diff 출력 모드로 채택. Ruby/Rails 스택 전체는 Windows 단일 PC 에 과중하므로 기각. | +| `dgtlmoon/changedetection.io` | **부분채택 (개념만)** | 알림 템플릿 토큰(`{{diff_added}}` 등), 필터 체인, 타임존 스케줄, 본문 길이 제한 규칙을 차용. 도구 자체는 텍스트 블록 diff 라서 레코드 키 기반 판정을 못 한다. | +| `thp/urlwatch` | **부분채택 (설정 스키마만)** | `urls.yaml` 잡 스키마와 `job_defaults` 상속, `ignore_connection_errors`, `--test-diff-filter` 개념을 `config/sources.yaml` 에 이식. **릴리스가 하나도 없는 저장소라 의존성 채택은 기각.** | +| `Mzands2622/Zanalytix` | **부분채택 (패턴만)** | 소스별 파서 함수 시그니처 통일, 변경 우선순위 1~5 점수, 스냅샷/변경 테이블 분리를 채택. Azure Functions·Zyte·Twilio·로그인 기능은 전부 기각. | +| `FDA/openfda` | **부분채택 (레이아웃만)** | `config/`·`schemas/`·`scripts/`·`/` 4분할을 채택. Luigi/Elasticsearch/Docker 는 하루 1회 소량 데이터에 과잉이므로 기각. | +| `winsw/winsw` | **채택 (서비스화 1순위)** | `` 다단 지연 + `` + `Automatic` + `` 로 "재부팅 후 자동 복구"를 XML 한 장으로 만족한다. ★14.3k, 안정판 v2.12.0. | +| `kirillkovalenko/nssm` / nssm.cc | **부분채택 (폴백)** | `AppThrottle`/`AppRestartDelay`/`AppExit Restart` 로 동등한 복구를 제공하지만 버전이 2014년(v2.24)이라 2순위. WinSW 가 실패할 때만 전환. | +| pywin32 서비스 (`HaroldMills/...`, gist `drmalex07`) | **기각** | issue #1563 — `SvcRun()` 예외/`sys.exit()` 시 pywin32 가 `SERVICE_STOPPED` 를 보고해 **Windows 복구 액션이 트리거되지 않는다.** 우회책이 `SIGABRT` 수준이면 운영에 못 쓴다. PyInstaller 도 Python 3.5 까지라는 낡은 제약. | +| `michalzobec/autorunsalerts` | **채택 (운영 패턴 정본)** | SYSTEM 스캔 태스크 + 사용자 컨텍스트 토스트 태스크 분리, `state.json`/`audit.log`/`configuration.json`/`install.ps1` 파일 구성을 그대로 매핑한다. | +| `Windos/BurntToast` | **부분채택** | 사용자 컨텍스트 알림용 대안 경로로 유지. "SYSTEM/서비스 계정에서 제약" 이라는 문서가 2단 태스크 분리의 근거가 되었다는 점이 더 큰 기여. | +| `GitHub30/win11toast` | **채택 (토스트 1순위)** | ★333 으로 파이썬 토스트 중 최다. `on_click=''` 로 리포트 바로 열기가 가능하고 buttons/image/duration 을 모두 지원한다. | +| `ysfchn/toasted` | **부분채택** | `Progress()` 요소로 장시간 백필의 진행률 토스트를 띄울 때만 사용. | +| `DatGuy1/Windows-Toasts` | **기각** | win11toast 대비 기능이 좁고(duration short/long), 별도 채택 이유가 없다. | +| `caronc/apprise` | **채택 (알림 추상화)** | 알림 채널을 URL 문자열로 선언해 Telegram/Slack/Email 로 코드 수정 없이 확장. `windows://` 는 250자 제한이 있으므로 리치 토스트는 win11toast 이중 경로로 보완. | +| XlsxWriter | **채택 (리포트 엔진)** | `write_url('internal:Sheet2!A1')` 이 "탭 간 연동" 요구를 만족하는 유일한 정공법이고, `add_table`/`conditional_format`/`set_column` 이 같은 API 안에 있다. | +| openpyxl (`Bwhiz/Auto-Excel-Reports`) | **기각 (엔진으로서)** | 생성/배포 파일 분리 아이디어만 채택. 서식·내부링크 품질 요구가 우리 쪽이 높아 XlsxWriter 가 낫다. | +| `jshchnz/claude-code-scheduler` | **채택 (스케줄 설정 스키마)** | `schedules.json` 필드 구성(`trigger.expression`, `execution.timeout`, `settings.logRetentionDays`)과 OS별 스케줄러 어댑터 분리, 태스크 ID별 로그 파일을 채택. TypeScript 구현체 자체는 미사용. | +| Claude Code Desktop 스케줄드 태스크 | **기각** | "앱이 열려 있고 컴퓨터가 깨어 있어야 발화" 라는 제약이 "재부팅 후 자동 복구" 요구와 충돌. 단 **놓친 실행 캐치업 1회 규칙**은 채택. | +| Claude Code Routines (cloud) | **기각** | 로컬 파일 접근 불가(fresh clone), 최소 간격 1시간. 로컬 xlsx 생성이 목적인 우리와 맞지 않는다. | +| `claude -p` / `gemini -p` / `codex exec` | **부분채택 (규약만)** | 세 CLI 의 공통 호출 규약(프롬프트 파일 → JSON 출력 → 파일 리다이렉트 → 종료코드 분기)을 `agy -p` 에 사상한다. **agy 실제 플래그는 실측 필요.** | +| `786raees/task-scheduler-python` | **부분채택** | `win32com.client` 로 태스크를 CRUD 하는 API 형태만 참고. 코드 복사는 안 함(★2, 2 commits). | +| `simonw/git-scraper-template` | **부분채택 (개념)** | `data/snapshots/` 를 로컬 git 리포로 두고 매일 커밋해 변경 이력을 무상으로 얻는다. GitHub Actions 부분은 기각(로컬 실행 요구). | +| `HasData/playwright-scraping` | **부분채택 (체크리스트)** | 디렉터리 목록을 fetch 모듈 요구사항 체크리스트로 사용. Playwright 도입 자체는 게시판이 JS 렌더링일 때만 조건부. | +| `ecprice/newsdiffs` | **부분채택** | per-run 로그와 누적 에러 로그 분리, `BaseParser` 상속 구조를 채택. Django 스택은 기각. | +| `WooilJeong/PublicDataReader` | **기각 (의존성으로)** | 식약처/의약품 커버리지가 없다고 문서에 명시. provider 별 모듈 레이아웃만 참고. | +| `Q00/data.go.kr-crawling` | **부분채택** | `config.py.example` 분리, 필드 사전 자동 생성, `page = int(totalCount/100)+1` 공식. **저장소 설명(gevent)과 실제 코드(동기 requests)가 다르므로 코드 신뢰 금지.** | +| `jjscan/data.go.kr-1` | **부분채택 (교훈만)** | 재시도 폴링, `totalCount == 0` 공백 판정, 대량 백필 병목(350k건 17시간) 경고를 설계에 반영. R 코드는 무관. | +| `NomaDamas/k-skill` | **부분채택 (규범만)** | API 키 분리 원칙과 "자동화가 직접 진단하지 않는다"는 안전 규범을 채택. | +| `Tanguy9862/AI-Powered-FDA-Drug-Scraper` | **부분채택** | scraper/classification/utils 3분리와 업체명 정규화 필요성. LangChain/GPT 의존은 agy 로 대체. | +| `suriyadeepan/WebScraping-for-Healthcare` | **부분채택 (설계만)** | 소스별 동일 인터페이스 규약만. **GPL-3.0 이므로 코드 복사 금지.** | +| `jbremz/FDA-Analysis` | **부분채택** | 스파이더/원시CSV/분석/노트북 4분할. 대상 사이트 은퇴로 프로젝트가 죽은 것은 파서 격리의 필요성 경고. | +| `coderxio/OpenFDA` | **부분채택** | `load_data`/`serve_data` 엔트리포인트 분리 → `dmf backfill` / `dmf daily`. | +| `logiover/fda-data-scraper` | **부분채택** | 출력 포맷을 최종 어댑터에서 스위칭하는 설계. Apify 플랫폼 의존은 기각. | +| `lorien/awesome-web-scraping` | **부분채택 (참고용)** | `python.md` 를 라이브러리 선정 참고로만. 변경감지/스케줄/엑셀 섹션이 없음을 확인했다. | +| `testing-in-production/gemini-jobs` | **기각** | HTTP 404. 존재하지 않는다. | + +### 12.2 기술 스택 최종 결정 + +| 계층 | 채택 | 대안(폴백) | 기각한 것 | +|---|---|---|---| +| 언어/런타임 | Python 3.11+ (venv) | — | — | +| HTTP | `requests` | `httpx` | Scrapy(단일 API 호출에 과잉) | +| HTML 파싱 | `beautifulsoup4` + `lxml` | Playwright(JS 렌더링 시) | Selenium | +| 데이터 프레임 | `pandas` | — | — | +| 저장 | SQLite (`sqlite3` 표준 라이브러리) + JSON 스냅샷 파일 | — | PostgreSQL, Elasticsearch | +| diff | 자체 구현 (csvdiff 스키마) | — | csvdiff 라이브러리(아카이브), changedetection.io | +| 리포트 | `XlsxWriter` | — | openpyxl, Google Sheets API | +| 알림 | `apprise` + `win11toast` | BurntToast(PowerShell) | win10toast, Windows-Toasts | +| 스케줄 | Windows Task Scheduler (`schtasks` / `win32com.client`) | — | cron, GitHub Actions, Claude Desktop tasks | +| 서비스 | WinSW v2.12.0 | NSSM 2.24 | pywin32 ServiceFramework | +| AI CLI | **Google Antigravity CLI `agy -p`** | (미사용 시 파이프라인은 정상 동작) | claude/gemini/codex (규약만 차용) | +| 설정 | YAML (`config/*.yaml`) + `.env` | — | 하드코딩, 레지스트리 | +| 버전관리 | 로컬 git (`data/snapshots` 포함) | — | 원격 push | + +--- + +## 13. 최종 디렉터리 구조 제안 + +각 줄 끝의 `←` 는 **어느 저장소의 어느 레이아웃을 근거로 했는지**를 표시한다. + +```text +D:\workspace\DMF_Crawler\ +│ +├─ README.md 프로젝트 개요·빠른 시작 +├─ pyproject.toml 패키지 메타/의존성 ← FDA/openfda(setup.py), Tanguy9862(setup.py) +├─ requirements.txt 고정 의존성 (운영 재현용) ← Q00/data.go.kr-crawling, larsekje/PythonWindowsServices +├─ .env.example API 키 템플릿(실제 .env 는 .gitignore) ← Q00 의 config.py.example, NomaDamas/k-skill 의 키 분리 원칙 +├─ .gitignore .env, data/raw, logs, reports 제외 +│ +├─ config\ ★ 설정은 전부 데이터. 코드에 상수 금지 ← FDA/openfda 의 config/, pharma-radar 의 config/ +│ ├─ sources.yaml 소스 정의(엔드포인트·파라미터·필터·job_defaults) ← pharma-radar sources.yaml + urlwatch urls.yaml 스키마 +│ ├─ schedule.yaml 태스크 정의(cron, timezone, timeout, logRetentionDays) ← claude-code-scheduler schedules.json +│ ├─ settings.yaml 전역 설정(알림 URL, 재시도, 임계값, 우선순위 규칙) ← autorunsalerts configuration.json +│ └─ field_map.yaml API 영문 필드 ↔ 리포트 한글 헤더 매핑 ← Q00 의 column.py 자동 생성 발상 +│ +├─ schemas\ ★ 데이터 계약을 코드와 분리해 버전 관리 ← FDA/openfda 의 schemas/ +│ ├─ dmf_record.schema.json 정규화된 DMF 레코드 스키마 +│ ├─ diff_result.schema.json diff 산출물 스키마(_index/added/removed/changed) ← csvdiff JSON 구조 +│ └─ agent_output.schema.json agy --output-format json 에 넘길 JSON Schema ← drew.tech 의 --json-schema 패턴 +│ +├─ prompts\ ★ 에이전트 프롬프트는 자산이지 코드가 아니다 ← pharma-radar 의 prompts/system_prompt.md +│ ├─ system.md agy 시스템 프롬프트(YAML frontmatter + 본문) ← Claude Desktop 의 SKILL.md 형식 +│ ├─ summarize_changes.md 변경 건 요약 프롬프트 +│ └─ classify_priority.md 변경 우선순위(1~5) 분류 프롬프트 ← Zanalytix 의 priority 1-5 +│ +├─ src\ +│ └─ dmf_crawler\ ★ python -m dmf_crawler ← nomenclator-delta 의 python3 -m nomenclator_delta +│ ├─ __init__.py +│ ├─ __main__.py CLI 진입점(argparse 서브커맨드) +│ ├─ cli.py daily / backfill / diff / report / notify-drain / validate / watchdog / doctor +│ ├─ settings.py config/*.yaml + .env 로딩, 경로 상수 +│ │ +│ ├─ fetch\ ★ 수집 (결정론) ← nomenclator-delta 의 collection +│ │ ├─ __init__.py +│ │ ├─ base.py BaseFetcher — 재시도·타임아웃·UA·디버그 덤프 ← newsdiffs BaseParser, playwright-scraping errors//browser//debug/ +│ │ ├─ dmf_api.py data.go.kr getMdcDmfList01 페이지네이션 수집 +│ │ ├─ nedrug_board.py nedrug.mfds.go.kr/bbs/117 공고 게시판 수집 +│ │ └─ registry.py source_id → Fetcher 매핑 ← Zanalytix 의 fetch_{company}_html() 규약 +│ │ +│ ├─ parse\ ★ 정규화 ← nomenclator-delta 의 normalization +│ │ ├─ __init__.py +│ │ ├─ dmf_api.py JSON/XML → DmfRecord +│ │ ├─ nedrug_board.py HTML → BoardPost (css/xpath 규칙은 sources.yaml 에서) ← huginn WebsiteAgent 의 extract 선언 +│ │ └─ normalize.py 업체명·주소·성분명 표기 정규화 ← Tanguy9862 의 회사명 1000→700 정규화 +│ │ +│ ├─ diff\ ★ 차이 판정 ← nomenclator-delta 의 diffing +│ │ ├─ __init__.py +│ │ ├─ engine.py 키 기반 added/removed/changed 산출 ← csvdiff diff_records() +│ │ ├─ modes.py all / on_change / merge ← huginn WebsiteAgent mode +│ │ └─ priority.py 변경 유형별 우선순위 1~5 부여 ← Zanalytix +│ │ +│ ├─ store\ ★ 저장 +│ │ ├─ __init__.py +│ │ ├─ db.py SQLite: snapshots / changes / runs / agent_runs ← Zanalytix db.py, Revised_MasterTable+Stream 분리 +│ │ ├─ snapshots.py data/snapshots/ 읽기·쓰기·해시 +│ │ └─ gitlog.py 스냅샷 디렉터리 자동 커밋 ← simonw/git-scraper-template +│ │ +│ ├─ report\ ★ 리포트 ← Bwhiz report_script.py +│ │ ├─ __init__.py +│ │ ├─ xlsx.py XlsxWriter 멀티시트 + internal 링크 + add_table + conditional_format +│ │ ├─ sheets.py 시트별 빌더(요약/신규/변경/취하/전체현황/실행로그) +│ │ └─ styles.py 서식 상수(폰트·색·너비) +│ │ +│ ├─ notify\ ★ 알림 ← Bwhiz auto_mail.py, pharma-radar src/deliver.py +│ │ ├─ __init__.py +│ │ ├─ queue.py state/notify_queue.json push/drain ← autorunsalerts 의 플래그 파일 패턴 +│ │ ├─ toast.py win11toast (os.chdir 처리 포함) +│ │ └─ apprise_sink.py Apprise URL 리스트 전송, 250자 트리밍 +│ │ +│ ├─ orchestrate\ ★ 오케스트레이션 +│ │ ├─ __init__.py +│ │ ├─ pipeline.py fetch→parse→diff→store→report→notify 순서 제어·부분 실패 허용 +│ │ ├─ agy.py agy -p 서브프로세스 호출(프롬프트 파일·스키마·타임아웃·종료코드) ← drew.tech 스니펫 +│ │ ├─ catchup.py 놓친 실행 1회 따라잡기 ← Claude Desktop missed-runs 규칙 +│ │ └─ watchdog.py 태스크 존재·최근 성공 시각 감시 ← autorunsalerts + Badgerati/Hook +│ │ +│ └─ util\ +│ ├─ logging.py per-run 로그 + 누적 에러 로그 분리 ← newsdiffs +│ ├─ retry.py 지수 백오프 ← jjscan/data.go.kr-1 의 폴링 재시도 +│ └─ hashing.py 스냅샷 해시 +│ +├─ ops\ ★ 운영 자산 ← FDA/openfda 의 scripts/, larsekje 의 windows_service/ +│ ├─ winsw\ +│ │ ├─ WinSW.exe (v2.12.0 배포본, .gitignore 대상) +│ │ ├─ dmf-watchdog.xml 서비스 정의 ← winsw samples/minimal.xml +│ │ └─ install.ps1 winsw install / start +│ ├─ tasks\ +│ │ ├─ install.ps1 schtasks 3종 등록 ← autorunsalerts install.ps1 +│ │ ├─ uninstall.ps1 제거 ← autorunsalerts uninstall.ps1 +│ │ ├─ run_daily.cmd python -m dmf_crawler daily 래퍼 ← claude-code-scheduler 의 wrapper 생성 패턴 +│ │ ├─ run_notify.cmd python -m dmf_crawler notify-drain +│ │ └─ run_catchup.cmd python -m dmf_crawler daily --catchup +│ ├─ agent\ +│ │ ├─ bootstrap_agy.ps1 agy 미설치 시 설치·인증·프롬프트 창 표시 +│ │ └─ run_agy.cmd agy -p "$(cat prompt)" --output-format json > out.json ← drew.tech +│ └─ doctor.ps1 환경 진단(파이썬, agy, WinSW, 태스크, 권한) +│ +├─ data\ ★ .gitignore 대상 중 snapshots 만 로컬 git 추적 ← nomenclator-delta 의 data/ +│ ├─ raw\ 원시 응답 원본 보존 (YYYY-MM-DD\.json|html) ← jbremz masterDrugList2.csv, coderxio ./data/ +│ ├─ snapshots\ 정규화된 시점 스냅샷 (YYYY-MM-DD.json) ← nomenclator-delta, Zanalytix 날짜별 JSON +│ ├─ history\ 일자별 diff 결과 (YYYY-MM-DD.diff.json) +│ ├─ debug\ 실패 시 HTML/스크린샷/trace ← playwright-scraping debug/ +│ └─ dmf.sqlite3 운영 DB +│ +├─ state\ +│ ├─ state.json 마지막 성공 실행 시각·스냅샷 해시 ← autorunsalerts state.json +│ └─ notify_queue.json 대기 중 알림(사용자 컨텍스트 태스크가 소비) +│ +├─ reports\ ★ 산출물 xlsx (YYYY-MM-DD_DMF_리포트.xlsx) ← nomenclator-delta 의 site/ 자리 +│ +├─ logs\ ← newsdiffs 로그 분리, claude-code-scheduler 의 task-id 별 로그 +│ ├─ runs\ -YYYYMMDD.log (per-run) +│ ├─ errors.log 누적 에러 (워치독 감시 대상) +│ └─ audit.log 누적 변경 이력 ← autorunsalerts audit.log +│ +├─ tests\ ← nomenclator-delta tests/, claude-code-scheduler src/__tests__/ +│ ├─ unit\ +│ ├─ integration\ +│ └─ fixtures\ 고정 응답 샘플(회귀 테스트용) +│ +├─ notebooks\ 탐색·검증용 ← jbremz 의 .ipynb, suriyadeepan 의 notebooks/ +│ +├─ scripts\ 1회성 유틸 ← FDA/openfda scripts/ +│ ├─ gen_field_map.py 공공데이터포털 명세에서 field_map.yaml 생성 ← Q00 의 url.py → column.py +│ └─ inspect_board.py nedrug 게시판 구조 실측 +│ +└─ docs\ + ├─ research\ ★ 본 문서 등 리서치 SSOT + │ ├─ 01-*.md + │ └─ 02-benchmark-github-projects.md + ├─ ops\ + │ ├─ runbook.md 일일 운영 절차·장애 대응 ← nomenclator-delta 의 monthly update runbook + │ └─ install.md 최초 설치 순서 + └─ decisions\ ADR (Architecture Decision Record) +``` + +### 13.1 이 구조가 만족하는 요구사항 대조표 + +| 요구사항 | 이 구조에서 어디가 담당하는가 | +|---|---| +| 매일 06:00 크롤링 | `ops/tasks/install.ps1` → `DMF_Crawler_Daily` → `run_daily.cmd` → `orchestrate/pipeline.py` | +| 신규·변경·취하 탐지 | `diff/engine.py` + `diff/modes.py`, 결과는 `data/history/*.diff.json` | +| 탭별 연동 xlsx | `report/xlsx.py` + `report/sheets.py` → `reports/YYYY-MM-DD_DMF_리포트.xlsx` | +| AI CLI headless | `orchestrate/agy.py` + `prompts/*.md` + `schemas/agent_output.schema.json` | +| agy 자동 부트스트랩 | `ops/agent/bootstrap_agy.ps1` (미설치 시 설치, 필요 시 프롬프트 창) | +| 재부팅 후 자동 복구 | WinSW `Automatic` + `` + Task Scheduler 등록 지속 | +| 서비스 사망 시 알림 | `orchestrate/watchdog.py` → `state/notify_queue.json` → `DMF_Crawler_Notify`(사용자 컨텍스트) → `notify/toast.py` | +| 놓친 실행 복구 | `DMF_Crawler_Catchup`(로그온 시) → `orchestrate/catchup.py` | +| 재현 가능성 | `data/raw` 원본 보존 + `data/snapshots` git 커밋 + `tests/fixtures` | + +--- + +## 14. 모듈 경계 제안과 입출력 계약 + +### 14.0 설계 원칙 (근거 저장소 명시) + +1. **결정론과 비결정론을 섞지 않는다** — `fetch`/`parse`/`diff` 는 순수 결정론. LLM 호출은 `orchestrate/agy.py` 한 곳에만. ← `Tanguy9862` 의 scraper/classification 분리 +2. **소스별 어댑터는 동일 시그니처를 강제한다** ← `Zanalytix` 의 `fetch_{company}_html()`/`process_{company}_html()`, `suriyadeepan` 의 `fetch()`/`crawl_k()` +3. **각 단계는 파일에 산출물을 남긴다** — 중간 산출물이 없으면 재현도, 부분 재실행도 불가능 ← `nomenclator-delta` 의 `data/` +4. **AI 는 선택적 보강이다** — agy 가 실패해도 xlsx 는 나와야 한다 ← `pharma-radar` 의 표준 라이브러리 배포 계층 +5. **부분 실패를 허용한다** — 소스 하나가 죽어도 나머지는 진행 ← urlwatch 의 `ignore_connection_errors` + +### 14.1 데이터 타입 정의 (공통 계약) + +```python +# src/dmf_crawler/types.py +from dataclasses import dataclass, field +from datetime import date, datetime +from typing import Any, Literal + +SourceId = Literal["dmf_api", "nedrug_board"] +ChangeKind = Literal["added", "removed", "changed"] + + +@dataclass(frozen=True) +class RawPayload: + """fetch 계층의 유일한 산출물. 파싱하지 않은 원본.""" + source_id: SourceId + fetched_at: datetime + url: str + status_code: int + content_type: str # "application/json" | "text/html" | "application/xml" + body: bytes + meta: dict[str, Any] = field(default_factory=dict) # pageNo, totalCount 등 + raw_path: str | None = None # data/raw/YYYY-MM-DD/_.json 로 저장된 경로 + + +@dataclass(frozen=True) +class DmfRecord: + """parse 계층의 산출물. 정규화된 DMF 1건.""" + dmf_permit_no: str # DMF_PERMIT_NO ← 진짜 유일 키 + ingr_kor_name: str # INGR_KOR_NAME 성분명 + entp_name: str # ENTP_NAME 업체명 (정규화 적용됨) + mnfctr_name: str # MNFCTR_NAME 제조소명 + mnfctr_place: str # MNFCTR_PLACE 제조소 소재지 + manuf_country: str # MANUF_COUNTRY_CODE_NM 제조국가명 + dmf_permit_date: date # DMF_PERMIT_DATE 발급일자 + source_id: SourceId + raw: dict[str, Any] = field(default_factory=dict) # 원본 필드 전량 보존 + + +@dataclass(frozen=True) +class BoardPost: + """공고 게시판 1건.""" + seq: int # 연번 + title: str # 제목 + view_count: int # 조회건수 + registrant: str # 등록자 + registered_on: date # 등록일자 + detail_url: str + attachments: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class FieldChange: + field_name: str + before: Any + after: Any + + +@dataclass(frozen=True) +class ChangeEvent: + kind: ChangeKind + key: str # dmf_permit_no + priority: int # 1~5 ← Zanalytix + record_after: DmfRecord | None + record_before: DmfRecord | None + fields: list[FieldChange] = field(default_factory=list) + + +@dataclass(frozen=True) +class DiffResult: + """csvdiff 호환 구조.""" + index: list[str] # == ["dmf_permit_no"] ← csvdiff _index + base_date: date # 이전 스냅샷 날짜 + head_date: date # 이번 스냅샷 날짜 + added: list[DmfRecord] + removed: list[DmfRecord] + changed: list[ChangeEvent] + ignored_columns: list[str] = field(default_factory=list) + + +@dataclass +class RunContext: + """파이프라인 전체를 관통하는 실행 컨텍스트.""" + run_id: str # "20260902-060000" + run_date: date + started_at: datetime + dry_run: bool = False + force: bool = False + catchup: bool = False + errors: list[str] = field(default_factory=list) # 부분 실패 누적 +``` + +### 14.2 모듈별 입출력 계약 + +| 모듈 | 입력 | 출력 | 부수효과 | 실패 시 | +|---|---|---|---|---| +| **fetch** | `source_id`, `config/sources.yaml`, `RunContext` | `list[RawPayload]` | `data/raw/YYYY-MM-DD/` 에 원본 저장, 실패 시 `data/debug/` 에 덤프 | `ignore_connection_errors: true` 면 빈 리스트 + `ctx.errors` 에 기록하고 계속. false 면 예외 | +| **parse** | `list[RawPayload]` | `list[DmfRecord]` 또는 `list[BoardPost]` | 없음 (순수 함수) | 레코드 단위 실패는 스킵 + 카운트. 전체 실패율 20% 초과 시 예외 | +| **diff** | `list[DmfRecord]`(head), `list[DmfRecord]`(base), `ignore_columns` | `DiffResult` | 없음 (순수 함수) | base 스냅샷이 없으면 `DiffResult(added=전량, ...)` 가 아니라 **"기준선 수립" 모드**로 종료 | +| **store** | `list[DmfRecord]`, `DiffResult`, `RunContext` | `snapshot_path`, `diff_path` | `data/snapshots/*.json`, `data/history/*.diff.json`, SQLite 테이블, git commit | DB 트랜잭션 롤백 후 예외 전파(이건 치명적) | +| **report** | `DiffResult`, `list[DmfRecord]`(전체현황), `RunContext` | `report_path` (xlsx 절대경로) | `reports/*.xlsx` 생성 | 예외 전파. 단 notify 는 "리포트 생성 실패" 알림으로 계속 | +| **notify** | `DiffResult` 요약, `report_path`, `ctx.errors` | `None` | `state/notify_queue.json` 에 push, Apprise 전송 | 절대 예외를 전파하지 않는다(알림 실패로 파이프라인을 죽이지 않음). `logs/errors.log` 에만 기록 | +| **orchestrate** | CLI 인자, 설정 | 프로세스 종료 코드 | `state/state.json` 갱신, `logs/runs/*.log` | 종료 코드 0(성공) / 1(부분 실패) / 2(치명적 실패) | + +### 14.3 함수 시그니처 (프로토콜) + +```python +# src/dmf_crawler/fetch/base.py +from typing import Protocol, Iterable + +class Fetcher(Protocol): + source_id: str + + def fetch(self, ctx: RunContext) -> Iterable[RawPayload]: + """페이지네이션을 내부에서 처리하고 RawPayload 를 순차 yield 한다. + 재시도·타임아웃·User-Agent 는 BaseFetcher 가 담당한다.""" + ... + + +# src/dmf_crawler/parse/base.py +class Parser(Protocol): + source_id: str + + def parse(self, payloads: Iterable[RawPayload]) -> list[DmfRecord]: + ... + + +# src/dmf_crawler/diff/engine.py +def compute_diff( + head: list[DmfRecord], + base: list[DmfRecord], + *, + key: str = "dmf_permit_no", + ignore_columns: list[str] | None = None, +) -> DiffResult: + """csvdiff 와 동일한 의미론. key 로 조인하고 나머지 필드를 비교한다. + ignore_columns 에 든 필드는 changed 판정에서 제외한다.""" + ... + + +# src/dmf_crawler/diff/priority.py +def assign_priority(event: ChangeEvent) -> int: + """1~5. 규칙은 config/settings.yaml 의 priority_rules 에서 읽는다.""" + ... + + +# src/dmf_crawler/report/xlsx.py +def build_report( + diff: DiffResult, + full_snapshot: list[DmfRecord], + ctx: RunContext, + out_path: str, +) -> str: + """XlsxWriter 로 멀티시트 리포트를 만들고 절대경로를 반환한다.""" + ... + + +# src/dmf_crawler/notify/queue.py +def push(item: dict) -> None: ... +def drain() -> list[dict]: ... + + +# src/dmf_crawler/orchestrate/agy.py +def run_agy( + prompt_path: str, + schema_path: str | None = None, + *, + timeout_sec: int = 300, + cwd: str | None = None, +) -> dict | None: + """agy -p 를 서브프로세스로 호출한다. + 실패·타임아웃 시 None 을 반환하고 예외를 던지지 않는다(선택적 보강 원칙).""" + ... +``` + +### 14.4 파이프라인 시퀀스 + +``` +python -m dmf_crawler daily + │ + ├─ 0. settings 로드 (config/*.yaml + .env) 실패 → exit 2 + ├─ 1. RunContext 생성, logs/runs/.log 열기 + ├─ 2. catchup 판정 (state.json 의 last_success 확인) 이미 오늘 성공 → exit 0 + │ + ├─ 3. for source in sources.yaml: + │ fetch() → data/raw/ ─────────── 실패 & ignore_connection_errors → ctx.errors 추가 후 continue + │ parse() → list[DmfRecord] ────── 실패율 20% 초과 → exit 2 + │ + ├─ 4. 레코드 병합 + normalize() → head_snapshot + ├─ 5. store.snapshots.write(head_snapshot) → data/snapshots/YYYY-MM-DD.json + │ store.gitlog.commit() + │ + ├─ 6. base = store.snapshots.read(직전 날짜) + │ base 없음 → "기준선 수립" 토스트 후 exit 0 + │ + ├─ 7. diff.compute_diff(head, base, ignore_columns=settings.ignore_columns) + │ diff.priority.assign_priority(each) + │ store.db.save_changes() → SQLite changes 테이블 + │ → data/history/YYYY-MM-DD.diff.json + │ + ├─ 8. (선택) orchestrate.agy.run_agy(prompts/summarize_changes.md, schemas/agent_output.schema.json) + │ → 실패 시 None. 요약 없이 계속. + │ + ├─ 9. report.build_report() → reports/YYYY-MM-DD_DMF_리포트.xlsx 실패 → ctx.errors 추가 + │ + ├─ 10. notify.queue.push({kind, counts, report_path, errors}) + │ notify.apprise_sink.send() 실패해도 무시 + │ + └─ 11. state.json 갱신(last_success), 로그 닫기 + exit 0 (ctx.errors 없음) / exit 1 (부분 실패) +``` + +### 14.5 SQLite 스키마 초안 + +`Zanalytix` 의 `Revised_MasterTable`(스냅샷) / `Stream`(변경+LLM 응답) 분리를 따른다. + +```sql +-- 실행 이력 +CREATE TABLE IF NOT EXISTS runs ( + run_id TEXT PRIMARY KEY, -- '20260902-060000' + run_date TEXT NOT NULL, -- '2026-09-02' + started_at TEXT NOT NULL, + finished_at TEXT, + exit_code INTEGER, + error_count INTEGER DEFAULT 0, + report_path TEXT +); + +-- 시점 스냅샷 (레코드 단위) +CREATE TABLE IF NOT EXISTS snapshots ( + run_id TEXT NOT NULL, + snapshot_date TEXT NOT NULL, + dmf_permit_no TEXT NOT NULL, + ingr_kor_name TEXT, + entp_name TEXT, + mnfctr_name TEXT, + mnfctr_place TEXT, + manuf_country TEXT, + dmf_permit_date TEXT, + source_id TEXT NOT NULL, + raw_json TEXT NOT NULL, -- 원본 필드 전량 + PRIMARY KEY (snapshot_date, dmf_permit_no) +); +CREATE INDEX IF NOT EXISTS idx_snapshots_key ON snapshots(dmf_permit_no); + +-- 변경 이벤트 +CREATE TABLE IF NOT EXISTS changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + base_date TEXT NOT NULL, + head_date TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('added','removed','changed')), + dmf_permit_no TEXT NOT NULL, + priority INTEGER NOT NULL CHECK (priority BETWEEN 1 AND 5), + fields_json TEXT, -- [{"field_name":..,"before":..,"after":..}] + notified_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_changes_run ON changes(run_id); +CREATE INDEX IF NOT EXISTS idx_changes_key ON changes(dmf_permit_no); + +-- AI 에이전트 호출 기록 (선택적 보강) +CREATE TABLE IF NOT EXISTS agent_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + prompt_path TEXT NOT NULL, + schema_path TEXT, + exit_code INTEGER, + duration_ms INTEGER, + output_json TEXT, + error_text TEXT +); +``` + +### 14.6 `config/sources.yaml` 초안 + +urlwatch 잡 스키마 + pharma-radar sources.yaml + huginn extract 선언을 합친 형태. + +```yaml +# job_defaults 는 urlwatch 의 개념 (모든 소스에 상속) +job_defaults: + timeout_sec: 30 + retries: 3 + backoff_sec: 2 + user_agent: "DMF_Crawler/1.0 (+internal use)" + ignore_connection_errors: false + encoding: utf-8 + +sources: + - id: dmf_api + name: "식품의약품안전처_원료의약품등록(DMF)현황" + kind: api + url: "https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01" + method: GET + params: + serviceKey: "${DATA_GO_KR_API_KEY}" # .env 에서 주입 + type: json + numOfRows: 100 + pageNo: 1 + pagination: + page_param: pageNo + size_param: numOfRows + page_size: 100 + total_field: totalCount # totalCount == 0 이면 '데이터 없음' ← jjscan 교훈 + formula: "int(totalCount/numOfRows) + 1" # ← Q00 의 공식 + key_field: DMF_PERMIT_NO + fields: + - DMF_PERMIT_NO + - INGR_KOR_NAME + - ENTP_NAME + - MNFCTR_NAME + - MNFCTR_PLACE + - MANUF_COUNTRY_CODE_NM + - DMF_PERMIT_DATE + + - id: nedrug_board + name: "의약품안전나라 원료의약품등록(DMF) 정보 게시판" + kind: html + url: "https://nedrug.mfds.go.kr/bbs/117" + method: GET + ignore_connection_errors: true # 게시판 장애로 파이프라인을 죽이지 않는다 + # huginn WebsiteAgent 의 extract 선언 방식 + extract: + seq: { css: "table tbody tr td:nth-child(1)" } + title: { css: "table tbody tr td:nth-child(2) a", value: "text" } + detail_url: { css: "table tbody tr td:nth-child(2) a", value: "@href" } + view_count: { css: "table tbody tr td:nth-child(3)" } + registrant: { css: "table tbody tr td:nth-child(4)" } + registered_on: { css: "table tbody tr td:nth-child(5)" } + filter: # urlwatch 필터 체인 개념 + - strip + pagination: + page_size_options: [10, 20, 30, 40, 50] + total_posts_hint: 710 # 2026-09 실측 [F#28] + # ⚠️ 미검증: 위 CSS 선택자는 실제 DOM 으로 반드시 재확인해야 함 +``` + +### 14.7 `config/settings.yaml` 초안 + +```yaml +run: + timezone: "Asia/Seoul" + daily_at: "06:00" + timeout_sec: 1800 # ← claude-code-scheduler execution.timeout + log_retention_days: 30 # ← claude-code-scheduler settings.logRetentionDays + max_execution_history: 100 + +diff: + key: DMF_PERMIT_NO + ignore_columns: # ← csvdiff --ignore-columns + - fetched_at + - view_count + # 우선순위 규칙 ← Zanalytix priority 1-5 + priority_rules: + removed: 5 # 등록 취하 — 가장 중요 + added: 3 # 신규 등록 + changed: + MNFCTR_NAME: 4 # 제조소 변경 + MNFCTR_PLACE: 2 # 소재지 변경 + ENTP_NAME: 4 # 업체 변경 + INGR_KOR_NAME: 4 # 성분명 변경 + _default: 1 + +notify: + urls: + - "windows://?duration=8" # ← apprise windows:// (pywin32 필요, 250자 제한) + # - "tgram:///" + # - "mailto://user:pass@company.co.kr" + max_body_chars: 250 + toast: + engine: win11toast + open_report_on_click: true + min_priority_to_notify: 2 # 우선순위 1은 조용히 로그만 + +watchdog: + stale_after_hours: 26 # 마지막 성공이 26시간 넘으면 경보 + check_interval_sec: 900 + +agent: + enabled: true + binary: "agy" # ⚠️ 미검증: 실제 실행파일명·플래그 실측 필요 + print_flag: "-p" + output_format_flag: "--output-format json" + timeout_sec: 300 + prompts: + summarize: "prompts/summarize_changes.md" + classify: "prompts/classify_priority.md" + schema: "schemas/agent_output.schema.json" + fail_open: true # 에이전트 실패 시 파이프라인 계속 +``` + +--- + +## 15. 데이터 소스 실측 정보 (DMF API / 공고 게시판) + +이 절은 raw dump 에서 확인된 **한국 DMF 데이터 소스의 1차 사실**을 손실 없이 보존한다. 벤치마킹 결과를 실제로 꽂을 자리이므로 본 문서에 포함한다. + +### 15.1 `식품의약품안전처_원료의약품등록(DMF)현황` OpenAPI [F#29] + +출처: https://www.data.go.kr/data/15057075/openapi.do + +| 항목 | 값 | +|---|---| +| API 명 | 식품의약품안전처_원료의약품등록(DMF)현황 | +| 서비스 URL | `https://apis.data.go.kr/1471000/MdcDmfInfoService01` | +| **엔드포인트** | `https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01` | +| 형식 | XML / JSON | +| 비용 | 무료 | +| 트래픽 제한(개발) | 10,000 calls | +| 최종 수정 | 2025년 9월 19일 | +| 제공기관 | 식품의약품안전처 | + +**요청 파라미터** + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `serviceKey` | string | Yes | 데이터 포털에서 발급받은 인증키 | +| `pageNo` | integer | No | 페이지 번호 (기본 1) | +| `numOfRows` | integer | No | 페이지당 결과 수 (**기본 3**) | +| `entp_name` | string | No | 업체/제조사명 | +| `ingr_kor_name` | string | No | 성분명(한글) | +| `type` | string | No | 응답 형식: `xml` 또는 `json` | + +**응답 필드** + +| 필드 | 의미 | +|---|---| +| `DMF_PERMIT_NO` | 등록번호 — **유일 키** | +| `INGR_KOR_NAME` | 성분명 | +| `ENTP_NAME` | 업체명 | +| `MNFCTR_NAME` | 제조소명 | +| `MNFCTR_PLACE` | 제조소 소재지 | +| `MANUF_COUNTRY_CODE_NM` | 제조국가명 | +| `DMF_PERMIT_DATE` | 발급일자 | +| `resultCode` | 상태 코드 | +| `resultMsg` | 상태 메시지 | +| `totalCount` | 전체 결과 수 | + +**동작 가능한 호출 예 (`numOfRows` 기본값 3 에 주의 — 반드시 명시할 것)** + +```python +# scripts/probe_dmf_api.py — 최초 실측용 +import os +import requests + +SERVICE_URL = "https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01" + +def fetch_page(page_no: int, num_of_rows: int = 100) -> dict: + params = { + "serviceKey": os.environ["DATA_GO_KR_API_KEY"], # 디코딩된 키 사용 + "type": "json", + "pageNo": page_no, + "numOfRows": num_of_rows, + } + resp = requests.get( + SERVICE_URL, + params=params, + timeout=30, + headers={"User-Agent": "DMF_Crawler/1.0 (+internal use)"}, + ) + resp.raise_for_status() + return resp.json() + + +def fetch_all(num_of_rows: int = 100) -> list[dict]: + first = fetch_page(1, num_of_rows) + body = first.get("body", first) + total = int(body.get("totalCount", 0)) + if total == 0: # ← jjscan/data.go.kr-1 의 공백 판정 규칙 + return [] + pages = int(total / num_of_rows) + 1 # ← Q00/data.go.kr-crawling 의 공식 + rows = list(body.get("items", [])) + for page in range(2, pages + 1): + b = fetch_page(page, num_of_rows).get("body", {}) + rows.extend(b.get("items", [])) + return rows + + +if __name__ == "__main__": + records = fetch_all() + print(f"총 {len(records)}건") + if records: + print(records[0]) +``` + +⚠️ **미검증 사항**: 응답 JSON 의 실제 중첩 구조(`response.body.items.item` 인지 `body.items` 인지), `serviceKey` 의 인코딩/디코딩 키 구분, `resultCode` 정상값. 최초 실행 시 위 스크립트로 실측하고 `schemas/dmf_record.schema.json` 을 확정한다. + +### 15.2 의약품안전나라 DMF 공고 게시판 [F#28] + +출처: https://nedrug.mfds.go.kr/bbs/117 + +| 항목 | 값 | +|---|---| +| 게시판 | 원료의약품등록(DMF) 정보 | +| **테이블 컬럼** | 연번 / 제목 / 조회건수 / 등록자 / 등록일자 | +| 페이지네이션 | 처음 \| 이전 \| 1-10 \| 다음 \| 마지막, **총 710건** | +| 페이지당 표시 | 10, 20, 30, 40, 50 선택 | +| 검색 폼 | "제목" 검색 필드 + 검색/초기화 버튼 | +| 게시물 제목 패턴 | **"등록대상 원료의약품(DMF) 등록 공고"** + 특정 날짜 범위, **주 단위**로 게시 | +| 표시된 샘플 범위 | 2021년 2월 ~ 2020년 11월 | +| 확인 사항 | 고급 필터나 "변경/취하" 전용 게시물 타입은 화면에 보이지 않음 | + +**중요**: 게시판에는 "**변경**"/"**취하**" 를 별도 구분하는 UI 가 없다. 즉 신규/변경/취하 판정은 **게시글 제목·첨부파일 내용이 아니라 API 스냅샷 diff 로 하는 것이 정공법**이다. 게시판은 (a) 공고 게시 사실의 근거 링크, (b) API 반영 지연 시의 조기 신호 로 쓴다. + +관련 확인: +- 한국보건산업진흥원(KHIDI) 제약산업정보포털에도 "등록대상 원료의약품(DMF) 등록 공고(7월 둘째주)" 같은 동일 공고가 게시된다 [S#11] — 보조 소스 후보 +- `[지침]원료의약품 등록(DMF) 처리 절차` 문서 [S#11] +- 식품의약품안전평가원 KDMF 페이지: https://www.nifds.go.kr/brd/m_87/list.do [S#11] +- 원료의약품 등록 현황(신규등록, 변경등록, 연차보고)은 식약처 홈페이지 **전자민원창구**에서도 확인 가능 [S#11] — ⚠️ 미검증, 별도 소스가 될 수 있음 + +**실측 실패**: `https://nedrug.mfds.go.kr/searchDmf` 는 **"The requested page cannot be found. The page you are looking for has been changed or is currently unavailable."** 에러 페이지를 반환했다 [F#63]. DMF 검색 화면의 실제 경로는 브라우저로 재확인 필요. + +### 15.3 관련 식약처/공공데이터 소스 (보조·참고) + +| 데이터 | URL | 비고 | +|---|---|---| +| 의약품 제품 허가정보 | https://www.data.go.kr/data/15095677/openapi.do | REST, JSON+XML. 필드 언급: `ITEM_SEQ`, `ITEM_NAME`, `ENTP_NAME`, `ITEM_PERMIT_DATE`, `CANCEL_DATE`, `CANCEL_NAME`, `CHANGE_DATE`. 최종수정 2025-10-31. 무료, 개발 10,000건 [F#54] — **엔드포인트 URL·파라미터는 문서에 미명시 ⚠️ 미검증** | +| 의약품개요정보(e약은요) | https://www.data.go.kr/data/15075057/openapi.do | 일반의약품 주요 정보 [S#13] | +| 의약품 낱알식별 정보 | https://www.data.go.kr/data/15057639/openapi.do | [S#13] | +| 필수의약품내역 | https://www.data.go.kr/data/15058207/openapi.do | [S#30] | +| 식품의약품안전처 의약품 관련 정보 (파일데이터) | https://www.data.go.kr/data/15020627/fileData.do | [S#30] | +| 공공데이터포털 (구 URL) | https://www.data.go.kr/dataset/15020626/openapi.do , https://www.data.go.kr/dataset/15020627/openapi.do | [S#2][S#13] | +| 연구관리 기술 분류 정보조회 | https://www.data.go.kr/data/15068423/openapi.do | [S#2] | +| 연구관리 전문기술분야코드 조회 | https://www.data.go.kr/data/15068280/openapi.do | [S#30] | +| 식의약 데이터 포털 | https://data.mfds.go.kr/ , https://data.mfds.go.kr/cntnts/20 , https://data.mfds.go.kr/OPCAA01F01 | 공공데이터 목록·이용안내 [S#2] | +| 의약품 공공데이터공개 | https://nedrug.mfds.go.kr/cntnts/80 | CSV/EXCEL + OpenAPI 제공 안내 [S#1][S#13] | +| 의약품안전나라 메인 / 검색 | https://nedrug.mfds.go.kr/ , https://nedrug.mfds.go.kr/index , https://nedrug.mfds.go.kr/searchDrug | [S#1] | +| 식품안전나라 API | https://www.foodsafetykorea.go.kr/apiMain.do , https://www.foodsafetykorea.go.kr/api/openApiAplcInfo.do | [S#13] | +| MFDS 영문 | https://nedrug.mfds.go.kr/eng/index , https://www.mfds.go.kr/eng/index.do | [S#22] | + +### 15.4 이 소스들을 벤치마크 구조에 꽂는 방법 + +``` +config/sources.yaml + ├─ dmf_api (1차, 필수) → fetch/dmf_api.py → parse/dmf_api.py → DmfRecord + └─ nedrug_board (2차, 선택) → fetch/nedrug_board.py → parse/nedrug_board.py → BoardPost + ignore_connection_errors: true + +diff/engine.py + key = DMF_PERMIT_NO + base = data/snapshots/<어제>.json + head = data/snapshots/<오늘>.json + → added / removed / changed + +검증 정책 (← pharma-radar 의 "≥2 독립 출처 또는 1 공식 1차 출처") + · API 에만 나타난 변경 → 리포트에 '확정' (공식 1차 출처) + · 게시판에만 나타난 공고 → 리포트에 '관찰 중(pending)' + · 양쪽 모두 일치 → 리포트에 '확정 + 공고 링크' +``` + +--- + +## 부록 A. 출처 목록 + +raw dump 에 등장한 **모든 URL** 이다. "확인" 열의 의미: **F#n** = WebFetch 로 실제 열어봄 / **S#n** = 검색 결과 링크로만 등장 / **404** = 열었으나 존재하지 않음. + +### A.1 GitHub 저장소·페이지 + +| # | 제목 | URL | 확인 | +|---|---|---|---| +| 1 | Q00/data.go.kr-crawling | https://github.com/Q00/data.go.kr-crawling | F#1 | +| 2 | Q00/data.go.kr-crawling — url.py (raw) | https://raw.githubusercontent.com/Q00/data.go.kr-crawling/master/url.py | F#62 | +| 3 | Q00/data.go.kr-crawling — go_data_crwaler.py (raw) | https://raw.githubusercontent.com/Q00/data.go.kr-crawling/master/go_data_crwaler.py | F#64 | +| 4 | FDA/openfda | https://github.com/FDA/openfda | F#2 | +| 5 | FDA/openfda — faers/pipeline.py | https://github.com/FDA/openfda/blob/master/openfda/faers/pipeline.py | S#23 | +| 6 | Food and Drug Administration (조직) | https://github.com/FDA | S#4 | +| 7 | jbremz/FDA-Analysis | https://github.com/jbremz/FDA-Analysis | F#3 | +| 8 | logiover/fda-data-scraper | https://github.com/logiover/fda-data-scraper | F#4 | +| 9 | coderxio/OpenFDA | https://github.com/coderxio/OpenFDA | F#18 | +| 10 | DarpitPatel/OpenFDA | https://github.com/DarpitPatel/OpenFDA | S#3 | +| 11 | rOpenHealth/openfda | https://github.com/rOpenHealth/openfda | S#23 | +| 12 | roivant/openfda | https://github.com/roivant/openfda | S#23 | +| 13 | shaayohn/fda-drug-aproval-data-scraping | https://github.com/shaayohn/fda-drug-aproval-data-scraping | S#6 | +| 14 | tsbischof/fda | https://github.com/tsbischof/fda | S#19 | +| 15 | sheetalkalburgi/web-scraping | https://github.com/sheetalkalburgi/web-scraping | S#19 | +| 16 | Norbaeocystin/FDA | https://github.com/Norbaeocystin/FDA | S#19 | +| 17 | vshah1016/pharma_scraper | https://github.com/vshah1016/pharma_scraper | S#19 | +| 18 | Tanguy9862/AI-Powered-FDA-Drug-Scraper | https://github.com/Tanguy9862/AI-Powered-FDA-Drug-Scraper | F#42 | +| 19 | Tanguy9862/new-drug-approvals-dashboard | https://github.com/Tanguy9862/new-drug-approvals-dashboard | F#42 | +| 20 | anton-semerenko/pharma-radar | https://github.com/anton-semerenko/pharma-radar | F#6 | +| 21 | mrueda/nomenclator-delta | https://github.com/mrueda/nomenclator-delta | F#30 | +| 22 | Mzands2622/Zanalytix | https://github.com/Mzands2622/Zanalytix | F#31 | +| 23 | suriyadeepan/WebScraping-for-Healthcare | https://github.com/suriyadeepan/WebScraping-for-Healthcare | F#22 | +| 24 | arpitamangal/pharma-scrape-and-analysis | https://github.com/arpitamangal/pharma-scrape-and-analysis | S#8 | +| 25 | kawsarlog/AmerisourceBergen | https://github.com/kawsarlog/AmerisourceBergen | F#19 | +| 26 | MohammedAhmed-01/DataDoseProject | https://github.com/MohammedAhmed-01/DataDoseProject | F#19 | +| 27 | Dagiayy/kara-medical-telegram-data-platform | https://github.com/Dagiayy/kara-medical-telegram-data-platform | F#19 | +| 28 | bdmorris238/pharmaco-database-project | https://github.com/bdmorris238/pharmaco-database-project | F#19 | +| 29 | khushihajiyani-dotcom/drug-spending-analysis | https://github.com/khushihajiyani-dotcom/drug-spending-analysis | F#19 | +| 30 | betagouv/api-medicaments | https://github.com/betagouv/api-medicaments | S#13 | +| 31 | dgtlmoon/changedetection.io | https://github.com/dgtlmoon/changedetection.io | F#7 | +| 32 | changedetection.io — Releases | https://github.com/dgtlmoon/changedetection.io/releases | F#61 | +| 33 | changedetection.io — Notification configuration notes (wiki) | https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes | F#55 | +| 34 | mattwolfe/changedetection (포크) | https://github.com/mattwolfe/changedetection | S#5 | +| 35 | thp/urlwatch | https://github.com/thp/urlwatch | F#5 | +| 36 | thp/urlwatch — Releases | https://github.com/thp/urlwatch/releases | F#60 | +| 37 | thp/urlwatch — Issue #246 (GitHub repo 감시) | https://github.com/thp/urlwatch/issues/246 | S#12 | +| 38 | huginn/huginn | https://github.com/huginn/huginn | F#8 | +| 39 | huginn — Agent Types (wiki) | https://github.com/huginn/huginn/wiki/Agent-Types | F#65 (로딩 실패) | +| 40 | huginn — website_agent.rb | https://github.com/huginn/huginn/blob/master/app/models/agents/website_agent.rb | F#68 | +| 41 | huginn/huginn_agent | https://github.com/huginn/huginn_agent | S#12 | +| 42 | roxwize/huginn | https://github.com/roxwize/huginn | S#12 | +| 43 | itkevin/huginn | https://github.com/itkevin/huginn | S#12 | +| 44 | larsyencken/csvdiff | https://github.com/larsyencken/csvdiff | F#32 | +| 45 | ecprice/newsdiffs | https://github.com/ecprice/newsdiffs | F#43 | +| 46 | simonw/git-scraper-template | https://github.com/simonw/git-scraper-template | F#47 | +| 47 | firecrawl/firecrawl — Releases | https://github.com/firecrawl/firecrawl/releases | S#24 | +| 48 | Bwhiz/Auto-Excel-Reports | https://github.com/Bwhiz/Auto-Excel-Reports | F#16 | +| 49 | god233012yamil/Excel-Automation-Using-Python | https://github.com/god233012yamil/Excel-Automation-Using-Python | S#7 | +| 50 | prabudevarajan/Task-Reminder-Automation-Python-Excel-CSV-Email-Alerts | https://github.com/prabudevarajan/Task-Reminder-Automation-Python-Excel-CSV-Email-Alerts | S#7 | +| 51 | HasData/playwright-scraping | https://github.com/HasData/playwright-scraping | F#41 | +| 52 | ManiMozaffar/linkedIn-scraper | https://github.com/ManiMozaffar/linkedIn-scraper | S#17 | +| 53 | dineshk-qa/playwright.slack.reporter | https://github.com/dineshk-qa/playwright.slack.reporter | S#17 | +| 54 | jshchnz/claude-code-scheduler | https://github.com/jshchnz/claude-code-scheduler | F#9 | +| 55 | claude-code-scheduler — examples | https://github.com/jshchnz/claude-code-scheduler/tree/main/examples | F#39 | +| 56 | claude-code-scheduler — examples/daily-review.json (raw) | https://raw.githubusercontent.com/jshchnz/claude-code-scheduler/main/examples/daily-review.json | F#44 | +| 57 | claude-code-scheduler — src | https://github.com/jshchnz/claude-code-scheduler/tree/main/src | F#57 | +| 58 | claude-code-scheduler — src/schedulers | https://github.com/jshchnz/claude-code-scheduler/tree/main/src/schedulers | F#66 | +| 59 | addyosmani/gemini-cli-tips | https://github.com/addyosmani/gemini-cli-tips | F#35 | +| 60 | google-gemini/gemini-cli — Discussion #3215 (Headless execution) | https://github.com/google-gemini/gemini-cli/discussions/3215 | S#20 | +| 61 | testing-in-production/gemini-jobs | https://github.com/testing-in-production/gemini-jobs | **404** (F#17) | +| 62 | winsw/winsw | https://github.com/winsw/winsw | F#10 | +| 63 | winsw — docs/xml-config-file.md (v3) | https://github.com/winsw/winsw/blob/v3/docs/xml-config-file.md | F#23 | +| 64 | winsw — samples/minimal.xml (v3) | https://github.com/winsw/winsw/blob/v3/samples/minimal.xml | S#15 | +| 65 | winsw — samples/complete.xml (v3) | https://github.com/winsw/winsw/blob/v3/samples/complete.xml | S#15 | +| 66 | winsw — Releases | https://github.com/winsw/winsw/releases | F#59 | +| 67 | WinSW-Windows (조직) | https://github.com/WinSW-Windows | S#15 | +| 68 | kirillkovalenko/nssm | https://github.com/kirillkovalenko/nssm | F#51 | +| 69 | larsekje/PythonWindowsServices | https://github.com/larsekje/PythonWindowsServices | F#14 | +| 70 | HaroldMills/Python-Windows-Service-Example | https://github.com/HaroldMills/Python-Windows-Service-Example | F#15 | +| 71 | HaroldMills — example_service.py | https://github.com/HaroldMills/Python-Windows-Service-Example/blob/master/example_service.py | S#16 | +| 72 | mhammond/pywin32 — win32serviceutil.py | https://github.com/mhammond/pywin32/blob/main/win32/Lib/win32serviceutil.py | S#16 | +| 73 | mhammond/pywin32 — Demos/service/serviceEvents.py | https://github.com/mhammond/pywin32/blob/main/win32/Demos/service/serviceEvents.py | S#16 | +| 74 | mhammond/pywin32 — Issue #1563 | https://github.com/mhammond/pywin32/issues/1563 | F#52 | +| 75 | SublimeText/Pywin32 — win32serviceutil.py | https://github.com/SublimeText/Pywin32/blob/master/lib/x32/win32/lib/win32serviceutil.py | S#16 | +| 76 | 786raees/task-scheduler-python | https://github.com/786raees/task-scheduler-python | F#45 | +| 77 | Windos/BurntToast | https://github.com/Windos/BurntToast | F#33 | +| 78 | Windos/BurntToast — Discussion #140 (재부팅 버튼) | https://github.com/Windos/BurntToast/discussions/140 | S#25 | +| 79 | Windos/BurntToast — Discussion #179 (시간 선택 버튼) | https://github.com/Windos/BurntToast/discussions/179 | S#25 | +| 80 | NakedPowerShell/BurntToast | https://github.com/NakedPowerShell/BurntToast | S#25 | +| 81 | Badgerati/Hook | https://github.com/Badgerati/Hook | S#25 | +| 82 | michalzobec/autorunsalerts | https://github.com/michalzobec/autorunsalerts | F#34 | +| 83 | DatGuy1/Windows-Toasts | https://github.com/DatGuy1/Windows-Toasts | F#11 | +| 84 | GitHub30/win11toast | https://github.com/GitHub30/win11toast | F#12 | +| 85 | GitHub30/win11toast — README (raw) | https://raw.githubusercontent.com/GitHub30/win11toast/main/README.md | F#67 | +| 86 | ysfchn/toasted | https://github.com/ysfchn/toasted | F#20 | +| 87 | jithurjacob/Windows-10-Toast-Notifications | https://github.com/jithurjacob/Windows-10-Toast-Notifications | S#9 | +| 88 | jithurjacob — win10toast 디렉터리 | https://github.com/jithurjacob/Windows-10-Toast-Notifications/tree/master/win10toast | S#9 | +| 89 | jithurjacob — win10toast/__init__.py | https://github.com/jithurjacob/Windows-10-Toast-Notifications/blob/master/win10toast/__init__.py | S#9 | +| 90 | jacobcolbert/Windows-10-Toast-Notifications | https://github.com/jacobcolbert/Windows-10-Toast-Notifications | S#9 | +| 91 | caronc/apprise | https://github.com/caronc/apprise | F#38 | +| 92 | caronc/apprise — wiki/Notify_windows | https://github.com/caronc/apprise/wiki/Notify_windows | F#56 | +| 93 | WooilJeong/PublicDataReader | https://github.com/WooilJeong/PublicDataReader | F#37 | +| 94 | jjscan/data.go.kr-1 | https://github.com/jjscan/data.go.kr-1 | F#46 | +| 95 | NomaDamas/k-skill — mfds-food-safety.md | https://github.com/NomaDamas/k-skill/blob/main/docs/features/mfds-food-safety.md | F#36 | +| 96 | lorien/awesome-web-scraping | https://github.com/lorien/awesome-web-scraping | F#13 | +| 97 | lorien/awesome-web-scraping — README.md | https://github.com/lorien/awesome-web-scraping/blob/master/README.md | S#8 | +| 98 | noirquant/awesome-web-scraping | https://github.com/noirquant/awesome-web-scraping | S#8 | +| 99 | jjwangnlp/awesome-web-scraping | https://github.com/jjwangnlp/awesome-web-scraping | S#8 | +| 100 | luminati-io/Awesome-Web-Scraping | https://github.com/luminati-io/Awesome-Web-Scraping | S#8 | +| 101 | spinov001-art/awesome-web-scraping-2026 | https://github.com/spinov001-art/awesome-web-scraping-2026 | S#8 | +| 102 | duyet/awesome-web-scraper | https://github.com/duyet/awesome-web-scraper | S#8 | +| 103 | patrickloeber/llm-data-scrapers | https://github.com/patrickloeber/llm-data-scrapers | S#18 | +| 104 | realpython/list-of-python-api-wrappers | https://github.com/realpython/list-of-python-api-wrappers | S#23 | +| 105 | FareedKhan-dev/best-llm-finder-pipeline | https://github.com/FareedKhan-dev/best-llm-finder-pipeline | S#18 | +| 106 | architkaila/Fine-Tuning-LLMs-for-Medical-Entity-Extraction | https://github.com/architkaila/Fine-Tuning-LLMs-for-Medical-Entity-Extraction | S#18 | +| 107 | guilopgar/Medication-Detection-LLM | https://github.com/guilopgar/Medication-Detection-LLM | S#18 | +| 108 | diakes/coupang_crawler_python | https://github.com/diakes/coupang_crawler_python | S#1 | +| 109 | simbakeila123 (사용자) | https://github.com/simbakeila123 | S#12 | + +### A.2 GitHub Topics + +| 제목 | URL | 확인 | +|---|---|---| +| pharmaceutical-data | https://github.com/topics/pharmaceutical-data | F#19 | +| pharmaceuticals (python) | https://github.com/topics/pharmaceuticals?l=python | S#4 | +| pharma | https://github.com/topics/pharma?o=desc&s=updated | S#18 | +| fda | https://github.com/topics/fda | S#4 | +| open-fda | https://github.com/topics/open-fda | S#4 | +| openfda (R) | https://github.com/topics/openfda?l=r&o=desc&s=updated | S#23 | +| openpyxl | https://github.com/topics/openpyxl?o=asc&s=forks | S#7 | +| openpyxl-python | https://github.com/topics/openpyxl-python | S#7 | +| excelwriter | https://github.com/topics/excelwriter?l=python | S#7 | +| python-excel | https://github.com/topics/python-excel | S#7 | +| xlsxwriter | https://github.com/topics/xlsxwriter?l=python | S#17 | +| scheduled-tasks | https://github.com/topics/scheduled-tasks?l=python&o=desc&s=updated | S#7 | +| task-scheduler (powershell) | https://github.com/topics/task-scheduler?l=powershell | S#29 | +| playwright-python | https://github.com/topics/playwright-python?o=asc&s=updated | S#17 | +| playwright (python) | https://github.com/topics/playwright?l=python | S#17 | +| python-scraper | https://github.com/topics/python-scraper | S#24 | +| huginn | https://github.com/topics/huginn?o=asc&s=stars | S#12 | +| llm-pipeline | https://github.com/topics/llm-pipeline | S#18 | + +### A.3 Gist + +| 제목 | URL | 확인 | +|---|---|---| +| pywin32 서비스 예제 (drmalex07) | https://gist.github.com/drmalex07/10554232 | F#40 | +| drugs@fda scraper (seanherron) | https://gist.github.com/seanherron/5997278 | S#3 | +| drugs@fda scraper (단축) | https://gist.github.com/5997278 | S#19 | +| Windows Task Scheduler 상호작용 스크립트 (nmpowell) | https://gist.github.com/nmpowell/dc8e7187948788c5c126f01755252164 | S#29 | +| Windows 10 토스트 생성 (hygull) | https://gist.github.com/hygull/32a742339a416dcfa2990504c848c1a9 | S#9 | +| Gemini CLI Job (HainanZhao) | https://gist.github.com/HainanZhao/92b43e68850189bfee8f39a2c2581ca6 | S#20 | + +### A.4 한국 식약처 / 공공데이터 + +| 제목 | URL | 확인 | +|---|---|---| +| **식품의약품안전처_원료의약품등록(DMF)현황 OpenAPI** | https://www.data.go.kr/data/15057075/openapi.do | **F#29** | +| **의약품안전나라 > 원료의약품등록(DMF) 정보 게시판** | https://nedrug.mfds.go.kr/bbs/117 | **F#28** | +| 의약품안전나라 DMF 검색 (에러 페이지) | https://nedrug.mfds.go.kr/searchDmf | F#63 (에러) | +| 식품의약품안전처_의약품 제품 허가정보 | https://www.data.go.kr/data/15095677/openapi.do | F#54 | +| 의약품안전나라 메인 | https://nedrug.mfds.go.kr/ | S#1 | +| 의약품안전나라 index | https://nedrug.mfds.go.kr/index | S#1 | +| 의약품등 검색 | https://nedrug.mfds.go.kr/searchDrug | S#1 | +| 사용자별서비스 > 일반소비자 | https://nedrug.mfds.go.kr/pbp/CCBRA01 | S#1 | +| 의약품 공공데이터공개 | https://nedrug.mfds.go.kr/cntnts/80 | S#1 | +| MFDS Drug Safety Korea (영문) | https://nedrug.mfds.go.kr/eng/index | S#22 | +| 식품의약품안전처_의약품개요정보(e약은요) | https://www.data.go.kr/data/15075057/openapi.do | S#13 | +| 식품의약품안전처_의약품 낱알식별 정보 | https://www.data.go.kr/data/15057639/openapi.do | S#13 | +| 식품의약품안전처_의약품 낱알식별 정보 (추천) | https://www.data.go.kr/data/15057639/openapi.do?recommendDataYn=Y | S#30 | +| 식품의약품안전처_필수의약품내역 | https://www.data.go.kr/data/15058207/openapi.do?recommendDataYn=Y | S#30 | +| 식품의약품안전처 의약품 관련 정보 (파일데이터) | https://www.data.go.kr/data/15020627/fileData.do | S#30 | +| 공공데이터포털 (구 URL 1) | https://www.data.go.kr/dataset/15020626/openapi.do | S#2 | +| 공공데이터포털 (구 URL 2) | https://www.data.go.kr/dataset/15020627/openapi.do | S#13 | +| 연구관리 기술 분류 정보조회 서비스 | https://www.data.go.kr/data/15068423/openapi.do | S#2 | +| 연구관리 전문기술분야코드 조회 서비스 | https://www.data.go.kr/data/15068280/openapi.do | S#30 | +| OPENAPI Detail (영문 포털) | https://www.data.go.kr/en/data/15117134/openapi.do | S#22 | +| 공공데이터포털 API 명세 조회 엔드포인트 | https://www.data.go.kr/pubn/lab/gui/IrosDevGuide/selectReqResPrmList.do | F#62 | +| 식의약 데이터 포털 | https://data.mfds.go.kr/ | S#2 | +| 식의약 데이터 포털 — 공공데이터 목록 및 이용안내 | https://data.mfds.go.kr/cntnts/20 | S#2 | +| 식의약 데이터 포털 — 공공데이터 상세 | https://data.mfds.go.kr/OPCAA01F01 | S#2 | +| 식의약 데이터 포털 — 공공데이터 검색 | https://data.mfds.go.kr/OPCAA01F01/search?selectedTab=tab1&taskDivsCd=3&taskDivsDtlCd=7&rchSrvcKorNm=&btnSearch= | S#2 | +| 식품안전나라 데이터활용서비스 | https://www.foodsafetykorea.go.kr/apiMain.do | S#13 | +| 식품안전나라 OpenAPI 신청 | https://www.foodsafetykorea.go.kr/api/openApiAplcInfo.do | S#13 | +| KHIDI — DMF 등록 공고(7월 둘째주) | https://www.khidi.or.kr/board/view?pageNum=48&rowCnt=10&menuId=MENU01872&maxIndex=00487793189998&minIndex=00487441479998&schType=0&schText=&categoryId=&continent=&country=&upDown=0&boardStyle=&no1=912&linkId=26604564 | S#11 | +| KHIDI — [지침]원료의약품 등록(DMF) 처리 절차 | https://www.khidi.or.kr/board/view?pageNum=1&rowCnt=10&menuId=MENU01872&maxIndex=99999999999999&minIndex=99999999999999&schType=0&schText=&categoryId=&continent=&country=&upDown=0&boardStyle=&no1=0&linkId=26605812 | S#11 | +| 식품의약품안전평가원 — KDMF | https://www.nifds.go.kr/brd/m_87/list.do | S#11 | +| MFDS 영문 메인 | https://www.mfds.go.kr/eng/index.do | S#22 | +| MFDS 영문 — Drugs > GIFT | https://www.mfds.go.kr/eng/wpge/m_1176/de011009l001.do | S#22 | +| MFDS 영문 — Drugs > Approval Process | https://www.mfds.go.kr/eng/wpge/m_17/denofile.do | S#22 | + +### A.5 FDA (미국) 및 상용 DMF 데이터 + +| 제목 | URL | 확인 | +|---|---|---| +| List of Drug Master Files (DMFs) | https://www.fda.gov/drugs/drug-master-files-dmfs/list-drug-master-files-dmfs | **404** (F#48) | +| Drug Master Files (DMFs) 개요 | https://www.fda.gov/drugs/drug-master-files-dmfs | **404** (F#53) | +| Drug Master Files (DMFs) — 제출 요건 | https://www.fda.gov/drugs/forms-submission-requirements/drug-master-files-dmfs | S#6 | +| Types of Drug Master Files (DMFs) | https://www.fda.gov/drugs/drug-master-files-dmfs/types-drug-master-files-dmfs | S#6 | +| Guideline for Drug Master Files (DMF) | https://www.fda.gov/drugs/drug-master-files-dmfs/guideline-drug-master-files-dmf | S#6 | +| openFDA 메인 | https://open.fda.gov/ | S#3 | +| openFDA — Drug API Endpoints | https://open.fda.gov/apis/drug/ | S#3 | +| openFDA — Orange Book | https://open.fda.gov/apis/drug/orangebook/ | S#3 | +| openFDA — drug NDC download | https://open.fda.gov/apis/drug/ndc/download/ | F#18 | +| PharmaCompass — US DMF Database | https://www.pharmacompass.com/us-drug-master-files-dmfs | S#6 | +| John Snow Labs — FDA DMF Directory | https://www.johnsnowlabs.com/marketplace/fda-drug-master-files-directory/ | S#27 | +| pharmaexcipients — Excipient DMF List | https://www.pharmaexcipients.com/excipient-sources/excipient-dmf-list/ | S#27 | +| Dmf List – FDA Quarterly Spreadsheet | https://dmf-list.backgroundscheck.info/ | S#27 | +| fdapals — DMF FDA Guidance | https://fdapals.com/services/dmf-fda-guidance/ | S#27 | +| Apify — FDA Orange Book Scraper | https://apify.com/labrat011/fda-orange-book-scraper/api | S#3 | +| Apify — OpenFDA Scraper (Python) | https://apify.com/fortuitous_pirate/openfda-scraper/api/python | S#3 | +| Apify — OpenFDA Drug Intelligence + AI (Python) | https://apify.com/benthepythondev/openfda-drug-intelligence/api/python | S#23 | +| Wikipedia — Drug Master File | https://en.wikipedia.org/wiki/Drug_Master_File | S#6 | +| Wikipedia — DMF | https://en.wikipedia.org/wiki/DMF | S#11 | +| Wikipedia — Approved Drug Products with Therapeutic Equivalence Evaluations | https://en.wikipedia.org/wiki/Approved_Drug_Products_with_Therapeutic_Equivalence_Evaluations | S#3 | +| Wikipedia — Web crawler | https://en.wikipedia.org/wiki/Web_crawler | S#1 | +| Wikipedia — Censorship of GitHub | https://en.wikipedia.org/wiki/Censorship_of_GitHub | S#11 | +| Wikipedia — Kim Gang-lip | https://en.wikipedia.org/wiki/Kim_Gang-lip | S#22 | + +### A.6 공식 문서 (도구·라이브러리) + +| 제목 | URL | 확인 | +|---|---|---| +| Claude Code — Run Claude Code programmatically (headless) | https://code.claude.com/docs/en/headless | **F#25** | +| Claude Code — Schedule recurring tasks in Desktop | https://code.claude.com/docs/en/desktop-scheduled-tasks | **F#49** | +| Claude Code — GitHub Actions | https://code.claude.com/docs/en/github-actions | S#14 | +| Claude Code — 문서 색인 | https://code.claude.com/docs/llms.txt | F#25 | +| ChangeDetection.io API v1 | https://changedetection.io/docs/api_v1/index.html | **F#27** | +| urlwatch — Jobs | https://urlwatch.readthedocs.io/en/latest/jobs.html | **F#26** | +| urlwatch — Filters | https://urlwatch.readthedocs.io/en/latest/filters.html | **F#50** | +| urlwatch — 문서 루트 | https://urlwatch.readthedocs.io/ | F#5 | +| urlwatch — 홈페이지 | https://thp.io/2008/urlwatch/ | F#5 | +| NSSM — Usage | https://nssm.cc/usage | **F#24** | +| NSSM — 홈페이지 | http://nssm.cc/ | F#51 | +| XlsxWriter — Worksheet | https://xlsxwriter.readthedocs.io/worksheet.html | **F#58** | +| win10toast (PyPI) | https://pypi.org/project/win10toast/ | S#9 | +| pyfda (PyPI) | https://pypi.org/project/pyfda/ | S#23 | +| github (PyPI) | https://pypi.org/project/github/ | S#23 | +| Gemini CLI — Automation and triage processes | https://geminicli.com/docs/issue-and-pr-automation/ | S#20 | +| JSON Schema | https://json-schema.org/ | F#25 | +| jq | https://jqlang.org/ | F#25 | +| Claude Console | https://platform.claude.com | F#25 | + +### A.7 블로그·튜토리얼·기사 + +| 제목 | URL | 확인 | +|---|---|---| +| Drew Bredvick — How to Run Claude Code as a Cron Job | https://drew.tech/posts/claude-code-as-a-cron-job | **F#21** | +| MindStudio — What Is Claude Code Headless Mode? | https://www.mindstudio.ai/blog/claude-code-headless-mode-autonomous-agents | S#10 | +| wmedia.es — Claude Code Can Work While You Sleep | https://wmedia.es/en/tips/claude-code-headless-mode-autonomous-agent | S#10 | +| hidekazu-konishi — Claude Code in CI/CD and Headless Automation | https://hidekazu-konishi.com/entry/claude_code_cicd_and_headless_automation.html | S#10 | +| Claude Code for Clinicians — Ch.17 Headless Mode | https://iyadsultan.github.io/claude-code-for-clinicians/ch17-headless-mode/ | S#10 | +| StackNotice — Claude Code in Scripts (2026) | https://stacknotice.com/blog/claude-code-headless-scripting-2026 | S#10 | +| Usagebar — How to Set Up Cron Jobs with Claude Code | https://usagebar.com/blog/how-to-do-cron-job-setup-on-claude-code | S#10 | +| DevShelfHub — Claude Code Automation: Non-Interactive Mode | https://www.devshelfhub.com/tutorials/claude-code/automation/ | S#10 | +| HeyClaude — Claude Code Process Automation | https://heyclau.de/entry/guides/business-process-automation | S#10 | +| Like One — Claude Code Headless Mode Guide (2026) | https://likeone.ai/blog/claude-code-headless-mode-guide-2026/ | S#10 | +| Build This Now — Claude Code Headless Mode | https://www.buildthisnow.com/blog/guide/development/claude-code-headless-mode | S#10 | +| jannikreinhard — Claude Code in GitHub Actions | https://jannikreinhard.com/claude-code-github-actions/ | S#14 | +| Level Up Coding — Claude Code Routines | https://levelup.gitconnected.com/claude-code-routines-the-cron-replacement-i-didnt-know-i-needed-6f53cf476577?gi=f0e7b272cd2b | S#14 | +| SmartScope — Claude Code Scheduled Execution (AI development) | https://smartscope.blog/en/ai-development/claude-code-scheduled-automation-guide/ | S#14 | +| SmartScope — Claude Code + Cron 2025 | https://smartscope.blog/en/generative-ai/claude/claude-code-cron-schedule-automation-complete-guide-2025/ | S#14 | +| SmartScope — Claude Code Scheduled Execution (generative-ai) | https://smartscope.blog/en/generative-ai/claude/claude-code-scheduled-automation-guide/ | S#14 | +| SmartScope — Claude Code × Cron Complete Automation Guide | https://smartscope.blog/en/generative-ai/claude/claude-code-cron-automation-guide/ | S#14 | +| SmartScope — Codex CLI Automation: 3 Workflow Patterns | https://smartscope.blog/en/generative-ai/chatgpt/codex-cli-automation-workflow-patterns/ | S#26 | +| claudefa.st — Claude Code Scheduled Tasks (2026) | https://claudefa.st/blog/guide/development/scheduled-tasks | S#28 | +| atalupadhyay — Scheduled Tasks: How to Put Claude on Autopilot | https://atalupadhyay.wordpress.com/2026/03/02/scheduled-tasks-how-to-put-claude-on-autopilot/ | S#28 | +| aixplore — Mastering Scheduled Tasks in Claude Code | https://aixplore.in/blog_post?slug=mastering-scheduled-tasks-in-claude-code-guide | S#28 | +| Claude Cowork — Scheduled Tasks Guide | https://claudecowork.im/blog/scheduled-tasks-guide | S#28 | +| MCP Market — Windows Task Scheduler Claude Code Skill | https://mcpmarket.com/tools/skills/windows-task-scheduler | S#28 | +| leeboonstra.dev — Unleashing Gemini CLI Power in GitHub Actions | https://www.leeboonstra.dev/genai/gemini_cli_github_actions/ | S#20 | +| Testing in Production — Scheduling Jobs With Gemini CLI and Cron | https://www.testinginproduction.co/blog/automating-ai-jobs-with-gemini-cli | S#20 | +| Gemini CLI All in One — 10 Real Workflows | https://geminicli.one/blog/gemini-cli-use-cases-workflows | S#20 | +| DeployHQ — OpenAI Codex CLI: Complete Getting Started Guide | https://www.deployhq.com/blog/getting-started-with-openai-codex-cli-ai-powered-code-generation-from-your-terminal | S#26 | +| Codex KB — Headless and Batch Mode | https://codex.danielvaughan.com/2026/04/18/codex-cli-headless-batch-mode-automation/ | S#26 | +| Codex KB — Automations as Lightweight CI | https://codex.danielvaughan.com/2026/07/19/codex-automations-lightweight-ci-scheduled-agents-codex-exec-github-actions/ | S#26 | +| Codex KB — Automations and Scheduled Tasks | https://codex.danielvaughan.com/2026/03/27/codex-cli-automations-scheduled-tasks/ | S#26 | +| Codex KB — codex exec, Non-Interactive Mode | https://codex.danielvaughan.com/2026/03/26/codex-cli-cicd-non-interactive/ | S#26 | +| Developers Digest — Codex Exec in CI | https://www.developersdigest.tech/blog/codex-exec-ci-headless-guide | S#26 | +| Codexlog — How to Set Up a CI/CD Pipeline with Codex | https://codexlog.dev/guides/tasks/setup-ci-cd-pipeline/ | S#26 | +| PageCrawl.io — Best Open-Source Website Change Detection Tools | https://pagecrawl.io/blog/open-source-website-change-detection-tools | S#5 | +| GIGAZINE — changedetection.io 리뷰 | https://gigazine.net/gsc_news/en/20260517-changedetection-io | S#5 | +| alternativeto — urlwatch 대안 | https://alternativeto.net/software/urlwatch | S#5 | +| alternativeto — urlwatch 대안 p5 | https://alternativeto.net/software/urlwatch/?p=5 | S#5 | +| alternativeto — changedetection.io 대안 p2 | https://alternativeto.net/software/changedetection-io/?p=2 | S#5 | +| alternativeto — changedetection.io 대안 p3 | https://alternativeto.net/software/changedetection-io/?p=3 | S#5 | +| Simon Willison — Git scraping | https://simonwillison.net/2020/Oct/9/git-scraping/ | S#24 | +| ScraperAPI — How to Scrape GitHub Data Repository With Python | https://www.scraperapi.com/web-scraping/github/ | S#24 | +| PDQ — Display toast notifications with BurntToast | https://www.pdq.com/blog/display-toast-notifications-with-powershell-burnt-toast-module/ | S#25 | +| CyberDrain — Monitoring with PowerShell: Windows Updates 알림 | https://www.cyberdrain.com/monitoring-with-powershell-notifying-users-of-windows-updates/ | S#25 | +| Joshua Dearing — Reboot Notifications with BurntToast | https://www.dearing.dev/posts/Reboot-Notifications-with-BurntToast-A-Simple-Guide/ | S#25 | +| PowerShell Forums — burnt toast notification | https://forums.powershell.org/t/powershell-burnt-toast-notification/24222 | S#25 | +| usro.net — How to Create a Windows Service with WinSW | https://blog.usro.net/2024/10/how-to-create-a-windows-service-with-winsw-a-step-by-step-guide/ | S#15 | +| ehmiiz.se — PowerShell Guide: Script as a Windows Service | https://www.ehmiiz.se/blog/ps_scriptasaservice/ | S#15 | +| Infonautics — Run any program as a Windows background service with WinSW | https://www.infonautics.ch/blog/run-any-program-as-a-windows-background-service-with-winsw/ | S#15 | +| python-win32 메일링리스트 — automatically restart python service after crash | https://mail.python.org/pipermail/python-win32/2017-January/013807.html | S#16 | +| Woteq Zone — How to Monitor Windows Services Using Python | https://woteq.com/how-to-monitor-windows-services-using-python-on-windows | S#16 | +| DEV Community — Building a Robust Windows Service in Python with win32serviceutil | https://dev.to/demola12/building-a-robust-windows-service-in-python-with-win32serviceutil-part-13-1k6k | S#16 | +| Oxylabs — Automated Web Scraper With Python & Windows Task Scheduler | https://oxylabs.io/blog/automated-web-scraper-windows-task-scheduler | S#29 | +| Flipnode — Automated Web Scraper With Python & Windows Task Scheduler | https://flipnode.io/automated-web-scraper-windows-task-scheduler | S#29 | +| JC Chouinard — How to Automate Python Scripts with Task Scheduler | https://www.jcchouinard.com/python-automation-using-task-scheduler/ | S#29 | +| Biztory — Run a python script on a schedule using Task Scheduler | https://biztory.com/blog/run-a-python-script-on-a-schedule-using-the-in-built-task-scheduler-windows-app | S#29 | +| Medium (Vnalla) — Two ways to run Python Scripts Every Day Automatically | https://medium.com/@vineelan09/two-ways-to-run-python-scripts-every-day-automatically-3c86079fe449 | S#29 | +| Oreate AI — Making Your Python Web Scraper Work for You | https://www.oreateai.com/blog/making-your-python-web-scraper-work-for-you-automating-with-windows-task-scheduler/7efa97048b3b0b28a3513e73ed1235fd | S#29 | +| Adobe User Sync Tool — Scheduling | https://adobe-apiplatform.github.io/user-sync.py/en/success-guide/scheduling.html | S#29 | +| Medium — Playwright report to Slack | https://medium.com/@indraaristya/playwright-report-to-slack-e07e8996c9de | S#17 | +| Medium — Playwright with GitHub Actions and Slack Notification | https://medium.com/@vinayakhk9/playwright-with-github-actions-and-slack-notification-b56eb982659b | S#17 | +| ScrapeGraphAI — LLM Web Scraping | https://scrapegraphai.com/blog/llm-web-scraping | S#18 | +| Grepsr — How to Design Scraping Systems for LLM Training Pipelines | https://www.grepsr.com/blog/llm-data-pipelines-web-scraping-grepsr/ | S#18 | +| IntuitionLabs — AI and the Future of Regulatory Affairs | https://intuitionlabs.ai/articles/ai-future-regulatory-affairs-pharma | S#4 | +| IntuitionLabs — Open Source Pharma: Tools & Trends | https://intuitionlabs.ai/articles/open-source-pharma-trends | S#4 | +| Clarivate — Biopharma Regulatory Compliance Services | https://clarivate.com/life-sciences-healthcare/research-development/regulatory-compliance-intelligence/ | S#4 | +| Vistaar — Regulatory Intelligence Database, Software & Tools | https://www.vistaar.ai/blog/regulatory-intelligence-database-software-tools-for-compliance/ | S#4 | +| Precision for Medicine — How to launch a clinical trial in South Korea | https://www.precisionformedicine.com/blog/how-to-launch-a-clinical-trial-in-south-korea-investigational-new-drug-application-process | S#22 | +| velog — 네이버 의약품사전 크롤링 | https://velog.io/@xenrose/naverPillCrawling | S#1 | +| samslow.github.io — 식품안전나라 크롤링 가이드 | https://samslow.github.io/diary/2019/02/11/sickfoom-crawling-guide/ | S#1 | +| velog — 공공데이터 포털API사용하기 | https://velog.io/@almondbreez0_3/xiniel0v | S#2 | +| Medium (Sarah Na) — Selenium으로 웹사이트 크롤링하기(2) | https://2island.medium.com/python-selenium%EC%9C%BC%EB%A1%9C-%EC%9B%B9%EC%82%AC%EC%9D%B4%ED%8A%B8-%ED%81%AC%EB%A1%A4%EB%A7%81%ED%95%98%EA%B8%B0-2-%EC%9B%B9-%EC%82%AC%EC%9D%B4%ED%8A%B8-%EC%A0%9C%EC%96%B4%ED%95%B4%EB%B3%B4%EA%B8%B0-1ffc5e05179d | S#21 | +| Steemit — Mediteam.us 개발 Python & Selenium 구글검색 크롤링 | https://steemit.com/kr/@junn/mediteam-us-python-and-selenium | S#21 | +| greeksharifa — Python Selenium 사용법 | https://greeksharifa.github.io/references/2020/10/30/python-selenium-usage/ | S#21 | +| Summer's Blog — GPT가 알려주는데로 크롤링 만들기 | https://sunmerrr.github.io/other/crawling-1/ | S#21 | +| teamlab.github.io — Selenium으로 네이버 연극 데이터 크롤링하기 | https://teamlab.github.io/jekyllDecent/blog/crawling%20with%20python/Selenium%EC%9C%BC%EB%A1%9C-%EB%84%A4%EC%9D%B4%EB%B2%84-%EC%97%B0%EA%B7%B9-%EB%8D%B0%EC%9D%B4%ED%84%B0-%ED%81%AC%EB%A1%A4%EB%A7%81%ED%95%98%EA%B8%B0-with-Python | S#21 | +| JaeSeoKim's Blog — Selenium을 이용한 웹 크롤링 | https://jaeseokim.dev/Python/python-Selenium%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%9B%B9-%ED%81%AC%EB%A1%A4%EB%A7%81-%EA%B0%84%EB%8B%A8-%EC%82%AC%EC%9A%A9%EB%B2%95-%EB%B0%8F-%EC%98%88%EC%A0%9C/ | S#21 | +| Hugging Face — stack-v2-python 데이터셋 (무관) | https://huggingface.co/datasets/yushengsu/stack-v2-python-with-content-chunk1-modified/viewer/default/train?p=1 | S#24 | +| Hugging Face — Job_Knowledge_Graph commit (무관) | https://huggingface.co/spaces/nqtruong/Job_Knowledge_Graph/commit/1049d38a30e7fa8fbd52d5076e265dafd2bcb104 | S#24 | + +--- + +## 부록 B. 미해결 질문 / 실측 필요 항목 + +### B.1 최우선 (설계를 바꿀 수 있는 것) + +- [ ] **`agy` (Google Antigravity CLI) 의 실제 CLI 표면을 실측한다.** raw dump 는 `agy` 를 전혀 조사하지 않았다. `agy --help` 로 다음 4가지를 확인하고 `docs/research/` 에 기록: ① 비대화형 프롬프트 플래그(`-p`?), ② JSON 출력 플래그, ③ JSON Schema 강제 옵션 존재 여부, ④ 승인/샌드박스 우회 플래그. 없으면 `config/settings.yaml` 의 `agent.*` 를 전면 수정해야 한다. +- [ ] **`agy` 설치 경로·인증 방식·환경변수를 확인한다.** `claude` 는 `ANTHROPIC_API_KEY`(bare 모드), `gemini` 는 `GEMINI_SYSTEM_MD` 등을 쓴다. `agy` 의 대응물이 무엇인지, 그리고 **스케줄드 태스크(비대화형 세션)에서 인증이 유지되는지** — drew.tech 이 지적한 "snapshots preserve auth state" 문제와 동형. +- [ ] **`agy` 미설치 시 자동 부트스트랩이 가능한지.** 요구사항에 "agy 가 없으면 자동 설치하고 필요하면 Windows 프롬프트 창을 띄운다"가 있으나, 무인 설치 경로(패키지 관리자? 설치 스크립트?)가 확인되지 않았다. +- [ ] **DMF OpenAPI 응답의 실제 JSON 중첩 구조.** `response.body.items.item` 인지 `body.items` 인지. `§15.1` 의 `probe_dmf_api.py` 로 실측 후 `schemas/dmf_record.schema.json` 확정. +- [ ] **`serviceKey` 의 인코딩/디코딩 키 구분.** data.go.kr 은 두 종류를 발급한다. `requests` 의 `params=` 로 넘길 때 어느 쪽이 맞는지 실측. +- [ ] **`resultCode` 정상값과 에러 코드 목록.** 재시도 대상 에러와 즉시 실패 에러를 구분해야 한다. +- [ ] **DMF 현황 전체 레코드 수(`totalCount`).** `jjscan/data.go.kr-1` 이 351,010건에 17시간을 썼다. DMF 가 몇 건인지에 따라 백필 전략(병렬 여부)이 달라진다. +- [ ] **`nedrug.mfds.go.kr/bbs/117` 이 정적 HTML 인지 JS 렌더링인지.** 정적이면 `requests` + BeautifulSoup, 아니면 Playwright 도입. `scripts/inspect_board.py` 로 실측. +- [ ] **게시판 페이지네이션 파라미터.** 총 710건, 페이지당 10/20/30/40/50 선택 가능하다는 것만 확인됨. 실제 쿼리스트링(`page=`? `pageNo=`? POST?)은 미확인. +- [ ] **`§14.6` 의 CSS 선택자 전량.** `table tbody tr td:nth-child(n)` 는 추정치다. 실제 DOM 으로 반드시 교체. + +### B.2 데이터 소스 (2차) + +- [ ] **`https://nedrug.mfds.go.kr/searchDmf` 의 올바른 경로.** WebFetch 가 에러 페이지를 반환했다 [F#63]. DMF 검색 UI 가 실제로 존재하는지, 있다면 URL 은 무엇인지. +- [ ] **식약처 전자민원창구의 "원료의약품 등록 현황(신규등록, 변경등록, 연차보고)"** [S#11] 이 별도 소스로 쓸 만한지. 특히 **"변경등록"이 명시적으로 구분되어 있다면** diff 없이도 변경 사유를 얻을 수 있다. +- [ ] **KHIDI 제약산업정보포털 공고**가 nedrug 게시판보다 빠른지/늦은지. 빠르면 조기 신호 소스로 추가. +- [ ] **`식품의약품안전처_의약품 제품 허가정보` API 의 엔드포인트 URL 과 파라미터.** 페이지에 명시되지 않았다 [F#54]. `CANCEL_DATE`/`CANCEL_NAME`/`CHANGE_DATE` 필드가 있으므로 **취하/변경 판정의 보조 근거**가 될 수 있다. +- [ ] **DMF API 의 갱신 주기.** "Last modified September 19, 2025" 만 확인됨. 일 단위인지 주 단위인지에 따라 06:00 실행의 의미가 달라진다(주 단위면 대부분의 실행이 no-change). +- [ ] **`ENTP_NAME` / `MNFCTR_NAME` 의 표기 흔들림 실태.** `Tanguy9862` 는 회사명 1000종→700종 정규화가 필요했다. 우리도 샘플 1,000건으로 실태 조사 후 `parse/normalize.py` 규칙 작성. + +### B.3 Windows 운영 + +- [ ] **WinSW `samples/minimal.xml` 의 필수 요소 확인.** `§10.1` 의 XML 초안에서 ``/``/`` 이 필수인지, `` 와 `` 를 언제 쓰는지. +- [ ] **WinSW v2.12.0 과 v3.0.0-alpha.11 중 어느 것을 쓸지.** alpha 는 위험하지만 `docs/xml-config-file.md` 는 v3 브랜치 문서다. **v2 에서 동일 요소가 지원되는지 확인 필요** — 특히 ``, ``, ``. +- [ ] **WinSW 실행에 .NET 런타임이 필요한지, 어느 버전인지.** Windows 11 기본 탑재로 충분한지. +- [ ] **`schtasks /create` 의 정확한 옵션.** `/ru`(실행 사용자), `/rp`(비밀번호), `/rl HIGHEST`, `/np`(비밀번호 저장 안 함), `/it`(대화형)의 조합. 특히 **사용자 컨텍스트 토스트 태스크는 `/it` 가 필요한지**. +- [ ] **사용자가 로그오프 상태일 때 06:00 태스크가 도는지.** `/ru SYSTEM` 이면 돌지만 토스트는 못 띄운다 — 그래서 큐 파일 방식을 택했으나, 실제 동작 검증 필요. +- [ ] **`win11toast` 의 `os.chdir()` 함정 재현.** "실행 시 CWD 가 `C:\Windows\system32`" [F#67] 가 Task Scheduler 실행 시에도 발생하는지. +- [ ] **Apprise `windows://` 가 pywin32 를 통해 실제로 토스트를 띄우는지, 250자 제한이 어떻게 잘리는지.** +- [ ] **PC 절전/최대절전 시 Task Scheduler 의 "작업을 실행하기 위해 절전 모드 해제" 옵션**을 켤 것인지. Claude Desktop 문서가 지적한 "컴퓨터가 자면 실행이 스킵된다" 문제의 Windows 네이티브 해법. + +### B.4 리포트 + +- [ ] **XlsxWriter `autofilter()` / `freeze_panes()` 의 정확한 시그니처.** [F#58] 에서 확보 실패. 문서 재확인 필요. +- [ ] **한글 시트명에 `write_url('internal:...')` 가 정상 동작하는지.** 문서는 "Worksheet names with spaces should be single quoted" 만 언급. 한글 + 공백 조합 실측 필요. +- [ ] **엑셀에서 열었을 때 internal 링크가 실제로 클릭되는지** (Excel 버전별, LibreOffice 호환성). +- [ ] **리포트 파일명 규칙 확정.** `YYYY-MM-DD_DMF_리포트.xlsx` 로 할 경우 한글 파일명이 `win11toast` 의 `on_click` 으로 열릴 때 문제가 없는지. + +### B.5 diff / 데이터 모델 + +- [ ] **`DMF_PERMIT_NO` 가 진짜 불변 유일 키인지.** 재발급·번호 변경 사례가 있으면 diff 가 대량 오탐을 낸다. 과거 스냅샷 2개를 확보해 검증. +- [ ] **"취하"가 API 에서 어떻게 표현되는지.** 레코드가 사라지는가(→ `removed`), 아니면 상태 필드가 바뀌는가(→ `changed`)? **전자라면 API 일시 장애 시 전량 `removed` 오탐이 발생하므로, 레코드 수가 전일 대비 N% 이상 감소하면 diff 를 중단하는 안전장치가 필수다.** +- [ ] **`ignore_columns` 에 넣어야 할 노이즈 필드 실태.** 조회수 외에 무엇이 매일 바뀌는지 1주일 관측. +- [ ] **우선순위 규칙(`§14.7` priority_rules)의 타당성**을 실무자에게 확인. + +### B.6 검색 예산 소진으로 미완인 조사 + +WebSearch 예산(200/200)이 소진되어 다음 3개 질의가 **수행되지 않았다**. 필요 시 WebFetch + Bing/DuckDuckGo 로 보완: + +- [ ] `github 식약처 공고 크롤링 텔레그램 알림 스케줄러 파이썬 회수 판매중지` [S#31 미수행] +- [ ] `github xlsxwriter multi-sheet report internal hyperlink summary sheet python generator repository` [S#32 미수행] +- [ ] `github Playwright python Windows Task Scheduler headless daily scrape report "pythonw" OR "schtasks" repository` [S#33 미수행] + +추가로 확인하지 못한 것: +- [ ] **`urlwatch` 의 `diff_tool` 옵션** — Filters 페이지에 없었다 [F#50]. Configuration 페이지 확인 필요. +- [ ] **Huginn 의 `ChangeDetectorAgent`, `DeDuplicationAgent`, `DigestAgent`, `EmailDigestAgent`, `SchedulerAgent`, `ShellCommandAgent` 설명** — wiki 로딩 실패 [F#65]. +- [ ] **`jshchnz/claude-code-scheduler` 의 `src/schedulers/windows.ts` 실제 구현** — schtasks 를 어떻게 호출하는지 [F#66 은 파일명만 확인]. +- [ ] **FDA DMF 목록 스프레드시트의 실제 다운로드 URL** — fda.gov 두 페이지가 모두 404 [F#48][F#53]. +- [ ] **`FDA/openfda`, `changedetection.io`, `huginn`, `urlwatch` 등의 정확한 최근 커밋 날짜** — GitHub 페이지에서 텍스트로 노출되지 않아 커밋 총수로 대체 기록했다. + +### B.7 라이선스·규범 + +- [ ] **참고한 저장소 중 라이선스 미확인 항목**(`§11.4` 표의 "미확인" 행)을 코드 참고 전에 확인. +- [ ] **식약처 공공데이터 이용약관** — 저장·재배포·사내 공유 범위. data.go.kr 활용 신청 시 "운영" 등급으로 승격하려면 활용 사례 등록이 필요하다 [F#54]. +- [ ] **크롤링 빈도 예의(rate limit)** — 개발 등급 10,000 calls 제한 [F#29] 안에서 일일 실행이 몇 콜을 쓰는지 계산하고, 게시판 스크래핑에는 요청 간 지연을 둘 것. diff --git a/docs/research/03-crawling-theory-and-papers.md b/docs/research/03-crawling-theory-and-papers.md new file mode 100644 index 0000000..b1284c2 --- /dev/null +++ b/docs/research/03-crawling-theory-and-papers.md @@ -0,0 +1,2622 @@ +# 크롤링 방법론 이론과 논문 근거 + +> **이 문서의 역할**: DMF_Crawler 가 "매일 06:00 에 식약처 DMF 공고/현황을 1회 크롤링해 신규·변경·취하를 탐지한다"는 요구를 만족시키기 위해, 어떤 이론·논문·표준을 근거로 주기·politeness·파서 구조·diff 엔진·LLM 역할 경계를 정했는지 확정하는 단일 정본(SSOT) 이다. + +--- + +## 0. 한눈에 보기 + +이 문서가 최종적으로 내린 결론이다. 근거는 각 섹션에 있다. + +- **크롤러 아키텍처는 Mercator(Heydon & Najork, 1999)의 6요소로 축소 적용한다.** URL Frontier → DNS → Protocol Module(HTTP) → RIS(재읽기 가능 스트림) → Content-Seen Test(fingerprint) → URL-Seen Test(DUE). 우리 규모(호스트 1개, URL 수십~수백)에서는 frontier 를 "우선순위 있는 리스트 + 호스트별 직렬 큐" 로, content-seen 을 "레코드 지문 테이블" 로 축소한다. **체크포인트(checkpointing)는 규모와 무관하게 반드시 구현한다** — Mercator 가 장기 실행 프로세스의 필수 요소로 지목한 항목이고, 우리는 재부팅 자동 복구를 요구사항으로 갖고 있기 때문이다. +- **재방문 정책은 Cho & Garcia-Molina(TODS 2003)의 결론에 따라 "균등(uniform) 고정 주기"를 채택한다.** 이들은 웹 페이지 변경이 Poisson 과정으로 잘 모델링되며(변경 간격이 λe^(−λt) 지수분포), **균등 정책이 비례(proportional) 정책보다 평균 freshness 가 높다**는 반직관적 결과를 증명했다. 우리처럼 "매일 06:00 1회"는 이론적으로 정당한 선택이며, 자주 바뀐다고 특정 페이지만 더 자주 긁는 최적화는 하지 않는다. +- **하루 1회(I = 1일) 주기에서 게시판당 기대 freshness 는 F̄ = (1 − e^(−λI))/(λI), 기대 age 는 Ā = I/2 − 1/λ + (1 − e^(−λI))/(λ²I) 로 계산한다.** DMF 공고처럼 λ ≈ 0.2~1 건/일 수준이면 F̄ ≈ 0.90~0.63, Ā ≈ 0.08~0.42일이다. 즉 "최악의 경우 하루 늦게 감지"가 설계상 허용 오차이며, 이 값을 SLA 로 문서화한다. +- **λ(변경률)는 매일 관측 결과로 온라인 추정한다.** n회 방문 중 X회에서 변경이 관측되면, 구간 검열(interval-censored) Poisson 의 최우추정은 λ̂ = −ln(1 − X/n)/I 이다. λ̂ 가 급등하면(예: 평소의 3배) 그날은 "이상 급증" 플래그를 리포트에 띄운다. 주기 자체는 바꾸지 않는다(균등 정책 유지). +- **본 추출은 100% 결정론적 파서가 담당하고, LLM 은 (a) 셀렉터 후보 생성, (b) 셀렉터 검증/복구 제안, (c) 사람이 읽을 요약문 작성 세 가지만 담당한다.** 근거: AutoScraper(EMNLP 2024)·AXE(2026)·"Automatic XPath generation agents"(2025) 모두 **LLM 이 매 페이지를 읽는 구조는 비용·재현성 면에서 실패**하고 **LLM 이 한 번 만든 XPath/wrapper 를 반복 실행하는 구조가 이긴다**는 동일한 결론에 도달했다. Prompt2DAG(2025)는 결정론적 템플릿 방식 92.3% vs 하이브리드 78.5% 성공률로 결정론 우위를 정량화했다. +- **셀렉터는 ROBULA+(Leotta et al., JSEP 2016) 원칙으로 작성한다.** 절대 XPath 대비 취약성 90% 감소, Selenium IDE 로케이터 대비 63% 감소. 실무 규칙: `id`/`data-*`/`itemprop`/ARIA role/헤더 텍스트 앵커 우선, 시각적 class 와 깊은 경로 체인 금지, "앵커 노드 기준 상대 XPath"(Huang & Song 2025) 사용. +- **schema drift(셀렉터 조용한 파손)는 RAPTURE(Kushmerick, AAAI 1999) 방식으로 매 실행마다 자동 검증한다.** 필드별 통계 특징(레코드 수, 문자열 길이 평균/분산, 숫자 비율, 날짜 파싱 성공률, null 비율)의 과거 분포 대비 이탈 확률을 계산해 임계 이하이면 크롤 결과를 **채택하지 않고** 알림을 띄운다. Lerman/Minton/Knoblock(JAIR 2003)은 이 접근으로 37건 파손 중 35건 탐지(precision 0.73 / recall 0.95)를 달성했다. +- **중복·변경 판정은 2단 구조다.** ① 레코드 식별키(DMF 등록번호 등) 기반 upsert 로 신규/변경/취하를 확정하고, ② 상세 본문 텍스트에는 64-bit SimHash + Hamming 거리 k=3 (Manku·Jain·Das Sarma, WWW 2007 검증값) 을 적용해 "의미 없는 표기 흔들림"과 "실질 변경"을 구분한다. 저장은 **스냅샷 + 이벤트 로그 병행**(Fowler, Event Sourcing 2005 / Kimball SCD Type 2): 현재 상태 테이블과 append-only 변경 이벤트 테이블을 둘 다 유지한다. +- **robots.txt 는 RFC 9309(2022-09, Koster·Illyes·Zeller·Sassman) 를 그대로 따른다.** 캐시 24시간 이내, 5xx 면 **완전 금지로 간주**, 4xx 면 접근 허용, 파싱 한계 최소 500 KiB, product token 은 `a-z A-Z _ -` 만. **crawl-delay 는 RFC 9309 에 정의되지 않았고 Google 도 지원하지 않는다** — 따라서 crawl-delay 가 있으면 "존중하되", 없어도 우리 자체 하한(2초)을 반드시 건다. +- **한국 법적 리스크는 낮으나 0은 아니다.** 대법원 2022. 5. 12. 선고 2021도1533 판결(숙박앱 크롤링 무죄)은 "보호조치 없고 이용약관상 제한이 비회원에 미치지 않는 공개 정보"의 크롤링에 대해 정보통신망법 침입·저작권법 DB제작자 권리 침해·업무방해를 모두 부정했다. 반대로 서울남부지법 2021. 9. 8. 선고 2021고단588 판결은 **회원 계정 로그인 + 약관상 금지 명시** 상황에서 유죄를 선고했다. → **로그인하지 않는다. 약관·robots 를 매 실행 확인한다. 서버 부하를 유발하지 않는다.** 이 세 가지가 우리의 법적 안전선이다. + +--- + +## 1. 목차 + +- [0. 한눈에 보기](#0-한눈에-보기) +- [1. 목차](#1-목차) +- [2. 논문·문헌 마스터 표](#2-논문문헌-마스터-표) +- [3. 크롤러 아키텍처 고전 — 프론티어·politeness·중복 제거·재방문](#3-크롤러-아키텍처-고전--프론티어politeness중복-제거재방문) +- [4. 증분 크롤링과 변경 감지 이론](#4-증분-크롤링과-변경-감지-이론) +- [5. 구조적 데이터 추출 — wrapper induction 부터 셀렉터 안정성까지](#5-구조적-데이터-추출--wrapper-induction-부터-셀렉터-안정성까지) +- [6. LLM 기반 추출 최신 연구(2023~2026)와 역할 분리 원칙](#6-llm-기반-추출-최신-연구20232026와-역할-분리-원칙) +- [7. 중복·변경 탐지 알고리즘 — SimHash/MinHash/키 기반 upsert/스냅샷 vs 이벤트 로그](#7-중복변경-탐지-알고리즘--simhashminhash키-기반-upsert스냅샷-vs-이벤트-로그) +- [8. robots.txt RFC 9309 규범과 politeness 실무](#8-robotstxt-rfc-9309-규범과-politeness-실무) +- [9. 한국 문헌·선행 시스템·법적 맥락](#9-한국-문헌선행-시스템법적-맥락) +- [10. 이 프로젝트의 크롤링 정책 확정안](#10-이-프로젝트의-크롤링-정책-확정안) +- [부록 A. 출처 목록](#부록-a-출처-목록) +- [부록 B. 미해결 질문 / 실측 필요 항목](#부록-b-미해결-질문--실측-필요-항목) + +--- + +## 2. 논문·문헌 마스터 표 + +raw dump 에서 확인된 **모든** 논문·표준·기술문서를 한 표에 모았다. "확인" 열은 리서치 에이전트가 WebFetch 로 **실제 본문을 열어 확인**했는지 여부다(✅ = 본문 확인, 🔶 = 검색 결과 메타데이터만, ❌ = 접근 실패). + +### 2.1 크롤러 아키텍처 고전 + +| # | 제목 | 저자 | 연도 | 게재처 | URL | 확인 | 이 프로젝트에 주는 시사점 | +|---|---|---|---|---|---|---|---| +| A1 | Mercator: A scalable, extensible Web crawler | Allan Heydon, Marc Najork (Compaq SRC, Palo Alto) | 1999 (1999-06-26) | World Wide Web journal, Vol. 2, No. 4, pp. 219–229 | https://link.springer.com/article/10.1023/A:1019213109274 | 🔶 | 확장 가능한 크롤러의 **부품 목록**을 정의한 원전이다. URL Frontier / DNS Resolver / Protocol Module / RIS / Content-Seen Test / URL-Seen Test(DUE) / Processing Module 이라는 분해는 규모와 무관하게 유효하므로, 우리 코드도 이 7개 모듈 이름을 그대로 파이썬 모듈명으로 쓴다. "동일 웹서버에 동시 다운로드 금지"라는 최소 politeness 정의도 여기서 온다. | +| A2 | High-Performance Web Crawling (SRC Research Report 173) | Marc Najork, Allan Heydon | 2001-09-26 | Compaq Systems Research Center, SRC Research Report 173 (26 pages) | https://www.cs.cornell.edu/courses/cs685/2002fa/mercator.pdf | ✅ | Mercator 후속 보고서로 **frontier 의 front-end/back-end 2단 구조**를 명시한다. front-end 는 우선순위 k개 FIFO 큐, back-end 는 n개 FIFO 큐이며 각 back-end 큐는 "한 호스트의 URL만" 담아 강한 politeness 를 보장한다. URL-Seen 은 Rabin fingerprint 8바이트 체크섬 해시테이블(10억 URL ≈ 5GB), robots.txt 는 호스트→규칙 LRU 캐시 기본 2^18 엔트리. 백그라운드 스레드가 기본 10초마다 깨어나 통계 로깅·종료 조건 확인·체크포인트를 수행한다 → 우리의 스케줄러/헬스체크 루프 설계를 그대로 이 패턴으로 만든다. | +| A3 | An Introduction to Heritrix: an open source archival quality web crawler | Gordon Mohr, Michael Stack, Igor Ranitovic, Dan Avery, Michele Kimpton | 2004-07 | Proc. 4th International Web Archiving Workshop (IWAW'04), Bath, UK, pp. 109–115 | https://docs.huihoo.com/heritrix/An-Introduction-To-Heritrix.ppt | 🔶 | Internet Archive 의 아카이브 품질 크롤러. "정중함(politeness)을 설정으로 노출한다"는 운영 철학이 핵심이다. 우리는 Heritrix 를 쓰지 않지만 **politeness 파라미터를 코드에 하드코딩하지 않고 설정 파일로 노출**하는 원칙을 그대로 가져온다. | +| A4 | Heritrix3 (소스 저장소) | Internet Archive | 현재 | GitHub, Java, Apache License 2.0 | https://github.com/internetarchive/heritrix3 | ✅ | robots.txt 및 META nofollow 준수, 크롤 잡(crawl job) 단위 설정, WARC 출력, Frontier 큐잉이 4대 개념. WARC 개념은 우리에게 **"원본 HTML 스냅샷을 날짜별로 보존한다"**는 요구로 번역된다(디버깅·소급 재파싱·법적 증빙 용도). | +| A5 | Heritrix 설정 문서 (configuring-jobs) | Internet Archive | 현재 | heritrix.readthedocs.io | https://heritrix.readthedocs.io/en/latest/configuring-jobs.html | ✅ | 실제 politeness 프로퍼티 이름과 기본값을 확인했다: `delayFactor`(기본 5.0, "직전 URI 를 가져오는 데 걸린 시간의 배수"), `minDelayMs`(delayFactor 계산값보다 우선하는 최소 대기), `maxDelayMs`(기본 30000ms), `maxPerHostBandwidthUsageKbSec`, `robotsPolicyName`(obey / classic / robotsTxtOnly / ignore, 기본 obey, RFC 9309 경로 와일드카드 지원), `metadata.operatorContactUrl`, `maxToeThreads`, `extract404s`. **우리의 백오프 정책은 delayFactor=5.0, minDelayMs=2000, maxDelayMs=30000 을 그대로 채택한다.** | +| A6 | Web Crawling (서베이) | Christopher Olston (Yahoo! Research), Marc Najork (Microsoft Research) | 2010 | Foundations and Trends in Information Retrieval, Vol. 4, No. 3, pp. 175–246, DOI 10.1561/1500000017 | https://doi.org/10.1561/1500000017 | ✅ (초록) | "웹 크롤링은 BFS 의 단순 응용처럼 보이지만 실제로는 초대형 자료구조 관리 같은 시스템 문제부터 **'변화하는 콘텐츠를 얼마나 자주 재방문할 것인가'** 같은 이론 문제까지 걸쳐 있다"는 문장이 이 프로젝트의 전체 난이도 지도를 요약한다. 우리 문제는 시스템 축은 거의 0(호스트 1개)이고 **재방문·변경 탐지 축이 전부**임을 이 서베이가 정당화한다. | +| A7 | A Brief History of Web Crawlers | Seyed M. Mirtaheri, Mustafa Emre Dinçktürk, Salman Hooshmand, Gregor V. Bochmann, Guy-Vincent Jourdan, Iosif Viorel Onut | 2014 | arXiv:1405.0749 | https://arxiv.org/abs/1405.0749 | ✅ (초록만) | 크롤러 평가 기준을 정립하려는 서베이. 초록 수준에서만 확인되어 Mercator/Heritrix/Cho 관련 상세 서술은 확인하지 못했다(⚠️ 본문 미확인). 배경 읽기용으로만 참조한다. | +| A8 | Web crawler (Wikipedia) — Re-visit policy / Politeness policy / Crawler identification | — | 현재 | Wikipedia | https://en.wikipedia.org/wiki/Web_crawler | ✅ | 실무 crawl-delay 값의 **실측 레퍼런스**: Mercator 계열은 "직전 다운로드 소요 시간의 10배"를 기다리는 적응형, Cho 는 10초, WIRE 는 기본 15초, 실제 관측된 접근 간격은 20초~3–4분. freshness 는 0/1 이진값, age 는 마지막 수정 이후 경과 시간. "최적 정책은 비례보다 균등에 가깝다", "특정 페이지 접근은 가능한 한 균등 간격으로 배치해야 한다"(Coffman et al.), 변경은 지수분포로 잘 모델링된다. **우리 하한값 2초는 이 스펙트럼의 보수적 끝단이 아니므로, DMF 게시판이 응답이 느릴 경우 delayFactor 로 자동 확대되게 한다.** | + +### 2.2 증분 크롤링 · 변경 감지 이론 + +| # | 제목 | 저자 | 연도 | 게재처 | URL | 확인 | 이 프로젝트에 주는 시사점 | +|---|---|---|---|---|---|---|---| +| B1 | The Evolution of the Web and Implications for an Incremental Crawler | Junghoo Cho, Hector Garcia-Molina | 2000 | Proc. 26th VLDB, pp. 200–209, Morgan Kaufmann | https://dl.acm.org/doi/10.5555/645926.671679 | ❌ (403) | 웹 변경 이력을 실측해 **증분(incremental) 크롤러 아키텍처**를 제안한 원전. 핵심 대비는 "주기적 크롤(periodic: 전체를 새로 긁고 통째로 교체)" vs "증분 크롤(incremental: 변경분만 갱신, 로컬 컬렉션을 항상 유지)". 우리는 매일 전체 목록을 긁되 **저장은 증분 upsert** 로 하는 하이브리드를 택한다. ⚠️ ACM 403 및 Semantic Scholar 429 로 본문 미확인 — 인용 시 2차 출처 기준. | +| B2 | Effective Page Refresh Policies for Web Crawlers | Junghoo Cho, Hector Garcia-Molina | 2003-12 | ACM Transactions on Database Systems (TODS), Vol. 28, Issue 4, pp. 390–426, DOI 10.1145/958942.958945 (Semantic Scholar citationCount 314) | https://dl.acm.org/doi/10.1145/958942.958945 | 🔶 (메타데이터 API 로 확인) | **이 프로젝트의 주기 정책 근거 1순위.** ① Poisson 과정이 웹 페이지 변경을 잘 기술한다 — 변경률 λ 인 페이지의 변경 간격은 지수분포 λe^(−λt). ② 제안된 refresh 정책이 freshness 를 유의하게 개선한다. ③ **균등(uniform) 정책이 비례(proportional) 정책을 이긴다** — 자주 바뀌는 페이지에 자원을 몰아주면 오히려 평균 freshness 가 떨어진다. 우리는 "매일 06:00 전 게시판 균등 1회"를 이 결과로 정당화한다. ⚠️ 원문 PDF(oak.cs.ucla.edu, ACM)는 접속 실패 — 수식은 표준 유도로 §4.2 에 재구성. | +| B3 | Tractable near-optimal policies for crawling | Yossi Azar (Tel-Aviv Univ.), Eric Horvitz (MSR), Eyal Lubetzky (NYU Courant), Yuval Peres (MSR), Dafna Shahaf (HUJI) | 2018-07-23 | PNAS Vol. 115, Issue 32, pp. 8099–8103, DOI 10.1073/pnas.1801519115 | https://www.pnas.org/doi/10.1073/pnas.1801519115 | ✅ (PMC 전문) | 문제 정식화가 우리에게 그대로 쓸 수 있다: 페이지별 **Poisson 변경률 Δᵢ**, **요청률 μᵢ**, **총 폴링 대역폭 제약 R**. 최적 무작위 정책은 "utility/change-rate 비로 정렬 후 할당 0인 페이지를 골라내기"로 O(n log n)에 구해지며, EDF(earliest-deadline-first)로 비무작위화한 결정론 정책이 최적해의 **99%** 성능을 낸다. → 우리가 나중에 대상 게시판을 여러 개로 늘릴 때, "중요도(μ)/변경률(Δ) 비로 정렬 후 상위 몇 개만 하루 2회" 같은 확장을 이 알고리즘으로 정당화할 수 있다. 지금은 n 이 작아 균등으로 충분하다. | +| B4 | Staying up to Date with Online Content Changes Using Reinforcement Learning for Scheduling | Andrey Kolobov, Yuval Peres, Cheng Lu, Eric J. Horvitz | 2019 | Advances in Neural Information Processing Systems 32 (NeurIPS 2019) | https://papers.nips.cc/paper/2019/hash/ad13a2a07ca4b7642959dc0c4c740ab6-Abstract.html | ✅ | **변경 관측이 불완전하고(mixed content change observability) 변경 모델 파라미터를 처음엔 모를 때**도 최적성 보장이 있는 스케줄링. 18.5M URL 을 14주간 매일 크롤한 실험으로 검증. → 우리도 λ 를 사전에 모르는 상태에서 시작하므로, "관측하면서 λ 를 온라인 갱신한다"는 §4.6 의 설계가 이 논문 계열의 표준 관행임을 근거로 삼는다. ⚠️ harmonic policy 등 세부 알고리즘은 초록 수준까지만 확인. | +| B5 | A Scalable Crawling Algorithm Utilizing Noisy Change-Indicating Signals | Róbert Busa-Fekete, Julian Zimmert, András György, Linhai Qiu, Tzu-Wei Sung, Hao Shen, Hyomin Choi, Sharmila Subramaniam, Li Xiao | 2025-02-04 (rev. 2025-03-20) | arXiv:2502.02430 | https://arxiv.org/abs/2502.02430 | ✅ | sitemap·CDN 시그널 같은 **잡음 섞인 부가 정보**(오탐도 있고 실제 변경을 놓치기도 함)를 최적으로 활용하는 크롤 스케줄링. Azar et al. 2018 의 "변경·요청이 독립 Poisson" 가정을 완화한다. → DMF 게시판의 "총 게시물 수", "최근 게시일", RSS/Atom, `Last-Modified` 헤더가 정확히 이 **noisy change-indicating signal** 이다. 우리는 이것들을 **1차 필터**로 쓰되 절대 신뢰하지 않고, 목록 페이지 1페이지는 항상 실제로 긁는다. | +| B6 | 웹 사이트 컨텐츠 변경 모니터링 시스템 (The Monitoring System for Informing the Change of Contents on the Web Sites) | 김원중, 조이기, 손철수 | 2002 | 한국정보통신학회논문지 제6권 제4호, pp. 505–512 | https://www.kci.go.kr/kciportal/ci/sereArticleSearch/ciSereArtiView.kci?sereArticleSearchBean.artiId=ART000881131 | ✅ | 국내 최초급 변경 모니터링 시스템 논문. 구성은 우리와 동일한 3요소다: ① **HTML 태그를 이용해 웹 문서를 의미 있는 단위로 구조화·분류**해 변경을 감지, ② 사용자 정의 모니터링 주기, ③ 변경 시 알람/E-mail 자동 통지. → "전체 페이지 diff" 가 아니라 **구조화된 단위(레코드) diff** 가 정답이라는 것이 20년 전에 이미 확립됐음을 보여준다. 우리 diff 엔진이 레코드 단위인 이유. | +| B7 | 실시간 웹 크롤링 분산 모니터링 시스템 설계 및 구현 (Design and Implementation of Real-time Web Crawling Distributed Monitoring System, R-WCMS) | 김영아, 김계희, 김현주, 김창근 (경남과학기술대학교 컴퓨터공학과) | 2019 | 융합정보논문지 Vol. 9, No. 1, pp. 45–53 | https://scienceon.kisti.re.kr/srch/selectPORSrchArticle.do?cn=JAKO201909258120005 | ✅ | Apache Kafka + Spark Streaming + Hadoop 으로 수집 시간 15–17% 단축. **우리에게 주는 시사점은 반대 방향이다** — 단일 게시판 일 1회 감시에 이런 스택은 명백한 과잉이다. "수집 시간 예측을 통한 효율화" 아이디어만 취해, 우리는 실행 소요 시간을 기록해 이상 지연(예: 평소의 3배)을 헬스체크 알림 조건으로 쓴다. | +| B8 | 실시간 웹 게시판 모니터링 및 모바일웹을 이용한 알람 서비스 개발 | 김종근, 심근호, 이요셉, 임영환 | 2012 | 디지털콘텐츠학회논문지 제13권 제1호, pp. 1–11 | https://www.kci.go.kr/kciportal/ci/sereArticleSearch/ciSereArtiView.kci?sereArticleSearchBean.artiId=ART001648031 | ✅ | 기존 방식(DB 직접 접근 / 공개 API)의 한계 = **비공개 게시판 접근 불가, 실시간 알림 어려움**. 이메일 대신 모바일 웹 알림으로 전환. → 우리 프로젝트에서 "알림 채널"을 xlsx 리포트 하나에만 걸지 말고, 서비스 사망 시 **Windows 토스트 알림**이라는 별도 out-of-band 채널을 갖는 설계가 선행 연구와 일치함을 보여준다. | +| B9 | 웹 크롤링 모니터링 시스템 및 방법 (특허) | — | 등록 | KR101757822B1 (Google Patents) | https://patents.google.com/patent/KR101757822B1/ko | 🔶 | 검색 결과로만 확인. ⚠️ 청구항 미확인 — 상용화 계획이 없는 사내 도구이므로 침해 리스크 평가는 보류하되, 존재는 기록해 둔다. | +| B10 | 결정 이론 웹 크롤링, 및 웹 페이지 변경의 예측 (특허) | — | 등록 | KR101213930B1 (Google Patents) | https://patents.google.com/patent/KR101213930B1/ko | 🔶 | 검색 결과로만 확인. 제목상 Azar/Kolobov 계열의 결정이론 스케줄링에 대응. ⚠️ 청구항 미확인. | +| B11 | Clustering-based incremental web crawling | — | 2010 | ACM Transactions on Information Systems | https://dl.acm.org/doi/10.1145/1852102.1852103 | 🔶 | 검색 결과로만 확인. 유사 변경 패턴 페이지를 군집화해 증분 크롤 효율을 올리는 계열. n 이 작은 우리에겐 적용 대상 아님. | +| B12 | A Dynamic Page-Refresh Index Policy for Web Crawlers | — | 2014 | Springer LNCS (978-3-319-08219-6_4) | https://link.springer.com/chapter/10.1007/978-3-319-08219-6_4 | 🔶 | 검색 결과로만 확인. refresh 정책 후속 연구로 기록만 남긴다. | +| B13 | Towards a Quality-Oriented Real-Time Web Crawler | — | 2010 | Springer LNCS (978-3-642-16515-3_10) | https://link.springer.com/chapter/10.1007/978-3-642-16515-3_10 | 🔶 | 검색 결과로만 확인. | +| B14 | Management Of Volatile Information In Incremental Web Crawler | — | 2009 | arXiv:0910.1869 | https://arxiv.org/pdf/0910.1869 | 🔶 | 검색 결과로만 확인. | +| B15 | Learning to Crawl | — | 2019 | arXiv:1905.12781 | https://arxiv.org/pdf/1905.12781 | 🔶 | 검색 결과로만 확인. Kolobov 계열 RL 크롤 스케줄링. | +| B16 | Online Learning for Active Cache Synchronization | — | 2020 | arXiv:2002.12014 | https://arxiv.org/pdf/2002.12014 | 🔶 | 검색 결과로만 확인. | +| B17 | Look back, look around: a systematic analysis of effective predictors for new outlinks in focused Web crawling | — | 2021 | arXiv:2111.05062 | https://arxiv.org/pdf/2111.05062 | 🔶 | 검색 결과로만 확인. "새 링크(=새 공고)를 예측하는 신호"라는 주제가 우리 문제와 유사하나 focused crawling 문맥. | + +### 2.3 구조적 데이터 추출 (wrapper induction 계열) + +| # | 제목 | 저자 | 연도 | 게재처 | URL | 확인 | 이 프로젝트에 주는 시사점 | +|---|---|---|---|---|---|---|---| +| C1 | Wrapper Induction for Information Extraction | Nicholas Kushmerick, Daniel S. Weld, Robert B. Doorenbos | 1997-08 (IJCAI-97, Nagoya, Japan, Aug 23–29) | Proc. 15th International Joint Conference on Artificial Intelligence (IJCAI), pp. 729–737 | https://www.semanticscholar.org/paper/Wrapper-Induction-for-Information-Extraction-Kushmerick-Weld/f9e7402ad740b73cc0bb64178f86df3478c3aaf5 | 🔶 | wrapper 자동 생성 개념의 원전. **hlrt** wrapper 클래스는 효율적으로 학습 가능하면서 당시 조사 대상 인터넷 리소스의 **48%** 를 처리할 만큼 표현력이 있었다. PAC 분석으로 표본 복잡도를 상한하고, 라벨링이 불완전해도 성능이 완만히 저하됨을 보였다. 가장 단순한 클래스는 **LR wrapper**(문서를 문자열로 보고 좌/우 구분자로 필드를 잘라냄). → "필드 앞뒤의 안정적인 구분자(레이블 텍스트)로 값을 자른다"는 우리 fallback 파서의 이론적 근거. Weld 개인 출판 목록에서도 IJCAI-97 항목이 확인되며, **Artificial Intelligence 저널 버전은 확인되지 않았다**(⚠️ 저널 버전 존재 미확인). | +| C2 | The Wrapper Induction Environment (WIEN) | Nicholas Kushmerick (Dublin City University) | 1998 | AAAI Workshop WS-98-10, pp. 022– | https://cdn.aaai.org/Workshops/1998/WS-98-10/WS98-10-022.pdf | 🔶 | WIEN 은 문서를 문자 시퀀스로 보고 여러 wrapper 언어 클래스를 정의한다(가장 단순한 것이 LR). | +| C3 | Regression testing for wrapper maintenance (RAPTURE) | Nicholas Kushmerick (University College Dublin) | 1999 | Proc. 16th National Conference on Artificial Intelligence (AAAI-99), 논문번호 AAAI99-011, 6 pages | https://cdn.aaai.org/AAAI/1999/AAAI99-011.pdf | ✅ (PDF 텍스트 추출) | **schema drift 탐지의 원전이며 우리 검증 로직의 직접 설계도.** 원문 인용: "The wrapper verification problem is to determine whether a wrapper is correct. Standard regression testing approaches are inappropriate, because both the formatting regularities and a site's underlying content may change." RAPTURE 는 도메인 독립·완전 구현된 휴리스틱 검증 알고리즘으로, wrapper 의 **기대 출력과 관측 출력 사이의 유사도**를 계산한다. 27개 실제 인터넷 사이트 실험에서 표준 회귀 테스트 대비 큰 성능 향상. 비교 대상인 STRAWMAN 은 "같은 질의에 대해 과거 정상 페이지와 현재 페이지 출력을 비교"하는 단순 방식이다. → **우리는 STRAWMAN(전날 결과와 오늘 결과 직접 비교)이 아니라 RAPTURE(통계적 특징 분포 비교)를 써야 한다.** 게시판 내용 자체가 매일 바뀌는 게 정상이기 때문이다. | +| C4 | Wrapper Maintenance: A Machine Learning Approach | Kristina Lerman, Steven N. Minton, Craig A. Knoblock | 2003 | Journal of Artificial Intelligence Research (JAIR), Vol. 18, pp. 149–181 | https://arxiv.org/abs/1106.4872 | ✅ | 양성 예시만으로 데이터의 구조 정보를 학습하는 효율적 알고리즘 → 두 응용: **wrapper verification**(파손 탐지)과 **wrapper reinduction**(자동 복구). 실측: 27개 wrapper 를 1년간 추적해 37건의 파손 중 **35건 탐지, precision 0.73 / recall 0.95**. reinduction 은 10개 소스에서 precision 0.90 / recall 0.80. → **파손 탐지는 recall 을 높이고(0.95) precision 은 희생해도 된다**(오탐 = 사람이 한 번 확인하면 끝, 미탐 = 잘못된 리포트가 배포됨)는 임계값 정책을 이 수치가 정당화한다. | +| C5 | RoadRunner: Towards Automatic Data Extraction from Large Web Sites | Valter Crescenzi, Giansalvatore Mecca, Paolo Merialdo | 2001 | Proc. 27th VLDB, Rome, Italy | http://www.vldb.org/conf/2001/P109.pdf | ✅ (PDF 텍스트 추출) | **완전 자동 wrapper 추론.** 핵심: 데이터 집약 사이트의 페이지는 백엔드 DBMS 내용을 스크립트가 찍어낸 것이므로, **같은 클래스의 페이지 2장을 비교**하면 템플릿(상수)과 데이터(변수)를 분리할 수 있다. 형식화는 **union-free regular expression(UFRE)**: 알파벳 Σ ∪ {#PCDATA, ·, +, ?, (, )} 위의 문자열로, `a·b`, `(a)+`, `(a)?` 로 구성되며 `(a)* = ((a)+)?`. UFRE ↔ nested type 대응(#PCDATA→string, +→list, ?→nullable). 알고리즘 **match(σ₁, σ₂)** = 두 UFRE 의 least upper bound 계산, 매칭 기법 이름은 **ACME (Align, Collapse under Mismatch, and Extract)**. 페이지를 XHTML 로 정규화 후 토큰 리스트(태그 or 문자열)로 만들고, page 1 을 초기 wrapper 로 삼아 page 2(sample)를 파싱하며 mismatch 를 풀어 일반화한다. mismatch 는 두 종류: **string mismatch → 필드(#PCDATA) 발견**(예: 'John Smith' vs 'Paul Jones' → #PCDATA 로 일반화하되 'Books of:' 같은 상수는 필드가 아님), **tag mismatch → iterator 또는 optional 발견**(먼저 반복 패턴을 찾고 실패하면 optional 로 처리; cross-search 로 optional 이 wrapper 쪽인지 sample 쪽인지 판별 후 `()?` 형태로 일반화). → **우리 크롤러의 "LLM 셀렉터 생성" 단계는 사실상 RoadRunner 를 LLM 으로 대체하는 것이다.** 따라서 LLM 에게도 "목록 페이지 2장 이상을 주고 공통 템플릿을 찾게" 하고, 상수/변수 구분과 optional 필드 존재를 명시적으로 물어야 한다. | +| C6 | Automatic Wrappers for Large Scale Web Extraction | Nilesh Dalvi (Yahoo! Research), Ravi Kumar (Yahoo! Research), Mohamed Soliman (Univ. of Waterloo) | 2011-03-12 | Proceedings of the VLDB Endowment (PVLDB), Vol. 4, No. 4, pp. 219–230 | https://arxiv.org/abs/1103.2406 | ✅ | **잡음 섞인 학습 데이터로도 동작하는** wrapper induction 프레임워크. 사전(dictionary)·정규식으로 자동 생성한 저비용·저품질 주석만으로 비지도 wrapper 학습이 가능해져 사이트별 사람 감독 없이 웹 스케일로 확장. Yahoo! 프로덕션 투입. → **우리도 "정답 라벨"을 사람이 만들 필요가 없다**: DMF 등록번호 패턴(정규식), 날짜 형식, 회사명 사전 같은 약한 신호로 필드를 자동 라벨링해 셀렉터 후보를 검증할 수 있다. ⚠️ 시간적 구조 변화 대한 강건성 평가 방법은 초록 수준에서 확인되지 않음. | +| C7 | Robust web extraction: an approach based on a probabilistic tree-edit model | Nilesh Dalvi, Philip Bohannon, Fei Sha | 2009 | Proc. ACM SIGMOD 2009, Providence, Rhode Island, USA | https://www.researchgate.net/publication/221214620_Robust_web_extraction_An_approach_based_on_a_probabilistic_tree-edit_model | 🔶 | 스크립트 생성 사이트의 페이지들은 공통 HTML 트리 구조를 공유하므로 wrapper 가 잘 동작하지만, **스크립트와 트리 구조가 시간에 따라 진화하면 wrapper 가 깨져 유지보수 비용이 커진다**는 문제를 확률적 tree-edit 모델로 다룬다. ⚠️ raw dump 의 검색 결과가 저자 조합(Dalvi/Kumar/Soliman vs Dalvi/Bohannon/Sha)을 혼동해 서술하므로, **저자는 Dalvi·Bohannon·Sha 로 표기하되 미검증**으로 둔다. | +| C8 | Robula+: An algorithm for generating robust XPath locators for web testing | Maurizio Leotta, Andrea Stocco, Filippo Ricca, Paolo Tonella | 2016 | Journal of Software: Evolution and Process (JSEP), Vol. 28, Issue 3, pp. 177–204, DOI 10.1002/smr.1771 | https://onlinelibrary.wiley.com/doi/10.1002/smr.1771 | ✅ | **셀렉터 안정성의 정량적 근거.** "test code fragility problem" — 애플리케이션이 진화하면 로케이터가 깨지고 수동 수리 비용이 든다. Robula+ 가 생성한 XPath 는 **절대 로케이터 대비 평균 90%, Selenium IDE 로케이터 대비 63% 취약성 감소**. 현재 강건 XPath 자동 생성의 state of the art 로 평가된다. → 우리 셀렉터 작성 규칙(§5.6)의 근거이자, 셀렉터 자동 생성 도구를 붙일 때의 알고리즘 선택. | +| C9 | robula-plus (TypeScript 구현) | Cyluxx | 현재 | GitHub | https://github.com/cyluxx/robula-plus | ✅ | API: `getRobustXPath(element, document)`, `getElementByXPath(xPath, document)`, `uniquelyLocate(xPath, element, document)`. Node.js 필요, `npm install` → `npm run build`. **라이선스가 미정이라 공개 install 패키지가 없다**("The License of this code needs some clarification, so until then there will be no public install package available") → **의존성으로 채택 불가.** 알고리즘 아이디어만 우리 코드에 재구현한다. | +| C10 | robula-plus (Python 포팅) | ZeusFSX | 현재 | GitHub | https://github.com/ZeusFSX/robula-plus | 🔶 | 파이썬 버전 존재만 확인. ⚠️ 라이선스·유지보수 상태 미확인 — 채택 전 실사 필요. | +| C11 | WebTables: Exploring the Power of Tables on the Web | Michael J. Cafarella, Alon Halevy, Daisy Zhe Wang, Eugene Wu, Yang Zhang | 2008 | Proceedings of the VLDB Endowment, Vol. 1, No. 1, pp. 538–549, DOI 10.14778/1453856.1453916 | http://www.vldb.org/pvldb/vol1/1453916.pdf | 🔶 | 웹에서 **141억 개 테이블**을 추출해 통계적 분류로 **1억 5,400만 개**만이 고품질 관계형 데이터임을 밝혔다(약 1.1%). 나머지는 레이아웃용. → **`` 을 봤다고 데이터 테이블이라 가정하지 마라.** 우리 파서는 헤더 행 존재, 열 수 일관성, 셀 타입 동질성 같은 "관계형성 판정"을 먼저 통과시킨 뒤 파싱해야 한다. | +| C12 | Schema Extraction for Tabular Data on the Web | Marco D. Adelfio, Hanan Samet | 2013 | PVLDB Vol. 6 | http://www.vldb.org/pvldb/vol6/p421-adelfio.pdf | 🔶 | 웹 테이블에서 스키마(헤더 행 식별 포함)를 추출하는 기법. 우리 헤더 판정 로직 참고용. | +| C13 | On Extracting Data from Tables that are Encoded using HTML | — | 2019 | arXiv:1903.08305 | https://arxiv.org/pdf/1903.08305 | 🔶 | rowspan/colspan 처리 등 HTML 테이블 파싱의 실무 함정 정리. | +| C14 | Web Table Extraction, Retrieval and Augmentation: A Survey | — | 2020 | arXiv:2002.00207 | https://arxiv.org/pdf/2002.00207 | 🔶 | 웹 테이블 처리 전반 서베이. | +| C15 | Identifying Web Tables: Supporting a Neglected Type of Content on the Web | — | 2015 | arXiv:1503.06598 / Springer | https://arxiv.org/pdf/1503.06598 | 🔶 | 레이아웃 테이블 vs 데이터 테이블 분류. C11 의 실무 보완. | +| C16 | An Annotated Corpus of Webtables for Information Extraction Tasks | — | 2020 | arXiv:2008.07680 | https://arxiv.org/pdf/2008.07680 | 🔶 | 웹 테이블 주석 코퍼스. | +| C17 | Structured Data Extraction: Wrapper Generation (교재 챕터) | — | 2011 | Springer (978-3-642-19460-3_9) | https://link.springer.com/chapter/10.1007/978-3-642-19460-3_9 | 🔶 | wrapper 생성 전반의 교과서적 정리. | +| C18 | Schema-guided wrapper maintenance for web-data extraction | — | 2003 | Proc. 5th ACM Intl. Workshop on Web Information and Data Management (WIDM) | https://dl.acm.org/doi/abs/10.1145/956699.956701 | 🔶 | 스키마를 힌트로 wrapper 를 유지보수. 우리가 "필드 스키마를 JSON Schema 로 명시"할 때의 이론적 지지. | +| C19 | Intelligent Self-repairable Web Wrappers | — | 2011 | Springer (978-3-642-23954-0_26) | https://link.springer.com/chapter/10.1007/978-3-642-23954-0_26 | 🔶 | 자가 수리 wrapper. LLM 기반 자동 복구의 전신. | +| C20 | Leveraging Flexible Tree Matching to Repair Broken Locators in Web Automation Scripts | — | 2021 | arXiv:2106.04916 | https://arxiv.org/pdf/2106.04916 | 🔶 | 깨진 로케이터를 트리 매칭으로 복구. LLM 없이도 셀렉터 복구가 가능한 경로. | +| C21 | Robust wrappers for web extraction (미국 특허) | — | 등록 | US 8,762,829 | https://image-ppubs.uspto.gov/dirsearch-public/print/downloadPdf/8762829 | 🔶 | 검색 결과로만 확인. | +| C22 | Web data extraction, applications and techniques (서베이) | — | 2014 | Knowledge-Based Systems | https://dl.acm.org/doi/abs/10.1016/j.knosys.2014.07.007 | 🔶 | 웹 데이터 추출 전반 서베이. | + +### 2.4 LLM 기반 추출 · 웹 에이전트 (2023~2026) + +| # | 제목 | 저자 | 연도 | 게재처 | URL | 확인 | 이 프로젝트에 주는 시사점 | +|---|---|---|---|---|---|---|---| +| D1 | AutoScraper: A Progressive Understanding Web Agent for Web Scraper Generation | Wenhao Huang, Zhouhong Gu, Chenghao Peng, Zhixu Li, Jiaqing Liang, Yanghua Xiao, Liqian Wen, Zulong Chen | 2024 (arXiv 2024-04-19 제출, 2024-09-26 개정) | EMNLP 2024 Main, arXiv:2404.12753, ACL Anthology 2024.emnlp-main.141 | https://arxiv.org/abs/2404.12753 | ✅ | **"LLM 으로 스크레이퍼를 생성한다"는 패러다임을 정의한 논문.** 2단계 프레임워크(progressive generation + synthesis)가 HTML 의 계층 구조와 페이지 간 유사성을 활용한다. 새 평가지표 **executability** 제안. 주장: wrapper 기반 방법은 적응성이 낮고, language agent 방법은 재사용성이 낮은데 AutoScraper 는 둘 다 이긴다. GPT-4-Turbo 로 SWDE 에서 zero-shot 임에도 지도학습 방법보다 높은 F1. → **우리 아키텍처의 정당화 그 자체**: LLM 은 스크레이퍼를 "생성"하고, 실행은 생성된 결정론적 스크레이퍼가 한다. | +| D2 | AutoScraper (구현체) | EZ-hwh | 현재 | GitHub, Python, Apache License 2.0, **492 stars / 45 forks / 12 watchers** | https://github.com/EZ-hwh/AutoScraper | ✅ | `crawler_generation.py` 로 스크레이퍼를 만들고 `crawler_extraction.py` 로 추출 실행. LLM(ChatGPT/GPT-4)에 **reflexion 패턴** 적용. 평가는 SWDE 벤치마크 + DS1 + Klarna. README 에 "실제 웹사이트 적응"과 "공개 데모"가 TODO 로 남아 있어 **연구 코드이지 프로덕션 코드가 아니다** → 의존성으로 채택하지 않고, 2단계 분리 아이디어와 reflexion 루프만 차용한다. | +| D3 | Automatic XPath generation agents for vertical websites by LLMs | Jing Huang, Jie Song | 2025 | Journal of King Saud University Computer and Information Sciences, DOI 10.1007/s44443-025-00071-w | https://link.springer.com/article/10.1007/s44443-025-00071-w | ✅ | **우리 "셀렉터 생성" 파이프라인의 최적 설계도.** 3단계 분해: ① **속성 추출** — HTML 을 먼저 Markdown 으로 변환해 LLM 이해도를 높인 뒤 **seed page 2~3장**에서 목표 정보를 뽑는다. ② **목표 노드 위치 특정** — 추출된 텍스트를 포함하는 후보 DOM 노드를 찾고, **perturbation testing**(각 노드의 텍스트를 변조해 LLM 출력이 바뀌는지 관찰; 바뀌면 그 노드가 정답)으로 확정. ③ **XPath 생성** — 구조 변화에 취약한 절대 경로 대신 **anchor node(주변의 구별되는 요소) 기준 상대 XPath** 를 만든다. 결과: **F1 86.83–92.46**, 비교 대상 대비 **14.84–23.86%p** 우위. LLM 호출 수는 직접 추출의 n 회 대비 **n_s + p·n_s + 1 회**(n_s = seed 페이지 수) — 페이지가 10개만 넘어도 큰 절감. → 우리는 seed 페이지 3장, perturbation 검증, anchor 기반 상대 XPath 를 그대로 채택한다. | +| D4 | AXE: Low-Cost Cross-Domain Web Structured Information Extraction | Abdelrahman Mansour, Khaled W. Alshaer, Moataz Elsaban | 2026-02-02 제출 (2026-03-30 개정) | arXiv:2602.01838 | https://arxiv.org/abs/2602.01838 | ✅ | "HTML DOM 을 읽어야 할 텍스트 덩어리가 아니라 **가지치기해야 할 트리**로 취급한다"가 논지. 3요소: ① **DOM Pruning**(보일러플레이트·무관 노드 제거로 고밀도 컨텍스트 생성), ② **0.6B 파라미터 소형 LLM** 으로 구조화 출력 생성, ③ **Grounded XPath Resolution (GXR)** — 추출된 모든 값이 **소스 노드로 물리적으로 추적 가능**하도록 보장. 결과: **SWDE F1 88.1%** zero-shot 으로 더 크고 완전 학습된 모델을 상회. → **GXR 은 우리 감사(audit) 요구와 정확히 일치한다**: xlsx 리포트의 모든 셀은 "어느 URL 의 어느 노드에서 왔는가"를 역추적할 수 있어야 한다. 또한 **DOM pruning 을 LLM 호출 전에 결정론적으로 수행**해 토큰과 비용을 줄인다. | +| D5 | Co-Scraper: query-aware DOM Pruning and Reusable Scraper Synthesis for Lightweight Web Data Extraction | Shoupeng Wang, Jiantao Qiu, Wuyang Zhang, Conghui He | 2026-06-12 제출 | arXiv:2606.14821 | https://arxiv.org/abs/2606.14821 | ✅ | 유사 페이지에 **재사용 가능한 스크레이퍼**를 생성하는 2단계(질의 기반 DOM pruning + 추출 전략 귀납). 파인튜닝된 LM 이 HTML 을 실행 가능한 wrapper 로 변환. **F1 94.78%, 재사용 성공률 90.39%.** → "재사용 성공률"이라는 지표를 우리도 도입한다: 어제 만든 셀렉터가 오늘도 동작한 비율을 매일 기록하고, 이 값이 떨어지면 셀렉터 재생성을 트리거한다. | +| D6 | Mind2Web: Towards a Generalist Agent for the Web | Xiang Deng, Yu Gu, Boyuan Zheng, Shijie Chen, Sam Stevens, Boshi Wang, Huan Sun, Yu Su | 2023 | NeurIPS 2023, Datasets and Benchmarks Track (Spotlight) | https://papers.nips.cc/paper_files/paper/2023/hash/5950bf290a1570ea401bf98882128160-Abstract-Datasets_and_Benchmarks.html | ✅ | 137개 웹사이트·31개 도메인에서 **2,000개 이상의 open-ended 태스크**와 크라우드소싱 액션 시퀀스. 핵심 기술적 교훈: "**실제 웹사이트의 raw HTML 은 LLM 입력 한계를 초과하므로, 먼저 소형 LM 으로 필터링하면 LLM 의 효과성과 효율성이 크게 개선된다**"(MindAct 의 2단계 구조). → 우리에게 그대로 적용: **LLM 에 HTML 전체를 넣지 마라.** 관련 서브트리만 잘라서 넣는다(D4 의 DOM pruning 과 동일한 결론에 독립적으로 도달). ⚠️ MindAct 의 후보 랭킹·객관식 액션 예측 세부와 cross-task/website/domain 분할 수치는 공식 페이지에서 확인 실패. | +| D7 | WebVoyager: Building an End-to-End Web Agent with Large Multimodal Models | Hongliang He, Wenlin Yao 외 6인 | 2024 (arXiv 2024-01-25 최초, 2024-06-06 최종) | ACL 2024 (Main), arXiv:2401.13919 | https://arxiv.org/abs/2401.13919 | ✅ | 스크린샷(시각) + 텍스트를 함께 처리하는 멀티모달 웹 에이전트. 15개 실제 사이트 태스크에서 **성공률 59.1%** — GPT-4(All Tools) 및 텍스트 전용 WebVoyager 를 상회. GPT-4V 자동 평가는 사람 판단과 **85.3% 일치**. 태스크는 Mind2Web 태스크를 시드로 변형·생성. → **59.1% 는 프로덕션 자동화에 쓸 수 없는 수치다.** "브라우저를 LLM 이 직접 조종해 매일 데이터를 긁는다"는 설계는 이 논문이 반증한다. 우리는 브라우저 조종을 **최초 1회 셀렉터 발견/디버깅**에만 쓰고, 일상 실행은 HTTP + 결정론적 파서로 한다. | +| D8 | OpenWebVoyager: Building Multimodal Web Agents via Iterative Real-World Exploration, Feedback and Optimization | — | 2024 | arXiv:2410.19609 | https://arxiv.org/pdf/2410.19609 | 🔶 | WebVoyager 오픈 계열. 검색 결과로만 확인. | +| D9 | The AI Committee: A Multi-Agent Framework for Automated Validation and Remediation of Web-Sourced Data | Sunith Vallabhaneni, Thomas Berkane, Maimuna Majumder | 2025-12-25 제출 | arXiv:2512.21481 | https://arxiv.org/abs/2512.21481 | ✅ | LLM 에이전트의 전형적 실패를 명시적으로 나열한다: **"값을 환각하거나 누락, 페이지 의미 오해, 무효 정보 탐지 실패"**. 각 에이전트가 출처 검증·팩트체크 등 별개 품질보증 과업을 맡는 다중 에이전트로 3개 실세계 데이터셋에서 **완전성 최대 78.7%, 정밀도 최대 100%**(태스크별 학습 없이). 오픈소스 공개. → **완전성 78.7% 라는 숫자가 "LLM 에게 본 추출을 맡기면 안 되는" 결정적 근거다.** 규제 데이터(DMF)에서 21%의 누락은 허용 불가. LLM 은 **검증자(validator)** 역할일 때만 가치가 있다. | +| D10 | DELM: a Python toolkit for Data Extraction with Language Models | — | 2025 | arXiv:2509.20617 | https://arxiv.org/pdf/2509.20617 | 🔶 | 동시 실행, 재시도 로직, 그리고 **완전히 렌더된 프롬프트·스키마·모델·생성 파라미터를 키로 하는 결정론적 캐싱**. → 우리도 LLM 호출에 동일한 캐시 키를 쓴다: `sha256(prompt + schema + model_id + temperature + top_p)`. 같은 입력에 두 번 과금하지 않고, 리포트 재현성도 확보된다. | +| D11 | A Reliability Evaluation of Hybrid Deterministic-LLM Based Approaches for Academic Course Registration PDF Information Extraction | — | 2026 | ResearchGate / arXiv:2604.00003 (Tabular PDF Information Extraction with Local LLMs and Layout-Aware Parsing) | https://arxiv.org/pdf/2604.00003 | 🔶 | 세 전략 비교: **LLM-only / Hybrid Deterministic-LLM(정규식 + LLM) / Camelot 파이프라인 + LLM fallback**. 결론: 하이브리드가 LLM-only 대비 효율이 좋고, **결정론적 메타데이터에서 특히 그렇다**. → DMF 등록번호·날짜·업체명처럼 형식이 고정된 필드는 **절대 LLM 에 맡기지 않는다.** 정규식/파서로 뽑고, LLM 은 자유서술 필드(변경 사유 요약 등)에만 쓴다. | +| D12 | Prompt2DAG: A Modular Methodology for LLM-Based Data Enrichment Pipeline Generation | — | 2025 | arXiv:2509.13487 | https://arxiv.org/html/2509.13487v1 | 🔶 | **결정론적 Template 기반 방법이 성공률 92.3% 로 최고**, 생성형 중에서는 Hybrid 가 78.5% 로 최고 품질. → 파이프라인 자체를 LLM 이 매번 만들게 하지 말고, **템플릿(코드)을 고정하고 파라미터(셀렉터·필드 매핑)만 LLM 이 채우게** 한다. | +| D13 | A Hybrid LLM and Supervised Model Pipeline for Polymer Property Extraction from Tables in Scientific Literature | — | 2025 | ACL Anthology 2025.wasp-main.11 | https://aclanthology.org/2025.wasp-main.11/ | 🔶 | 테이블 추출에서 LLM + 지도학습 모델 하이브리드. 도메인은 다르나 "테이블에서 규제/과학 데이터 추출" 이라는 구조가 우리와 동일. | +| D14 | RATE: An LLM-Powered Retrieval Augmented Generation Technology-Extraction Pipeline | — | 2025 | arXiv:2507.21125 | https://arxiv.org/html/2507.21125v1 | 🔶 | 검색 결과로만 확인. | +| D15 | ZeroShotCeres: Zero-Shot Relation Extraction from Semi-Structured Webpages | — | 2020 | ACL | https://www.researchgate.net/publication/343297191_ZeroShotCeres_Zero-Shot_Relation_Extraction_from_Semi-Structured_Webpages | 🔶 | LLM 이전의 zero-shot 반구조 웹 추출. SWDE 계열 비교 기준선. | +| D16 | From one tree to a forest: a unified solution for structured web data extraction | — | 2011 | SIGIR | https://www.researchgate.net/publication/221299838_From_one_tree_to_a_forest_a_unified_solution_for_structured_web_data_extraction | 🔶 | 검색 결과로만 확인. | + +### 2.5 중복·유사도 탐지, 저장 모델 + +| # | 제목 | 저자 | 연도 | 게재처 | URL | 확인 | 이 프로젝트에 주는 시사점 | +|---|---|---|---|---|---|---|---| +| E1 | Detecting Near-Duplicates for Web Crawling | Gurmeet Singh Manku (Google), Arvind Jain (Google), Anish Das Sarma (Stanford) | 2007 | Proc. 16th International World Wide Web Conference (WWW 2007) | https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/33026.pdf | ✅ (PDF 텍스트 추출) | **우리 near-duplicate 파라미터의 출처.** 원문 기여 3가지: (A) Charikar 의 simhash 가 다중 십억 페이지 저장소의 근접 중복 식별에 실용적임을 입증하고, **80억(8B) 웹페이지 저장소에 대해 64-bit simhash 지문과 k=3 이 합리적임을 실험으로 검증**. (B) **Hamming Distance Problem** 해법: f-bit 지문 집합에서 주어진 지문과 최대 k 비트 다른 것을 빠르게 찾기. (C) 중복 탐지 알고리즘 서베이. 크기 이점: Broder 의 shingle 기반 지문은 지문당 **24바이트**가 필요한 반면 simhash 는 **8바이트(64bit)** 로 충분. simhash 의 두 상충 성질: (A) 문서 지문은 그 특징들의 "해시"이고 (B) **유사 문서는 유사한 해시값**을 갖는다(암호학적 해시엔 없는 성질). 소박한 해법의 비용: 64-bit·k=3 이면 정렬 테이블 탐색에 C(64,3) = **41,664 회 프로브** 필요 → 실용 알고리즘은 순열 + 블록 분할(예: 64비트를 11,11,11,11,10,10 의 6블록으로 나눠 3개 선택 = C(6,3)=**20개 테이블**, pᵢ=31/32/33, 프로브당 평균 2^(34−31)=8개 지문 회수; 또는 16bit×4 블록 → **16개 테이블**, pᵢ=28, 프로브당 2^(34−28)=64개; 또는 13,13,13,13,12 의 5블록에서 2개 선택 = **10개 테이블**). 압축: 연속 지문이 상위 d 비트를 공유하므로 XOR 최상위 1비트 위치에 Huffman 코드(블록 크기 B 전형값 1024바이트)를 적용해 테이블 크기를 약 절반으로. | +| E2 | Similarity Estimation Techniques from Rounding Algorithms (SimHash 원전) | Moses Charikar | 2002 | Proc. 34th Annual ACM Symposium on Theory of Computing (STOC 2002) | https://en.wikipedia.org/wiki/SimHash | ✅ (Wikipedia 경유) | 알고리즘: 입력을 특징들로 분해 → 각 특징을 해시 → 비트 위치별로 1의 개수와 0의 개수를 세어(가중치 적용) 1이 우세하면 최종 해시의 그 비트를 1, 아니면 0. 유사 입력 → **Hamming 거리가 작은 해시**. 전체 문서 비교(O(n²)) 없이 정렬만으로 유사 항목 발견 가능. ⚠️ STOC 2002 원문 PDF 는 Semantic Scholar 429 로 직접 확인 실패. | +| E3 | On the Resemblance and Containment of Documents (MinHash 원전) | Andrei Z. Broder | 1997 | Compression and Complexity of Sequences 1997, IEEE, pp. 21–29 | https://www.semanticscholar.org/paper/On-the-resemblance-and-containment-of-documents-Broder/8addb1718c2bc6bbb0d82cd1a57b41198bf65965 | 🔶 (Wikipedia 로 보완 확인) | **shingling**(텍스트를 단어 n-gram 으로 자름) + **resemblance = Jaccard 유사도**, **containment**, **min-wise independent permutations 스케치**. 핵심 성질: `P(h_min(A) = h_min(B)) = J(A,B)`. 표본 내 공통 shingle 수는 초기하분포. **AltaVista** 검색엔진의 중복 웹페이지 제거에 실제 사용. LSH 스킴이므로 근접 이웃 검색·군집화에 사용 가능(banding). → **우리는 SimHash 를 1순위로 쓴다**(지문 8바이트, 짧은 게시판 레코드에 적합). MinHash 는 첨부파일/장문 상세 페이지에 대해 **집합 유사도가 필요할 때**의 대안으로 남긴다. | +| E4 | python-simhash | scrapinghub | 아카이브됨 (2026-06-24) | GitHub, Python + C 확장(GCC), **BSD-3-Clause**, 127 stars / 29 forks / 11 watchers | https://github.com/scrapinghub/python-simhash | ✅ | 함수: `fingerprint()`(해시 시퀀스에서 지문 생성), `hamming_distance()`, `simpair_indices()`(임계 내 유사 해시 탐색), `fnvhash()`(FNV-1a). 사용 예: `hash1 = fingerprint(map(hash, "some text we want to hash"))` → `hamming_distance(hash1, hash2)` → `2`. **아카이브된 저장소이므로 의존성으로 채택하지 않고**, 순수 파이썬 60줄로 직접 구현한다(§7.1 코드). BSD-3-Clause 라 참고·재구현에 제약 없음. | +| E5 | Probabilistic near-duplicate detection using simhash | Sood, Loguinov | 2011 | Proc. 20th ACM CIKM | https://dl.acm.org/doi/10.1145/2063576.2063737 | 🔶 | simhash 의 확률적 확장. 검색 결과로만 확인. | +| E6 | Event Sourcing | Martin Fowler | 2005-12-12 | martinfowler.com (Enterprise Application Architecture) | https://martinfowler.com/eaaDev/EventSourcing.html | ✅ | 정의: "애플리케이션 상태의 **모든 변경을 이벤트의 시퀀스로 포착**한다." 두 지속 요소 = **이벤트 로그**와 **애플리케이션 상태**. 상태는 이벤트에서 완전히 유도 가능하므로 **스냅샷은 성능 최적화이지 대체물이 아니다**. 기능: complete rebuild(상태를 버리고 전 이벤트 재실행), event replay(잘못된 이벤트를 되돌리고 수정 후 재처리), **temporal query**(특정 시점까지만 재생해 그 시점 상태 확인). 트레이드오프: 외부 시스템 상호작용·코드 변경·이벤트 되돌리기 로직에서 복잡도가 커지므로 기본 선택지가 아니며, **감사(audit)·디버깅·확장성에서 실질 이득이 있을 때만** 채택해야 한다. → **DMF 는 감사가 본질인 도메인이다.** "언제 무엇이 어떻게 바뀌었는가"가 산출물 그 자체이므로 event sourcing 의 채택 조건을 명백히 충족한다. | +| E7 | Type 2 Slowly Changing Dimension (Kimball 기법) | Kimball Group | — | kimballgroup.com Dimensional Modeling Techniques | https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/type-2/ | ✅ | 변경 시 기존 행을 갱신하지 않고 **새 행을 추가**한다. 필수 요건: ① 자연키가 아닌 **대리키(surrogate key)** 를 PK 로, ② 3개 컬럼 — **Effective Date**(변경 발효), **Expiration Date**(행 만료), **Current Row Indicator**(활성 플래그). 완전한 이력 감사 추적을 유지하면서 참조 무결성 유지. → **우리 `dmf_record` 테이블의 정확한 스키마다.** 자연키 = DMF 등록번호, 대리키 = 자동 증가 id, `valid_from` / `valid_to` / `is_current`. | +| E8 | Event Sourcing Pattern (Azure Architecture Center) | Microsoft | 현재 | Microsoft Learn | https://learn.microsoft.com/en-us/azure/architecture/patterns/event-sourcing | 🔶 | 검색 결과로만 확인. 구현 가이드로 참조. | +| E9 | Change Data Capture: From Batch to Real-Time | Capital One | 현재 | capitalone.com/tech | https://www.capitalone.com/tech/software-engineering/batch-to-real-time-with-change-data-capture/ | 🔶 | **CDC vs Event Sourcing 구분**: CDC 는 CRUD 저장소에 사후적으로 이벤트 스트림을 덧씌운 것으로 **도메인 의도 없는 행 델타**이고, Event Sourcing 은 애플리케이션 수준의 **도메인 이벤트**를 포착한다. → 우리는 "신규 등록 / 성분 변경 / 취하"라는 **도메인 이벤트**를 쓴다. "row updated" 같은 CDC 수준 이벤트는 리포트에 쓸모가 없다. | +| E10 | Event Sourcing (arc42 Quality Model) | arc42 | 현재 | quality.arc42.org | https://quality.arc42.org/approaches/event-sourcing | 🔶 | 스냅샷은 "일정 개수의 이벤트가 쌓이면 만드는 최적화"라는 실무 규칙 제시. → 우리는 이벤트 수 기준이 아니라 **매 실행마다 스냅샷을 남긴다**(하루 1회이므로 비용이 무의미하게 작다). | + +### 2.6 표준 · 도구 문서 + +| # | 제목 | 저자/발행 | 연도 | 게재처 | URL | 확인 | 이 프로젝트에 주는 시사점 | +|---|---|---|---|---|---|---|---| +| F1 | RFC 9309: Robots Exclusion Protocol | M. Koster, G. Illyes, H. Zeller, L. Sassman (Google LLC) | 2022-09 | IETF, Internet Standard | https://www.rfc-editor.org/rfc/rfc9309.html | ✅ | §8 에 전문 인용. 1994년 Martijn Koster 가 정의한 방식을 표준화·확장. | +| F2 | Google robots.txt 사양 문서 | Google | 현재 | developers.google.com | https://developers.google.com/search/docs/crawling-indexing/robots/robots_txt | ✅ | "Google 은 다음 필드를 지원한다(**crawl-delay 같은 다른 필드는 지원하지 않는다**)", "allow / disallow / user-agent 외의 규칙은 파서가 무시한다", "가장 구체적인 user agent 그룹을 선택한다", "일반적으로 최대 24시간 캐시, 갱신 불가 상황에선 더 길게", "**500 KiB 파일 크기 제한, 초과분 무시**". | +| F3 | urllib.robotparser (Python 표준 라이브러리) | Python Software Foundation | 현재 | docs.python.org | https://docs.python.org/3/library/urllib.robotparser.html | ✅ | `RobotFileParser` 메서드: `set_url(url)`, `read()`, `parse(lines)`, `can_fetch(useragent, url)`, `crawl_delay(useragent)` (**3.6 추가**), `request_rate(useragent)` → `RequestRate(requests, seconds)` (**3.6 추가**), `site_maps()` (**3.8 추가**), `mtime()`, `modified()`. → **표준 라이브러리만으로 RFC 준수 파싱이 가능하다.** 외부 의존성 불필요. 단, 5xx→완전 금지 규칙은 직접 구현해야 한다(§8.3 코드). | +| F4 | Scrapy AutoThrottle 문서 | Scrapy | 현재 | docs.scrapy.org | https://docs.scrapy.org/en/latest/topics/autothrottle.html | ✅ | 설정과 기본값: `AUTOTHROTTLE_ENABLED`(기본 `False`), `AUTOTHROTTLE_START_DELAY`(기본 `5.0`초), `AUTOTHROTTLE_MAX_DELAY`(기본 `60.0`초), `AUTOTHROTTLE_TARGET_CONCURRENCY`(기본 `1.0`, "값이 낮을수록 크롤러가 보수적이고 정중해진다"), `DOWNLOAD_DELAY`(최소 하한, AutoThrottle 이 이 아래로 내리지 않음). **알고리즘**: ① 시작 지연으로 출발 → ② 응답 수신 시 목표 지연 = `latency / N`(N = target concurrency) → ③ 다음 요청 지연 = (이전 지연 + 목표 지연) / 2 → ④ **비 200 응답은 지연을 늘리기만 하고 줄이지 않는다** → ⑤ 최종 지연은 `DOWNLOAD_DELAY` 와 `AUTOTHROTTLE_MAX_DELAY` 사이로 클램프. → Scrapy 를 안 쓰더라도 **이 4단계 알고리즘을 그대로 구현**한다(§10 표). ⚠️ 문서 내에 `ROBOTSTXT_OBEY` 언급은 없음. | +| F5 | Heritrix User Manual | Kristinn Sigurðsson, Michael Stack 외 | — | crawler.archive.org | http://crawler.archive.org/articles/user_manual.pdf | 🔶 | 검색 결과로만 확인. A5 의 readthedocs 문서로 대체 확인함. | +| F6 | Respecting Robots Exclusion Protocol or robots.txt at Scale | Rashad Moarref (GumGum Tech) | — | Medium | https://medium.com/gumgum-tech/respecting-robots-exclusion-protocol-or-robots-txt-at-scale-60ee57dc1295 | 🔶 | 대규모 robots.txt 준수 실무기. 검색 결과로만 확인. | +| F7 | RFC 9309 — status, mechanics & checks | AgentGrade | 현재 | agentgrade.com | https://agentgrade.com/standards/rfc-9309 | 🔶 | 검색 결과로만 확인. | +| F8 | How Attackers Exploit robots.txt? | Baeldung | 현재 | baeldung.com/cs | https://www.baeldung.com/cs/robots-txt-risk-threat | 🔶 | robots.txt 의 보안적 함의. 우리는 robots.txt 를 "숨겨진 경로 목록"으로 쓰지 않는다(윤리·법적 리스크). | + +### 2.7 실무 블로그 (셀렉터 파손 / schema drift) + +| # | 제목 | 발행 | URL | 확인 | 핵심 내용 및 시사점 | +|---|---|---|---|---|---| +| G1 | How to Fix Web Scraping Errors: 2026 Complete Troubleshooting Guide | PromptCloud | https://www.promptcloud.com/blog/how-to-fix-web-scraping-errors-2026/ | 🔶 | 검색 요약으로 확인: **schema drift** = 페이지 구조가 조금 바뀌어 CSS/XPath 셀렉터가 깨지는 현상. 프로덕션 스크레이퍼는 DOM 스냅샷 시점 기준으로 셀렉터를 작성하므로, 사이트가 레이아웃을 개편하거나 class 명을 바꾸거나 테이블을 재구성하면 **셀렉터가 조용히 아무것도 반환하지 않거나 잘못된 데이터를 반환한다**. | +| G2 | Managing Change in Web Scraping: 10 Critical Challenges | PromptCloud | https://www.promptcloud.com/blog/managing-change-in-web-scraping-10-challenges/ | 🔶 | 대응책: **야간 검증 실행**으로 출력 필드 개수를 과거 기준선과 비교, **셀렉터 버저닝**(스크레이퍼에 스키마 날짜 태깅 후 이상 자동 플래그). 가장 위험한 실패는 "셀렉터가 여전히 **무언가**에 매치되지만 그게 올바른 노드가 아닌" **조용한 정확성 드리프트(silent correctness drift)**. → 우리 검증기는 "0건"뿐 아니라 **"건수는 맞는데 값이 이상한"** 경우까지 잡아야 한다(§5.7). | +| G3 | Web Scraping With XPath and CSS Selectors | Crawlbase | https://crawlbase.com/blog/web-scraping-with-xpath-and-css-selectors/ | 🔶 | 시각적 class 보다 안정 속성 우선: `data-testid`, `id`, `itemprop`, ARIA role 이 리스타일을 살아남을 확률이 훨씬 높다. 길고 깊은 셀렉터 체인은 레이아웃 전체를 인코딩하므로 경로 중간에 래퍼 하나만 추가돼도 깨진다. | +| G4 | When the Scraper Breaks Itself: Building a Self-Healing CSS Selector Repair System | Vinicius Puerto (DEV) | https://dev.to/viniciuspuerto/when-the-scraper-breaks-itself-building-a-self-healing-css-selector-repair-system-312d | 🔶 | 자가 치유 셀렉터 시스템 실무 사례. 우리 LLM 복구 루프의 참고 패턴. | +| G5 | What are CSS selectors and XPath in web extraction? / What is an xpath selector in web scraping? | Firecrawl Glossary | https://www.firecrawl.dev/glossary/web-extraction-apis/what-are-css-selectors-xpath-web-extraction · https://www.firecrawl.dev/glossary/web-scraping-apis/what-is-xpath-selector-in-web-scraping | 🔶 | 용어 정리. | + +--- + +## 3. 크롤러 아키텍처 고전 — 프론티어·politeness·중복 제거·재방문 + +### 3.1 Mercator 의 부품 목록 (Heydon & Najork 1999; Najork & Heydon 2001) + +SRC Research Report 173 의 Figure 1 이 명시하는 Mercator 구성 요소는 다음과 같다(원문 표기 그대로): + +``` +Protocol Modules: HTTP, FTP, Gopher +Processing Modules: Link Extractor, GIF Stats, Tag Counter +공통 부품: DNS Resolver, RIS, Content Seen?, URL Filter, DUE, URL Frontier +저장소: Queue Files, URL Set, Log, Doc FPs +``` + +원문이 정의하는 크롤러 기본 알고리즘: + +> "Remove a URL from the URL list, determine the IP address of its host name, download the corresponding document, and extract any links contained in it. For each of the extracted links, ensure that it is an absolute URL (derelativizing it if necessary), and add it to the list of URLs to download, provided it has not been encountered before. If desired, process the downloaded document in other ways (e.g., index its content)." + +**우리 프로젝트로의 매핑**: + +| Mercator 부품 | 원래 역할 | DMF_Crawler 에서의 축소 구현 | +|---|---|---| +| URL Frontier | 다운로드 대기 URL 우선순위 큐 | `frontier.py` — 시드 = DMF 공고 목록 URL(페이지 파라미터 포함). 우선순위 = 목록 페이지 > 상세 페이지. 호스트가 1개이므로 back-end 큐는 1개. | +| DNS Resolver | 호스트명 → IP, 캐시 필수 | OS/`requests` 기본 해석기 + 세션 keep-alive. 별도 구현 불필요하나 **DNS 실패는 별도 에러 코드로 분류**(네트워크 장애 vs 사이트 개편 구분). | +| Protocol Module (HTTP) | 프로토콜별 fetch | `fetcher.py` — `requests.Session` 1개, 재시도·백오프·조건부 요청 담당. | +| RIS (RewindInputStream) | 임의 입력 스트림을 **여러 번 다시 읽을 수 있게** 하는 I/O 추상 | 원본 HTML 바이트를 `raw/YYYY-MM-DD/.html.gz` 로 **먼저 저장한 뒤** 파싱한다. 파싱 실패 시 재파싱·소급 재처리가 가능해진다. Mercator 가 RIS 를 둔 이유와 동일. | +| Content-Seen Test | 다른 URL이지만 같은 내용인 문서를 걸러냄 (Doc FPs) | `dedup.py` — 레코드 지문(§7). Mercator 는 문서 단위였지만 우리는 **레코드 단위**로 내린다. | +| URL Filter | 스코프 밖 URL 제거 | 도메인 화이트리스트 + 경로 정규식. 외부 링크는 절대 따라가지 않는다. | +| DUE (URL-seen test) | 이미 큐에 넣은 URL 재삽입 방지. Rabin fingerprint **8바이트 체크섬** 해시테이블. 체크섬 상위 3바이트를 스파인 인덱스로, 하위 5바이트만 저장 → **10억 URL ≈ 5GB** | `set[str]` 로 충분(URL 수 수백). Rabin 지문은 불필요하나, **정규화된 URL 문자열**(쿼리 파라미터 정렬, 세션 ID 제거)을 키로 쓰는 원칙은 유지. | +| Checkpointing | "장기 실행 프로세스의 필수 요소". 실패 시 체크포인트를 읽어 **정확히 그 시점 상태로 복구**할 수 있을 만큼의 상태를 안정 저장소에 기록 | `state.json` + SQLite WAL. 목록 페이지 n장 중 k장까지 처리했다는 커서를 기록해, 중단 후 재실행 시 이어서 진행. 재부팅 자동 복구 요구사항과 직결. | +| 백그라운드 스레드 | 기본 **10초마다** 깨어나 통계 로깅 / 종료 조건 확인 / 체크포인트 시점 판단 | 헬스체크 루프. 우리는 실행이 짧으므로 10초 대신 **각 페이지 처리 후** 통계 기록 + 체크포인트. | +| robots 캐시 | 호스트명 → robots 규칙, 기본 **2^18 엔트리 LRU** | 호스트 1개이므로 단일 엔트리. **RFC 9309 의 24시간 캐시 규칙**만 지킨다. | + +### 3.2 Frontier 의 front-end / back-end 2단 구조 = politeness 의 구조적 보장 + +SRC 173 원문: + +> "The frontier consists of a front-end (the top part of the figure) that is responsible for prioritizing URLs, and a back-end (the bottom part of the figure) that is responsible for ensuring strong politeness. When a URL u is added to the frontier, a pluggable prioritizer component computes a priority value p between 1 and k based on the URL and its download history (e.g. whether the document has changed since the last download), and inserts u into front-end FIFO queue p. The back-end maintains n FIFO queues, each of which is guaranteed to be non-empty and to contain URLs of one [host]." + +**핵심 통찰 3가지**: + +1. **politeness 를 "sleep 을 잘 넣자"는 규율 문제가 아니라 자료구조 문제로 바꿨다.** back-end 큐가 호스트당 1개이고 큐당 워커가 1개면, 동일 호스트 동시 접속은 **구조적으로 불가능**하다. → 우리 코드도 `HostQueue` 클래스를 만들어, 요청 함수가 이 큐를 통해서만 호출되게 강제한다. `requests.get` 을 아무 데서나 부르지 못하게 한다. +2. **우선순위 계산에 "지난번 다운로드 이후 문서가 변경되었는가"가 명시적으로 들어간다.** 이것이 §4 의 증분 크롤 이론과 아키텍처가 만나는 지점이다. +3. front-end/back-end 분리 덕에 **우선순위 로직과 politeness 로직이 서로 오염되지 않는다.** + +### 3.3 Mercator 의 politeness 정의 (원문) + +> "Despite the need for speed, anyone running a web crawler that overloads web servers soon learns that such behavior is considered unacceptable. At the very least, a web crawler should not attempt to download multiple pages from the same web server simultaneously; better, it should impose a limit on the portion of a web server's resources it consumes." + +두 단계가 명시돼 있다: **최소 요건 = 동시 접속 금지**, **더 나은 요건 = 서버 자원 점유율 상한**. 우리는 둘 다 채택한다(동시성 1 + delayFactor 5.0). + +성능 참고치(원문): Compaq DS20E 666MHz Alpha 서버 4대, 160 Mbit/sec 회선 포화 상태에서 **17일간 하루 약 5,000만 문서** 다운로드. 우리는 하루 수십~수백 요청이다 — **5~6 자릿수의 여유**가 있으므로 속도 최적화는 전면 금지하고 전부 안전성에 쓴다. + +### 3.4 robots.txt 캐싱 (Mercator 원문) + +> "Courteous web crawlers implement the Robots Exclusion Protocol, which allows web masters to declare parts of their sites off limits to crawlers. The Robots Exclusion Protocol requires a web crawler to fetch a resource named '/robots.txt' containing these declarations from a web site before downloading any real content from it. To avoid downloading this resource on every request, Mercator's HTTP protocol module maintains a fixed-sized cache mapping host names to their robots exclusion rules. By default, the cache is limited to 2^18 entries, and uses an LRU replacement strategy." + +→ **매 요청마다 robots.txt 를 받지 마라.** 실행 시작 시 1회 받아 프로세스 수명 동안 캐시하고, RFC 9309 의 24시간 상한을 지키면 하루 1회 실행에서는 실행당 정확히 1회 fetch 가 된다. + +### 3.5 Content-Seen Test (Mercator 원문) + +> "Once the document has been written to the RIS, the worker thread invokes the content-seen test to determine whether this document with the same content, but a different URL, has been seen before. If so, the document is not processed any further, and the worker thread goes back to step 1." + +→ 우리의 대응물: **같은 DMF 레코드가 목록 페이지와 상세 페이지, 혹은 페이징 경계에서 중복 등장**할 수 있다. 레코드 지문 테이블로 한 번 본 레코드를 두 번 처리하지 않는다. + +### 3.6 Heritrix — politeness 를 설정으로 노출한다 + +`heritrix.readthedocs.io/en/latest/configuring-jobs.html` 에서 확인된 프로퍼티(정확한 이름): + +| 프로퍼티 | 의미 | 기본값 | DMF_Crawler 채택값 | +|---|---|---|---| +| `delayFactor` | "직전 URI 를 가져오는 데 걸린 시간의 배수"만큼 대기 | 5.0 | **5.0 (그대로)** | +| `minDelayMs` | 요청 간 최소 대기. **delayFactor 계산값보다 우선** | — | **2000** | +| `maxDelayMs` | politeness 지연 상한 | 30000 | **30000 (그대로)** | +| `maxPerHostBandwidthUsageKbSec` | 호스트당 최대 대역폭 | — | 미사용(요청 수가 적음) | +| `robotsPolicyName` | `obey` / `classic` / `robotsTxtOnly` / `ignore`. RFC 9309 경로 와일드카드 지원 | `obey` | **obey (그대로)** | +| `metadata.operatorContactUrl` | "문제 발생 시 크롤 대상 호스트 관리자가 참조할 URI 제공" | — | **User-Agent 에 연락처 포함** | +| `maxToeThreads` | 워커 스레드 수(도메인 크롤 시 호스트 수의 약 2배 권장) | — | **1** | +| `extract404s` | 404 응답에서 링크 추출 여부 | — | **false** | + +Heritrix3 README 에서 확인된 개념: robots.txt 및 META nofollow 준수, 크롤 잡 단위 설정, **WARC 출력**, Frontier 큐잉. Java, Apache License 2.0. + +→ WARC 는 우리에게 **"원본 보존"** 원칙으로 번역된다. 파싱 결과만 저장하고 원본을 버리면, 셀렉터가 깨진 날의 데이터를 되살릴 방법이 없다. + +### 3.7 Olston & Najork 서베이가 정의하는 문제 공간 + +> "Though the idea of web crawling may seem straightforward — a simple application of breadth-first-search — the field actually presents numerous challenges, ranging from systems concerns such as managing very large data structures to theoretical questions such as how often to revisit evolving content sources." +> — Web Crawling, Foundations and Trends in IR 4(3):175–246, 2010, DOI 10.1561/1500000017 + +이 프로젝트에서 두 축의 무게는 극단적으로 비대칭이다: + +| 축 | 웹 스케일 크롤러 | DMF_Crawler | +|---|---|---| +| 초대형 자료구조 관리 | 지배적 난제 (URL frontier 수십억, 지문 테이블 수 TB) | **사실상 0** — SQLite 한 파일 | +| 분산·병렬화 | 필수 | **불필요** — 단일 프로세스 | +| politeness | 필수 | **필수 (동일)** | +| 재방문 주기 / 변경 감지 | 이론적 난제 | **본질적 난제 — 이 프로젝트의 전부** | +| 구조적 추출 정확도 | 부차적(검색 인덱싱은 잡음에 관대) | **치명적** — 규제 데이터는 1건 누락도 실패 | + +→ 그러므로 이 프로젝트의 엔지니어링 예산은 **재방문 정책(§4) + 추출 정확도/검증(§5) + diff 정확도(§7)** 에 몰아야 한다. 성능·확장성 작업은 전부 금지 항목이다. + +### 3.8 실측 crawl-delay 값 레퍼런스 (Wikipedia "Web crawler" 정리) + +| 크롤러/연구 | 접근 간격 정책 | +|---|---| +| MercatorWeb | 적응형 — **직전 다운로드 소요 시간의 10배** 대기 | +| Cho | **10초** 고정 | +| WIRE | 기본 **15초** | +| 관측된 실제 접근 간격 | **20초 ~ 3–4분** | +| Heritrix | `delayFactor` 5.0 × 직전 소요 시간, `minDelayMs`/`maxDelayMs`(기본 30000) 로 클램프 | + +→ 우리의 `min_delay = 2.0s` 는 이 스펙트럼에서 **가장 공격적인 쪽**이다. 정당화: 하루 요청 총량이 수십 건에 불과하므로 절대 부하가 무의미하게 작다. 그러나 delayFactor 5.0 이 살아 있으므로, 서버 응답이 3초로 느려지면 자동으로 15초 간격이 된다. **안전은 하한이 아니라 적응 규칙이 보장한다.** + +--- + +## 4. 증분 크롤링과 변경 감지 이론 + +### 4.1 주기적(periodic) vs 증분(incremental) 크롤러 + +Cho & Garcia-Molina (VLDB 2000) 가 대비시킨 두 설계: + +| | 주기적 크롤러 | 증분 크롤러 | +|---|---|---| +| 동작 | 컬렉션 전체를 새로 긁고 통째로 교체 | 로컬 컬렉션을 유지하며 변경분만 갱신 | +| 신선도 | 크롤 주기 = 최대 지연 | 지속적으로 최신에 가까움 | +| 비용 | 매번 전체 | 변경 감지 비용 + 변경분만 | +| 이력 | 없음(교체됨) | 자연스럽게 축적 | + +**DMF_Crawler 의 선택 = 하이브리드**: +- **수집은 주기적으로** — 매일 06:00 에 목록 페이지 전체를 다시 긁는다. DMF 게시판은 총 레코드가 수천 건 규모이고 페이지 수가 적어, "전체를 보고 비교"하는 것이 "변경만 골라내는" 것보다 **단순하고 정확하다**. +- **저장은 증분으로** — 긁은 결과를 통째로 덮어쓰지 않고, 키 기반 upsert 로 신규/변경/취하 이벤트를 생성한다. **"취하(사라짐)" 탐지는 전체 스냅샷 비교로만 가능하다** — 이것이 목록 전체를 매일 긁어야 하는 결정적 이유다. + +> ⚠️ VLDB 2000 원문은 ACM 403, Semantic Scholar 429 로 본문 확인 실패. 위 대비는 논문 초록·2차 출처 요약에 기반하며, 페이지 변경률 실측치·half-life 수치는 **확인하지 못했다**(부록 B 항목). + +### 4.2 Poisson 변경 모델과 freshness / age + +Cho & Garcia-Molina (TODS 2003) 의 핵심 명제: + +> "A Poisson process is a good model to describe the changes of Web pages." +> "the time between changes follow an exponential distribution λe^(−λt) if the change frequency of the page is λ" + +**정의** (Wikipedia "Web crawler" 의 Re-visit policy 절에서 확인된 표준 정의): + +- **Freshness** `F(p; t)` — 이진값. 로컬 사본이 시각 t 에 최신이면 1, 아니면 0. +- **Age** `A(p; t)` — 로컬 사본이 낡은 채로 얼마나 오래 있었는가. 원본이 수정된 시점부터 경과한 시간. + +**고정 주기 I 로 재방문할 때의 기대값** (Poisson 변경률 λ 가정, 표준 유도): + +한 번 동기화한 직후를 t=0 이라 하면, 시각 t 에 사본이 여전히 최신일 확률은 "그 사이 변경이 0회 발생할 확률" 이므로 + +``` +P[F(t) = 1] = e^(−λt) +``` + +주기 I 에 대한 시간 평균 freshness: + +``` + 1 ⌠I 1 − e^(−λI) +F̄(I) = ─ │ e^(−λt) dt = ─────────── + I ⌡0 λI +``` + +시각 t 에서의 기대 age 는 `E[A(t)] = t − (1 − e^(−λt))/λ` 이므로, 시간 평균 age: + +``` + 1 ⌠I ⎡ 1 − e^(−λt)⎤ I 1 1 − e^(−λI) +Ā(I) = ─ │ ⎢t − ───────────⎥ dt = ─ − ─ + ─────────── + I ⌡0 ⎣ λ ⎦ 2 λ λ²I +``` + +> ⚠️ 위 수식은 표준 Poisson 유도로 재구성한 것이다. Cho & Garcia-Molina 원문 PDF(`oak.cs.ucla.edu/~cho/papers/cho-tods03.pdf`, `dl.acm.org/doi/pdf/10.1145/958942.958945`)는 각각 ECONNREFUSED / 403 으로 **본문 확인 실패**. Poisson 모델과 지수분포 λe^(−λt) 라는 명제 자체는 검색 결과에서 확인됨. 수식 표기는 원문과 다를 수 있다(부록 B 항목). + +**DMF_Crawler 실측 대입 (I = 1일)**: + +| 게시판 성격 | λ (건/일) | λI | F̄ | Ā (일) | Ā (시간) | +|---|---|---|---|---|---| +| 매우 한산 (주 1회 변경) | 0.14 | 0.14 | 0.932 | 0.047 | 1.1h | +| 한산 (5일에 1회) | 0.20 | 0.20 | 0.906 | 0.065 | 1.6h | +| 보통 (2일에 1회) | 0.50 | 0.50 | 0.787 | 0.148 | 3.6h | +| 활발 (매일 1회) | 1.00 | 1.00 | 0.632 | 0.264 | 6.3h | +| 매우 활발 (하루 3회) | 3.00 | 3.00 | 0.317 | 0.394 | 9.5h | + +**해석 — 그리고 이것이 왜 우리 문제에서 오해를 부르는가**: +위 F̄ 는 "임의의 순간에 사본이 최신일 확률"이다. 그러나 **DMF_Crawler 의 실제 SLA 는 "게시된 공고를 며칠 안에 리포트에 싣는가"** 이고, 하루 1회 균등 크롤에서 이 값은 **λ 와 무관하게 항상 최대 24시간, 평균 12시간**이다(공고가 하루 중 균등하게 올라온다고 가정). freshness/age 는 "실시간 캐시 품질" 지표이지 "탐지 지연" 지표가 아니다. + +→ **문서화할 SLA**: `최대 탐지 지연 = 24시간 + 실행 소요 시간`, `평균 탐지 지연 ≈ 12시간`. 이것이 요구사항("매일 06:00 1회")에서 수학적으로 도출되는 값이며, 더 좋게 만들려면 주기를 줄이는 방법밖에 없다. freshness 를 올리려고 스케줄링을 정교하게 만드는 것은 **효과가 없다.** + +### 4.3 균등(uniform) vs 비례(proportional) — 반직관적 핵심 결과 + +Wikipedia "Web crawler" Re-visit policy 절에서 확인된 서술: + +> "Cho and Garcia-Molina [demonstrated] that the uniform policy outperforms the proportional policy in terms of average freshness" +> "The optimal [policy] is closer to the uniform policy than to the proportional policy" +> Coffman et al.: "accesses to any particular page should be kept as evenly spaced as possible" + +**두 정책**: +- **Proportional**: 변경률 λᵢ 에 비례해 재방문 빈도를 배분. 직관적으로 옳아 보인다. +- **Uniform**: 모든 페이지를 같은 빈도로 재방문. + +**왜 균등이 이기는가**: 매우 자주 바뀌는 페이지는 아무리 자주 긁어도 금방 낡는다 — 그 페이지에 자원을 쓰면 **한계 효용이 급감**한다. 반면 그 자원을 덜 바뀌는 페이지에 쓰면 그 페이지는 오래 최신 상태를 유지한다. 총 freshness 를 최대화하려면 **낭비되는 곳(초고빈도 변경 페이지)에서 자원을 빼야 한다.** + +**DMF_Crawler 의 결론 (확정)**: +- 대상 게시판이 여러 개여도 **모두 하루 1회 균등**으로 긁는다. +- "이 게시판은 자주 바뀌니까 하루 3번 긁자"는 최적화는 **하지 않는다.** +- Coffman 의 "가능한 한 균등 간격" 원칙에 따라, **매일 정확히 06:00**(±지터 최소)에 실행한다. 실행 시각이 들쭉날쭉하면 간격 분산이 커져 이론적 이점이 사라진다. + +### 4.4 λ 의 온라인 추정 — 구간 검열(interval-censored) 최우추정 + +우리는 방문 사이에 몇 번 바뀌었는지 볼 수 없다. **"바뀌었다 / 안 바뀌었다"만 관측**한다(interval censoring). 주기 I 로 n 회 방문해 그중 X 회에서 변경이 관측되었다면: + +``` +P[한 주기 안에 1회 이상 변경] = 1 − e^(−λI) +X / n ≈ 1 − e^(−λI) + ⇒ λ̂ = − ln(1 − X/n) / I +``` + +**경계 처리**: `X = n`(매번 변경)이면 λ̂ = ∞ 이므로, Laplace 보정을 적용한다. + +``` +λ̂ = − ln(1 − (X + 0.5) / (n + 1)) / I +``` + +`n < 14`(2주 미만 관측)이면 추정을 신뢰하지 않고 사전값 λ₀ = 0.5 건/일 을 쓴다. + +```python +# src/dmf_crawler/change_rate.py +"""Poisson 변경률 λ 의 구간 검열 최우추정 (Cho & Garcia-Molina 2003 의 Poisson 모델 기반). + +관측 모델: + - 주기 I(일) 로 n 회 방문했고, 그중 X 회에서 '이전 방문 대비 변경'이 관측되었다. + - 한 주기 내 1회 이상 변경 확률 = 1 - exp(-lambda * I) +""" +from __future__ import annotations + +import math +from dataclasses import dataclass + +PRIOR_LAMBDA_PER_DAY = 0.5 # 관측이 부족할 때 쓰는 사전값 +MIN_OBSERVATIONS = 14 # 이 미만이면 사전값 사용 +ANOMALY_RATIO = 3.0 # 최근 λ 가 장기 λ 의 이 배를 넘으면 이상 급증 + + +@dataclass(frozen=True) +class ChangeRateEstimate: + lambda_per_day: float + n_observations: int + n_changes: int + is_prior: bool + + def expected_freshness(self, interval_days: float = 1.0) -> float: + """F̄(I) = (1 - e^(-λI)) / (λI)""" + x = self.lambda_per_day * interval_days + if x <= 1e-12: + return 1.0 + return (1.0 - math.exp(-x)) / x + + def expected_age_days(self, interval_days: float = 1.0) -> float: + """Ā(I) = I/2 - 1/λ + (1 - e^(-λI)) / (λ²I)""" + lam = self.lambda_per_day + if lam <= 1e-12: + return interval_days / 2.0 + x = lam * interval_days + return interval_days / 2.0 - 1.0 / lam + (1.0 - math.exp(-x)) / (lam * lam * interval_days) + + +def estimate_lambda(n_observations: int, n_changes: int, interval_days: float = 1.0) -> ChangeRateEstimate: + if interval_days <= 0: + raise ValueError("interval_days must be positive") + if n_observations < 0 or n_changes < 0 or n_changes > n_observations: + raise ValueError("invalid observation counts") + + if n_observations < MIN_OBSERVATIONS: + return ChangeRateEstimate(PRIOR_LAMBDA_PER_DAY, n_observations, n_changes, is_prior=True) + + # Laplace 보정: X = n 일 때 발산을 막는다. + p = (n_changes + 0.5) / (n_observations + 1.0) + p = min(max(p, 1e-9), 1.0 - 1e-9) + lam = -math.log(1.0 - p) / interval_days + return ChangeRateEstimate(lam, n_observations, n_changes, is_prior=False) + + +def is_anomalous_burst(recent: ChangeRateEstimate, longterm: ChangeRateEstimate) -> bool: + """최근 변경률이 장기 변경률 대비 급등했는지 판정. 주기는 바꾸지 않고 리포트에 플래그만 단다.""" + if recent.is_prior or longterm.is_prior: + return False + if longterm.lambda_per_day <= 1e-9: + return recent.lambda_per_day > 0.0 + return recent.lambda_per_day / longterm.lambda_per_day >= ANOMALY_RATIO + + +if __name__ == "__main__": + for n, x in [(30, 6), (30, 15), (30, 30), (5, 2)]: + est = estimate_lambda(n, x) + print( + f"n={n:3d} X={x:3d} lambda={est.lambda_per_day:6.3f}/day " + f"F̄={est.expected_freshness():.3f} Ā={est.expected_age_days()*24:5.2f}h prior={est.is_prior}" + ) +``` + +**이 추정값의 용도 (그리고 용도가 아닌 것)**: +- ✅ 리포트의 "이 게시판은 평소 며칠에 한 번 바뀝니다" 컨텍스트 제공. +- ✅ **이상 급증 탐지** — λ̂_recent(최근 14일) ≥ 3 × λ̂_longterm(전체) 이면 리포트 상단에 경고 배지. +- ✅ **이상 정지 탐지** — 평소 λ 가 0.5 인 게시판에서 30일간 변경 0건이면 "셀렉터가 조용히 깨졌을 가능성"을 의심한다(§5.7 의 검증과 교차 확인). +- ❌ **크롤 주기 변경에는 쓰지 않는다** (§4.3 균등 정책 유지). + +### 4.5 조건부 요청 — 서버가 알려주는 "변경 없음" + +λ 추정과 별개로, HTTP 레벨에서 변경을 저비용으로 판별하는 표준 수단이 있다. + +| 헤더 | 방향 | 의미 | +|---|---|---| +| `Last-Modified` | 응답 | 리소스 최종 수정 시각 | +| `ETag` | 응답 | 리소스 버전 식별자 | +| `If-Modified-Since` | 요청 | 이 시각 이후 변경됐을 때만 본문 전송 | +| `If-None-Match` | 요청 | 이 ETag 와 다를 때만 본문 전송 | +| `304 Not Modified` | 응답 | 변경 없음 — 본문 없음 | + +**정책 (확정)**: +- 저장해 둔 `ETag`/`Last-Modified` 가 있으면 **항상** 조건부 요청을 보낸다. +- **304 를 받아도 "변경 없음"으로 즉시 확정하지 않는다.** 이는 §4.6 의 noisy signal 원칙에 따른다. 304 는 "본문 다운로드를 생략해도 좋다"는 신호일 뿐이며, 정부 게시판의 CMS 가 `Last-Modified` 를 정확히 관리한다는 보장이 없다. **목록 페이지 1페이지만은 조건부 요청 없이 무조건 다시 받아 파싱한다.** +- 상세 페이지에 대해서는 304 를 신뢰해 다운로드를 생략하되, **주 1회(일요일)는 조건부 요청을 끄고 전체를 다시 받아 검증**한다. + +### 4.6 잡음 섞인 변경 신호를 어떻게 다룰 것인가 (Busa-Fekete et al. 2025) + +arXiv:2502.02430 의 문제의식: + +> sitemap·CDN 같은 side information 은 유용하지만 **오탐(false positive)이 있고 실제 갱신을 놓치기도 한다**. 기존 연구(Azar et al. 2018)는 변경·요청이 각각 독립 Poisson 과정이라는 이상화된 가정 위에 최적 스케줄을 유도했으나, 실제 신호는 잡음이 있다. + +**DMF 게시판에서의 noisy change-indicating signal 목록과 신뢰도 등급**: + +| 신호 | 취득 비용 | 신뢰도 | 정책 | +|---|---|---|---| +| 목록 페이지의 "총 게시물 수" 표시 | 매우 낮음 | 중 — 증가는 신뢰, **감소·동일은 불신**(수정은 총수를 안 바꾼다) | 증가 시 즉시 상세 크롤 트리거 | +| 목록 1페이지 최상단 게시일 | 매우 낮음 | 중 — 상단 고정 공고에 의해 왜곡됨 | 보조 신호로만 | +| `Last-Modified` / `ETag` | 낮음(HEAD 또는 조건부 GET) | **낮음** — 정부 CMS 는 동적 생성으로 매번 바뀌거나 아예 없는 경우가 많음 | 상세 페이지에만, 주 1회 전체 재검증 | +| RSS/Atom 피드(존재 시) | 낮음 | 중상 | 존재 여부 실측 필요 (부록 B) | +| 공공데이터포털/식의약 데이터 포털 OpenAPI | 낮음 | **높음** — 정형 데이터 | 존재 시 **1순위 소스로 전환**, 크롤링은 대조·보완용 (부록 B) | +| 목록 페이지 전체 파싱 결과 | 중 | **높음** | **최종 근거. 매일 무조건 수행.** | + +**확정 규칙**: 위 신호들은 **상세 페이지 크롤을 생략할지 결정하는 데만** 쓴다. "오늘은 신호가 없으니 크롤을 건너뛴다"는 절대 하지 않는다. 신호는 비용을 줄이고, 진실은 항상 목록 페이지 파싱이 정한다. + +### 4.7 자원 배분 이론이 준비해 둔 확장 경로 (Azar et al. 2018 / Kolobov et al. 2019) + +지금은 필요 없지만, 대상이 수십 개 게시판으로 늘어날 때를 위해 기록해 둔다. + +**Azar et al., PNAS 2018 의 정식화**: +- 페이지 i 의 Poisson 변경률 `Δᵢ`, 사용자 요청률 `μᵢ`, 총 폴링 대역폭 `R`. +- 목적: freshness 가중 효용 최대화(요청 시 최신 페이지를 제공). +- Algorithm 1(이산시간) / Algorithm 2(연속시간): **utility-to-change-rate 비로 정렬 후 할당 0인 페이지를 식별**해 유일한 최적 무작위 정책을 `O(n log n)` 에 계산. +- Algorithm 3: **EDF(earliest-deadline-first)** 로 비무작위화 → 실험에서 수치 최적해의 **99%** 달성. 확률적 할당을 실용적 순환 refresh 스케줄로 변환. + +**Kolobov et al., NeurIPS 2019**: 변경 관측이 부분적이고 파라미터를 모르는 상황에서도 최적성 보장이 있는 알고리즘. **18.5M URL 을 14주간 매일** 크롤한 실험으로 검증. + +→ **확장 트리거**: 대상 게시판 수가 10개를 넘고, 실행 시간이 politeness 제약 때문에 30분을 넘기 시작하면, 그때 `μᵢ`(사용자가 실제로 어느 탭을 보는가) × `Δᵢ`(추정 λ) 로 정렬해 상위 그룹만 하루 2회로 올린다. **그 전에는 하지 않는다.** + +--- + +## 5. 구조적 데이터 추출 — wrapper induction 부터 셀렉터 안정성까지 + +### 5.1 Wrapper induction 의 계보 + +``` +1997 Kushmerick, Weld, Doorenbos — Wrapper Induction for Information Extraction (IJCAI-97) + · LR wrapper: 문서를 문자 시퀀스로 보고 좌/우 구분자로 필드를 자름 + · hlrt 클래스: 효율적 학습 가능 + 조사 대상 인터넷 리소스의 48% 커버 + · PAC 분석으로 표본 복잡도 상한, 불완전 라벨링에 완만한 성능 저하 + ↓ +1998 Kushmerick — WIEN (AAAI Workshop WS-98-10) + ↓ +1999 Kushmerick — RAPTURE (AAAI-99) ★ wrapper verification 문제 정의 + ↓ +2001 Crescenzi, Mecca, Merialdo — RoadRunner (VLDB 2001) ★ 완전 자동, 2페이지 비교 + ↓ +2003 Lerman, Minton, Knoblock — Wrapper Maintenance (JAIR 18:149-181) ★ verification + reinduction + ↓ +2009 Dalvi, Bohannon, Sha — Robust web extraction (SIGMOD 2009) 확률적 tree-edit +2011 Dalvi, Kumar, Soliman — Automatic Wrappers for Large Scale Web Extraction (PVLDB 4(4):219-230) + ↓ +2016 Leotta, Stocco, Ricca, Tonella — ROBULA+ (JSEP 28(3):177-204) ★ 강건 XPath 생성 + ↓ +2024~ AutoScraper / AXE / Co-Scraper / LLM XPath agents — LLM 이 wrapper 생성자 자리를 차지 +``` + +**이 계보가 우리에게 주는 한 문장**: 30년간 이 분야의 모든 진보는 **"wrapper 를 누가 만드는가"** 를 바꿨을 뿐, **"실행은 결정론적 wrapper 가 한다"** 는 전제는 한 번도 바뀌지 않았다. LLM 시대의 최신 논문들(§6)도 여전히 wrapper 를 만들어 실행한다. + +### 5.2 RoadRunner — 페이지 2장 비교로 템플릿과 데이터 분리 + +VLDB 2001 원문(PDF 텍스트 추출로 확인)에서: + +**전제**: "Pages in data-intensive sites are usually automatically generated: data are stored in a back-end DBMS, and HTML pages are produced using scripts – i.e., programs – from the content of the database." + +**형식화 — union-free regular expression (UFRE)**: + +> "Given a special symbol `#PCDATA`, and an alphabet of symbols Σ not containing `#PCDATA`, a union-free regular expression (UFRE) over Σ is a string over alphabet Σ ∪ {`#PCDATA`, `·`, `+`, `?`, `(`, `)`} defined as follows. First, the empty string, ϵ and all elements of Σ ∪ {`#PCDATA`} are union-free regular expressions. If a and b are UFRE, then `a·b`, `(a)+`, and `(a)?` are UFRE." + +`(a)* = ((a)+)?` 는 축약. UFRE ↔ nested type 대응: `#PCDATA` → string 필드, `+` → 리스트(중첩 가능), `?` → nullable 필드. + +**문제 정식화**: HTML 문자열 s₁…s_k 가 nested type τ 의 인스턴스 i₁…i_k 의 인코딩이라면, **L(σ) ⊇ {s₁…s_k} 인 최소 UFRE σ 를 찾으면 τ = type(σ)** 이고, σ 를 wrapper 로 써서 원본 데이터를 복원할 수 있다. 따라서 문제는 **두 UFRE 의 least upper bound 계산** 으로 환원된다 → 알고리즘 `match(σ₁, σ₂)`. + +**ACME 매칭 기법 (Align, Collapse under Mismatch, and Extract)**: + +1. HTML 을 XHTML 로 정규화(태그가 제대로 닫히고 중첩되도록). "several tools are available to turn an HTML page into an XHTML one." +2. 어휘 분석기로 **토큰 리스트**(각 토큰은 HTML 태그 또는 문자열 값)로 변환. 논문 Figure 3 예시에서 두 HTML 샘플이 각각 20개, 27개 토큰으로 변환된다. +3. page 1 을 **초기 wrapper** 로 삼고 page 2 를 **sample** 로 파싱한다. +4. sample 의 토큰이 wrapper 문법에 맞지 않으면 **mismatch** 발생 → wrapper 를 일반화해 해소. +5. 모든 mismatch 를 해소하면 공통 wrapper 완성. + +**mismatch 두 종류와 처리**: + +| 종류 | 발생 조건 | 의미 | 처리 | +|---|---|---|---| +| **String mismatch** | wrapper 와 sample 의 대응 위치에 **다른 문자열** | 같은 클래스 페이지라면 **DB 필드 값 차이일 수밖에 없다** | 그 위치를 `#PCDATA` 로 일반화 = **필드 발견** | +| **Tag mismatch** | 다른 태그끼리, 또는 태그 vs 문자열 | **iterator 또는 optional** | ① 먼저 반복 패턴(iterator) 탐색 → ② 실패하면 optional 로 처리 | + +**String mismatch 예시(원문)**: 토큰 4에서 `'John Smith'` vs `'Paul Jones'` → wrapper(초기값 = page 1)의 `'John Smith'` 를 `#PCDATA` 로 치환. 몇 단계 뒤 `'Database Primer'` vs `'XML at Work'` 도 동일. **중요**: 토큰 2의 `'Books of:'` 처럼 **두 페이지에서 동일한 상수 문자열은 필드가 되지 않는다** — 생성 스크립트가 HTML 레이아웃의 일부로 넣은 것이다. + +**Tag mismatch → optional 처리 (원문)**: 토큰 6에서 wrapper 쪽 ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +11153: +11154: 성분명 +11160: +11212: 의약품 등 심사결과정보 공개 테이블 :: 순번, 대상의약품, 등록번호, 성분명, 신청인, 제조소명, 제조소소재지, 제조국가, 최초등록일자, 최종변경일자, 최종연차보고년도, 문서번호, 취소/취하구분, 취소/취하일자 +11247: >등록번호 +11255: >성분명 +11266: +11267: +=== all input/select in searchForm === + + + + + + + + + +=== JS bodies === + function goSearch() { + /*날짜 검증 1. 시작일자 유무, 2. 종료일자 유무*/ + var date_trMinDate = $('#date_trMinDate').val(); + var date_trMaxDate = $('#date_trMaxDate').val(); + + if(date_trMinDate == ""){ + alert("시작 검색일(발급일자)을 입력하세요"); + $('#date_trMinDate').focus(); + return false; + } + function getList(page_no){ + if (page_no) { + $('#searchForm #page').val(page_no); + isFirst = false; + } + function changePageSize(page_size) { + $('#searchForm #limit').val(page_size); + $('#searchForm').submit(); + } +=== nav CCBAC links === +=== footer copyright === +Copyright ⓒ Ministry of Food and Drug Safety. All Rights Reserved. +저작권정책 새창열림" target="_blank">저작권정책 +``` + +## [CMD] cd "C:/Users/encep/AppData/Local/Temp/claude/D--workspace-DMF-Crawler/c00474d0-f118-4917-817d-1c16591aefc7/scratchpad" && echo "=== getList page 2 limit 50 (GET) ===" && curl -sS -L --max-time 60 -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" -o p2.html -w "%{http_code} %{size_download}\n" "https:// +…[truncated 695 chars] + +``` +=== getList page 2 limit 50 (GET) === +200 501975 + + + +50 +=== excel POST test === +200 3575 application/download; UTF-8; charset=UTF-8 +Access-Control-Allow-Headers: Origin, Content-Type, content-type, Content-Style-Type, Accept, Authorization,DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,If-Modified-Since, x-requested-with, Content-Security-Policy, X-UA-Compatible, X-Content-Type-Options, X-FRAME-OPTIONS, Cache-Control, Pragma +Content-Disposition: attachment; filename="의약품등심사결과공개.xlsx"; +X-Content-Type-Options: nosniff +Content-Type: application/download; UTF-8; charset=UTF-8 +00000000: 504b 0304 1400 0808 PK...... +``` + +## [CMD] cd "C:/Users/encep/AppData/Local/Temp/claude/D--workspace-DMF-Crawler/c00474d0-f118-4917-817d-1c16591aefc7/scratchpad" && echo "=== EDQM export head ===" && curl -sS -L --max-time 120 -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" -D edqm_export_headers.txt -o edqm_export.txt -w "%{http_code} %{size +…[truncated 309 chars] + +``` +=== EDQM export head === +200 1459534 application/download +Content-Type: application/download +Content-Length: 1459534 +Monograph Number Substance Type CEP Certificate (CEP) Holder Holder SPOR ORG-ID / SPOR LOC-ID Certificate (CEP) Number Issue Date CEP Status CEP Renewal due End date CEP Closure Date of last Procedure +0 (6,7)-3-hydroxymethyl-7-(z-2-methoxyimino-2-(fur-2-yl)acetamido)ceph-3-em-4-carb, (640/2 sodium) TSE Glaxo Wellcome London GB R0-CEP 2000-275 - Rev 01 21/11/2001 Withdrawn by Holder 03/08/2006 03/08/2006 +0 1,2-dihydrotriamcinolone TSE Pharmacia & Upjohn Company Kalamazoo US R0-CEP 2001-231 - Rev 00 09/04/2002 Expired 09/04/2007 15/06/2007 +9435 edqm_export.txt +=== EDQM select options === +
1대상의약품별표1 + 등록번호 + 20260901-209-J-2270 + 성분명탐스로신염산염신청인주식회사지맥스파마켐제조소명
Hema Pharmaceuticals Pvt. Ltd.
제조소소재지
Plot No. 6201/A & B, G.I.D.C., Opp. EWAC Alloys, Ciyt – Ankleshwar – 393 002, Dist.- Bharuch, Gujarat State, India
제조국가
인도
최초등록일자2026-09-01최종변경일자최종연차보고년도취소/취하구분정상취소/취하일자문서번호v0.0.0/2026연계심사문서번호
2대상의약품별표1 + 등록번호 + 20260901-209-J-2268 + 성분명미분화부데소니드신청인㈜하이플제조소명
Avik Pharmaceutical Limited
제조소소재지
A-1/7 & A-1/8, 1ST Phase, G.I.D.C., City - Vapi - 396 195, Dist.- Valsad, Gujarat State, India
제조국가
인도
최초등록일자2026-09-01최종변경일자최종연차보고년도취소/취하구분정상취소/취하일자문서번호v0.0.0/2026연계심사문서번호
3대상의약품신물질 + 등록번호 + 수6580-16-ND(20) + 성분명프레가발린신청인엠피크코리아(주)515253
+ + + + + + + + + + + + + +
Subjects: + Software Engineering (cs.SE); Machine Learning (cs.LG)
Cite as:arXiv:2312.04687 [cs.SE]
 (or + arXiv:2312.04687v1 [cs.SE] for this version) +
  https://doi.org/10.48550/arXiv.2312.04687
+ + + +
+
+ + + +
+

Submission history

From: Allison Sullivan [view email]
[v1] + Thu, 7 Dec 2023 20:37:54 UTC (714 KB)
+
+ + +
+ + Full-text links: +

Access Paper:

+ + +
+
+

Current browse context:

+
cs.SE
+ +
+ + < prev + +   |   + next > +
+
+ new + | + recent + | 2023-12 +
+ Change to browse by: +
+ cs
+ cs.LG
+
+
+ +
+
+

References & Citations

+ +
+
+ +
+ + +
+ +
+

Bookmark

+ BibSonomy + + + Reddit + +
+ + +
+
+ +
+

Bibliographic and Citation Tools

+
+
+
+ +
+
+ Bibliographic Explorer (What is the Explorer?) +
+
+
+
+ +
+
+ Connected Papers (What is Connected Papers?) +
+
+
+ +
+
+ Litmaps (What is Litmaps?) +
+
+
+
+ +
+
+ scite Smart Citations (What are Smart Citations?) +
+
+
+ +
+
+
+
+ + + + +
+

Code, Data and Media Associated with this Article

+
+
+
+ +
+
+ alphaXiv (What is alphaXiv?) +
+
+ +
+
+ +
+
+ CatalyzeX Code Finder for Papers (What is CatalyzeX?) +
+
+ +
+
+ +
+
+ DagsHub (What is DagsHub?) +
+
+ +
+
+ +
+
+ Gotit.pub (What is GotitPub?) +
+
+ +
+
+ +
+
+ Hugging Face (What is Huggingface?) +
+
+ +
+
+ +
+
+ ScienceCast (What is ScienceCast?) +
+
+
+ + + + + + + +
+ + + + +
+

Demos

+
+
+
+ +
+
+ Replicate (What is Replicate?) +
+
+
+
+ +
+
+ Hugging Face Spaces (What is Spaces?) +
+
+
+
+ +
+
+ TXYZ.AI (What is TXYZ.AI?) +
+
+
+
+
+
+
+ + +
+

Recommenders and Search Tools

+
+
+
+ +
+
+ Influence Flower (What are Influence Flowers?) +
+
+
+
+ +
+
+ CORE Recommender (What is CORE?) +
+
+
+ +
+
+
+ + + +
+
+
+

arXivLabs: experimental projects with community collaborators

+

arXivLabs is a framework that allows collaborators to develop and share new arXiv features directly on our website.

+

Both individuals and organizations that work with arXivLabs have embraced and accepted our values of openness, community, excellence, and user data privacy. arXiv is committed to these values and only works with partners that adhere to them.

+

Have an idea for a project that will add value for arXiv's community? Learn more about arXivLabs.

+
+
+

+
+
+
+ +
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/research/_raw/tdd-red/arxiv-2505-09027-tests-as-prompt.raw.html b/docs/research/_raw/tdd-red/arxiv-2505-09027-tests-as-prompt.raw.html new file mode 100644 index 0000000..96c7a52 --- /dev/null +++ b/docs/research/_raw/tdd-red/arxiv-2505-09027-tests-as-prompt.raw.html @@ -0,0 +1,629 @@ + + + + [2505.09027] Tests as Prompt: A Test-Driven-Development Benchmark for LLM Code Generation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/research/_raw/tdd-red/arxiv-2506-09289-utboost.raw.html b/docs/research/_raw/tdd-red/arxiv-2506-09289-utboost.raw.html new file mode 100644 index 0000000..40a6b37 --- /dev/null +++ b/docs/research/_raw/tdd-red/arxiv-2506-09289-utboost.raw.html @@ -0,0 +1,633 @@ + + + + [2506.09289] UTBoost: Rigorous Evaluation of Coding Agents on SWE-Bench + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/research/_raw/tdd-red/arxiv-2602-07900-agent-generated-tests.raw.html b/docs/research/_raw/tdd-red/arxiv-2602-07900-agent-generated-tests.raw.html new file mode 100644 index 0000000..aabcc63 --- /dev/null +++ b/docs/research/_raw/tdd-red/arxiv-2602-07900-agent-generated-tests.raw.html @@ -0,0 +1,629 @@ + + + + [2602.07900] Rethinking the Value of Agent-Generated Tests for LLM-Based Software Engineering Agents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/research/_raw/tdd-red/arxiv-2603-08806-test-driven-ai-agent-definition.raw.html b/docs/research/_raw/tdd-red/arxiv-2603-08806-test-driven-ai-agent-definition.raw.html new file mode 100644 index 0000000..880008e --- /dev/null +++ b/docs/research/_raw/tdd-red/arxiv-2603-08806-test-driven-ai-agent-definition.raw.html @@ -0,0 +1,633 @@ + + + + [2603.08806] Test-Driven AI Agent Definition (TDAD): Compiling Tool-Using Agents from Behavioral Specifications + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/research/_raw/tdd-red/arxiv-2603-17973-tdad.raw.html b/docs/research/_raw/tdd-red/arxiv-2603-17973-tdad.raw.html new file mode 100644 index 0000000..79caf4f --- /dev/null +++ b/docs/research/_raw/tdd-red/arxiv-2603-17973-tdad.raw.html @@ -0,0 +1,639 @@ + + + + [2603.17973] TDAD: Test-Driven Agentic Development - Reducing Code Regressions in AI Coding Agents via Graph-Based Impact Analysis + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/research/_raw/tdd-red/gds-way-test-driven-development.raw.html b/docs/research/_raw/tdd-red/gds-way-test-driven-development.raw.html new file mode 100644 index 0000000..107b3ad --- /dev/null +++ b/docs/research/_raw/tdd-red/gds-way-test-driven-development.raw.html @@ -0,0 +1,290 @@ + + + + + + + + + + + Test-driven development (TDD) - The GDS Way + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ +
+ + + + + +
+ +
+ +
+ + +
+
+
+

The GDS Way and its content is intended for internal use by the GDS Digital Products community.

+
+ + +

Test-driven development (TDD)

+

GDS advocates for agile software development practices because we believe that flexibility and responsiveness-to-change are key to building software that meets the needs of our users.

+

Test-driven development (TDD) is a core agile software development practice which aims to build flexibility and correctness into production software.

+

The process starts with a set of desired behaviours for a piece of code. This list could include happy-path behaviours, error cases, and interaction points with other components.

+

Red–Green–Refactor

+

The inner-loop of TDD is also commonly referred to as “Red-Green-Refactor”; red to represent a failing test, green to represent all passing tests, and refactoring only when all tests are passing.

+

+

Red - write a failing test

+

Turn exactly one item on the list into an actual, concrete, runnable test. This test should fail when you run it, and it should fail in the way you expect.

+

If it fails unexpectedly, that could indicate a functionality gap or a misimplementation. For example, setting a field as immutable when you expect to mutate it on later interactions.

+

Green - make all tests pass

+

Change the code to make all tests, including the new test, pass. If you discover that you are missing a behaviour or test, add it to the list.

+

Tests should pass in a timely manner and slow-running tests should be investigated in the refactoring stage. You may choose to reduce the size of your test case or decompose the functionality into multiple, separately testable, components.

+

Refactor - iterate on your design

+

Improve the design and implementation of the code by removing duplication, utilising well known patterns and structures.

+

This is where the majority of software design happens. You have code that behaves as specified in the tests and that is your safety net for iterating the models and structures of the code to best represent the software’s needs at that particular time.

+

You should also let yourself be guided by the tests you’ve written; if a test is hard to write or you find yourself having to update many tests to support a new test without using automated refactoring tools, then take a step back examine why.

+

These steps are repeated until your set of behaviours is exhausted.

+

Why we recommend it

+

The advantage of writing the tests before the implementation is that the behaviour of the code you will write is defined first, rather than implementing the change and retroactively applying tests.

+

A test-first approach gives you quick feedback on the design of your code - if your code is becoming difficult to test or has many dependencies, these are signals that you likely need to refactor.

+ +
+

For each desired change, make the change easy (warning: this may be hard), then make the easy change - Kent Beck

+
+

TDD naturally lends itself to our recommended approach to breaking down work. If you are careful about introducing feature toggles so that code can be “dark launched” into production environments, TDD can give you a robust and reliable iterative workflow.

+

For example, if you are defining a new component, you could first define the interface, integrate a “do nothing” or noop version of that component into the live codebase, and then iterate on that implementation or another implementation safely.

+

TDD is also a great tool for cultivating a testing mindset in teams. Practicing these techniques and concepts will teach how to approach a problem with the curiosity (what does this code do if I give it these inputs?) and skepticism (does this code really do what it’s supposed to?) necessary for building robust and resilient user-facing software.

+

What if we can’t do TDD?

+

The most valuable output of the TDD process is not the code itself but the tight feedback loops that allow you to reflect and the iterative design decisions that you make along the way.

+

If doing what Kent Beck calls canon TDD is not possible, for whatever reason, that’s okay as long as you have another mechanism for realising the fast feedback and incremental delivery advantages that TDD does provide.

+

Beyond TDD

+

TDD works best when the feedback loops are fast. For unit testing, which should be subsecond, and component integration testing, the TDD process is very effective.

+

However, it is less effective for testing processes with longer feedback loops such as feature acceptance testing across multiple components, especially if you are unable to run those tests locally before pushing your code.

+

As systems get more complex, we usually cannot enumerate and emulate all possible interactions within unit and integration tests. Exploratory testing, done collaboratively and built on a solid foundation of fast tests, can help a team tease out unknown or unexpected behaviours of their system.

+

Useful resources

+

Getting started

+

If you’re struggling to write your first test, consider the approach of Zero-One-Many: start with the empty case, then consider the singular case, then generalise to many kinds of inputs.

+

For example, if you were processing some kind of file input, handle the empty case first (what if the file has no lines?), then the singular case (what if the file has a single line?), and then generalise.

+

Getting better

+

TDD is a skill like any other, and one which you can develop your fluency with through practice.

+

Try working through some example problems while adding a constraint such as TDD-As-If-You-Meant-It, where you can only write code in your test package, and any production code must be refactored out rather than written directly. This constraint reframes the TDD approach by forcing you to write tests first rather than retrofitting them onto the existing production code.

+

You could also look at GeePaw Hill’s Many More Much Smaller Steps, which advocates for framing work as a composition of a lot of smaller, and therefore safer, changes than a few large, and therefore risky, changes.

+

Books and blogposts

+ + + + +
+
+ This page was last reviewed on 20 August 2026. + + It needs to be reviewed again on 20 August 2027 + by the page owner #gds-way + . +
+ + +
+ +
+ + +
+
+ + + + + + + + + diff --git a/docs/research/_raw/tdd-red/google-testing-blog-way-of-tdd-2026.raw.html b/docs/research/_raw/tdd-red/google-testing-blog-way-of-tdd-2026.raw.html new file mode 100644 index 0000000..44d5c4c --- /dev/null +++ b/docs/research/_raw/tdd-red/google-testing-blog-way-of-tdd-2026.raw.html @@ -0,0 +1,5923 @@ + + + + + +Google Testing Blog: The Way of TDD + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+
+
+
+

+ +

+
+
+ +
+
+
+
+ + +
+
+ + +
+ + +
+
+ +
+
+
+
+
+ +
+ + +
+
+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/docs/research/_raw/tdd-red/ieee-what-do-we-know-tdd.raw.html b/docs/research/_raw/tdd-red/ieee-what-do-we-know-tdd.raw.html new file mode 100644 index 0000000..e69de29 diff --git a/docs/research/_raw/tdd-red/informit-kent-beck-tdd-by-example.raw.html b/docs/research/_raw/tdd-red/informit-kent-beck-tdd-by-example.raw.html new file mode 100644 index 0000000..fb11f34 --- /dev/null +++ b/docs/research/_raw/tdd-red/informit-kent-beck-tdd-by-example.raw.html @@ -0,0 +1,644 @@ +Test Driven Development: By Example | InformIT + + + + + + + + + +
+ + + + + + + +
+ +

+ Home + > + Store +

+
+
+ +
+ Test Driven Development: By Example +
+ + +
+
+

+ Register your product + to gain access to bonus material or receive a coupon. +

+ + + +
+
+
+
+

+ Test Driven Development: By Example +

+
+ + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Best Value Purchase

+
+

+Book + eBook Bundle

+
    +
  • Your Price: $56.79
  • +
  • List Price: $97.98
  • +
  • Includes EPUB and PDF
  • +
  • + About eBook Formats +
  • +
    +

    + This eBook includes the following formats, accessible from your Account page after purchase: +

    +

    + + ePub + EPUB + The open industry format known for its reflowable content and usability on supported mobile devices. +

    +

    + + Adobe Reader + PDF + The popular standard, used most often with the free Acrobat® Reader® software. +

    +

    This eBook requires no passwords or activation to read. We customize your eBook by discreetly watermarking it with your name, making it uniquely yours.

    + +
    +
+ + +
+

More Purchase Options

+
+

+Book

+
    +
  • Your Price: $39.99
  • +
  • List Price: $49.99
  • +
  • Usually ships in 24 hours.
  • +
+ +
+ + + +
+

+eBook

+
    +
  • Your Price: $38.39
  • +
  • List Price: $47.99
  • +
  • Includes EPUB and PDF
  • +
  • + About eBook Formats +
  • +
    +

    + This eBook includes the following formats, accessible from your Account page after purchase: +

    +

    + + ePub + EPUB + The open industry format known for its reflowable content and usability on supported mobile devices. +

    +

    + + Adobe Reader + PDF + The popular standard, used most often with the free Acrobat® Reader® software. +

    +

    This eBook requires no passwords or activation to read. We customize your eBook by discreetly watermarking it with your name, making it uniquely yours.

    + +
    +
+
+ Add to cart + + +
+ +
+ + + + + +
+
+
+ + + + +
+ + +
+
+
+ + + + + + +
+ +
+
+
+

About

+

Features

+

Test Driven Development (TDD) is Kent Beck's latest focus; the approach is proven to reduce defects and produce more robust software.

° Write clean code that works with the help of this groundbreaking software method

° Begin to write automated tests that allow you to "test on the fly," and learn to optimize the practice of refactoring

° Example-driven teaching; Kent Beck's step-by-step instruction will have you using TDD to further your projects

+ +
+
+

Description

+ + +
    +
  • Copyright 2003
  • +
  • Dimensions: 7-3/8" x 9-1/4"
  • +
  • Pages: 240
  • +
  • Edition: 1st
  • +
+
    +
  • + +Book +
  • +
  • ISBN-10: 0-321-14653-0
  • +
  • ISBN-13: 978-0-321-14653-3
  • +
+
+ + + +

Quite simply, test-driven development is meant to eliminate fear in application development. While some fear is healthy (often viewed as a conscience that tells programmers to "be careful!"), the author believes that byproducts of fear include tentative, grumpy, and uncommunicative programmers who are unable to absorb constructive criticism. When programming teams buy into TDD, they immediately see positive results. They eliminate the fear involved in their jobs, and are better equipped to tackle the difficult challenges that face them. TDD eliminates tentative traits, it teaches programmers to communicate, and it encourages team members to seek out criticism However, even the author admits that grumpiness must be worked out individually! In short, the premise behind TDD is that code should be continually tested and refactored. Kent Beck teaches programmers by example, so they can painlessly and dramatically increase the quality of their work.

+ +
+ +
+

Sample Content

+

+ Online Sample Chapter +

+

+ + Test Driven Development: Equality for All + +

+

Downloadable Sample Chapter

+

Click below for Sample Chapter(s) related to this title:
Sample Chapter + 3

+

+

Sample Pages

+

Download the sample pages (includes Chapter 3 and Index)

+

Table of Contents

+


Preface.
Acknowledgments.
Introduction.

I. THE MONEY EXAMPLE.

1. Multi-Currency Money.


2. Degenerate Objects.


3. Equality for All.


4. Privacy.


5. Franc-ly Speaking.


6. Equality for All, Redux.


7. Apples and Oranges.


8. Makin' Objects.


9. Times We're Livin' In.


10. Interesting Times.


11. The Root of All Evil.


12. Addition, Finally.


13. Make It.


14. Change.


15. Mixed Currencies.


16. Abstraction, Finally.


17. Money Retrospective.


II. The xUnit Example.

18. First Steps to xUnit.


19. Set the Table.


20. Cleaning Up After.


21. Counting.


22. Dealing with Failure.


23. How Suite It Is.


24. xUnit Retrospective.


III. Patterns for Test-Driven Development.

25. Test-Driven Development Patterns.


26. Red Bar Patterns.


27. Testing Patterns.


28. Green Bar Patterns.


29. xUnit Patterns.


30. Design Patterns.


31. Refactoring.


32. Mastering TDD.


Appendix I: Influence Diagrams.


Appendix II: Fibonacci.


Afterword.


Index. 0321146530T10172002

+

Preface

+

“Clean code that works” is Ron Jeffries’ pithy phrase. The goal is clean code that works, and for a whole bunch of reasons:

  • Clean code that works is a predictable way to develop. You know when you are finished, without having to worry about a long bug trail.
  • Clean code that works gives you a chance to learn all the lessons that the code has to teach you. If you only ever slap together the first thing you think of, you never have time to think of a second, better, thing.
  • Clean code that works improves the lives of users of our software.
  • Clean code that works lets your teammates count on you, and you on them.
  • Writing clean code that works feels good.But how do you get to clean code that works? Many forces drive you away from clean code, and even code that works. Without taking too much counsel of our fears, here’s what we do—drive development with automated tests, a style of development called “Test-Driven Development” (TDD for short).

    In Test-Driven Development, you:

  • Write new code only if you first have a failing automated test.
  • Eliminate duplication.

    Two simple rules, but they generate complex individual and group behavior. Some of the technical implications are:

  • You must design organically, with running code providing feedback between decisions
  • You must write your own tests, since you can’t wait twenty times a day for someone else to write a test
  • Your development environment must provide rapid response to small changes
  • Your designs must consist of many highly cohesive, loosely coupled components, just to make testing easy

    The two rules imply an order to the tasks of programming:
    1. Red—write a little test that doesn’t work, perhaps doesn’t even compile at first
    2. Green—make the test work quickly, committing whatever sins necessary in the process
    3. Refactor—eliminate all the duplication created in just getting the test to work

    Red/green/refactor. The TDD’s mantra.

    Assuming for the moment that such a style is possible, it might be possible to dramatically reduce the defect density of code and make the subject of work crystal clear to all involved. If so, writing only code demanded by failing tests also has social implications:

  • If the defect density can be reduced enough, QA can shift from reactive to pro-active work
  • If the number of nasty surprises can be reduced enough, project managers can estimate accurately enough to involve real customers in daily development
  • If the topics of technical conversations can be made clear enough, programmers can work in minute-by-minute collaboration instead of daily or weekly collaboration
  • Again, if the defect density can be reduced enough, we can have shippable software with new functionality every day, leading to new business relationships with customers

    So, the concept is simple, but what’s my motivation? Why would a programmer take on the additional work of writing automated tests? Why would a programmer work in tiny little steps when their mind is capable of great soaring swoops of design? Courage.

    Courage

    Test-driven development is a way of managing fear during programming. I don’t mean fear in a bad way, pow widdle prwogwammew needs a pacifiew, but fear in the legitimate, this-is-a-hard-problem-and-I-can’t-see-the-end-from-the-beginning sense. If pain is nature’s way of saying “Stop!”, fear is nature’s way of saying “Be careful.” Being careful is good, but fear has a host of other effects:

  • Makes you tentative
  • Makes you want to communicate less
  • Makes you shy from feedback
  • Makes you grumpy

    None of these effects are helpful when programming, especially when programming something hard. So, how can you face a difficult situation and:

  • Instead of being tentative, begin learning concretely as quickly as possible.
  • Instead of clamming up, communicate more clearly.
  • Instead of avoiding feedback, search out helpful, concrete feedback.
  • (You’ll have to work on grumpiness on your own.)

    Imagine programming as turning a crank to pull a bucket of water from a well. When the bucket is small, a free-spinning crank is fine. When the bucket is big and full of water, you’re going to get tired before the bucket is all the way up. You need a ratchet mechanism to enable you to rest between bouts of cranking. The heavier the bucket, the closer the teeth need to be on the ratchet.

    The tests in test-driven development are the teeth of the ratchet. Once you get one test working, you know it is working, now and forever. You are one step closer to having everything working than you were when the test was broken. Now get the next one working, and the next, and the next. By analogy, the tougher the programming problem, the less ground should be covered by each test.

    Readers of Extreme Programming Explained will notice a difference in tone between XP and TDD. TDD isn’t an absolute like Extreme Programming. XP says, “Here are things you must be able to do to be prepared to evolve further.” TDD is a little fuzzier. TDD is an awareness of the gap between decision and feedback during programming, and techniques to control that gap. “What if I do a paper design for a week, then test-drive the code? Is that TDD?” Sure, it’s TDD. You were aware of the gap between decision and feedback and you controlled the gap deliberately.

    That said, most people who learn TDD find their programming practice changed for good. “Test Infected” is the phrase Erich Gamma coined to describe this shift. You might find yourself writing more tests earlier, and working in smaller steps than you ever dreamed would be sensible. On the other hand, some programmers learn TDD and go back to their earlier practices, reserving TDD for special occasions when ordinary programming isn’t making progress.

    There are certainly programming tasks that can’t be driven solely by tests (or at least, not yet). Security software and concurrency, for example, are two topics where TDD is not sufficient to mechanically demonstrate that the goals of the software have been met. Security relies on essentially defect-free code, true, but also on human judgement about the methods used to secure the software. Subtle concurrency problems can’t be reliably duplicated by running the code.

    Once you are finished reading this book, you should be ready to:

  • Start simply
  • Write automated tests
  • Refactor to add design decisions one at a time

    This book is organized into three sections.

  • An example of writing typical model code using TDD. The example is one I got from Ward Cunningham years ago, and have used many times since, multi-currency arithmetic. In it you will learn to write tests before code and grow a design organically.
  • An example of testing more complicated logic, including reflection and exceptions, by developing a framework for automated testing. This example also serves to introduce you to the xUnit architecture that is at the heart of many programmer-oriented testing tools. In the second example you will learn to work in even smaller steps than in the first example, including the kind of self-referential hooha beloved of computer scientists.
  • Patterns for TDD. Included are patterns for the deciding what tests to write, how to write tests using xUnit, and a greatest hits selection of the design patterns and refactorings used in the examples.

    I wrote the examples imagining a pair programming session. If you like looking at the map before wandering around, you may want to go straight to the patterns in Section 3 and use the examples as illustrations. If you prefer just wandering around and then looking at the map to see where you’ve been, try reading the examples through and refering to the patterns when you want more detail about a technique, then using the patterns as a reference.

    Several reviewers have commented they got the most out of the examples when they started up a programming environment and entered the code and ran the tests as they read.

    A note about the examples. Both examples, multi-currency calculation and a testing framework, appear simple. There are (and I have seen) complicated, ugly, messy ways of solving the same problems. I could have chosen one of those complicated, ugly, messy solutions to give the book an air of “reality.” However, my goal, and I hope your goal, is to write clean code that works. Before teeing off on the examples as being too simple, spend 15 seconds imagining a programming world in which all code was this clear and direct, where there were no complicated solutions, only apparently complicated problems begging for careful thought. TDD is a practice that can help you lead yourself to exactly that careful thought.



    0321146530P08142002

    +

    Index

    +

    +

    Click below to download the Index file related to this title:
    Index
    +

    +

    + +
  • +
    +

    Updates

    +

    Submit Errata

    + +
    + +
    +

    More Information

    + +
    +
    + + +
    + +
    + + + + + +
    + +
    + +
    +
    + + +
    +
    +
    +

    InformIT Promotional Mailings & Special Offers

    + + + +
    +
    +

    + I would like to receive exclusive offers and hear about products from InformIT and its family of brands. I can unsubscribe at any time. +

    +
    + +
    + + + + +
    + +
    +
    +
    +
    +
    + +
    + +
    + + +
    +
    + \ No newline at end of file diff --git a/docs/research/_raw/tdd-red/nng-form-placeholders.raw.html b/docs/research/_raw/tdd-red/nng-form-placeholders.raw.html new file mode 100644 index 0000000..8bdf942 --- /dev/null +++ b/docs/research/_raw/tdd-red/nng-form-placeholders.raw.html @@ -0,0 +1,1682 @@ + + + + + + + + + + Placeholders in Form Fields Are Harmful - NN/G + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + +
    +
    +
    +
    + + +
    +

    Placeholders in Form Fields Are Harmful

    + +
    + + +

    Share

    +
    + +
    +
    + +
    +
    + +
    + + Summary:  + Placeholder text within a form field makes it difficult for people to remember what information belongs in a field, and to check for and fix errors. It also poses additional burdens for users with visual and cognitive impairments. +
    + +
    +

    In-context descriptions or hints can help clarify what goes inside each form field, and therefore improve completion and conversion rates. There are many ways to provide hints. A common implementation is by inserting instructions within form fields. Unfortunately, user testing continually shows that placeholders in form fields often hurt usability more than help it.

    +

    Labels and Placeholders

    +

    Labels tell users what information belongs in a given form field and are usually positioned outside the form field. Placeholder text, located inside a form field, is an additional hint, description, or example of the information required for a particular field. These hints typically disappear when the user types in the field.

    +

    Labels and placeholders

    +

    Placeholders that Replace Labels

    +

    Some forms replace field labels with in-field placeholder text to reduce clutter on the page, or to shorten the length of the form. While this approach is based on good intentions, our research shows that it has many negative consequences.

    +
    +
    Placeholder as label +
    Worst: In this example, placeholder text is used instead of a label.
    +
    +
    +

    Below are 7 main reasons why placeholders should not be used as replacements for field labels.

    +

    1. Disappearing placeholder text strains users’ short-term memory.

    +

    If the user forgets the hint, which people often do while filling out long forms, he has to delete what he wrote and, in some cases, click away from the field to reveal the placeholder text again. In an ideal world, users would be entirely focused when filling out a form. But in reality, users multitask. They have different tabs open, or they might be pulled away by an email or phone call. For complex tasks, they might have to stop and go retrieve a document or order number. From our research on mobile usability, we know that mobile users are also frequently distracted and interrupted while using their devices. So, it’s important to help users pick up where they left off.
    +
    +On simple, frequently used forms with one or two fields, such as a search box or login form, the strain on memory is less of an issue than with complex or rarely used forms. That is because with simple, familiar forms, users can guess what they are supposed to enter. Although, on even a simple login form without labels, users may not remember if they have the option to type Username or Email or just Username.

    +

    2. Without labels, users cannot check their work before submitting a form.

    +

    The lack of labels makes it impossible for customers to glance through the form and make sure that their responses are correct. Similarly, browsers that autocomplete form fields may fill in information incorrectly. If there are no labels, or if special instructions are no longer visible, customers must reveal the placeholder text by deleting the text in each field one by one in order verify that it matches the description. Realistically though, many won’t even realize that potential for error, and they won’t make the effort to double check.

    +

    3. When error messages occur, people don’t know how to fix the problem.

    +

    If the form has been filled out, but there are no labels or instructions visible outside the form fields, then users have to go back to each field to reveal the description in order to fix the error.

    +

    4. Placeholder text that disappears when the cursor is placed in a form field is irritating for users navigating with the keyboard.

    +

    People using the Tab key move quickly from field to field, and they don’t stop to study the next field before tabbing to it.

    +

    5. Fields with stuff in them are less noticeable.

    +

    Eyetracking studies show that users’ eyes are drawn to empty fields. At the minimum, users will spend more time locating a non-empty field — a nuisance. At the worst, they will overlook the field completely—a potential business-killing disaster.

    +

    6. Users may mistake a placeholder for data that was automatically filled in.

    +

    When there is already text in the field, people are less likely to realize that they can type there. Some users assume the placeholder text is a default value and skip the field completely.

    +

    7. Occasionally users have to delete placeholder text manually.

    +

    Sometimes placeholders do not disappear when users move their input focus into the field. If the placeholder remains in the field as editable text, users are forced to manually select and delete it. This creates an unnecessary burden on users and increases the interaction cost of filling in the form.
    +
    +Sometimes the placeholder dims when the cursor is placed in a text field. Unfortunately, this interaction pattern is rare and users are not familiar with it: some still think they have to delete the text manually. It often takes a few failed attempts and lots of clicking to realize that they can start typing over the dimmed text.

    +

    Placeholder Text in Addition to Labels

    +

    Using placeholder text in combination with form labels is a step in the right direction. Labels outside the form fields make the essential information visible at all times, while placeholder text inside form fields is reserved for supplementary information. However, even when using labels, placing important hints or instructions within a form field can still cause the 7 issues mentioned above, albeit with less severity. If some of the fields require an extra description that is essential to completing the form correctly, it’s best to place that text outside the field so that it is always visible. 

    +
    +
    Label outside, placeholder inside +
    Better: Here, placeholder text is used as a hint in addition to the label.
    +
    +
    +

    Placeholders and Accessibility

    +

    One last issue to consider is that placeholder text is generally bad for accessibility. Certainly, accessibility software and modern browsers are improving, but they still have a long way to go. Three of the biggest problems for accessibility are as follows:

    +
      +
    1. The default light-grey color of placeholder text has poor color contrast against most backgrounds. For users with a visual impairment, poor color contrast makes it difficult to read the text. Because not all browsers allow placeholder text to be styled using CSS, this is a difficult issue to mitigate.
    2. +
    3. Users with cognitive or motor impairments are more heavily burdened.  As we saw, placeholders can be problematic for all users: disappearing placeholders increase the memory load; persistent dimmable placeholders cause confusion when they look clickable but aren’t, and placeholders that do not disappear require more keyboard or mouse interaction to be deleted. These difficulties are magnified for people with cognitive or motor impairments.
    4. +
    5. Not all screen readers read placeholder text aloud. Blind or visually impaired users may miss the hint completely if their software does not speak the placeholder content.
    6. +
    +

    Floating Labels

    +

    Rooted in minimalist web design, the floating-label pattern is a modified approach to placeholders that mitigates some of the disadvantages of traditional placeholders. This pattern has been around for years, but it has finally made way onto mainstream websites, and it has even been officially embraced by Google's Material Design.

    +

    In this pattern, labels are placed within the form field as placeholders until the field becomes active and the user moves the input focus into the field. At that point, the placeholder label moves to the top of the field. As a result, the floating label (also known as an adaptive placeholder) is always visible, either in the center of the form field, or above the text that the user entered. 

    +
    Placeholder label 'First name' rises to the top of the field on input focus +
    Good: Floating labels move to the top of the form field when the user selects it to start typing (Warbyparker.com).
    +
    +

    There are two main advantages to this approach:

    +
      +
    • It can save space on mobile devices, by not requiring extra vertical space to put the label above the field.
    • +
    • The visible label serves as a memory aid while people are in the typing stage. This therefore addresses points 1-4 from the list of pitfalls above.
    • +
    +

    However, issues #5 and #6 from above are still a problem: fields with text in them are less noticeable, and users might think there is already a default value entered in the field. Also, the accessibility issues described earlier may still apply, because some browsers and assistive technologies don’t properly or reliably read placeholder text.

    +

    Ultimately, floating labels do offer a better user experience than the label as a placeholder. But if you have the screen space, placing the label and hint outside the field is still the best way to go.  

    +

    Conclusion

    +

    Rather than risk having users stumble while filling out forms or waste valuable time figuring out how they work, the best solution is to have clear, visible labels that are placed outside empty form fields.

    +
    +
    Label and placeholder text outside form field +
    Best: The label and hint are placed outside the form field and are always visible to the user.
    +
    +
    +

    Hints and instructions should also be persistent and placed outside of the field. Forms are an important part of many conversion goals, so it’s worthwhile to make sure that your users can get through them quickly and accurately.

    +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/research/_raw/tdd-red/nng-form-white-space.raw.html b/docs/research/_raw/tdd-red/nng-form-white-space.raw.html new file mode 100644 index 0000000..65bdc4a --- /dev/null +++ b/docs/research/_raw/tdd-red/nng-form-white-space.raw.html @@ -0,0 +1,1556 @@ + + + + + + + + + + Group Form Elements Effectively Using White Space - NN/G + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + +
    +
    +
    +
    + + +
    +

    Form Design Quick Fix: Group Form Elements Effectively Using White Space

    + +
    + + +

    Share

    +
    + +
    +
    + +
    +
    + +
    + + Summary:  + Improve the layout of your online forms by placing form labels near the associated text field and by grouping similar fields. +
    + +
    +

    Imagine you had to fill out this form:

    +

    4 field form about breakfast with equal spacing between label and text field

    +

    When looking at the form, you may hesistate for a second, because it's not immediately obvious what information to enter in each field. 

    +

    From our research studies, we've learned that even the slightest moment of hesitation when completing a form can significantly hurt the form’s response rate (that is, the number of people who complete the form).  

    +

    Forms are just a means to an end. Users should be able to complete forms quickly and without confusion. The following two tips show how using white space effectively will help reduce users' response time and the effort spent to determine what information is required where.

    +

    Tip #1: Place the label closer to the associated text field than to other text fields.

    +

    In the form above, the spacing between the label and the corresponding field makes it difficult to determine what to enter where. We often see this mistake in form design. Just remember this principle: items near each other appear related.

    +

    As you can see below, by placing the field labels close to the corresponding fields, we’ve increased your confidence in filling out each element.

    +

    4 field form about breakfast with more spacing between fields than between labels and fields

    +

    This principle of placing related items closer to each other isn't new; it's actually the Law of Proximity from Gestalt psychology. Gestalt psychologists were concerned with how and why the brain perceives an object as a whole rather than as a sum of individual parts. They came up with a several laws explaining how people organize visual information; the Law of Proximity was one of these.

    +

    In the next image, how many groups do you see?

    +

    20 dots arranged into two groups: one of 12 dots and one of 8

    +

    Instead of seeing 20 individual dots, we see two groups: one of 12 dots and one of 8. This is a shortcut our brain takes when processing visual information and is in fact another example of the Law of Proximity. Based on the same Gestalt principle, users might overlook other interface elements if they are placed too far from the object they act on.

    + +

    Long forms, with many fields, can feel overwhelming. Grouping related fields together helps users make sense of the information that they must fill in. For example, the name and the date of birth could be Personal Information, while the address and the phone number could form the group Contact Information.

    +

    Here’s what Tip #2 looks like in practice: Compare the signup form from Walgreens on the left to our version on the right.

    +

    Comparison of original Walgreens.com registration form to one recreated by NN/g with three groupings: Personal information, Account information, and Contact Information. Each with 4-6 fields.
    +The recreated Walgreens.com registration form (right) is easier to complete than the original (left) because related fields are grouped together, making it seem like 3 short forms.

    +

    Increasing the white space between form elements makes this 15-field form less overwhelming and therefore more likely to be completed. Users now see a 3-part form with 4–6 fields. You don’t need group headings, but they can help provide context.

    +

    A Note on Label Alignment

    +

    In the Walgreens example above, the field labels are correctly placed close to the text fields; however, the right-aligned labels make it more difficult to scan the form, for the same reasons for which right-aligned menu items impede scannability

    +

    We recommend placing field labels above the corresponding text fields. Although this increases the form's overall length, it makes the form easier to scan, because users can see the text field in the same fixation as the label. Top placement also allows for longer field labels, as horizontal space isn't an issue.

    +

    If form length is a concern, you can place field labels to the left of the text fields. However, make sure that labels are of similar length and are placed as close to the text fields as possible. If the labels are too far to the left, it can be difficult to associate the correct label with its corresponding field, as in the following donation form from the San Diego Zoo. (More about design for online donations.)

    +

    Form with field labels left aligned and far from corresponding text fields
    +It is difficult to determine what information to enter in each field on the San Diego Zoo donation form, because field labels are placed too far from the corresponding text field. 

    +

    You might be wondering, "if proximity is such a problem, then why not place the labels inside the text fields?". Don't be tempted. When you place the labels inside the text field as filler text, the label disappears when users input their text and they must remember it as they fill in the field. This especially causes an issue if users use the Tab key to move through a form. When they tab to the next field without looking, they will miss what information is required. Also, users are drawn to open text fields; they might mistake the filler text as a default answer and skip that form element.

    +

    Conclusion

    +

    Users can be hesitant to fill out forms online, so you want to make this process as easy as possible. Minor changes — such as using white space effectively to group related fields and indicating what information goes in each field — can significantly increase form usability.

    +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/research/_raw/tdd-red/nng-good-visual-design.raw.html b/docs/research/_raw/tdd-red/nng-good-visual-design.raw.html new file mode 100644 index 0000000..a2acf06 --- /dev/null +++ b/docs/research/_raw/tdd-red/nng-good-visual-design.raw.html @@ -0,0 +1,1704 @@ + + + + + + + + + + Good Visual Design, Explained - NN/G + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + +
    +
    +
    +
    + + +
    +

    Good Visual Design, Explained

    + +
    + + +

    Share

    +
    + +
    +
    + +
    +
    + +
    + + Summary:  + To create appealing designs, align type and elements to a grid, build a clear visual hierarchy, use color intentionally, and stay consistent with every design choice. +
    + +
    +

    In this 3rd article in the anatomy-of-good-design series, I explain visual-design principles that contribute to good-looking designs, with real-site examples. How something looks does affect the perception of how well it functions.

    +

    Visual Principle: Grid Use and Alignment

    +

    Grids help designers create cohesive layouts, allowing end users to easily scan interfaces. A good grid adapts to various screen sizes and orientations, ensuring consistency across platforms. Grids are made up of columns with gutters between them. While there is no ideal gutter size, different gutter sizes are suited to different purposes.

    +

    Example: 3-Column Grid Base with Thin Gutters

    +

    This page from Flamingo Estate uses a 3-column grid, with thin gutters.

    +

    The product images and content align to the columns, providing a predictable and organized shopping experience for end users. The thin gutters provide just enough space to distinguish between the products, and work well because the product images are quite airy.

    +
    +
    Flamingo Estate's product page, split into a 3 using a purple 3 column overlay. +
    Flamingo Estate uses a 3-column grid with narrow gutters to organize elements.
    +
    +
    +

    Example: 4-Column Grid Base with Wide Gutters

    +

    This page from Figma Shortcut uses a 4-column grid with wide gutters. Text and other elements can span multiple columns, making them appear as one wide column. It's common for content to extend into the gutter when spanning multiple columns.

    +
    +
    A article page split by a 4 column grid overlay revealing the text lines up with the center two columns and the gutter with the outer two. +
    Figma Shortcut uses a 4-column grid with wide gutters to organize elements.
    +
    +
    +

    The main body text is aligned to the middle two columns, while extra text (like callouts) is anchored in the left and right columns (in the image below, the left column has been cropped out, as it does not contain any callouts). This ample separation between callouts on the right and the main body text in the center helps create a readable design. 

    +
    +
    In the 4 column grid layout, the right-most column (which is also the gutter) holds call out text. +
    Figma Shortcut: Callouts are aligned in the right column.
    +
    +
    +

    Be sure to use a grid in your designs to keep alignment consistent across different pages and elements.

    +

    Visual Principle: Use of a Typographic System

    +

    Typography is a key component of every design. It not only can increase webpage legibility (and therefore, usability) but can also make a design feel polished. Let’s break down a couple of examples. 

    +

    Example: Hierarchy Created Through Typography

    +

    This example is from Seed.com, a probiotic company. Except for the phrase Viacap technology, this design uses one font family (small caps, medium, regular) and uses white for all text color. The font is clean and easy to read, allowing the user to focus on the content, rather than the typography. 

    +

    Despite using only one text color and a font family with minimal visual differences across regular and medium weights, the design still achieves text hierarchy. This is accomplished through size. 

    +
      +
    • The largest size (Most probiotics don’t survive digestion) calls the most attention and, therefore, is the element your eye reads first. 
    • +
    • The middle size, which signals medium importance, is the text Increases healthy digestion by 4.6x.
    • +
    • The smallest text size, which breaks down the capsule design, is less vital to be read by the user. 
    • +
    +

    As a rule of thumb, limiting your designs to 3 type sizes will establish a strong hierarchy without overwhelming the design or user.

    +
    +
    A product page with three different text sizes highlighted with overlays that indicate the hierarchy with the largest text being the most dominant, and the smallest the least.  +
    Seeds.com uses 3 type sizes to establish a strong hierarchy.
    +
    +
    +

    Example: Typography for Reading

    +

    Just like in our previous example, three text styles are present in Figma Shortcut’s design. They establish a clear visual hierarchy: 

    +
      +
    • A larger, bold style used for the introduction of the article 
    • +
    • The medium style for the main body-copy text
    • +
    • A smaller style for captions and callouts
    • +
    +
    +
    An article with 3 different text sizes. They're highlighted to show the hierarchy with the largest text as the most dominant. +
    Figma Shortcut: Three text styles establish a clear visual hierarchy.
    +
    +
    +

    However, even more important in this example is that the type system was designed and used with reading in mind. Shorter lines of text and just right leading (i.e., the distance between the baselines of two consecutive text line) make reading easier, as the eye can quickly go from one text line to the next, without accidentally skipping a line. 

    +
    +
    Text with overlays showing that the line lengths and leading are ideal for reading. +
    Figma Shortcut: The slightly increased leading — combined with shorter line lengths — creates a not-too-dense text block.
    +
    +
    +

    Consider slightly increasing the default leading if you have a lot of text (either several paragraphs or longer paragraphs), as walls of text can feel overwhelming.

    +

    Visual Principle: Strategic Color Palette

    +

    Aside from the grid and typography, color is one of the most important design tools. Color can set the brand tone and influence brand perception, draw users’ attention, affect their emotions, and increase usability. Using color strategically can be challenging. Let’s break down how color is used in a couple of examples.

    +

    Example: Monochromatic Color Palette

    +

    Seed.com’s color palette is limited to shades of green and white. This monochromatic color palette (i.e.,  tones and shades of a single hue or color) creates a pleasant and polished experience, while emphasizing the content. Monochromatic palettes are the easiest to work with and create, and are the most accessible to novice visual designers. (The ability to strategically select and combine colors is a complex skill that’s often underestimated by nondesigners.)

    +
    +
    A product page in soft, natural greens and creams. +
    Seed.com uses a monochromatic color palette to create a polished experience.
    +
    +
    +

    The shades of green are sophisticated and refined — neither too bright nor overly saturated. When choosing color shades for your design, exercise caution with neon colors (such as highlighter yellow), as they may distract users and affect the overall effectiveness of the design.

    +

    Example: Color Reserved for Product Photos

    +

    In the example from Flamingo Estate, the color palette is also monochromatic, relying mostly on shades of white and cream for the page’s background color, as well as for some button text (e.g., Add to Basket). Refined green colors are also used in the palette but are reserved mostly for text backgrounds or for typography. 

    +
    +
    A product page that uses soft white and green. +
    Flamingo Estate: The use of a monochromatic and simple color palette allows the product photos to stand out without overwhelming users.
    +
    +
    +

    Because this design relies on just two main colors, white and green, the product photos can take center stage, without overwhelming users. 

    +

    In your own designs, limiting your color palette to just two colors will create balance and enforce visual hierarchy. Only if you have extensive color experience should you experiment with more complex color harmonies.

    +

    Visual Principle: Useful Imagery

    +

    The use of imagery in visual design plays a critical role in engaging users and conveying brand identity. Let’s look at a couple of examples.     

    +

    Example: Intentional Imagery

    +

    Imagery is used purposefully in Seed.com. 

    +
    +
    A product page with a probiotic split open revealing two capsules. In the background is a image that looks like cells under a microscope. +
    Seed.com: Imagery is used with intent, providing insight into the product being sold.
    +
    +
    +

    The image in the right column adds valuable information about the product being sold. This image is direct, with no visual clutter to distract from the product. The background image creates an interesting pattern reminiscent of viewing cells under a microscope. To keep the background from becoming too distracting for reading, a glassmorphic element is placed between the type and image background.

    +

    The imagery subtly contributes to the impression that Seed.com’s designers are surely looking to achieve — that the company’s product is backed by scientific research.

    +

    Example: Balanced, Direct Imagery

    +

    In Flamingo Estate’s product photos, the actual products are in the center of the photograph. There is no additional, potentially distracting visual clutter. The resulting image is a balanced and direct image. The product photos are centered within the grid columns, further enhancing the sense of balance and symmetry.

    +
    +
    Images are aligned with the center of their columns. +
    Flamingo Estate: The products are centered in the product photos, which are centered in the grid columns, providing a sense of balance.
    +
    +
    +

    On hover, the image changes, revealing additional information about the product (in this case, the inclusion of a tomato alludes to the composition and fragrance of the soap). The Add to Basket button is also center-aligned, further adding to the design's sense of balance.

    +

    Designs Don’t Look Good by Chance

    +

    Be intentional about decisions you make in your design by relying on well-established visual-design principles. Align typography and other elements to a grid, establish a clear visual hierarchy, use color strategically, and be consistent in your application of various design elements. These core principles will create the foundation for a beautiful, usable design.

    +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/research/_raw/tdd-red/nng-web-form-design.raw.html b/docs/research/_raw/tdd-red/nng-web-form-design.raw.html new file mode 100644 index 0000000..e8ea13b --- /dev/null +++ b/docs/research/_raw/tdd-red/nng-web-form-design.raw.html @@ -0,0 +1,1579 @@ + + + + + + + + + + Website Forms Usability: Top 10 Recommendations - NN/G + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + +
    +
    +
    +
    + + +
    +

    Website Forms Usability: Top 10 Recommendations

    + +
    + + +

    Share

    +
    + +
    +
    + +
    +
    + +
    + + Summary:  + Follow these well-established — but frequently ignored — UX design guidelines to ensure users can successfully complete your website forms. +
    + +
    +

    The Transportation Security Administration (TSA) helps keep air travelers safe. But since being delayed or forced to take clothes off in public is also guaranteed to annoy a lot of people, you’d expect the TSA to get a pretty healthy volume of complaints.

    +

    So when I first saw the TSA’s complaint form, the design error seemed so obvious that I wondered if it might be intentional. The form includes 2 buttons at the bottom: Preview and Clear Form. The Preview label is less than ideal, since most users would expect a Submit or at least a Next button. But the real problem is the Clear Form button, which actually deletes anything entered into the form.

    +

    Intentional or not, this arrangement undoubtedly reduces the volume of complaints! However it also violates a form-design guideline we first wrote about more than 15 years ago: to avoid Reset buttons on web forms.

    +
    +
    TSA complaints form has a Clear Form button +
    TSA’s web form includes a Clear Form button, which violates usability guidelines dating back more than 15 years. To add insult to injury, the Clear Form button is positioned closer to the input fields than the Preview button, thus making it even more likely that people will hit it by mistake (and violating the additional guideline of proximity between objects and their primary actions).
    +
    +
    +

    I’ve recently concluded that the design of this form was not intentionally bad, because the TSA actually has a second complaint form that correctly uses a single Submit button below the form. Since one form follows the guideline, it seems likely that the poor design of the other version is just accidental.

    +

    As a taxpayer, it’s comforting to think that my government agency isn’t deliberately using bad design to avoid hearing my comments. But from a UX perspective it’s a painful reminder that despite the buzz and popularity of “UX” in recent years, basic understanding of usability is often still lacking. Even simple guidelines that ought to be well established are often unknown or disregarded.

    +

    Careful form design has a huge impact on the speed with which users can understand and accurately complete a form. In fact, a recent paper published in CHI by Seckler and her colleagues shows that, when forms follow basic usability guidelines, the completion time decreases significantly and users are almost twice as likely to submit the form with no errors from the first try (78% one-try submissions in forms compliant with usability guidelines versus only 42% one-try submissions in forms violating them). If you wonder why your conversion funnel has big drop-offs on forms pages, this study may give you a clue: usability problems on forms really hurt business.

    +

    Do your website forms follow usability best practices?

    +
    +
    +
    +
    +

    + In This Article: +

    + +
    + +
    + +
    +

    Best Practices for Web Form Design

    +

    The best design solution for any given form depends on many factors: the length of the form, the context of use, and the data being collected. The exact implementation you should use may vary in certain circumstances, but this is no excuse for ignoring guidelines altogether. Instead, use these recommendations as a starting point, and if you stray from these established best practices make certain you have a good reason for doing so.

    +
      +
    1. Keep it short. The mathematician Blaise Pascal famously said: “I have made this longer than usual because I have not had time to make it shorter.” This principle applies to web forms as well as prose writing. Eliminating unnecessary fields requires more time, but the reduced user effort and increased completion rates make it worthwhile. Remove fields which collect information that can be (a) derived in some other way, (b) collected more conveniently at a later date, or (c) simply omitted. (We recently applied this technique to one of our own forms and reduced it from 6 fields down to only 2 fields.) Every time you cut a field or question from a form, you increase its conversion rate — the business case for this guideline is that simple.
    2. +
    3. Visually group related labels and fields. Labels should be close to the fields they describe (immediately above the field for mobile and shorter desktop forms, or next to the field for extremely long desktop forms). Avoid ambiguous spacing, where labels are equidistant from multiple fields, and make sure to include the label attribute for screen readers. If your form asks about two different topics, section it into two separate groups of fields (and tag the groups for screen readers).
    4. +
    5. Present fields in a single column layout. Multiple columns interrupt the vertical momentum of moving down the form. Rather than requiring users to visually reorient themselves, keep them in the flow by sticking to a single column with a separate row for each field. (Exceptions to this rule: short and/or logically related fields such as City, State, and Zip Code can be presented on the same row.)
    6. +
    7. Use logical sequencing. Stick to standard sequences both for fields (e.g., Credit-card number, Expiration date, Security code) and for value choices (e.g., Standard shipping, 2-day shipping, 1-day shipping). But for field values, also consider usage frequency, and list the most common values first when possible. Help keyboard users by testing the Tab-key navigation to ensure it follows the correct field sequence.
    8. +
    +
    +
    Starbucks iPhone app screen with Decaf options +
    The Starbucks iPhone application, which includes a mobile form to let you customize your drink order, unfortunately hides the full ‘Decaf’ option off screen to the right, requiring horizontal scrolling. If the full ‘Decaf’ is more frequently selected that the other options, it should be displayed first.
    +
    +
    +
      +
    1. Avoid placeholder text. Designers like placeholder text because it eliminates visual clutter. But placeholder text causes many usability problems, and is best avoided.
    2. +
    3. Match fields to the type and size of the input. Avoid drop-downs when there are only 2 or 3 options that could be displayed as radio buttons (which require only a single click or tap). Text fields should be about the same size as the expected input since it’s extremely error prone when users can’t see their full entry. For example, for 2,130 recent participants in the UX Conference, the user’s city of residence ranged between 3 characters (Leo, Indiana) to 22 characters (San Pedro Garza Garcia, Mexico). 99.9% of city names were 19 characters or shorter, making 19 characters a reasonable width for a city field.
    4. +
    5. Distinguish optional and required fields. First, eliminate as many optional fields as possible (see the first recommendation above). If some fields truly are necessary, but only apply to a subset of users, don’t make users find out through trial and error. Limit the form to only 1 or 2 optional fields, and clearly label them as optional.
    6. +
    7. Explain any input or formatting requirements. If a field requires a specific format or type of input, state the exact instructions. Don’t make users guess your obscure password requirements. The same applies to syntax rules such as punctuation or spacing for phone numbers or credit cards. (Though as much as possible you should eliminate these arbitrary formatting rules: death to parentheses for phone-number area codes!)
    8. +
    +
    +
    Netgear website screen with password reset error +
    Netgear’s Reset Password form explains its password requirements…but only as an error message after you have failed the test. Don’t set users up for failure with secret rules.
    +
    +
    +
      +
    1. Avoid Reset and Clear buttons. The risk of accidental deletion outweighs the unlikely need to ‘start over’ on a web form. In forms that collect extremely sensitive input such as financial information, provide a ‘Cancel’ button to support those users who abandon the form and want to delete their information. But make sure that the Cancel button has significantly less visual prominence than the Submit button, to avoid accidental clicks.
    2. +
    3. Provide highly visible and specific error messages. Errors should be signaled through a variety of cues, not solely through color: outline the field AND use red text AND use a heavier font, to ensure users don’t overlook this critical information. Now is not the time to be subtle.
    4. +
    +

    Erroneous input should be preserved so users can correct it, and accompanied by a specific explanation of the problem.

    +

    Conclusion

    +

    The usability of web forms is by no means a new topic. It has been covered in general usability references (including several NN/g books of both general usability guidelines, eyetracking usability research, and mobile usability). Many of the 114 UX guidelines for e-commerce shopping carts are specialized issues in forms design. There are also entire books specifically written about form design, as well as academic studies demonstrating the effectiveness of complying with guidelines.

    +

    This brief summary is not intended to replace the in-depth analysis found in other resources: if you work extensively with form design, absorbing the intricacies of best practices in various situations is well worth your time.

    +

    But many bad web forms have problems that are not intricate or complex, and that could have been avoided by a simple reminder of what we already know. Take a look at the forms on your site and make sure that they don’t make these well-known mistakes. Who knows, you just might double your conversion rate.

    +

     

    +

    References:

    +

    Mirjam Seckler, Silvia Heinz, Javier A. Bargas-Avila, Klaus Opwis, and Alexandre N. Tuch. 2014. Designing usable web forms: empirical evaluation of web form improvement guidelines. In Proceedings of the SIGCHI Conference on Human Factors in Computing Systems (CHI '14). DOI=http://dx.doi.org/10.1145.2556288.2557265

    +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/research/_raw/tdd-red/playwright-best-practices.raw.html b/docs/research/_raw/tdd-red/playwright-best-practices.raw.html new file mode 100644 index 0000000..e76ccc8 --- /dev/null +++ b/docs/research/_raw/tdd-red/playwright-best-practices.raw.html @@ -0,0 +1,110 @@ +Best Practices | Playwright + + +

    Best Practices

    Introduction

    +

    This guide should help you to make sure you are following our best practices and writing tests that are more resilient.

    +

    Testing philosophy

    +

    Test user-visible behavior

    +

    Automated tests should verify that the application code works for the end users, and avoid relying on implementation details such as things which users will not typically use, see, or even know about such as the name of a function, whether something is an array, or the CSS class of some element. The end user will see or interact with what is rendered on the page, so your test should typically only see/interact with the same rendered output.

    +

    Make tests as isolated as possible

    +

    Each test should be completely isolated from another test and should run independently with its own local storage, session storage, data, cookies etc. Test isolation improves reproducibility, makes debugging easier and prevents cascading test failures.

    +

    In order to avoid repetition for a particular part of your test you can use before and after hooks. Within your test file add a before hook to run a part of your test before each test such as going to a particular URL or logging in to a part of your app. This keeps your tests isolated as no test relies on another. However it is also ok to have a little duplication when tests are simple enough especially if it keeps your tests clearer and easier to read and maintain.

    +
    import { test } from '@playwright/test';

    test.beforeEach(async ({ page }) => {
    // Runs before each test and signs in each page.
    await page.goto('https://github.com/login');
    await page.getByLabel('Username or email address').fill('username');
    await page.getByLabel('Password').fill('password');
    await page.getByRole('button', { name: 'Sign in' }).click();
    });

    test('first', async ({ page }) => {
    // page is signed in.
    });

    test('second', async ({ page }) => {
    // page is signed in.
    });
    +

    You can also reuse the signed-in state in the tests with setup project. That way you can log in only once and then skip the log in step for all of the tests.

    +

    Avoid testing third-party dependencies

    +

    Only test what you control. Don't try to test links to external sites or third party servers that you do not control. Not only is it time consuming and can slow down your tests but also you cannot control the content of the page you are linking to, or if there are cookie banners or overlay pages or anything else that might cause your test to fail.

    +

    Instead, use the Playwright Network API and guarantee the response needed.

    +
    await page.route('**/api/fetch_data_third_party_dependency', route => route.fulfill({
    status: 200,
    body: testData,
    }));
    await page.goto('https://example.com');
    +

    Testing with a database

    +

    If working with a database then make sure you control the data. Test against a staging environment and make sure it doesn't change. For visual regression tests make sure the operating system and browser versions are the same.

    +

    Best Practices

    +

    Use locators

    +

    In order to write end to end tests we need to first find elements on the webpage. We can do this by using Playwright's built in locators. Locators come with auto waiting and retry-ability. Auto waiting means that Playwright performs a range of actionability checks on the elements, such as ensuring the element is visible and enabled before it performs the click. To make tests resilient, we recommend prioritizing user-facing attributes and explicit contracts.

    +
    // 👍
    page.getByRole('button', { name: 'submit' });
    +

    Use chaining and filtering

    +

    Locators can be chained to narrow down the search to a particular part of the page.

    +
    const product = page.getByRole('listitem').filter({ hasText: 'Product 2' });
    +

    You can also filter locators by text or by another locator.

    +
    await page
    .getByRole('listitem')
    .filter({ hasText: 'Product 2' })
    .getByRole('button', { name: 'Add to cart' })
    .click();
    +

    Prefer user-facing attributes to XPath or CSS selectors

    +

    Your DOM can easily change so having your tests depend on your DOM structure can lead to failing tests. For example consider selecting this button by its CSS classes. Should the designer change something then the class might change, thus breaking your test.

    +
    // 👎
    page.locator('button.buttonIcon.episode-actions-later');
    +

    Use locators that are resilient to changes in the DOM.

    +
    // 👍
    page.getByRole('button', { name: 'submit' });
    +

    Generate locators

    +

    Playwright has a test generator that can generate tests and pick locators for you. It will look at your page and figure out the best locator, prioritizing role, text and test id locators. If the generator finds multiple elements matching the locator, it will improve the locator to make it resilient and uniquely identify the target element, so you don't have to worry about failing tests due to locators.

    +

    Use codegen to generate locators

    +

    To pick a locator run the codegen command followed by the URL that you would like to pick a locator from.

    +
    npx playwright codegen playwright.dev
    +

    This will open a new browser window as well as the Playwright inspector. To pick a locator first click on the 'Record' button to stop the recording. By default when you run the codegen command it will start a new recording. Once you stop the recording the 'Pick Locator' button will be available to click.

    +

    You can then hover over any element on your page in the browser window and see the locator highlighted below your cursor. Clicking on an element will add the locator into the Playwright inspector. You can either copy the locator and paste into your test file or continue to explore the locator by editing it in the Playwright Inspector, for example by modifying the text, and seeing the results in the browser window.

    +generating locators with codegen +

    Use the VS Code extension to generate locators

    +

    You can also use the VS Code Extension to generate locators as well as record a test. The VS Code extension also gives you a great developer experience when writing, running, and debugging tests.

    +generating locators in vs code with codegen +

    Use web first assertions

    +

    Assertions are a way to verify that the expected result and the actual result matched or not. By using web first assertions Playwright will wait until the expected condition is met. For example, when testing an alert message, a test would click a button that makes a message appear and check that the alert message is there. If the alert message takes half a second to appear, assertions such as toBeVisible() will wait and retry if needed.

    +
    // 👍
    await expect(page.getByText('welcome')).toBeVisible();

    // 👎
    expect(await page.getByText('welcome').isVisible()).toBe(true);
    +

    Don't use manual assertions

    +

    Don't use manual assertions that are not awaiting the expect. In the code below the await is inside the expect rather than before it. When using assertions such as isVisible() the test won't wait a single second, it will just check the locator is there and return immediately.

    +
    // 👎
    expect(await page.getByText('welcome').isVisible()).toBe(true);
    +

    Use web first assertions such as toBeVisible() instead.

    +
    // 👍
    await expect(page.getByText('welcome')).toBeVisible();
    +

    Configure debugging

    +

    Local debugging

    +

    For local debugging we recommend you debug your tests live in VS Code by installing the VS Code extension. You can run tests in debug mode by right-clicking on the line next to the test you want to run which will open a browser window and pause at where the breakpoint is set.

    +debugging tests in vscode +

    You can live debug your test by clicking or editing the locators in your test in VS Code which will highlight this locator in the browser window as well as show you any other matching locators found on the page.

    +live debugging locators in vscode +

    You can also debug your tests with the Playwright inspector by running your tests with the --debug flag.

    +
    npx playwright test --debug
    +

    You can then step through your test, view actionability logs and edit the locator live and see it highlighted in the browser window. This will show you which locators match, how many of them there are.

    +debugging with the playwright inspector +

    To debug a specific test add the name of the test file and the line number of the test followed by the --debug flag.

    +
    npx playwright test example.spec.ts:9 --debug
    +

    Debugging on CI

    +

    For CI failures, use the Playwright trace viewer instead of videos and screenshots. The trace viewer gives you a full trace of your tests as a local Progressive Web App (PWA) that can easily be shared. With the trace viewer you can view the timeline, inspect DOM snapshots for each action using dev tools, view network requests and more.

    +playwrights trace viewer +

    Traces are configured in the Playwright config file and are set to run on CI on the first retry of a failed test. We don't recommend setting this to on so that traces are run on every test as it's very performance heavy. However you can run a trace locally when developing with the --trace flag.

    +
    npx playwright test --trace on
    +

    Once you run this command your traces will be recorded for each test and can be viewed directly from the HTML report.

    +
    npx playwright show-report
    +Playwrights HTML report +

    Traces can be opened by clicking on the icon next to the test file name or by opening each of the test reports and scrolling down to the traces section.

    +Screenshot 2023-01-13 at 09 58 34 +

    Use Playwright's Tooling

    +

    Playwright comes with a range of tooling to help you write tests.

    +
      +
    • The VS Code extension gives you a great developer experience when writing, running, and debugging tests.
    • +
    • The test generator can generate tests and pick locators for you.
    • +
    • The trace viewer gives you a full trace of your tests as a local PWA that can easily be shared. With the trace viewer you can view the timeline, inspect DOM snapshots for each action, view network requests and more.
    • +
    • The UI Mode lets you explore, run and debug tests with a time travel experience complete with watch mode. All test files are loaded into the testing sidebar where you can expand each file and describe block to individually run, view, watch and debug each test.
    • +
    • TypeScript in Playwright works out of the box and gives you better IDE integrations. Your IDE will show you everything you can do and highlight when you do something wrong. No TypeScript experience is needed and it is not necessary for your code to be in TypeScript, all you need to do is create your tests with a .ts extension.
    • +
    +

    Test across all browsers

    +

    Playwright makes it easy to test your site across all browsers no matter what platform you are on. Testing across all browsers ensures your app works for all users. In your config file you can set up projects adding the name and which browser or device to use.

    +
    playwright.config.ts
    import { defineConfig, devices } from '@playwright/test';

    export default defineConfig({
    projects: [
    {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'] },
    },
    {
    name: 'firefox',
    use: { ...devices['Desktop Firefox'] },
    },
    {
    name: 'webkit',
    use: { ...devices['Desktop Safari'] },
    },
    ],
    });
    +

    Keep your Playwright dependency up to date

    +

    By keeping your Playwright version up to date you will be able to test your app on the latest browser versions and catch failures before the latest browser version is released to the public.

    +
    npm install -D @playwright/test@latest
    +

    Check the release notes to see what the latest version is and what changes have been released.

    +

    You can see what version of Playwright you have by running the following command.

    +
    npx playwright --version
    +

    Run tests on CI

    +

    Setup CI/CD and run your tests frequently. The more often you run your tests the better. Ideally you should run your tests on each commit and pull request. Playwright comes with a GitHub actions workflow so that tests will run on CI for you with no setup required. Playwright can also be setup on the CI environment of your choice.

    +

    Use Linux when running your tests on CI as it is cheaper. Developers can use whatever environment when running locally but use linux on CI. Consider setting up Sharding to make CI faster.

    +

    Optimize browser downloads on CI

    +

    Only install the browsers that you actually need, especially on CI. For example, if you're only testing with Chromium, install just Chromium.

    +
    .github/workflows/playwright.yml
    # Instead of installing all browsers
    npx playwright install --with-deps

    # Install only Chromium
    npx playwright install chromium --with-deps
    +

    This saves both download time and disk space on your CI machines.

    +

    Lint your tests

    +

    We recommend TypeScript and linting with ESLint for your tests to catch errors early. Use @typescript-eslint/no-floating-promises ESLint rule to make sure there are no missing awaits before the asynchronous calls to the Playwright API. On your CI you can run tsc --noEmit to ensure that functions are called with the right signature.

    +

    Use parallelism and sharding

    +

    Playwright runs tests in parallel by default. Tests in a single file are run in order, in the same worker process. If you have many independent tests in a single file, you might want to run them in parallel

    +
    import { test } from '@playwright/test';

    test.describe.configure({ mode: 'parallel' });

    test('runs in parallel 1', async ({ page }) => { /* ... */ });
    test('runs in parallel 2', async ({ page }) => { /* ... */ });
    +

    Playwright can shard a test suite, so that it can be executed on multiple machines.

    +
    npx playwright test --shard=1/3
    +

    Productivity tips

    +

    Use Soft assertions

    +

    If your test fails, Playwright will give you an error message showing what part of the test failed which you can see either in VS Code, the terminal, the HTML report, or the trace viewer. However, you can also use soft assertions. These do not immediately terminate the test execution, but rather compile and display a list of failed assertions once the test ended.

    +
    // Make a few checks that will not stop the test when failed...
    await expect.soft(page.getByTestId('status')).toHaveText('Success');

    // ... and continue the test to check more things.
    await page.getByRole('link', { name: 'next page' }).click();
    \ No newline at end of file diff --git a/docs/research/_raw/tdd-red/testing-library-guiding-principles.raw.html b/docs/research/_raw/tdd-red/testing-library-guiding-principles.raw.html new file mode 100644 index 0000000..0ea4d3f --- /dev/null +++ b/docs/research/_raw/tdd-red/testing-library-guiding-principles.raw.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + +Guiding Principles | Testing Library + + + + +
    +

    Guiding Principles

    The more your tests resemble the way your software is used, the more +confidence they can give you.

    We try to only expose methods and utilities that encourage you to write tests +that closely resemble how your web pages are used.

    Utilities are included in this project based on the following guiding +principles:

    1. If it relates to rendering components, then it should deal with DOM nodes +rather than component instances, and it should not encourage dealing with +component instances.
    2. It should be generally useful for testing the application components in the +way the user would use it. We are making some trade-offs here because +we're using a computer and often a simulated browser environment, but in +general, utilities should encourage tests that use the components the way +they're intended to be used.
    3. Utility implementations and APIs should be simple and flexible.

    At the end of the day, what we want is for this library to be pretty +light-weight, simple, and understandable.

    + + + + + \ No newline at end of file diff --git a/docs/research/_raw/tdd-red/vercel-web-interface-guidelines-command.latest.raw.md b/docs/research/_raw/tdd-red/vercel-web-interface-guidelines-command.latest.raw.md new file mode 100644 index 0000000..e1e8e34 --- /dev/null +++ b/docs/research/_raw/tdd-red/vercel-web-interface-guidelines-command.latest.raw.md @@ -0,0 +1,190 @@ +--- +description: Review UI code for Vercel Web Interface Guidelines compliance +argument-hint: +--- + +# Web Interface Guidelines + +Review these files for compliance: $ARGUMENTS + +Read files, check against rules below. Output concise but comprehensive—sacrifice grammar for brevity. High signal-to-noise. + +## Rules + +### Accessibility + +- Icon-only buttons need `aria-label` +- Form controls need `