fed3526b20
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1087 lines
43 KiB
Python
Executable File
1087 lines
43 KiB
Python
Executable File
#!/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'<title>\s*([^<]+?)\s*</title>', 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'<div style="{S["card"]}">'
|
|
f'<div style="{S["card_title"]}">{html_escape(title)}</div>'
|
|
f'{inner_html}'
|
|
f'</div>'
|
|
)
|
|
|
|
|
|
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'<div style="{S["line"]}">{html_escape(_strip_bullet(l))}</div>'
|
|
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'<div style="{S["line"]}">{html_escape(_strip_bullet(l))}</div>')
|
|
continue
|
|
right = html_escape(value)
|
|
if change:
|
|
color = _market_change_color(change)
|
|
right = (
|
|
f'{html_escape(value)} '
|
|
f'<span style="{color}">({html_escape(change)})</span>'
|
|
)
|
|
rows.append(
|
|
f'<div style="{S["market_line"]}">'
|
|
f'<span style="{S["market_label"]}">{html_escape(label)}</span>'
|
|
f' '
|
|
f'<span style="{S["market_value"]}">{right}</span>'
|
|
f'</div>'
|
|
)
|
|
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' | <a href="{html_escape(link)}" style="{S["link"]}" target="_blank">원문 ↗</a>'
|
|
if link else ''
|
|
)
|
|
parts = [f'<div style="{S["article"]}">']
|
|
parts.append(
|
|
f'<div><span style="{S["category"]}">{cat}</span>'
|
|
f'<span style="{S["title"]}">{title}</span></div>'
|
|
)
|
|
parts.append(f'<div style="{S["source"]}">출처: {source}{link_html}</div>')
|
|
if summary:
|
|
parts.append(
|
|
f'<div style="{S["summary"]}">{html_escape(summary).replace(chr(10), "<br>")}</div>'
|
|
)
|
|
parts.append('</div>')
|
|
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'<div style="{S["wrap"]}">']
|
|
out.append(
|
|
f'<div style="{S["greet"]}">{html_escape(greet_line1)}<br>{html_escape(greet_line2)}</div>'
|
|
)
|
|
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'<div style="{S["news_section"]}">주요 뉴스</div>')
|
|
for a in articles:
|
|
out.append(_article_card(a))
|
|
out.append('</div>')
|
|
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)
|