Files
openclaw/agents/stock/workspace/scripts/ipo_calendar_sync.py
T
hyowons d8a778cf67 auto: 일일 백업 2026-09-30 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:00:03 +09:00

583 lines
22 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from html import unescape
from pathlib import Path
from zoneinfo import ZoneInfo
KST = ZoneInfo('Asia/Seoul')
CALENDAR_ID = 'mini.snowoyh@gmail.com'
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
STATE_DIR = WORKSPACE / 'state'
STATE_DIR.mkdir(parents=True, exist_ok=True)
STATE_FILE = STATE_DIR / 'ipo_calendar_sync.json'
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
TELEGRAM_ACCOUNT = 'stock'
SUBSCRIPTION_URL = 'https://www.38.co.kr/html/fund/index.htm?o=k'
LISTING_URL = 'https://www.38.co.kr/html/fund/index.htm?o=nw'
NAVER_IPO_API_URL = 'https://stock.naver.com/api/domestic/market/ipo/progress'
SOURCE_LABEL = '네이버페이 증권 IPO'
LISTING_LOOKBACK_DAYS = 14
class IpoParserError(RuntimeError):
def __init__(self, errors: list[str]):
self.errors = errors
super().__init__('IPO 파서 전부 실패: ' + ' | '.join(errors))
@dataclass
class EventSpec:
kind: str
name: str
start_date: date
end_date: date # inclusive
brokers: str
source_url: str
source_label: str = SOURCE_LABEL
@property
def summary(self) -> str:
prefix = '[공모청약]' if self.kind == 'subscription' else '[신규상장]'
return f'{prefix} {self.name}'
@property
def description(self) -> str:
label = '청약일' if self.kind == 'subscription' else '상장일'
if self.start_date == self.end_date:
date_str = self.start_date.isoformat()
else:
date_str = f'{self.start_date.isoformat()} ~ {self.end_date.isoformat()}'
return (
f'종목명: {self.name}\n'
f'증권사: {self.brokers or "미확인"}\n'
f'{label}: {date_str}\n'
f'기준: {self.source_label}\n'
f'출처: {self.source_url}'
)
@property
def state_key(self) -> str:
return f'{self.kind}|{self.name}'
def run(cmd: list[str]) -> str:
p = subprocess.run(cmd, capture_output=True, text=True)
if p.returncode != 0:
raise RuntimeError(p.stderr.strip() or p.stdout.strip() or 'command failed')
return p.stdout
def send_telegram(text: str) -> bool:
try:
cfg = json.loads(CONFIG_PATH.read_text())
acct = cfg['channels']['telegram']['accounts'][TELEGRAM_ACCOUNT]
token = acct['botToken']
chat_ids = acct.get('allowFrom') or []
except Exception as e:
print(f'telegram cfg load failed: {e}', file=sys.stderr)
return False
if not chat_ids:
print('no telegram chat_ids', file=sys.stderr)
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 response:
if response.status != 200:
ok = False
except Exception as e:
print(f'telegram send failed: {e}', file=sys.stderr)
ok = False
return ok
def fetch(url: str, encoding: str = 'euc-kr') -> str:
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read()
if encoding == 'auto':
# 한글 페이지는 utf-8과 cp949 둘 중 하나 — strict 디코드 성공하는 쪽 사용 (네이버가 메타태그로 거짓말하는 경우 대비)
for enc in ('utf-8', 'cp949'):
try:
return raw.decode(enc)
except UnicodeDecodeError:
continue
return raw.decode('utf-8', errors='ignore')
return raw.decode(encoding, 'ignore')
def clean_text(html_fragment: str) -> str:
text = re.sub(r'<br\s*/?>', ' ', html_fragment, flags=re.I)
text = re.sub(r'<[^>]+>', ' ', text)
text = unescape(text)
text = text.replace('\xa0', ' ')
return re.sub(r'\s+', ' ', text).strip()
def parse_html_rows(table_html: str) -> list[list[str]]:
rows = []
for row in re.findall(r'<tr[^>]*>(.*?)</tr>', table_html, re.S | re.I):
cols = [clean_text(c) for c in re.findall(r'<t[dh][^>]*>(.*?)</t[dh]>', row, re.S | re.I)]
if cols:
rows.append(cols)
return rows
def parse_date_range(text: str) -> tuple[date, date] | None:
text = text.strip()
m = re.match(r'(\d{4})\.(\d{2})\.(\d{2})~(\d{2})\.(\d{2})$', text)
if not m:
return None
y, m1, d1, m2, d2 = map(int, m.groups())
return date(y, m1, d1), date(y, m2, d2)
def _naver_api_rows(payload: dict) -> list[dict]:
"""네이버 IPO progress API의 단계별 배열을 종목 단위로 합친다."""
list_keys = (
'examinationList', 'demandForecastingList', 'forecastingCompleteList',
'subscriptionList', 'subscriptionCompleteList', 'listingList',
)
if not isinstance(payload, dict) or not any(key in payload for key in list_keys):
raise ValueError('네이버 IPO API 스키마 불일치')
rows: dict[str, dict] = {}
for key in list_keys:
items = payload.get(key) or []
if not isinstance(items, list):
raise ValueError(f'네이버 IPO API {key} 타입 불일치')
for item in items:
if not isinstance(item, dict):
continue
code = (item.get('ipoCode') or item.get('itemCode') or '').strip()
name = (item.get('compName') or item.get('stockName') or '').strip()
if not code or not name:
continue
rows[code] = {**rows.get(code, {}), **item}
return list(rows.values())
def parse_naver_api_events_from(cutoff: date) -> list[EventSpec]:
payload = json.loads(fetch(NAVER_IPO_API_URL, encoding='utf-8'))
events: list[EventSpec] = []
for info in _naver_api_rows(payload):
code = info.get('ipoCode') or info.get('itemCode')
name = (info.get('compName') or info.get('stockName') or '').strip()
brokers = (info.get('orgNm') or '').strip()
source_url = f'https://stock.naver.com/ipo/{code}'
start_raw = (info.get('poStartDate') or '').strip()
end_raw = (info.get('poEndDate') or '').strip()
if start_raw and end_raw:
try:
start_date = date.fromisoformat(start_raw)
end_date = date.fromisoformat(end_raw)
except ValueError:
pass
else:
if end_date >= cutoff:
events.append(EventSpec(
'subscription', name, start_date, end_date, brokers, source_url
))
listed_raw = (info.get('lcalDate') or info.get('listingDate') or '').strip()
if listed_raw:
try:
listed_date = date.fromisoformat(listed_raw)
except ValueError:
pass
else:
if listed_date >= cutoff:
events.append(EventSpec(
'listing', name, listed_date, listed_date, brokers, source_url
))
return events
def _table_by_summary(html: str, summary: str) -> str:
match = re.search(
r'<table[^>]*summary=["\']' + re.escape(summary) + r'["\'][^>]*>(.*?)</table>',
html,
re.S | re.I,
)
if not match:
raise ValueError(f'38커뮤니케이션 테이블 없음: {summary}')
return match.group(0)
def parse_38_subscription_html(html: str, cutoff: date) -> list[EventSpec]:
events: list[EventSpec] = []
for cols in parse_html_rows(_table_by_summary(html, '공모주 청약일정')):
if len(cols) < 6 or cols[0] == '종목명':
continue
parsed = parse_date_range(cols[1])
if not parsed:
continue
start_date, end_date = parsed
if end_date < cutoff:
continue
events.append(EventSpec(
'subscription', cols[0], start_date, end_date, cols[5], SUBSCRIPTION_URL,
source_label='38커뮤니케이션 IPO',
))
return events
def parse_38_listing_html(html: str, cutoff: date) -> list[EventSpec]:
events: list[EventSpec] = []
for cols in parse_html_rows(_table_by_summary(html, '신규상장종목')):
if len(cols) < 2 or cols[0] == '기업명':
continue
try:
listed_date = date.fromisoformat(cols[1].replace('/', '-'))
except ValueError:
continue
if listed_date < cutoff:
continue
events.append(EventSpec(
'listing', cols[0], listed_date, listed_date, '', LISTING_URL,
source_label='38커뮤니케이션 IPO',
))
return events
def parse_38_events_from(cutoff: date) -> list[EventSpec]:
subscriptions = parse_38_subscription_html(fetch(SUBSCRIPTION_URL), cutoff)
listing_cutoff = cutoff - timedelta(days=LISTING_LOOKBACK_DAYS)
listings = parse_38_listing_html(fetch(LISTING_URL), listing_cutoff)
# 보조 소스는 스팩 종목명이 주 소스와 크게 달라 과거 건을 중복 생성할 수 있다.
listings = [
ev for ev in listings
if ev.start_date == cutoff or '스팩' not in ev.name
]
return subscriptions + listings
def collect_ipo_events(cutoff: date) -> tuple[list[EventSpec], str, list[str]]:
"""네이버 API를 우선 사용하고 실패·0건이면 38 파서로 자동 전환한다.
네이버 API는 상장 당일 장 시작 후 종목을 listingList에서 제거하므로, 주 파서가
정상이어도 최근 14일 신규상장을 38 목록으로 보강한다.
"""
errors: list[str] = []
try:
events = parse_naver_api_events_from(cutoff)
except Exception as e:
events = []
errors.append(f'naver_api: {e}')
parser_used = 'naver_api'
if not events:
if not errors:
errors.append('naver_api: 수집 결과 0건')
try:
events = parse_38_events_from(cutoff)
parser_used = '38_fallback'
except Exception as e:
errors.append(f'38_fallback: {e}')
raise IpoParserError(errors) from e
else:
try:
listing_cutoff = cutoff - timedelta(days=LISTING_LOOKBACK_DAYS)
recent_listings = parse_38_listing_html(fetch(LISTING_URL), listing_cutoff)
existing_keys = {ev.state_key for ev in events}
events.extend(
ev for ev in recent_listings
if ev.state_key not in existing_keys
and ev.start_date <= cutoff
and (ev.start_date == cutoff or '스팩' not in ev.name)
)
except Exception as e:
errors.append(f'38_same_day: {e}')
deduped = {ev.state_key: ev for ev in events}
return list(deduped.values()), parser_used, errors
def load_state() -> dict:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except Exception:
return {}
return {}
def save_state(state: dict):
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
def update_parser_health(
state: dict,
status: str,
errors: list[str],
total_found: int,
dry_run: bool = False,
) -> None:
"""파서 상태가 바뀔 때만 알리고, 실패한 발송은 다음 실행에서 재시도한다."""
now_iso = datetime.now(KST).isoformat()
previous = state.get('parser_health_status')
state['parser_health_status'] = status
state['parser_health_checked_at'] = now_iso
state['parser_errors'] = errors
if status == 'healthy':
state['parser_last_success_at'] = now_iso
else:
state['parser_last_failure_at'] = now_iso
if dry_run:
return
message = None
if status == 'failed' and (
previous != status or state.get('parser_alerted_status') != status
):
message = (
'[IPO 일정] 파서 전체 실패\n'
+ '\n'.join(errors)
+ '\n캘린더 동기화를 중단했고 기존 일정은 유지했습니다.'
)
elif status == 'degraded' and (
previous != status or state.get('parser_alerted_status') != status
):
source = '38커뮤니케이션으로 자동 전환했습니다.'
if not any(error.startswith('naver_api:') for error in errors):
source = '네이버 주 파서는 정상이며 당일 상장 보강 파서만 실패했습니다.'
message = (
'[IPO 일정] 파서 일부 실패\n'
+ '\n'.join(errors)
+ f'\n{source}\n수집 결과: {total_found}건\n기존 일정 자동 삭제는 중지했습니다.'
)
elif status == 'healthy' and previous in {'degraded', 'failed'}:
message = (
'[IPO 일정] 파서 복구\n'
f'네이버 IPO API와 당일 상장 보강 파서가 정상입니다.\n수집 결과: {total_found}건'
)
if message and send_telegram(message):
state['parser_alerted_status'] = status
state['parser_alert_sent_at'] = now_iso
def event_key_from_summary(summary: str) -> tuple[str, str] | None:
summary = (summary or '').strip()
if summary.startswith('[공모청약] '):
return ('subscription', summary.replace('[공모청약] ', '', 1).strip())
if summary.startswith('[신규상장] '):
return ('listing', summary.replace('[신규상장] ', '', 1).strip())
return None
def fetch_existing_events(start_date: date, end_date: date) -> dict[str, dict]:
start_dt = datetime(start_date.year, start_date.month, start_date.day, 0, 0, tzinfo=KST)
end_dt = datetime(end_date.year, end_date.month, end_date.day, 0, 0, tzinfo=KST)
out = run([
'gog', 'calendar', 'events', CALENDAR_ID,
'--from', start_dt.isoformat(), '--to', end_dt.isoformat(),
'--all-pages', '--max', '250', '--json'
])
data = json.loads(out)
existing: dict[str, list[dict]] = {}
for ev in data.get('events', []):
parsed = event_key_from_summary(ev.get('summary', ''))
if not parsed:
continue
kind, name = parsed
key = f'{kind}|{name}'
existing.setdefault(key, []).append(ev)
return existing
def create_event(ev: EventSpec, dry_run: bool = False):
start_date = ev.start_date.isoformat()
end_date = (ev.end_date + timedelta(days=1)).isoformat() # Google Calendar 종일 이벤트는 end가 exclusive
if dry_run:
print(json.dumps({'summary': ev.summary, 'date': start_date, 'description': ev.description, 'all_day': True}, ensure_ascii=False))
return
run([
'gog', 'calendar', 'create', CALENDAR_ID,
'--summary', ev.summary,
'--description', ev.description,
'--from', start_date,
'--to', end_date,
'--all-day',
'--event-color', '5' if ev.kind == 'subscription' else '10'
])
def update_event(event_id: str, ev: EventSpec, dry_run: bool = False):
start_date = ev.start_date.isoformat()
end_date = (ev.end_date + timedelta(days=1)).isoformat()
if dry_run:
print(json.dumps({'update_event_id': event_id, 'summary': ev.summary, 'date': start_date, 'description': ev.description, 'all_day': True}, ensure_ascii=False))
return 'updated'
try:
run([
'gog', 'calendar', 'update', CALENDAR_ID, event_id,
'--summary', ev.summary,
'--description', ev.description,
'--from', start_date,
'--to', end_date,
'--all-day',
'--event-color', '5' if ev.kind == 'subscription' else '10'
])
return 'updated'
except Exception:
run(['gog', 'calendar', 'delete', CALENDAR_ID, event_id, '--force', '--no-input'])
create_event(ev, dry_run=False)
return 'recreated'
def delete_event(event_id: str, dry_run: bool = False):
if dry_run:
print(json.dumps({'delete_event_id': event_id}, ensure_ascii=False))
return
run(['gog', 'calendar', 'delete', CALENDAR_ID, event_id, '--force', '--no-input'])
def event_date_range(ev: dict) -> tuple[str, str]:
start_info = ev.get('start', {})
end_info = ev.get('end', {})
start = start_info.get('date') or start_info.get('dateTime', '')[:10]
end_excl = end_info.get('date') or end_info.get('dateTime', '')[:10]
end = ''
if end_excl:
end = (date.fromisoformat(end_excl) - timedelta(days=1)).isoformat()
return start, end
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
cutoff = datetime.now(KST).date()
state = load_state()
previous_parser_used = state.get('parser_used')
try:
events, parser_used, parser_errors = collect_ipo_events(cutoff)
except IpoParserError as e:
update_parser_health(state, 'failed', e.errors, 0, dry_run=args.dry_run)
state['last_run_at'] = datetime.now(KST).isoformat()
state['parser_used'] = 'failed'
if not args.dry_run:
save_state(state)
print(json.dumps({
'cutoff_after': cutoff.isoformat(),
'parser_used': 'failed',
'parser_errors': e.errors,
'total_found': 0,
'dry_run': args.dry_run,
}, ensure_ascii=False), file=sys.stderr)
return 1
events.sort(key=lambda e: (e.start_date, e.kind, e.name))
parser_health = 'degraded' if parser_errors else 'healthy'
update_parser_health(
state, parser_health, parser_errors, len(events), dry_run=args.dry_run
)
state['parser_used'] = parser_used
if not args.dry_run:
# 알림 상태를 먼저 저장해 이후 캘린더 작업 실패 시 같은 경보가 반복되지 않게 한다.
save_state(state)
end_date = max((ev.end_date for ev in events), default=cutoff) + timedelta(days=1)
# 사이트 스크래핑 실패 시 모든 일정이 삭제되는 사고 방지 — events 비면 cleanup 스킵
listing_cutoff = cutoff - timedelta(days=LISTING_LOOKBACK_DAYS)
existing = {} if not events else fetch_existing_events(listing_cutoff, end_date)
created = []
updated = []
recreated = []
deleted = []
duplicates_removed = []
unchanged = 0
changes = []
event_keys = {ev.state_key for ev in events}
for ev in events:
existing_list = existing.get(ev.state_key, [])
# 같은 state_key로 여러 건이면 가장 최근 created를 남기고 나머지 삭제
if len(existing_list) > 1:
existing_list.sort(key=lambda e: e.get('created', ''), reverse=True)
for dup in existing_list[1:]:
delete_event(dup['id'], dry_run=args.dry_run)
duplicates_removed.append(ev.state_key)
old_start, old_end = event_date_range(dup)
old_date = old_start if old_start == old_end or not old_end else f'{old_start}~{old_end}'
changes.append({'kind': ev.kind, 'name': ev.name, 'action': 'duplicate_removed', 'event_id': dup['id'], 'old_date': old_date})
existing_ev = existing_list[0] if existing_list else None
if not existing_ev:
create_event(ev, dry_run=args.dry_run)
created.append(ev.state_key)
changes.append({'kind': ev.kind, 'name': ev.name, 'action': 'created', 'new_date': ev.start_date.isoformat() if ev.start_date == ev.end_date else f'{ev.start_date.isoformat()}~{ev.end_date.isoformat()}'})
continue
existing_start, existing_end = event_date_range(existing_ev)
desired_start = ev.start_date.isoformat()
desired_end = ev.end_date.isoformat()
existing_description = (existing_ev.get('description') or '').strip()
desired_description = ev.description.strip()
if existing_start != desired_start or existing_end != desired_end or existing_description != desired_description:
result = update_event(existing_ev['id'], ev, dry_run=args.dry_run)
if result == 'recreated':
recreated.append(ev.state_key)
else:
updated.append(ev.state_key)
old_date = existing_start if existing_start == existing_end or not existing_end else f'{existing_start}~{existing_end}'
new_date = desired_start if desired_start == desired_end else f'{desired_start}~{desired_end}'
changes.append({'kind': ev.kind, 'name': ev.name, 'action': result, 'old_date': old_date, 'new_date': new_date})
else:
unchanged += 1
# 보조 소스는 종목명 표기가 다를 수 있어 failover 실행 중에는 stale 삭제를 하지 않는다.
# 장기간 장애 후 복구 첫 실행도 오탐 삭제를 막고, 주 파서가 2회 연속 정상일 때만 정리한다.
cleanup_allowed = parser_used == 'naver_api' and previous_parser_used == 'naver_api'
if cleanup_allowed:
for key, existing_list in existing.items():
if key in event_keys:
continue
kind, name = key.split('|', 1)
for stale in existing_list:
old_start, old_end = event_date_range(stale)
# 소급 조회는 누락 상장 보강용이다. 이미 지난 일정은 삭제하지 않는다.
if old_end and date.fromisoformat(old_end) < cutoff:
continue
delete_event(stale['id'], dry_run=args.dry_run)
deleted.append(key)
old_date = old_start if old_start == old_end or not old_end else f'{old_start}~{old_end}'
changes.append({'kind': kind, 'name': name, 'action': 'deleted', 'event_id': stale['id'], 'old_date': old_date})
state['last_changes'] = changes
state['last_run_at'] = datetime.now(KST).isoformat()
if not args.dry_run:
save_state(state)
print(json.dumps({'cutoff_after': cutoff.isoformat(), 'parser_used': parser_used, 'parser_errors': parser_errors, 'total_found': len(events), 'newly_created': len(created), 'updated': len(updated), 'recreated': len(recreated), 'deleted': len(deleted), 'duplicates_removed': len(duplicates_removed), 'unchanged': unchanged, 'dry_run': args.dry_run, 'changes': changes}, ensure_ascii=False))
return 0
if __name__ == '__main__':
raise SystemExit(main())