fed3526b20
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
274 lines
11 KiB
Python
Executable File
274 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""한국 주식시장(KRX/KOSDAQ) 휴장일을 investing.com에서 받아 state/market_holidays.json에 저장.
|
|
|
|
- 올해 + 내년 데이터를 함께 수집해 12월 → 1월 갭 방지.
|
|
- launchd `ai.openclaw.stock.holiday-sync`가 주 1회 호출 (LLM 미경유).
|
|
- 실패 시 stderr만 남기고 기존 state 파일은 보존 (web view는 옛 데이터로 계속 동작).
|
|
|
|
CLI:
|
|
python3 holiday_sync.py # 올해+내년 fetch 후 저장
|
|
python3 holiday_sync.py --years 1 # 올해만
|
|
python3 holiday_sync.py --show # 저장된 휴장일 출력 (network 호출 없음)
|
|
|
|
Source: https://kr.investing.com/holiday-calendar/service/getCalendarFilteredData
|
|
필터: country[]=11(한국), 거래소 = "서울 증권 거래소" (KOSDAQ는 같은 날 동시 휴장이라 첫 매칭만)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo
|
|
|
|
WORKSPACE = Path(__file__).resolve().parent.parent
|
|
STATE_FILE = WORKSPACE / 'state' / 'market_holidays.json'
|
|
HEALTH_FILE = WORKSPACE / 'state' / 'holiday_sync_health.json'
|
|
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
|
|
KST = ZoneInfo('Asia/Seoul')
|
|
|
|
URL = 'https://kr.investing.com/holiday-calendar/service/getCalendarFilteredData'
|
|
NAGER_URL = 'https://date.nager.at/api/v3/PublicHolidays/{year}/KR'
|
|
KOREA_COUNTRY_ID = 11
|
|
USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
|
|
|
|
ROW_RE = re.compile(
|
|
r'<tr>\s*<td class="date[^"]*">([^<]*)</td>.*?<td>([^<]+)</td>\s*<td class="last">([^<]+)</td>',
|
|
re.DOTALL,
|
|
)
|
|
DATE_RE = re.compile(r'(\d{4})년\s*(\d{1,2})월\s*(\d{1,2})일')
|
|
|
|
|
|
def fetch_year(year: int) -> dict[str, str]:
|
|
"""{date(YYYY-MM-DD) → 휴장명} for the given year. 빈 dict 반환 가능."""
|
|
body = urllib.parse.urlencode([
|
|
('country[]', str(KOREA_COUNTRY_ID)),
|
|
('dateFrom', f'{year}-01-01'),
|
|
('dateTo', f'{year}-12-31'),
|
|
]).encode()
|
|
req = urllib.request.Request(URL, data=body, method='POST', headers={
|
|
'User-Agent': USER_AGENT,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'Referer': 'https://kr.investing.com/holiday-calendar/',
|
|
'Origin': 'https://kr.investing.com',
|
|
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
|
'Accept-Language': 'ko-KR,ko;q=0.9',
|
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
})
|
|
with urllib.request.urlopen(req, timeout=20) as r:
|
|
payload = json.loads(r.read().decode('utf-8'))
|
|
html_str = payload.get('data', '')
|
|
last_date: str | None = None
|
|
out: dict[str, str] = {}
|
|
for date_raw, exch, name in ROW_RE.findall(html_str):
|
|
date_raw = date_raw.strip()
|
|
exch = exch.strip()
|
|
name = name.strip()
|
|
if date_raw:
|
|
m = DATE_RE.match(date_raw)
|
|
if m:
|
|
last_date = f'{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}'
|
|
# 한국 KRX 본장 기준 — 서울/코스닥 공동 휴장이라 서울만 보면 충분.
|
|
if last_date and last_date.startswith(str(year)) and '서울' in exch and last_date not in out:
|
|
out[last_date] = name
|
|
return out
|
|
|
|
|
|
def fetch_nager(year: int) -> dict[str, str]:
|
|
"""date.nager.at에서 한국 공휴일 fetch (평일만).
|
|
investing.com이 임시공휴일(선거일·정부 지정 대체공휴일)을 누락하는 패턴 보강용.
|
|
토·일은 KRX 자체 휴장이라 제외 — KRX 휴장 의미가 없음."""
|
|
req = urllib.request.Request(NAGER_URL.format(year=year), headers={
|
|
'User-Agent': USER_AGENT,
|
|
'Accept': 'application/json',
|
|
})
|
|
with urllib.request.urlopen(req, timeout=20) as r:
|
|
data = json.loads(r.read().decode('utf-8'))
|
|
out: dict[str, str] = {}
|
|
for item in data:
|
|
d = (item.get('date') or '').strip()
|
|
name = (item.get('localName') or item.get('name') or '').strip()
|
|
if not d or not name:
|
|
continue
|
|
try:
|
|
y, m, dd = map(int, d.split('-'))
|
|
if date(y, m, dd).weekday() >= 5:
|
|
continue
|
|
except Exception:
|
|
continue
|
|
out[d] = name
|
|
return out
|
|
|
|
|
|
def is_holiday(date_str: str) -> bool:
|
|
"""date_str(YYYY-MM-DD)이 KRX 휴장일이면 True. 데이터 파일 없거나 깨지면 False(안전 fallback).
|
|
다른 스크립트가 self-skip 게이트로 import해서 사용한다."""
|
|
if not STATE_FILE.exists():
|
|
return False
|
|
try:
|
|
data = json.loads(STATE_FILE.read_text())
|
|
return date_str in (data.get('holidays') or {})
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def is_holiday_today() -> bool:
|
|
return is_holiday(datetime.now(KST).strftime('%Y-%m-%d'))
|
|
|
|
|
|
def is_market_day_today() -> bool:
|
|
"""오늘이 KRX 정규장 영업일이면 True (평일 + 휴장일 아님).
|
|
데이터 파일 누락 시 평일이면 True로 fall through — 검증 신호 가리지 않음."""
|
|
now = datetime.now(KST)
|
|
if now.weekday() >= 5: # 토(5)·일(6)
|
|
return False
|
|
return not is_holiday_today()
|
|
|
|
|
|
def _send_telegram(text: str) -> bool:
|
|
"""레이 텔레그램으로 자가 알림. 자격증명·발송 실패 시 stderr만 남기고 False."""
|
|
try:
|
|
cfg = json.loads(CONFIG_PATH.read_text())
|
|
acct = cfg['channels']['telegram']['accounts']['stock']
|
|
token = acct['botToken']
|
|
chat_ids = acct.get('allowFrom') or []
|
|
except Exception as e:
|
|
sys.stderr.write(f'telegram cfg load failed: {e}\n')
|
|
return False
|
|
if not chat_ids:
|
|
return False
|
|
url = f'https://api.telegram.org/bot{token}/sendMessage'
|
|
ok = True
|
|
for chat_id in chat_ids:
|
|
body = urllib.parse.urlencode({
|
|
'chat_id': chat_id, 'text': text[:4000], 'disable_web_page_preview': 'true',
|
|
}).encode()
|
|
try:
|
|
req = urllib.request.Request(url, data=body, method='POST')
|
|
with urllib.request.urlopen(req, timeout=15) as r:
|
|
if r.status != 200:
|
|
ok = False
|
|
except Exception as e:
|
|
sys.stderr.write(f'telegram send failed: {e}\n')
|
|
ok = False
|
|
return ok
|
|
|
|
|
|
def _load_health() -> dict:
|
|
if not HEALTH_FILE.exists():
|
|
return {'consecutive_both_failures': 0}
|
|
try:
|
|
return json.loads(HEALTH_FILE.read_text())
|
|
except Exception:
|
|
return {'consecutive_both_failures': 0}
|
|
|
|
|
|
def _save_health(h: dict) -> None:
|
|
HEALTH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
HEALTH_FILE.write_text(json.dumps(h, ensure_ascii=False, indent=2))
|
|
|
|
|
|
def _update_health(both_failed: bool, error_lines: list[str]) -> None:
|
|
"""sync 시도 결과로 health 상태 갱신 + 2회 연속 진입 시점에만 알림 1회."""
|
|
now_iso = datetime.now(KST).isoformat()
|
|
h = _load_health()
|
|
prev = int(h.get('consecutive_both_failures') or 0)
|
|
h['last_attempt_at'] = now_iso
|
|
if both_failed:
|
|
h['consecutive_both_failures'] = prev + 1
|
|
h['last_failure_reason'] = '\n'.join(error_lines)[:500]
|
|
if h['consecutive_both_failures'] == 2 and prev < 2:
|
|
last_ok = h.get('last_success_at') or '기록 없음'
|
|
text = (
|
|
'🚨 holiday-sync 2회 연속 실패\n'
|
|
'investing.com · nager.at 양쪽 모두 데이터 0건\n'
|
|
f'마지막 성공: {last_ok}\n'
|
|
f'오류:\n{h["last_failure_reason"]}\n'
|
|
'state file은 옛 데이터 유지 — 휴장 가드는 정상 동작'
|
|
)
|
|
sent = _send_telegram(text)
|
|
h['alert_sent_at'] = now_iso if sent else None
|
|
else:
|
|
h['consecutive_both_failures'] = 0
|
|
h['last_success_at'] = now_iso
|
|
h.pop('last_failure_reason', None)
|
|
h.pop('alert_sent_at', None)
|
|
_save_health(h)
|
|
|
|
|
|
def cmd_sync(years_forward: int) -> int:
|
|
this_year = datetime.now(KST).year
|
|
years = list(range(this_year, this_year + max(1, years_forward)))
|
|
investing_data: dict[str, str] = {}
|
|
errors: list[str] = []
|
|
for y in years:
|
|
try:
|
|
investing_data.update(fetch_year(y))
|
|
except Exception as e:
|
|
errors.append(f'investing fetch_year({y}): {e}')
|
|
sys.stderr.write(errors[-1] + '\n')
|
|
# nager는 healthcheck용으로 항상 시도 — 휴리스틱(investing 0건 연도 skip)은 사용 시점에만 적용.
|
|
nager_data_raw: dict[str, str] = {}
|
|
for y in years:
|
|
try:
|
|
nager_data_raw.update(fetch_nager(y))
|
|
except Exception as e:
|
|
errors.append(f'nager fetch_nager({y}): {e}')
|
|
sys.stderr.write(errors[-1] + '\n')
|
|
# 양쪽 다 0건이면 영구 변경/네트워크 차단 의심 — health 카운트 + 2회 연속 시 알림.
|
|
both_failed = (not investing_data) and (not nager_data_raw)
|
|
_update_health(both_failed, errors)
|
|
if not investing_data:
|
|
sys.stderr.write('no holidays parsed — investing.com 미반영. state file은 옛 데이터 유지.\n')
|
|
return 3
|
|
# KRX는 다음 연도 휴장 캘린더를 보통 11~12월에 공식 발표 → 그 전엔 investing.com도 비어 있음.
|
|
# investing이 한 건이라도 잡은 연도에만 nager 보강 — 미발표 연도의 일반 공휴일이 KRX 휴장으로 잘못 등록되는 것 차단.
|
|
investing_years = {int(d[:4]) for d in investing_data}
|
|
nager_data = {d: n for d, n in nager_data_raw.items() if int(d[:4]) in investing_years}
|
|
# investing.com(거래소 캘린더) 우선 — KRX 휴장 권위. nager는 누락분만 채움.
|
|
holidays = {**nager_data, **investing_data}
|
|
nager_extra = sorted(d for d in nager_data if d not in investing_data)
|
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
payload = {
|
|
'fetched_at': datetime.now(KST).isoformat(),
|
|
'sources': {'primary': URL, 'fallback': NAGER_URL.format(year='YYYY')},
|
|
'years': years,
|
|
'holidays': dict(sorted(holidays.items())),
|
|
}
|
|
tmp = STATE_FILE.with_suffix('.json.tmp')
|
|
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
tmp.replace(STATE_FILE)
|
|
extra_msg = f' (nager 보강 {len(nager_extra)}일: {nager_extra})' if nager_extra else ''
|
|
print(f'wrote {STATE_FILE} — {len(holidays)} holidays for {years}{extra_msg}', flush=True)
|
|
return 0
|
|
|
|
|
|
def cmd_show() -> int:
|
|
if not STATE_FILE.exists():
|
|
sys.stderr.write(f'no state file: {STATE_FILE}\n')
|
|
return 1
|
|
data = json.loads(STATE_FILE.read_text())
|
|
print(f'fetched_at: {data.get("fetched_at")}')
|
|
print(f'years: {data.get("years")}')
|
|
for d, n in (data.get('holidays') or {}).items():
|
|
print(f' {d} {n}')
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
p = argparse.ArgumentParser(prog='holiday_sync')
|
|
p.add_argument('--years', type=int, default=2, help='올해 + N년 (default 2: 올해와 내년)')
|
|
p.add_argument('--show', action='store_true', help='저장된 휴장일 출력 후 종료 (네트워크 호출 없음)')
|
|
args = p.parse_args(argv)
|
|
if args.show:
|
|
return cmd_show()
|
|
return cmd_sync(args.years)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main(sys.argv[1:]))
|