d8a778cf67
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
583 lines
22 KiB
Python
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())
|