auto: 일일 백업 2026-08-13 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+7
-4
@@ -16,10 +16,10 @@
|
||||
|
||||
| 이름 | 일정 (Asia/Seoul) | 스크립트 |
|
||||
|---|---|---|
|
||||
| 오전 브리핑 | 매일 07:30 | `scripts/briefing_mail.py` (prepare→augment→compose→send) + 🌎 해외 + 🇰🇷 국내 증시·이슈 카드 2개. 소스=`youtube_briefing_digest.py` 두 채널(@futuresnow '오늘의 요약' 상세 미국 / 비하이브 '주식시황' 국내+미국). **겹치는 미국 부분은 LLM이 합쳐 중복제거 → 해외/국내로 재정리**(overseas_summary·domestic_summary). 게이트 `us_send_directive`: 둘 중 하나라도 게시면 proceed, 평일 둘 다 미게시면 wait |
|
||||
| 오전 브리핑 1차폴백 | 매일 07:55 | 위와 동일(`prepare morning`, --final 아님). 07:30 미게시 보류 시 재시도, 여전히 미게시면 또 보류 |
|
||||
| 오전 브리핑 2차폴백·최종 | 매일 08:30 | 위 + `prepare morning --final`. US 미게시여도 무조건 발송, `already_sent`로 idempotent |
|
||||
| 오후 브리핑 | 매일 19:00 | 위 동일 (evening) + 📕 마감시황 카드(비하이브 '마감시황', `behive_close`/`close_summary`, best-effort). 순서: 내일일정→오늘증시요약→마감시황→뉴스 |
|
||||
| 오전 브리핑 | **월~토** 07:30 | `scripts/briefing_mail.py` (prepare→augment→compose→send) + 🌎 해외 + 🇰🇷 국내 증시·이슈 카드 2개. 소스=`youtube_briefing_digest.py` 두 채널(@futuresnow '오늘의 요약' 상세 미국 / 비하이브 '주식시황' 국내+미국). **겹치는 미국 부분은 LLM이 합쳐 중복제거 → 해외/국내로 재정리**(overseas_summary·domestic_summary). 게이트 `us_send_directive`: 둘 중 하나라도 게시면 proceed, 평일 둘 다 미게시면 wait |
|
||||
| 오전 브리핑 1차폴백 | **월~토** 07:55 | 위와 동일(`prepare morning`, --final 아님). 07:30 미게시 보류 시 재시도, 여전히 미게시면 또 보류 |
|
||||
| 오전 브리핑 2차폴백·최종 | **월~토** 08:30 | 위 + `prepare morning --final`. US 미게시여도 무조건 발송, `already_sent`로 idempotent |
|
||||
| 오후 브리핑 | **평일** 19:00 | 위 동일 (evening) + 📕 마감시황 카드(비하이브 '마감시황', `behive_close`/`close_summary`, best-effort). 순서: 내일일정→오늘증시요약→마감시황→뉴스 |
|
||||
|
||||
### macOS launchd (LLM 미경유 직접 실행)
|
||||
|
||||
@@ -50,5 +50,8 @@
|
||||
- 2026-04-24 재설계(컷오프·카테고리 상한·LLM 4줄·Google News 본문 스크래핑 불가) + 2026-04-26 시간 컷오프·발송 이력 dedup 2단 방어. 상세는 `scripts/briefing_mail.py` 주석과 auto-memory `project_briefing_mail.md` 참조.
|
||||
- 월요일 오전 브리핑은 레이 `ipo_calendar_sync.py` 호출로 공모주 캘린더 동기화 동반.
|
||||
- "왜 같은 뉴스가 또 와?" 류 피드백 시 진입점: `state/news_sent_history.json`(48h dedup) + `state/briefing_pending_selection.json`(prepare↔send 핸드오프).
|
||||
- 2026-08-12 **메일 통수 축소**(관리자님 "겹치는 부분 많고 메일 너무 많다"): 주 17통 → 11통. ①**일요일 브리핑 없음**, 오후는 평일만(cron expr `1-6`/`1-5`) ②**토요일 오전은 해외 전용** — `build_prepare_payload`가 `now.weekday()==5`면 `overseas_only=True`로 해외 피드(`OVERSEAS_NEWS_FEEDS`='국제' 2개)만 쓰고 일정·IPO·내일상장은 **수집 자체를 생략**(gog 3콜 소멸 + IPO seen 선점 안 함 → 금요일 sync 결과가 월요일에 정상 노출). `build_html_body`는 `overseas_only`면 일정 카드를 뺀다(events가 비어도 "일정 없습니다" 카드가 렌더되므로 명시적 스킵 필요). 국내 카드·비하이브 각주도 자동 제외 ③**주식 리포트 메일 중지**(스냅샷은 계속) — 아래 별도 항목.
|
||||
- 2026-08-12 **뉴스 dedup 강화**: 48h 발송이력 제외가 정규화 제목 **완전일치뿐**이라 "같은 사안 다른 제목"이 오후에 재등장했다. `recent_history_token_sets()`로 이력 제목의 토큰 집합을 만들어 `collect_news_articles(exclude_token_sets=...)`에 넘겨 **기존 `_is_similar_to_seen`(min-overlap 0.40)을 이력에도 적용**. 실측: 오늘 아침 발송 "암살 겁났나…골프 치는 트럼프", "젤렌스키 푸틴 북한군 투입"과 같은 사안 2건이 저녁 후보에서 걸러지고 그 자리에 새 기사가 채워짐(8건 유지). ⚠️ **한국어 조사·복합명사 때문에 같은 사안이 0.40 아래로 떨어지는 쌍이 남아 있다**(예 "트럼프 골프장에 이동식 방공미사일" vs "골프장에 방공 미사일…트럼프의 암살 노이로제" = 0.29) — 문턱을 낮추면 오탐이 늘어 손대지 않았다. 같은 브리핑 안 중복이 계속 거슬리면 토크나이저(조사 절단·문자 n-gram)를 고치는 쪽이 맞다.
|
||||
- 2026-08-12 **`prepare`의 pending 오염 수정**: `already_sent=true`(폴백이 스킵할 상황)일 때도 prepare가 기사를 재선별해 `briefing_pending_selection.json`을 덮어써서, 발송본과 무관한 목록이 남았다(실측 08-12 발송 7건 vs 파일 4건, 교집합 0). 위 진입점 파일이 **디버깅 때 틀린 증거**를 주던 원인. 이제 `already_sent`면 pending을 쓰지 않는다.
|
||||
- 2026-06-16 미국증시 요약 통합: @futuresnow '오늘의 요약' 영상이 미국장 마감 후 07:20~08:30 KST 게시. 영상 제목 날짜는 미국 세션일(어제)이라 신뢰X — watch 페이지 `publishDate`(절대시각)를 KST 변환해 오늘 게시여부 판정. 미게시면 `us_send_directive=wait`로 발송 보류 후 폴백이 보완. 판정은 스크립트가, 요약 작성은 cron LLM이 담당(자동자막 오타 문맥 교정).
|
||||
- 2026-06-17 해외/국내 재정리로 확장: 비하이브 '주식시황'(평일 ~06:00 KST 게시, 국내+미국 다룸)을 둘째 소스로 추가. 비하이브가 미국도 다뤄 @futuresnow와 겹쳐서, **카드를 채널별이 아니라 해외/국내로** 재편 — LLM이 두 자막 합쳐 중복제거 후 `overseas_summary`(해외, 미국수치는 @futuresnow 우선)/`domestic_summary`(국내, 비하이브 국내부분만) 카테고리 그룹으로 작성. 게이트는 둘 중 하나라도 게시면 proceed로 완화. 3단계 발송(07:30/07:55/08:30 --final)은 유지. 강조(`**...**`→형광펜)·코스피 야간선물/MSCI는 비하이브 자막값 오버라이드(`kospi_futures_override`/`msci_override`, 스크랩값은 전날 것이라). @futuresnow엔 코스피/MSCI 언급 없음.
|
||||
|
||||
@@ -32,6 +32,8 @@ NEWS_FEEDS = [
|
||||
('국제', 'https://rss.edaily.co.kr/world_news.xml'),
|
||||
('증시', 'https://rss.edaily.co.kr/stock_news.xml'),
|
||||
]
|
||||
# 토요일 오전(해외 전용 브리핑)에서 쓰는 축소 피드 — 국내 경제·증시 제외
|
||||
OVERSEAS_NEWS_FEEDS = [f for f in NEWS_FEEDS if f[0] == '국제']
|
||||
MARKET_FEEDS = [
|
||||
('USD/KRW', 'https://query1.finance.yahoo.com/v8/finance/chart/KRW%3DX?range=2d&interval=1d'),
|
||||
]
|
||||
@@ -353,6 +355,21 @@ def recent_history_keys(history: dict, now: datetime, lookback_hours: int = NEWS
|
||||
}
|
||||
|
||||
|
||||
def recent_history_token_sets(history: dict, now: datetime,
|
||||
lookback_hours: int = NEWS_HISTORY_LOOKBACK_HOURS) -> list:
|
||||
"""최근 발송 제목의 토큰 집합 — 완전일치(key)로는 못 막는 '같은 사안 다른 제목'용."""
|
||||
cutoff = now - timedelta(hours=lookback_hours)
|
||||
out = []
|
||||
for it in history.get('items', []):
|
||||
title = (it.get('title') or '').strip()
|
||||
if not title or _parse_iso(it.get('sent_at')) < cutoff:
|
||||
continue
|
||||
tokens = _title_tokens(title)
|
||||
if tokens:
|
||||
out.append(tokens)
|
||||
return out
|
||||
|
||||
|
||||
def mark_news_sent_from_pending(mode: str, today_key: str, now: datetime) -> int:
|
||||
"""발송 성공 후 pending selection을 history로 옮기고 pending에서 제거. 반환: 추가된 항목 수."""
|
||||
pending = load_json(PENDING_SELECTION_FILE, {})
|
||||
@@ -387,17 +404,21 @@ def collect_news_articles(
|
||||
per_category_cap: int = NEWS_PER_CATEGORY_CAP,
|
||||
published_after: Optional[datetime] = None,
|
||||
exclude_keys: Optional[set] = None,
|
||||
exclude_token_sets: Optional[list] = None,
|
||||
feeds: Optional[list] = 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: normalized title이 이 set에 있으면 제외 (최근 발송 이력, 완전일치)
|
||||
exclude_token_sets: 최근 발송 이력 제목의 토큰 집합 — fuzzy 유사도로도 제외
|
||||
feeds: 사용할 (category, url) 목록. 기본 NEWS_FEEDS, 토요일은 OVERSEAS_NEWS_FEEDS
|
||||
"""
|
||||
exclude_keys = exclude_keys or set()
|
||||
seen_norm = set()
|
||||
seen_token_sets = []
|
||||
seen_token_sets = list(exclude_token_sets or [])
|
||||
result = []
|
||||
for category, url in NEWS_FEEDS:
|
||||
for category, url in (feeds or NEWS_FEEDS):
|
||||
try:
|
||||
items = parse_google_news_feed(url)
|
||||
except Exception as e:
|
||||
@@ -762,32 +783,48 @@ def build_prepare_payload(mode: str, final: bool = False) -> dict:
|
||||
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)
|
||||
# 토요일 오전은 해외 전용 — 일정·IPO는 수집하지 않는다. gog 3콜이 빠지고,
|
||||
# IPO seen을 선점하지 않아 금요일 sync 결과가 월요일 아침에 정상 노출된다.
|
||||
overseas_only = (mode == 'morning' and now.weekday() == 5)
|
||||
if overseas_only:
|
||||
events, ipo_changes, tomorrow_listings = [], [], []
|
||||
else:
|
||||
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)
|
||||
exclude_token_sets = recent_history_token_sets(history, now)
|
||||
articles = collect_news_articles(
|
||||
published_after=cutoff,
|
||||
exclude_keys=exclude_keys,
|
||||
exclude_token_sets=exclude_token_sets,
|
||||
feeds=OVERSEAS_NEWS_FEEDS if overseas_only else None,
|
||||
)
|
||||
|
||||
today_key = now.strftime('%Y-%m-%d')
|
||||
already_sent = was_already_sent(mode, today_key)
|
||||
|
||||
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)
|
||||
# 이미 발송된 뒤(폴백이 스킵할 예정)에 pending을 덮어쓰면 발송본과 무관한 목록이 남아
|
||||
# "같은 뉴스가 또 와?" 디버깅 때 잘못된 증거를 보게 된다.
|
||||
if not already_sent:
|
||||
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)
|
||||
|
||||
# 오전: @futuresnow(상세 미국) + 비하이브 '주식시황'(국내+미국) → LLM이 해외/국내로 재정리.
|
||||
# 오후: 비하이브 '마감시황'(국내 장 마감 해설) → best-effort 마감시황 카드.
|
||||
@@ -818,6 +855,7 @@ def build_prepare_payload(mode: str, final: bool = False) -> dict:
|
||||
'ipo_changes': ipo_changes,
|
||||
'tomorrow_listings': tomorrow_listings,
|
||||
'articles': articles,
|
||||
'overseas_only': overseas_only,
|
||||
'news_window': {
|
||||
'published_after': cutoff.isoformat(),
|
||||
'history_lookback_hours': NEWS_HISTORY_LOOKBACK_HOURS,
|
||||
@@ -828,7 +866,7 @@ def build_prepare_payload(mode: str, final: bool = False) -> dict:
|
||||
'msci': get_msci_korea() if mode == 'morning' else [],
|
||||
'indices': get_market_snapshot(include_domestic=(mode == 'evening')),
|
||||
},
|
||||
'already_sent': was_already_sent(mode, today_key),
|
||||
'already_sent': already_sent,
|
||||
'us_market': us_market,
|
||||
'behive_market': behive_market,
|
||||
'behive_close': behive_close,
|
||||
@@ -1146,10 +1184,12 @@ def _morning_missing_summary_notes(data: dict) -> list[str]:
|
||||
elif not data.get('overseas_summary'):
|
||||
notes.append('오선 요약 없음')
|
||||
|
||||
if not behive_market.get('available_today'):
|
||||
notes.append('비하이브 요약 없음')
|
||||
elif not data.get('domestic_summary'):
|
||||
notes.append('비하이브 요약 없음')
|
||||
# 토요일 해외 전용 브리핑은 국내 시황을 애초에 넣지 않으므로 "없음" 각주가 노이즈다.
|
||||
if not data.get('overseas_only'):
|
||||
if not behive_market.get('available_today'):
|
||||
notes.append('비하이브 요약 없음')
|
||||
elif not data.get('domestic_summary'):
|
||||
notes.append('비하이브 요약 없음')
|
||||
|
||||
return notes
|
||||
|
||||
@@ -1197,7 +1237,9 @@ def build_html_body(data: dict) -> str:
|
||||
if notice:
|
||||
out.append(notice)
|
||||
# 섹션 순서: 오늘 일정(+내일 상장·공모일정) → 시장 체크 → 해외 → 국내 → 주요 뉴스
|
||||
out.append(_list_card(today_title, events, today_empty))
|
||||
# 토요일 해외 전용 브리핑은 일정 카드를 아예 뺀다 (events가 비어도 "일정 없습니다" 카드가 렌더되므로)
|
||||
if not data.get('overseas_only'):
|
||||
out.append(_list_card(today_title, events, today_empty))
|
||||
if tomorrow_listings:
|
||||
out.append(_list_card('내일 상장', tomorrow_listings))
|
||||
if ipo:
|
||||
|
||||
Reference in New Issue
Block a user