#!/usr/bin/env python3 import json import os import re import subprocess import sys import urllib.parse import urllib.request import xml.etree.ElementTree as ET from email.utils import parsedate_to_datetime from html import unescape from datetime import datetime, timedelta from pathlib import Path from typing import Optional from zoneinfo import ZoneInfo KST = ZoneInfo('Asia/Seoul') WORKSPACE = Path('/Users/snowoyh/.openclaw/workspace') STATE_DIR = WORKSPACE / 'state' STATE_DIR.mkdir(parents=True, exist_ok=True) SEND_STATE = STATE_DIR / 'briefing_send_state.json' IPO_SYNC_STATE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace/state/ipo_calendar_sync.json') IPO_SEEN_STATE = STATE_DIR / 'briefing_ipo_seen.json' NEWS_HISTORY_FILE = STATE_DIR / 'news_sent_history.json' PENDING_SELECTION_FILE = STATE_DIR / 'briefing_pending_selection.json' NEWS_HISTORY_LOOKBACK_HOURS = 48 NEWS_HISTORY_RETENTION_DAYS = 7 RECIPIENT = 'mini.snowoyh@gmail.com' NEWS_FEEDS = [ ('국내 경제', 'https://www.yna.co.kr/rss/economy.xml'), ('국제', 'https://www.yna.co.kr/rss/international.xml'), ('국제', 'https://rss.edaily.co.kr/world_news.xml'), ('증시', 'https://rss.edaily.co.kr/stock_news.xml'), ] MARKET_FEEDS = [ ('USD/KRW', 'https://query1.finance.yahoo.com/v8/finance/chart/KRW%3DX?range=2d&interval=1d'), ] NAVER_INDEX_FEEDS = [ ('.INX', 'https://m.stock.naver.com/worldstock/index/.INX/total'), ('.IXIC', 'https://m.stock.naver.com/worldstock/index/.IXIC/total'), ] NAVER_DOMESTIC_INDEX_FEEDS = [ ('KOSPI', 'https://m.stock.naver.com/domestic/index/KOSPI/total'), ('KOSDAQ', 'https://m.stock.naver.com/domestic/index/KOSDAQ/total'), ] KOSPI_FUTURES_INVESTING_URL = 'https://kr.investing.com/indices/korea-200-futures' MSCI_KOREA_INVESTING_URL = 'https://kr.investing.com/etfs/ishares-south-korea-index' def run(cmd: list[str]) -> str: p = subprocess.run(cmd, capture_output=True, text=True) if p.returncode != 0: raise RuntimeError(f"command failed: {' '.join(cmd)}\n{p.stderr.strip()}") return p.stdout def load_json(path: Path, default): if path.exists(): try: return json.loads(path.read_text()) except Exception as e: print(f"[briefing_mail:load_json:{path}] {type(e).__name__}: {e}", file=sys.stderr) return default return default def save_json(path: Path, data): path.write_text(json.dumps(data, ensure_ascii=False, indent=2)) def fetch(url: str) -> str: req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) with urllib.request.urlopen(req, timeout=30) as r: return r.read().decode('utf-8', 'ignore') def fetch_bytes(url: str) -> bytes: req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) with urllib.request.urlopen(req, timeout=30) as r: return r.read() def get_today_events(target_date: datetime) -> list[str]: start = target_date.astimezone(KST).replace(hour=0, minute=0, second=0, microsecond=0) end = start + timedelta(days=1) out = run([ 'gog', 'calendar', 'events', 'mini.snowoyh@gmail.com', '--from', start.isoformat(), '--to', end.isoformat(), '--json' ]) data = json.loads(out) items = [] for ev in data.get('events', []): summary = ev.get('summary', '(제목 없음)') s = ev.get('start', {}) e = ev.get('end', {}) if 'dateTime' in s: st = datetime.fromisoformat(s['dateTime']).astimezone(KST) en = datetime.fromisoformat(e['dateTime']).astimezone(KST) if 'dateTime' in e else None times = f"{st:%H:%M}" if en: times += f"-{en:%H:%M}" else: times = '종일' items.append(f"- {summary} ({times})") return items def section(title: str, lines: list[str]) -> list[str]: if not lines: return [] return [f'■ {title}'] + lines + [''] def clean_title(title: str) -> str: title = re.sub(r'\s+', ' ', title).strip() title = re.sub(r'\s*-\s*[^-]+$', '', title) return title DESCRIPTION_MAX_LEN = 500 def clean_description(desc: str) -> str: if not desc: return '' text = re.sub(r'<[^>]+>', ' ', desc) text = unescape(text) text = re.sub(r'\(서울=연합뉴스\)\s*[^=]{0,40}기자\s*=\s*', '', text) text = re.sub(r'\s+', ' ', text).strip() if len(text) > DESCRIPTION_MAX_LEN: text = text[:DESCRIPTION_MAX_LEN].rstrip() + '…' return text def source_from_link(link: str) -> str: m = re.search(r'https?://(?:www\.|rss\.)?([^/]+)', link or '') if not m: return '' host = m.group(1).lower() mapping = { 'yna.co.kr': '연합뉴스', 'edaily.co.kr': '이데일리', 'mk.co.kr': '매일경제', 'hankyung.com': '한국경제', } for key, name in mapping.items(): if host.endswith(key): return name return host def parse_google_news_feed(url: str) -> list[dict]: xml_text = fetch(url) try: root = ET.fromstring(xml_text) except ET.ParseError: # Recover from bare-& in attribute values (common RSS breakage, e.g. youtube embed URLs). fixed = re.sub(r'&(?!#?\w+;)', '&', xml_text) root = ET.fromstring(fixed) items = [] for item in root.findall('./channel/item'): title = clean_title(item.findtext('title', default='')) link = item.findtext('link', default='').strip() pub_date = item.findtext('pubDate', default='').strip() description = clean_description(item.findtext('description', default='')) source_el = item.find('source') source = source_el.text.strip() if source_el is not None and source_el.text else '' if not source: source = source_from_link(link) pub_dt = None if pub_date: try: pub_dt = parsedate_to_datetime(pub_date) if pub_dt.tzinfo is None: pub_dt = pub_dt.replace(tzinfo=KST) except (TypeError, ValueError): pub_dt = None if title and link: items.append({ 'title': title, 'link': link, 'pubDate': pub_date, 'pub_dt': pub_dt, 'source': source, 'description': description, }) return items def news_priority(title: str) -> int: t = title.lower() score = 0 keywords = { '전쟁': 7, '침공': 6, '공습': 6, '폭격': 6, '포격': 6, '미사일': 6, '휴전': 6, '정전': 6, '총격': 6, '총기난사': 6, '저격': 6, '암살': 6, '피습': 6, '테러': 6, '핵실험': 6, '관세': 5, '금리': 5, '환율': 5, '인플레이션': 5, 'cpi': 5, 'fomc': 5, '지정학': 5, '제재': 5, '반도체': 4, 'ai': 4, '실적': 4, '수출': 4, '경기': 4, '유가': 4, '코스피': 3, '코스닥': 3, '나스닥': 3, 's&p': 3, '증시': 3, '달러': 3, '국채': 3, '트럼프': 3, '푸틴': 3, '시진핑': 3, '김정은': 3, } for k, w in keywords.items(): if k in t: score += w return score def summarize_news_title(title: str) -> tuple[str, str, str, str]: t = title.strip() rules = [ ( '관세', '핵심: 관세가 올라간다는 뜻입니다. 물건을 수출할 때 더 많은 돈을 내야 한다는 얘기입니다.', '의미: 삼성이나 LG 같은 큰 회사들이 장난감을 팔 때 비용이 더 커집니다. 그럼 이익이 줄어들 수 있어요.', '체크: 한국 회사들이 미국이나 다른 나라에 물건을 많이 팔기 때문에 관세 소식을 정말 자세히 봐야 합니다.', '제 의견: 관세는 뉴스가 나온 후 2~3일 더 지켜봐야 돼요. 한 번 뉴스 떴다고 끝이 아니니까요.' ), ( '금리', '핵심: 금리가 올라가거나 내려간다는 뜻입니다. 은행에 돈을 맡길 때 받는 이자 같은 거요.', '의미: 금리가 올라가면 돈을 빌려 쓰는 회사들이 더 많이 내야 해요. 그럼 회사 이익이 줄어들 수 있습니다.', '체크: 달러 환율과 채권 금리가 함께 움직이는지 봐야 해요. 그럼 시장이 어떻게 될지 더 쉽게 알 수 있어요.', '제 의견: 금리 뉴스는 한 번으로 끝나지 않아요. 2~3일 동안 계속 주의 깊게 봐야 합니다.' ), ( '환율', '핵심: 원화와 달러의 교환 비율이 바뀐다는 뜻입니다. 달러가 올라가면 한국 돈이 약해진다는 거예요.', '의미: 한국 회사가 미국에 물건을 팔면 원화로 받는 돈이 줄어들어요. 반대로 물건을 싸게 만들 수 있어서 도움도 되고요.', '체크: 외국 사람들이 한국 주식을 사거나 팔 때 환율이 얼마나 영향을 주는지 봐야 해요.', '제 의견: 환율은 숫자가 얼마 올랐냐보다 빠르게 변하는 게 더 중요해요. 빠르게 변하면 위험신호입니다.' ), ( '반도체', '핵심: 반도체(스마트폰이나 컴퓨터에 들어가는 아주 작은 부품) 소식이 나왔다는 뜻입니다.', '의미: 반도체 소식은 삼성, SK하이닉스 같은 큰 회사에 직접 영향을 줘요. 그럼 한국 전체 주가에도 미쳐요.', '체크: 반도체 수요가 정말 늘어나는 건지, 아니면 그냥 하루 뉴스인지 구분해서 봐야 합니다.', '제 의견: 반도체는 장기로 수요가 늘어날지를 보는 게 가장 중요해요. 한 번 소식보다요.' ), ( '실적', '핵심: 회사가 얼마나 돈을 벌었는지 발표한 것입니다. 좋은 결과인지 나쁜 결과인지를 뜻해요.', '의미: 실적이 좋으면 주가가 올라가고, 나쁘면 내려가요. 아주 중요한 신호입니다.', '체크: 실적이 시장이 기대했던 것보다 좋았는지 나빴는지를 봐야 해요. 그게 더 중요합니다.', '제 의견: 실적 뉴스가 나왔을 때는 하루 반응보다 그 다음날 움직임을 봐야 돼요. 정확해집니다.' ), ( '유가', '핵심: 석유 값이 올라가거나 내려간다는 뜻입니다. 세계 모든 나라의 물가에 영향을 줘요.', '의미: 유가가 올라가면 휘발유, 택시, 배송비가 모두 비싸져요. 그럼 회사 비용이 늘어납니다.', '체크: 환율과 유가가 같이 올라가는지 봐야 해요. 같이 올라가면 한국 회사들 부담이 훨씬 커집니다.', '제 의견: 유가는 한두 번 변해도 괜찮아요. 추세가 바뀌는 게 더 중요합니다. 계속 올라갈 건지 내려갈 건지요.' ), ( '나스닥', '핵심: 미국의 기술주 지수가 올라가거나 내려갔다는 뜻입니다. 애플, 구글, 마이크로소프트 같은 회사들이요.', '의미: 미국 기술주가 올라가면 한국의 AI, 반도체 회사들도 주가가 올라갈 가능성이 커집니다.', '체크: 정말 미국 기술주 전체가 올라갔는지, 아니면 몇 개 회사만 올라갔는지 구분해서 봐야 합니다.', '제 의견: 미국에서 좋은 소식이 나도 한국에선 반대가 될 수 있어요. 항상 신중하게 봐야 합니다.' ), ( '코스피', '핵심: 한국 큰 회사들(삼성, SK, 현대 같은)의 주가 지수가 변했다는 뜻입니다.', '의미: 코스피가 올라가면 한국 경제가 잘 나간다는 신호예요. 외국인도 한국 주식을 사고 싶다는 뜻이기도 해요.', '체크: 원화 환율과 외국인이 얼마나 사고 파는지를 함께 봐야 해요. 그럼 진짜 강한 건지 약한 건지 알 수 있습니다.', '제 의견: 코스피 뉴스 하나보다 계속 올라가는 흐름이 중요해요. 한 번 올랐다고 끝이 아니니까요.' ), ( '코스닥', '핵심: 한국의 작은 회사, 새로운 회사들(스타트업 같은)의 주가 지수가 변했다는 뜻입니다.', '의미: 코스닥이 올라가면 젊은 세대, 벤처 회사들이 잘 나간다는 신호예요. 위험도는 높지만요.', '체크: 코스닥이 올라갈 때 거래량도 함께 늘어나는지 봐야 해요. 거래량이 없으면 진짜가 아니거든요.', '제 의견: 코스닥은 재미있는 테마가 많지만 아주 빠르게 변해요. 계속 지켜봐야 합니다.' ), ] for keyword, line1, line2, line3, opinion in rules: if keyword.lower() in t.lower(): return line1, line2, line3, opinion return ( '핵심: 시장에 영향을 줄 수 있는 소식이 나왔습니다.', '의미: 이 소식이 어떤 회사들한테 좋은지 나쁜지 생각해봐야 합니다.', '체크: 이 뉴스가 그 다음날, 그 다음주에도 계속 영향을 주는지 지켜봐야 합니다.', '제 의견: 뉴스 제목만 보지 말고 실제로 주가가 어떻게 움직이는지 봐야 가장 정확해요.' ) NEWS_CUTOFF_SCORE = 5 NEWS_PER_CATEGORY_CAP = 2 NEWS_TOTAL_CAP = 12 # 전체 상한: 카테고리별 cap 통과 후 점수순 상위 N건만 NEWS_DUP_THRESHOLD = 0.40 # min-overlap ratio: overlap / min(|A|,|B|) >= threshold → 같은 사안으로 간주 def _title_tokens(title: str) -> set: return set(re.findall(r'[가-힣0-9A-Za-z]{2,}', title.lower())) def _is_similar_to_seen(tokens: set, seen_token_sets: list, threshold: float = NEWS_DUP_THRESHOLD) -> bool: if not tokens: return False for t in seen_token_sets: if not t: continue denom = min(len(tokens), len(t)) if not denom: continue if len(tokens & t) / denom >= threshold: return True return False def briefing_cutoff(mode: str, now: datetime) -> datetime: """뉴스 발행시각 하한선: 이 시각 이후 발행분만 포함.""" if mode == 'morning': return (now - timedelta(days=1)).replace(hour=19, minute=0, second=0, microsecond=0) return now.replace(hour=7, minute=0, second=0, microsecond=0) def _normalize_news_key(title: str) -> str: return re.sub(r'[^0-9A-Za-z가-힣]+', '', title).lower() def _parse_iso(s: Optional[str]) -> datetime: if not s: return datetime.min.replace(tzinfo=KST) try: dt = datetime.fromisoformat(s) if dt.tzinfo is None: dt = dt.replace(tzinfo=KST) return dt except Exception: return datetime.min.replace(tzinfo=KST) def load_news_history() -> dict: return load_json(NEWS_HISTORY_FILE, {'items': []}) def gc_news_history(history: dict, now: datetime) -> dict: cutoff = now - timedelta(days=NEWS_HISTORY_RETENTION_DAYS) history['items'] = [ it for it in history.get('items', []) if _parse_iso(it.get('sent_at')) >= cutoff ] return history def recent_history_keys(history: dict, now: datetime, lookback_hours: int = NEWS_HISTORY_LOOKBACK_HOURS) -> set: cutoff = now - timedelta(hours=lookback_hours) return { it['key'] for it in history.get('items', []) if it.get('key') and _parse_iso(it.get('sent_at')) >= cutoff } def mark_news_sent_from_pending(mode: str, today_key: str, now: datetime) -> int: """발송 성공 후 pending selection을 history로 옮기고 pending에서 제거. 반환: 추가된 항목 수.""" pending = load_json(PENDING_SELECTION_FILE, {}) selection = pending.get(mode) or {} if selection.get('date') != today_key: return 0 articles = selection.get('articles', []) or [] history = load_news_history() history = gc_news_history(history, now) sent_at = now.isoformat() added = 0 for a in articles: key = a.get('key') if not key: continue history['items'].append({ 'key': key, 'title': a.get('title'), 'url': a.get('url'), 'mode': mode, 'sent_at': sent_at, }) added += 1 save_json(NEWS_HISTORY_FILE, history) pending.pop(mode, None) save_json(PENDING_SELECTION_FILE, pending) return added def collect_news_articles( cutoff_score: int = NEWS_CUTOFF_SCORE, per_category_cap: int = NEWS_PER_CATEGORY_CAP, published_after: Optional[datetime] = None, exclude_keys: Optional[set] = None, ) -> list[dict]: """Return structured news articles passing cutoff + per-category cap + fuzzy title dedup. published_after: 발행일이 이보다 이전인 항목 제외 (pub_dt 없으면 통과 — history에서 막힘) exclude_keys: normalized title이 이 set에 있으면 제외 (최근 발송 이력) """ exclude_keys = exclude_keys or set() seen_norm = set() seen_token_sets = [] result = [] for category, url in NEWS_FEEDS: try: items = parse_google_news_feed(url) except Exception as e: print(f"[briefing_mail:news_feed:{category}] {type(e).__name__}: {e}", file=sys.stderr) continue candidates = [] for item in items: title = item['title'] normalized = re.sub(r'[^0-9A-Za-z가-힣]+', '', title).lower() if not normalized: continue pub_dt = item.get('pub_dt') if published_after and pub_dt and pub_dt < published_after: continue if normalized in exclude_keys: continue score = news_priority(title) if score < cutoff_score: continue candidates.append({ 'category': category, 'title': title, 'link': item.get('link', ''), 'source': item.get('source', ''), 'description': item.get('description', ''), 'score': score, '_norm': normalized, '_tokens': _title_tokens(title), }) candidates.sort(key=lambda x: x['score'], reverse=True) cat_accepted = 0 for c in candidates: if cat_accepted >= per_category_cap: break if c['_norm'] in seen_norm: continue if _is_similar_to_seen(c['_tokens'], seen_token_sets): continue seen_norm.add(c['_norm']) seen_token_sets.append(c['_tokens']) result.append({k: v for k, v in c.items() if not k.startswith('_')}) cat_accepted += 1 if NEWS_TOTAL_CAP and len(result) > NEWS_TOTAL_CAP: result = sorted(result, key=lambda x: x['score'], reverse=True)[:NEWS_TOTAL_CAP] return result def get_simple_news(limit: Optional[int] = None) -> list[str]: """Legacy text-line output. Applies new cutoff/cap logic. `limit` caps total items (no forced min).""" picked = collect_news_articles() if limit is not None: picked = sorted(picked, key=lambda x: x['score'], reverse=True)[:limit] if not picked: return ['- 지금 기준 통과한 핵심 경제 뉴스가 없습니다.'] lines = [] for item in picked: lines.append(f"- [{item['category']}] {item['title']}") if item.get('source'): lines.append(f" 출처: {item['source']}") if item.get('link'): lines.append(f" 원문: {item['link']}") lines.append('') if lines and lines[-1] == '': lines.pop() return lines def format_change(symbol: str, prev_close: float, close: float) -> str: diff = close - prev_close pct = (diff / prev_close * 100) if prev_close else 0.0 arrow = '▲' if diff > 0 else '▼' if diff < 0 else '-' return f'- {symbol}: {close:,.2f} ({arrow} {abs(diff):,.2f}, {abs(pct):.2f}%)' def get_naver_index(code: str, page_url: str, domestic: bool = False): try: base = 'https://m.stock.naver.com/api' if domestic else 'https://api.stock.naver.com' raw = fetch_bytes(f'{base}/index/{code}/basic') data = json.loads(raw.decode('utf-8', 'ignore')) name = data.get('indexName') or data.get('stockName') or code price = data.get('closePrice', '') change = data.get('compareToPreviousClosePrice', '') pct = data.get('fluctuationsRatio', '') if not price: return None try: change_f = float(change.replace(',', '')) arrow = '▲' if change_f > 0 else '▼' if change_f < 0 else '-' suffix = f' ({arrow} {abs(change_f):,.2f}, {abs(float(pct)):.2f}%)' except (ValueError, TypeError): suffix = '' return f'- {name}: {price}{suffix}' except Exception as e: print(f"[briefing_mail:naver_index:{code}] {type(e).__name__}: {e}", file=sys.stderr) return None def get_market_snapshot(include_domestic: bool = False) -> list[str]: lines = [] fx_lines = [] if include_domestic: for code, page_url in NAVER_DOMESTIC_INDEX_FEEDS: line = get_naver_index(code, page_url, domestic=True) if line: lines.append(line) for code, page_url in NAVER_INDEX_FEEDS: line = get_naver_index(code, page_url) if line: lines.append(line) for symbol, url in MARKET_FEEDS: try: raw = fetch_bytes(url) data = json.loads(raw.decode('utf-8', 'ignore')) result = data['chart']['result'][0] closes = [x for x in result['indicators']['quote'][0]['close'] if x is not None] if len(closes) >= 2: fx_lines.append(format_change(symbol, closes[-2], closes[-1])) elif len(closes) == 1: fx_lines.append(f'- {symbol}: {closes[-1]:,.2f}') except Exception as e: print(f"[briefing_mail:market_feed:{symbol}] {type(e).__name__}: {e}", file=sys.stderr) continue merged = lines + fx_lines return merged or ['- 주요 시장 지표를 불러오지 못했습니다.'] def parse_investing_quote(url: str, fallback_title: str) -> Optional[str]: try: html = fetch(url) title_match = re.search(r'\s*([^<]+?)\s*', html, re.IGNORECASE) title = unescape(title_match.group(1)).strip() if title_match else fallback_title price_match = re.search(r'data-test="instrument-price-last">\s*([^<]+?)\s*<', html) change_match = re.search(r'data-test="instrument-price-change">\s*([^<]+?)\s*<', html) pct_match = re.search(r'data-test="instrument-price-change-percent">\s*([^<]+?)\s*<', html) price = unescape(price_match.group(1)).strip() if price_match else '' change = unescape(change_match.group(1)).strip() if change_match else '' pct = unescape(pct_match.group(1)).strip() if pct_match else '' if price: clean_title = re.sub(r'\s*선물 시세\s*$', '', title).strip() change_clean = change.replace('+', '').strip() pct_clean = pct.replace('(', '').replace(')', '').strip() arrow = '-' if change.startswith('+'): arrow = '▲' elif change.startswith('-'): arrow = '▼' suffix = '' if change_clean and pct_clean: suffix = f' ({arrow} {change_clean}, {pct_clean})' elif change_clean: suffix = f' ({arrow} {change_clean})' elif pct_clean: suffix = f' ({pct_clean})' return f'- {clean_title}: {price}{suffix}' except Exception as e: print(f"[briefing_mail:investing_quote:{fallback_title}] {type(e).__name__}: {e}", file=sys.stderr) return None return None def get_kospi_night_futures() -> list[str]: line = parse_investing_quote(KOSPI_FUTURES_INVESTING_URL, '코스피200 선물 (F)') if line: line = re.sub(r'^-\s*[^:]+:', '- 코스피 선물:', line) return [line] return ['- 코스피 야간선물 데이터를 불러오지 못했습니다.'] def get_msci_korea() -> list[str]: line = parse_investing_quote(MSCI_KOREA_INVESTING_URL, 'MSCI') if line: line = re.sub(r'^-\s*[^:]+:', '- MSCI:', line) return [line] return ['- MSCI 데이터를 불러오지 못했습니다.'] def was_already_sent(mode: str, today_key: str) -> bool: state = load_json(SEND_STATE, {}) return state.get(mode) == today_key def mark_sent(mode: str, today_key: str): state = load_json(SEND_STATE, {}) state[mode] = today_key save_json(SEND_STATE, state) IPO_KIND_LABEL = {'subscription': '청약', 'listing': '상장'} IPO_ACTION_LABEL = {'created': '신규', 'updated': '변경', 'recreated': '변경', 'deleted': '취소'} def _format_ipo_change(item: dict) -> Optional[str]: kind = (item.get('kind') or '').strip() action = (item.get('action') or '').strip() name = (item.get('name') or '').strip() if not name or action not in IPO_ACTION_LABEL: return None kind_label = IPO_KIND_LABEL.get(kind, kind or '?') new_date = (item.get('new_date') or '').strip() old_date = (item.get('old_date') or '').strip() if action in ('updated', 'recreated') and old_date and new_date and old_date != new_date: return f'- [{kind_label}] {name} (변경, {old_date} → {new_date})' if action == 'deleted': return f'- [{kind_label}] {name} (취소)' + (f' — 기존 {old_date}' if old_date else '') if new_date: return f'- [{kind_label}] {name} ({IPO_ACTION_LABEL[action]}, {new_date})' return f'- [{kind_label}] {name} ({IPO_ACTION_LABEL[action]})' def read_ipo_changes_for_brief() -> list[str]: """IPO sync state(`ipo_calendar_sync.json`)에서 아직 브리핑에 노출되지 않은 changes를 줄 목록으로 반환. sync 자체는 launchd(`ai.openclaw.stock.ipo-calendar-sync`, 매주 금 17:00)가 책임짐. 여기서는 읽기만. 같은 sync 결과(last_run_at 동일)는 한 번만 노출하기 위해 `briefing_ipo_seen.json` dedup 사용. """ state = load_json(IPO_SYNC_STATE, {}) last_run = (state.get('last_run_at') or '').strip() if not last_run: return [] seen = load_json(IPO_SEEN_STATE, {}) if seen.get('last_seen_run_at') == last_run: return [] lines = [] for item in state.get('last_changes', []) or []: line = _format_ipo_change(item) if line: lines.append(line) return lines def mark_ipo_changes_seen(): state = load_json(IPO_SYNC_STATE, {}) last_run = (state.get('last_run_at') or '').strip() if not last_run: return save_json(IPO_SEEN_STATE, {'last_seen_run_at': last_run}) def get_tomorrow_listings(now: datetime) -> list[str]: """내일 [신규상장] 종목을 캘린더에서 추출 (D-1 임박 알림용).""" start = now.astimezone(KST).replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) end = start + timedelta(days=1) try: out = run([ 'gog', 'calendar', 'events', 'mini.snowoyh@gmail.com', '--from', start.isoformat(), '--to', end.isoformat(), '--json' ]) data = json.loads(out) except Exception as e: print(f"[briefing_mail:tomorrow_listings] {type(e).__name__}: {e}", file=sys.stderr) return [] lines = [] for ev in data.get('events', []): summary = (ev.get('summary') or '').strip() if not summary.startswith('[신규상장]'): continue name = summary.replace('[신규상장]', '', 1).strip() date_str = (ev.get('start', {}) or {}).get('date') or '' lines.append(f'- {name} ({date_str})' if date_str else f'- {name}') return lines def build_morning() -> tuple[str, str]: now = datetime.now(KST) events = get_today_events(now) ipo_changes = read_ipo_changes_for_brief() tomorrow_listings = get_tomorrow_listings(now) body = [ f'관리자님, 좋은 아침입니다.', f'{now:%Y-%m-%d} 오전 브리핑만 간단히 정리드리겠습니다.', '' ] body += section('오늘 일정', events or ['- 오늘 등록된 일정이 없습니다.']) body += section('내일 상장', tomorrow_listings) body += section('공모일정 변경', ipo_changes) body += section('시장 체크', get_kospi_night_futures() + get_msci_korea() + get_market_snapshot()) body += section('중요 뉴스 요약', get_simple_news()) if body and body[-1] == '': body.pop() return (f'[오전 브리핑] {now:%Y-%m-%d}', '\n'.join(body)) def build_evening() -> tuple[str, str]: now = datetime.now(KST) tomorrow = now + timedelta(days=1) events = get_today_events(tomorrow) ipo_changes = read_ipo_changes_for_brief() tomorrow_listings = get_tomorrow_listings(now) body = [ f'관리자님, 오늘 마감 브리핑입니다.', f'{now:%Y-%m-%d} 저녁 기준으로 짧게 정리드리겠습니다.', '' ] body += section('내일 일정', events or ['- 내일 등록된 일정이 없습니다.']) body += section('내일 상장', tomorrow_listings) body += section('공모일정 변경', ipo_changes) body += section('오늘 증시 간단 요약', get_market_snapshot(include_domestic=True)) body += section('핵심 뉴스', get_simple_news(3)) if body and body[-1] == '': body.pop() return (f'[오후 브리핑] {now:%Y-%m-%d}', '\n'.join(body)) def send_mail(subject: str, body: str, html: bool = False) -> str: base = ['gog', 'gmail', 'send', '--to', RECIPIENT, '--subject', subject] if html: cmd = base + ['--body-html', body] p = subprocess.run(cmd, text=True, capture_output=True) else: cmd = base + ['--body-file', '-'] p = subprocess.run(cmd, input=body, text=True, capture_output=True) if p.returncode != 0: raise RuntimeError(p.stderr.strip() or p.stdout.strip()) return p.stdout.strip() WEEKDAY_KR = ['월', '화', '수', '목', '금', '토', '일'] def build_prepare_payload(mode: str) -> dict: if mode not in {'morning', 'evening'}: raise SystemExit('mode must be morning or evening') now = datetime.now(KST) target = now if mode == 'morning' else (now + timedelta(days=1)) events = get_today_events(target) ipo_changes = read_ipo_changes_for_brief() tomorrow_listings = get_tomorrow_listings(now) cutoff = briefing_cutoff(mode, now) history = load_news_history() history = gc_news_history(history, now) save_json(NEWS_HISTORY_FILE, history) exclude_keys = recent_history_keys(history, now) articles = collect_news_articles(published_after=cutoff, exclude_keys=exclude_keys) today_key = now.strftime('%Y-%m-%d') pending = load_json(PENDING_SELECTION_FILE, {}) pending[mode] = { 'date': today_key, 'articles': [ { 'key': _normalize_news_key(a['title']), 'title': a['title'], 'url': a.get('link', ''), } for a in articles ], } save_json(PENDING_SELECTION_FILE, pending) return { 'mode': mode, 'date': today_key, 'date_target': target.strftime('%Y-%m-%d'), 'weekday_kr': WEEKDAY_KR[now.weekday()], 'events': events, 'ipo_changes': ipo_changes, 'tomorrow_listings': tomorrow_listings, 'articles': articles, 'news_window': { 'published_after': cutoff.isoformat(), 'history_lookback_hours': NEWS_HISTORY_LOOKBACK_HOURS, 'excluded_recent_count': len(exclude_keys), }, 'market': { 'futures': get_kospi_night_futures() if mode == 'morning' else [], 'msci': get_msci_korea() if mode == 'morning' else [], 'indices': get_market_snapshot(include_domestic=(mode == 'evening')), }, 'already_sent': was_already_sent(mode, today_key), } def cmd_prepare(mode: str) -> int: payload = build_prepare_payload(mode) print(json.dumps(payload, ensure_ascii=False, indent=2)) return 0 def html_escape(s: str) -> str: return (str(s) .replace('&', '&') .replace('<', '<') .replace('>', '>') .replace('"', '"') .replace("'", ''')) HTML_STYLES = { 'wrap': "font-family:-apple-system,BlinkMacSystemFont,'Apple SD Gothic Neo',sans-serif;font-size:14px;color:#222;max-width:680px;line-height:1.55;", 'greet': 'color:#444;margin-bottom:18px;', 'card': 'margin-bottom:18px;border:1px solid #e5e5e5;border-radius:8px;padding:14px 16px;background:#fafafa;', 'card_title': 'font-size:14px;font-weight:700;color:#111;margin-bottom:10px;', 'line': 'color:#333;font-size:13px;margin:3px 0;', 'market_line': 'color:#333;font-size:13px;margin:4px 0;', 'market_label': 'color:#444;', 'market_value': "font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#111;", 'pos': 'color:#d24f4f;font-weight:600;', 'neg': 'color:#1565c0;font-weight:600;', 'flat': 'color:#666;', 'news_section': 'font-size:14px;font-weight:700;color:#111;margin:24px 0 10px;border-left:3px solid #333;padding-left:8px;', 'article': 'margin-bottom:12px;border:1px solid #eee;border-radius:6px;padding:12px 14px;background:#fff;', 'category': 'display:inline-block;background:#eef3ff;color:#0066cc;font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;margin-right:6px;vertical-align:middle;', 'title': 'color:#111;font-weight:600;font-size:14px;', 'source': 'color:#888;font-size:12px;margin:6px 0 8px;', 'link': 'color:#0066cc;text-decoration:none;', 'summary': 'color:#333;font-size:13px;line-height:1.6;', } def _strip_bullet(text: str) -> str: return re.sub(r'^\s*-\s*', '', text).strip() def _split_market_line(line: str) -> tuple[str, str, str]: """`- 코스피: 6,641.02 (▲ 25.99, 0.39%)` → (label, value, change)""" text = _strip_bullet(line) label, _, rest = text.partition(':') rest = rest.strip() change = '' value = rest if '(' in rest and rest.rstrip().endswith(')'): head, _, tail = rest.rpartition('(') value = head.strip() change = tail.rstrip(')').strip() return label.strip(), value, change def _market_change_color(change: str) -> str: if '▲' in change: return HTML_STYLES['pos'] if '▼' in change: return HTML_STYLES['neg'] return HTML_STYLES['flat'] def _card(title: str, inner_html: str) -> str: S = HTML_STYLES return ( f'
' f'
{html_escape(title)}
' f'{inner_html}' f'
' ) def _list_card(title: str, lines: list, empty: Optional[str] = None) -> str: if not lines: if empty is None: return '' lines = [empty] S = HTML_STYLES body = '\n'.join( f'
{html_escape(_strip_bullet(l))}
' for l in lines ) return _card(title, body) def _market_card(title: str, lines: list) -> str: if not lines: return '' S = HTML_STYLES rows = [] for l in lines: label, value, change = _split_market_line(l) if not label and not value: rows.append(f'
{html_escape(_strip_bullet(l))}
') continue right = html_escape(value) if change: color = _market_change_color(change) right = ( f'{html_escape(value)} ' f'({html_escape(change)})' ) rows.append( f'
' f'{html_escape(label)}' f'   ' f'{right}' f'
' ) return _card(title, '\n'.join(rows)) def _article_card(a: dict) -> str: S = HTML_STYLES title = html_escape(a.get('title', '')) cat = html_escape(a.get('category', '')) source = html_escape(a.get('source', '')) link = a.get('link', '') summary = a.get('summary') or '' if isinstance(summary, dict): summary = ' '.join( str(v) for v in (summary.get(k) for k in ('core', 'meaning', 'check', 'opinion')) if v ) link_html = ( f' | 원문 ↗' if link else '' ) parts = [f'
'] parts.append( f'
{cat}' f'{title}
' ) parts.append(f'
출처: {source}{link_html}
') if summary: parts.append( f'
{html_escape(summary).replace(chr(10), "
")}
' ) parts.append('
') return '\n'.join(parts) def build_html_body(data: dict) -> str: S = HTML_STYLES mode = data.get('mode', 'morning') date = data.get('date', '') weekday = data.get('weekday_kr', '') events = data.get('events', []) or [] ipo = data.get('ipo_changes', []) or [] tomorrow_listings = data.get('tomorrow_listings', []) or [] articles = data.get('articles', []) or [] market = data.get('market', {}) or {} indices = market.get('indices', []) or [] futures = market.get('futures', []) or [] msci = market.get('msci', []) or [] if mode == 'morning': greet_line1 = '관리자님, 좋은 아침입니다.' greet_line2 = f'{date} ({weekday}) 오전 브리핑입니다.' today_title = '오늘 일정' today_empty = '오늘 등록된 일정이 없습니다.' market_title = '시장 체크' market_lines = futures + msci + indices else: greet_line1 = '관리자님, 오늘 마감 브리핑입니다.' greet_line2 = f'{date} ({weekday}) 저녁 기준입니다.' today_title = '내일 일정' today_empty = '내일 등록된 일정이 없습니다.' market_title = '오늘 증시 요약' market_lines = indices out = [f'
'] out.append( f'
{html_escape(greet_line1)}
{html_escape(greet_line2)}
' ) out.append(_list_card(today_title, events, today_empty)) if tomorrow_listings: out.append(_list_card('내일 상장', tomorrow_listings)) if ipo: out.append(_list_card('공모일정 변경', ipo)) out.append(_market_card(market_title, market_lines)) if articles: out.append(f'
주요 뉴스
') for a in articles: out.append(_article_card(a)) out.append('
') return '\n'.join(out) def cmd_compose(input_file: str) -> int: if input_file == '-': data = json.loads(sys.stdin.read()) else: data = json.loads(Path(input_file).read_text()) print(build_html_body(data)) return 0 def cmd_send(mode: str, subject: str, body_file: str, html: bool = False) -> int: if mode not in {'morning', 'evening'}: raise SystemExit('mode must be morning or evening') if body_file == '-': body = sys.stdin.read() else: body = Path(body_file).read_text() if not body.strip(): raise SystemExit('empty body — refusing to send') now = datetime.now(KST) today_key = now.strftime('%Y-%m-%d') if was_already_sent(mode, today_key): print(f'skipped: {mode} briefing already sent for {today_key}') return 0 out = send_mail(subject, body, html=html) mark_sent(mode, today_key) try: added = mark_news_sent_from_pending(mode, today_key, now) if added: print(f'news history: +{added} items', file=sys.stderr) except Exception as e: print(f'[briefing_mail:news_history] {type(e).__name__}: {e}', file=sys.stderr) try: mark_ipo_changes_seen() except Exception as e: print(f'[briefing_mail:ipo_seen] {type(e).__name__}: {e}', file=sys.stderr) print(out) return 0 def cmd_legacy(mode: str) -> int: """Legacy single-shot path (no LLM summary). Kept for backward compat until cron is switched.""" if mode not in {'morning', 'evening'}: raise SystemExit('usage: briefing_mail.py [morning|evening]') today_key = datetime.now(KST).strftime('%Y-%m-%d') if was_already_sent(mode, today_key): print(f'skipped: {mode} briefing already sent for {today_key}') return 0 subject, body = build_morning() if mode == 'morning' else build_evening() out = send_mail(subject, body) mark_sent(mode, today_key) try: mark_ipo_changes_seen() except Exception as e: print(f'[briefing_mail:ipo_seen] {type(e).__name__}: {e}', file=sys.stderr) print(out) return 0 USAGE = ( 'usage:\n' ' briefing_mail.py prepare {morning|evening}\n' ' briefing_mail.py compose --input {-|path} # input = augmented prepare JSON with summaries\n' ' briefing_mail.py send {morning|evening} --subject "..." --body-file {-|path} [--html]\n' ' briefing_mail.py {morning|evening} # legacy single-shot\n' ) def main() -> int: args = sys.argv[1:] if not args: raise SystemExit(USAGE) cmd = args[0] if cmd == 'prepare': if len(args) < 2: raise SystemExit(USAGE) return cmd_prepare(args[1]) if cmd == 'compose': input_file = None i = 1 while i < len(args): tok = args[i] if tok == '--input' and i + 1 < len(args): input_file = args[i + 1] i += 2 else: raise SystemExit(f'unknown arg: {tok}\n{USAGE}') if not input_file: raise SystemExit('compose requires --input\n' + USAGE) return cmd_compose(input_file) if cmd == 'send': if len(args) < 2: raise SystemExit(USAGE) mode = args[1] subject = None body_file = None html = False i = 2 while i < len(args): tok = args[i] if tok == '--subject' and i + 1 < len(args): subject = args[i + 1] i += 2 elif tok == '--body-file' and i + 1 < len(args): body_file = args[i + 1] i += 2 elif tok == '--html': html = True i += 1 else: raise SystemExit(f'unknown arg: {tok}\n{USAGE}') if not subject or not body_file: raise SystemExit('send requires --subject and --body-file\n' + USAGE) return cmd_send(mode, subject, body_file, html=html) if cmd in {'morning', 'evening'}: return cmd_legacy(cmd) raise SystemExit(f'unknown command: {cmd}\n{USAGE}') if __name__ == '__main__': sys.exit(main() or 0)