auto: 일일 백업 2026-06-17 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-06-17 02:00:02 +09:00
parent eaa276b26d
commit b3696949f5
233 changed files with 673 additions and 236 deletions
+3 -1
View File
@@ -16,7 +16,8 @@
| 이름 | 일정 (Asia/Seoul) | 스크립트 |
|---|---|---|
| 오전 브리핑 | 매일 07:00 | `scripts/briefing_mail.py` (prepare→augment→compose→send) |
| 오전 브리핑 | 매일 07:55 | `scripts/briefing_mail.py` (prepare→augment→compose→send) + 🇺🇸 미국증시 요약 카드(`us_market_digest.py`, @futuresnow '오늘의 요약'). prepare가 `us_send_directive` 산출 — 영상 미게시(화~토 게시예정)면 `wait`로 보류 |
| 오전 브리핑 fallback | 매일 08:30 | 위와 동일 + `prepare morning --final`. 07:55에 US 미게시로 보류된 경우만 실발송, `already_sent`로 idempotent |
| 오후 브리핑 | 매일 19:00 | 위 동일 (evening) |
| Gmail 라벨 분류/정리 | 매일 01:00 | `scripts/gmail_label_classify.py` (`[오전/오후 브리핑]``브리핑`, `[비하이브 종목분석]``종목분석`, `[주식 리포트]``주식브리핑`, 24시간 지난 테스트메일 휴지통 이동) |
@@ -45,3 +46,4 @@
- 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-06-16 미국증시 요약 통합: @futuresnow '오늘의 요약' 영상이 미국장 마감 후 07:20~08:30 KST 게시. 영상 제목 날짜는 미국 세션일(어제)이라 신뢰X — watch 페이지 `publishDate`(절대시각)를 KST 변환해 오늘 게시여부 판정. 07:55에 미게시면 `us_send_directive=wait`로 발송 보류 후 08:30 fallback이 보완. 판정은 스크립트가, 요약 작성은 cron LLM이 담당(자동자막 오타 문맥 교정).
+112 -5
View File
@@ -720,7 +720,25 @@ def send_mail(subject: str, body: str, html: bool = False) -> str:
WEEKDAY_KR = ['', '', '', '', '', '', '']
def build_prepare_payload(mode: str) -> dict:
def get_us_market_block() -> dict:
"""오전 브리핑용 미국증시 요약 블록. 실패해도 브리핑이 죽지 않도록 예외를 삼킨다."""
try:
sys.path.insert(0, str(Path(__file__).resolve().parent))
import us_market_digest # type: ignore
return us_market_digest.get_us_summary()
except Exception as e:
return {
'channel': '미국증시 요약',
'recap_expected_today': False,
'available_today': False,
'video': None,
'transcript': '',
'transcript_status': 'error',
'error': f'{type(e).__name__}: {e}',
}
def build_prepare_payload(mode: str, final: bool = False) -> dict:
if mode not in {'morning', 'evening'}:
raise SystemExit('mode must be morning or evening')
now = datetime.now(KST)
@@ -752,6 +770,17 @@ def build_prepare_payload(mode: str) -> dict:
}
save_json(PENDING_SELECTION_FILE, pending)
us_market = None
us_send_directive = 'proceed'
if mode == 'morning':
us_market = get_us_market_block()
if us_market.get('available_today'):
us_send_directive = 'proceed' # 오늘 요약 영상 있음 → 포함 발송
elif us_market.get('recap_expected_today') and not final:
us_send_directive = 'wait' # 예정일인데 아직 미게시 → 08:30 재시도
else:
us_send_directive = 'proceed' # 주말 등 예정 없음 / 최종 발송 → US 없이 진행
return {
'mode': mode,
'date': today_key,
@@ -772,11 +801,13 @@ def build_prepare_payload(mode: str) -> dict:
'indices': get_market_snapshot(include_domestic=(mode == 'evening')),
},
'already_sent': was_already_sent(mode, today_key),
'us_market': us_market,
'us_send_directive': us_send_directive,
}
def cmd_prepare(mode: str) -> int:
payload = build_prepare_payload(mode)
def cmd_prepare(mode: str, final: bool = False) -> int:
payload = build_prepare_payload(mode, final=final)
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
@@ -888,6 +919,78 @@ def _market_card(title: str, lines: list) -> str:
return _card(title, '\n'.join(rows))
_US_GROUP_ICONS = [
('지수', '📊'), ('특징주', '🏢'), ('섹터', '🏢'), ('테마', '🏢'),
('연준', '🏦'), ('금리', '🏦'), ('일정', '🗓'), ('지표', '🗓'),
('전문가', '💬'), ('시각', '💬'), ('전망', '💬'), ('이슈', '📰'),
('기타', '🗒'), ('그 외', '🗒'),
]
def _us_group_icon(label: str) -> str:
for key, icon in _US_GROUP_ICONS:
if key in label:
return icon
return '📰'
def _us_items_html(items: list) -> str:
lis = '\n'.join(
f'<li style="margin:6px 0;padding-left:3px;line-height:1.7;color:#1a1a1a;'
f'font-size:13.5px;">{html_escape(_strip_bullet(str(it).strip()))}</li>'
for it in items if str(it).strip()
)
return f'<ul style="margin:2px 0 0;padding-left:19px;">{lis}</ul>'
def _us_card(summary, us_market: dict) -> str:
"""미국증시 요약 카드.
summary: 카테고리 그룹 리스트 [{"label": "지수", "items": [...]}, ...] 또는
하위호환용 평문 문자열(줄바꿈으로 불릿 구분).
"""
video = (us_market or {}).get('video') or {}
blocks = []
if isinstance(summary, list):
first = True
for g in summary:
if not isinstance(g, dict):
continue
label = str(g.get('label', '')).strip()
items = [x for x in (g.get('items') or []) if str(x).strip()]
if not items:
continue
mt = '0' if first else '12px'
blocks.append(
f'<div style="margin:{mt} 0 1px;font-size:12.5px;font-weight:700;'
f'color:#1565c0;letter-spacing:-0.2px;">{_us_group_icon(label)} {html_escape(label)}</div>'
)
blocks.append(_us_items_html(items))
first = False
else:
lines = [l for l in str(summary).split('\n') if l.strip()]
blocks.append(_us_items_html(lines))
src = ''
if video.get('url'):
title = html_escape(video.get('title', '영상'))
src = (
'<div style="margin-top:11px;padding-top:9px;border-top:1px dashed #cfd8ea;'
'color:#8a93a6;font-size:12px;">출처: '
f'<a href="{html_escape(video["url"])}" style="color:#1565c0;text-decoration:none;" '
f'target="_blank">{title} ↗</a></div>'
)
return (
'<div style="margin-bottom:20px;border:1px solid #c5d4f0;border-left:4px solid #1565c0;'
'border-radius:8px;background-color:#eef4ff;'
'padding:14px 16px 13px;">'
'<div style="font-size:15px;font-weight:700;color:#0d47a1;margin-bottom:6px;'
'letter-spacing:-0.2px;">🇺🇸 미국증시 요약</div>'
f'{"".join(blocks)}'
f'{src}</div>'
)
def _article_card(a: dict) -> str:
S = HTML_STYLES
title = html_escape(a.get('title', ''))
@@ -950,6 +1053,9 @@ def build_html_body(data: dict) -> str:
out.append(
f'<div style="{S["greet"]}">{html_escape(greet_line1)}<br>{html_escape(greet_line2)}</div>'
)
us_summary = data.get('us_market_summary')
if mode == 'morning' and us_summary:
out.append(_us_card(us_summary, data.get('us_market') or {}))
out.append(_list_card(today_title, events, today_empty))
if tomorrow_listings:
out.append(_list_card('내일 상장', tomorrow_listings))
@@ -1024,7 +1130,7 @@ def cmd_legacy(mode: str) -> int:
USAGE = (
'usage:\n'
' briefing_mail.py prepare {morning|evening}\n'
' briefing_mail.py prepare {morning|evening} [--final] # --final: 08:30 최종 발송(US 미게시여도 진행)\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'
@@ -1039,7 +1145,8 @@ def main() -> int:
if cmd == 'prepare':
if len(args) < 2:
raise SystemExit(USAGE)
return cmd_prepare(args[1])
final = '--final' in args[2:]
return cmd_prepare(args[1], final=final)
if cmd == 'compose':
input_file = None
i = 1
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""오선의 미국 증시 라이브(@futuresnow) '오늘의 요약' 최신 영상 감지 + 자막 수집.
오전 브리핑(briefing_mail.py) import 해서 메일에 '미국증시 요약' 카드를 넣는다.
미국 정규장 마감( 05:00 KST) 매일 아침 07:20~08:30 KST 사이에 한국어 요약 영상이
올라온다. 영상 제목 날짜는 '미국장 세션일'이라 헷갈리므로, '오늘 올라온 영상인지'
watch 페이지의 publishDate(절대시각) KST로 변환해 판정한다.
CLI:
python3 us_market_digest.py latest 최신 요약 영상 메타 + 자막 + 오늘게시여부 JSON
python3 us_market_digest.py transcript 최신 영상 자막만 출력
"""
from __future__ import annotations
import json
import re
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone, timedelta
KST = timezone(timedelta(hours=9))
CHANNEL_HANDLE = 'futuresnow'
CHANNEL_NAME = '오선의 미국 증시 라이브'
CHANNEL_VIDEOS_URL = f'https://www.youtube.com/@{CHANNEL_HANDLE}/videos'
WATCH_URL = 'https://www.youtube.com/watch?v={vid}'
TITLE_FILTER = '오늘의 요약' # 공지/광고 영상 제외
TRANSCRIPT_LANGS = ['ko', 'ko-KR', 'en']
TRANSCRIPT_CHAR_LIMIT = 8000
USER_AGENT = (
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/122.0.0.0 Safari/537.36'
)
def _get(url: str) -> str:
req = urllib.request.Request(url, headers={'User-Agent': USER_AGENT})
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode('utf-8', 'ignore')
def _lockup_text(o) -> str:
"""lockupMetadataViewModel title 등에서 평문 텍스트 추출."""
if isinstance(o, dict):
if isinstance(o.get('content'), str):
return o['content']
for k in ('text', 'simpleText'):
if isinstance(o.get(k), str):
return o[k]
return ''
def fetch_channel_videos() -> list[dict]:
"""채널 영상 페이지를 긁어 최신 영상 목록을 [{video_id, title}]로 반환.
2026-06 YouTube가 videoRenderer lockupViewModel 구조로 바뀌어 그에 맞춰 파싱한다.
RSS 피드(feeds/videos.xml) 현재 IP 차원에서 404 잦아 사용하지 않는다.
"""
html = _get(CHANNEL_VIDEOS_URL)
marker = 'var ytInitialData = '
start = html.find(marker)
if start < 0:
raise RuntimeError('ytInitialData not found in channel page')
start += len(marker)
end = html.find(';</script>', start)
if end < 0:
raise RuntimeError('ytInitialData terminator not found in channel page')
data = json.loads(html[start:end])
rows: list[dict] = []
seen: set[str] = set()
def walk(node):
if isinstance(node, dict):
lvm = node.get('lockupViewModel')
if lvm and lvm.get('contentId'):
vid = lvm['contentId']
if vid not in seen:
meta = lvm.get('metadata', {}).get('lockupMetadataViewModel', {})
title = _lockup_text(meta.get('title', {})).strip()
seen.add(vid)
rows.append({'video_id': vid, 'title': title})
for v in node.values():
walk(v)
elif isinstance(node, list):
for v in node:
walk(v)
walk(data)
return rows
def get_publish_kst(video_id: str) -> datetime | None:
"""watch 페이지 publishDate(절대시각, PT)를 KST datetime으로 변환. 실패 시 None."""
try:
html = _get(WATCH_URL.format(vid=video_id))
except Exception:
return None
m = re.search(r'"publishDate":"([^"]+)"', html)
if not m:
return None
try:
dt = datetime.fromisoformat(m.group(1))
return dt.astimezone(KST)
except Exception:
return None
def fetch_transcript(video_id: str) -> tuple[str, str]:
try:
from youtube_transcript_api import YouTubeTranscriptApi
except Exception as e:
return '', f'error: youtube_transcript_api import 실패 ({e})'
try:
api = YouTubeTranscriptApi()
fetched = api.fetch(video_id, languages=TRANSCRIPT_LANGS)
parts = []
for snippet in fetched:
text = getattr(snippet, 'text', None)
if text is None and isinstance(snippet, dict):
text = snippet.get('text', '')
if text:
parts.append(text.strip())
full = ' '.join(parts).strip()
if not full:
return '', 'unavailable'
if len(full) > TRANSCRIPT_CHAR_LIMIT:
full = full[:TRANSCRIPT_CHAR_LIMIT] + '... [자막 일부 생략]'
return full, 'ok'
except Exception as e:
return '', f'unavailable: {type(e).__name__}'
def get_us_summary() -> dict:
"""오전 브리핑용 미국증시 요약 데이터.
반환 :
channel 채널명
recap_expected_today 오늘(KST) 미국장 요약이 올라올 예정인 날인지 (~)
available_today 오늘(KST) 게시된 최신 요약 영상이 실제로 있는지
video {id, title, url, published_kst} (없으면 None)
transcript available_today일 때만 자막 본문
transcript_status ok / unavailable / error...
error 수집 실패 메시지 ( 없음)
"""
now = datetime.now(KST)
# 미국장(월~금 ET) 요약은 다음날 KST 아침(화~토)에 올라온다.
recap_expected_today = now.weekday() in {1, 2, 3, 4, 5}
result = {
'channel': CHANNEL_NAME,
'recap_expected_today': recap_expected_today,
'available_today': False,
'video': None,
'transcript': '',
'transcript_status': 'not_fetched',
}
try:
videos = fetch_channel_videos()
except Exception as e:
result['error'] = f'channel fetch 실패: {type(e).__name__}: {e}'
return result
latest = next((v for v in videos if TITLE_FILTER in v.get('title', '')), None)
if not latest:
result['error'] = f"'{TITLE_FILTER}' 영상을 찾지 못함"
return result
vid = latest['video_id']
published = get_publish_kst(vid)
result['video'] = {
'id': vid,
'title': latest['title'],
'url': f'https://www.youtube.com/watch?v={vid}',
'published_kst': published.isoformat() if published else None,
}
result['available_today'] = bool(published and published.date() == now.date())
if result['available_today']:
text, status = fetch_transcript(vid)
result['transcript'] = text
result['transcript_status'] = status
return result
def main() -> int:
cmd = sys.argv[1] if len(sys.argv) > 1 else 'latest'
if cmd == 'transcript':
data = get_us_summary()
vid = (data.get('video') or {}).get('id')
if not vid:
print(data.get('error', 'no video'), file=sys.stderr)
return 1
text, status = fetch_transcript(vid)
print(f'transcript_status: {status}')
print('---')
print(text)
return 0
if cmd == 'latest':
print(json.dumps(get_us_summary(), ensure_ascii=False, indent=2))
return 0
print(f'unknown command: {cmd}', file=sys.stderr)
return 2
if __name__ == '__main__':
sys.exit(main())