\uacf5\uacf5\ub370\uc774\ud130\ud3ec\ud138 \ud55c\uad6d\ucc9c\ubb38\uc5f0\uad6c\uc6d0 \ud2b9\uc77c\uc815\ubcf4 API\ub85c \uc784\uc2dc\uacf5\ud734\uc77c\uae4c\uc9c0 \uc815\ubd80 \uacf5\uc778 \ub370\uc774\ud130\ub85c \ubcf4\uac15. holidays \ud328\ud0a4\uc9c0\ub294 fallback. - utils/holiday_api.py: getRestDeInfo \uc5d4\ub4dc\ud3ec\uc778\ud2b8 + \uc751\ub2f5 \ud30c\uc11c (\ub2e8\uc77c/\ub2e4\uc218 item) - Database.add_korean_holidays_from_api(year) + add_korean_holidays_auto fallback chain - migrate_v290_holidays_auto_sync: \uc77c 1\ud68c \ubc31\uadf8\ub77c\uc6b4\ub4dc \ub3d9\uae30\ud654 (sentinel holidays_synced_date, daemon thread, CLOCKOUT_DISABLE_HOLIDAY_SYNC env var) - Settings UI \uc548\ub0b4\ubb38 \uc5c5\ub370\uc774\ud2b8 Tests: tests/test_holiday_api.py 14\uac1c + conftest.py + 175\u2192189 pytest \uc804\ubd80 green \ud1b5\ud569 \uc2dc\ub098\ub9ac\uc624 53/53 green \uc8fc\uc758: \ud0a4 \ud65c\uc6a9\uae30\uac04 \uc2dc\uc791 \uc9c1\ud6c4 (2026-05-01) propagation \uc73c\ub85c 401 \uac00\ub2a5, fallback \uacbd\ub85c\uac00 \ud574\ub2f9 \uc0ac\ub840 \ucee4\ubc84 \u2014 \uadfc\ub85c\uc790\uc758 \ub0a0 \ud3ec\ud568 22\uac1c \ud734\uc77c \uc790\ub3d9 \ub4f1\ub85d \ud655\uc778
99 lines
3.7 KiB
Python
99 lines
3.7 KiB
Python
"""
|
|
공공데이터포털 — 한국천문연구원 특일정보 OpenAPI 클라이언트.
|
|
|
|
엔드포인트: getRestDeInfo (국경일/공휴일 — 임시공휴일 포함)
|
|
공식 문서: https://www.data.go.kr/data/15012690/openapi.do
|
|
|
|
`holidays` 패키지가 누락하는 임시공휴일·근로자의 날 등을
|
|
정부 공인 데이터로 보강하기 위해 사용.
|
|
|
|
설계:
|
|
- 네트워크 실패는 silent (None 반환) — 호출자가 fallback 처리
|
|
- API 키는 코드 내 박혀있으나 dev 본인 계정의 특일정보 API 한정 키
|
|
(50명 이내 사용 환경에서 일일 한도 1000회 충분)
|
|
"""
|
|
from __future__ import annotations
|
|
import json
|
|
import urllib.parse
|
|
import urllib.request
|
|
import urllib.error
|
|
from typing import List, Dict, Optional
|
|
|
|
|
|
# 공공데이터포털 dev 키 (특일정보 API 한정).
|
|
# 노출 시 data.go.kr 마이페이지에서 즉시 폐기/재발급 가능.
|
|
_SERVICE_KEY = 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93'
|
|
|
|
_BASE = 'https://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService'
|
|
_USER_AGENT = 'ClockOutCalculator/2.10 (KASI special-day client)'
|
|
|
|
|
|
def fetch_korean_holidays(year: int, timeout: int = 10) -> Optional[List[Dict]]:
|
|
"""해당 연도의 한국 공휴일 전체를 정부 API에서 받아 반환.
|
|
|
|
Returns:
|
|
성공: [{'date': '2026-05-01', 'name': '근로자의 날', 'is_holiday': True}, ...]
|
|
실패: None (네트워크 오류, 인증 실패, 응답 파싱 실패 등)
|
|
"""
|
|
params = {
|
|
'serviceKey': _SERVICE_KEY,
|
|
'solYear': str(year),
|
|
'_type': 'json',
|
|
'numOfRows': '100',
|
|
'pageNo': '1',
|
|
}
|
|
url = f"{_BASE}/getRestDeInfo?" + urllib.parse.urlencode(params)
|
|
req = urllib.request.Request(url, headers={'User-Agent': _USER_AGENT})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
data = json.loads(resp.read())
|
|
except (urllib.error.URLError, urllib.error.HTTPError,
|
|
json.JSONDecodeError, OSError, TimeoutError):
|
|
return None
|
|
return _parse_response(data)
|
|
|
|
|
|
def _parse_response(data: Dict) -> Optional[List[Dict]]:
|
|
"""API 응답 JSON을 표준 형식으로 정규화.
|
|
|
|
API 응답 패턴:
|
|
- resultCode == '00' → 정상
|
|
- items.item: 단일 결과면 dict, 여러 개면 list
|
|
- items가 빈 문자열일 때 (totalCount=0)도 정상으로 간주
|
|
"""
|
|
try:
|
|
response = data.get('response') or {}
|
|
header = response.get('header') or {}
|
|
if header.get('resultCode') != '00':
|
|
return None
|
|
body = response.get('body') or {}
|
|
items_root = body.get('items')
|
|
if not items_root:
|
|
return [] # 그 해 공휴일 없음 (드물지만 정상 응답)
|
|
item = items_root.get('item') if isinstance(items_root, dict) else None
|
|
if item is None:
|
|
return []
|
|
if isinstance(item, dict):
|
|
item = [item]
|
|
out = []
|
|
for entry in item:
|
|
locdate = entry.get('locdate')
|
|
name = entry.get('dateName')
|
|
is_holiday = (entry.get('isHoliday') == 'Y')
|
|
if not locdate or not name:
|
|
continue
|
|
# locdate: 20260501 (int 또는 str)
|
|
ds = str(locdate)
|
|
if len(ds) != 8 or not ds.isdigit():
|
|
continue
|
|
iso = f"{ds[0:4]}-{ds[4:6]}-{ds[6:8]}"
|
|
out.append({'date': iso, 'name': str(name), 'is_holiday': is_holiday})
|
|
return out
|
|
except (AttributeError, TypeError, KeyError):
|
|
return None
|
|
|
|
|
|
def is_configured() -> bool:
|
|
"""키가 설정되어 있는지 (테스트/빈 키 환경 가드)."""
|
|
return bool(_SERVICE_KEY) and len(_SERVICE_KEY) > 10
|