#!/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'', ' ', 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']*>(.*?)', table_html, re.S | re.I): cols = [clean_text(c) for c in re.findall(r']*>(.*?)', 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']*summary=["\']' + re.escape(summary) + r'["\'][^>]*>(.*?)', 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())