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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-06-18 02:00:02 +09:00
parent b3696949f5
commit 70e2251995
444 changed files with 9612 additions and 997 deletions
+6 -4
View File
@@ -16,9 +16,10 @@
| 이름 | 일정 (Asia/Seoul) | 스크립트 |
|---|---|---|
| 오전 브리핑 | 매일 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) |
| 오전 브리핑 | 매일 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). 순서: 내일일정→오늘증시요약→마감시황→뉴스 |
| Gmail 라벨 분류/정리 | 매일 01:00 | `scripts/gmail_label_classify.py` (`[오전/오후 브리핑]``브리핑`, `[비하이브 종목분석]``종목분석`, `[주식 리포트]``주식브리핑`, 24시간 지난 테스트메일 휴지통 이동) |
(macOS launchd 트리거 없음 — 클로 담당 자동 실행은 위 OpenClaw cron 한 종류)
@@ -46,4 +47,5 @@
- 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이 담당(자동자막 오타 문맥 교정).
- 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 언급 없음.
+144 -38
View File
@@ -720,22 +720,41 @@ def send_mail(subject: str, body: str, html: bool = False) -> str:
WEEKDAY_KR = ['', '', '', '', '', '', '']
def _empty_market_block(name: str, err: str) -> dict:
return {
'channel': name, 'recap_expected_today': False, 'available_today': False,
'video': None, 'transcript': '', 'transcript_status': 'error', 'error': err,
}
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()
import youtube_briefing_digest # type: ignore
return youtube_briefing_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}',
}
return _empty_market_block('미국증시 요약', f'{type(e).__name__}: {e}')
def get_behive_market_block() -> dict:
"""오전 브리핑용 국내 증시 시황 블록(비하이브 '주식시황'). best-effort — 없어도 브리핑 진행."""
try:
sys.path.insert(0, str(Path(__file__).resolve().parent))
import youtube_briefing_digest # type: ignore
return youtube_briefing_digest.get_behive_market_summary()
except Exception as e:
return _empty_market_block('국내 증시 시황', f'{type(e).__name__}: {e}')
def get_behive_close_block() -> dict:
"""오후 브리핑용 마감시황 블록(비하이브 '마감시황'). best-effort — 없어도 브리핑 진행."""
try:
sys.path.insert(0, str(Path(__file__).resolve().parent))
import youtube_briefing_digest # type: ignore
return youtube_briefing_digest.get_behive_close_summary()
except Exception as e:
return _empty_market_block('마감시황', f'{type(e).__name__}: {e}')
def build_prepare_payload(mode: str, final: bool = False) -> dict:
@@ -770,16 +789,25 @@ def build_prepare_payload(mode: str, final: bool = False) -> dict:
}
save_json(PENDING_SELECTION_FILE, pending)
# 오전: @futuresnow(상세 미국) + 비하이브 '주식시황'(국내+미국) → LLM이 해외/국내로 재정리.
# 오후: 비하이브 '마감시황'(국내 장 마감 해설) → best-effort 마감시황 카드.
us_market = None
behive_market = None
behive_close = 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 재시도
behive_market = get_behive_market_block()
any_available = us_market.get('available_today') or behive_market.get('available_today')
any_expected = us_market.get('recap_expected_today') or behive_market.get('recap_expected_today')
if any_available:
us_send_directive = 'proceed' # 해외/국내 소스 중 하나라도 게시 → 진행
elif any_expected and not final:
us_send_directive = 'wait' # 평일인데 둘 다 미게시 → 다음 폴백 재시도
else:
us_send_directive = 'proceed' # 주말 등 예정 없음 / 최종 발송 → US 없이 진행
us_send_directive = 'proceed' # 주말 등 예정 없음 / 최종 발송
elif mode == 'evening':
behive_close = get_behive_close_block() # best-effort, 발송 게이트 없음
return {
'mode': mode,
@@ -802,6 +830,8 @@ def build_prepare_payload(mode: str, final: bool = False) -> dict:
},
'already_sent': was_already_sent(mode, today_key),
'us_market': us_market,
'behive_market': behive_market,
'behive_close': behive_close,
'us_send_directive': us_send_directive,
}
@@ -909,6 +939,11 @@ def _market_card(title: str, lines: list) -> str:
f'{html_escape(value)} '
f'<span style="{color}">({html_escape(change)})</span>'
)
else:
# 괄호 없이 값 자체에 ▲/▼가 있으면(예: 비하이브 야간선물 "▼ 1.15%") 값에 색 적용
color = _market_change_color(value)
if color != HTML_STYLES['flat']:
right = f'<span style="{color}">{html_escape(value)}</span>'
rows.append(
f'<div style="{S["market_line"]}">'
f'<span style="{S["market_label"]}">{html_escape(label)}</span>'
@@ -934,22 +969,62 @@ def _us_group_icon(label: str) -> str:
return '📰'
def _emphasize(text: str) -> str:
"""`**강조**` 마크다운을 굵게+형광펜 하이라이트 <span>으로 변환. 나머지는 html_escape."""
parts = re.split(r'\*\*(.+?)\*\*', str(text))
out = []
for i, p in enumerate(parts):
if i % 2 == 1: # ** ** 안쪽 — 배경 없이 글자만 굵게 + 진한 강조색
out.append(
f'<span style="font-weight:700;color:#c62828;">{html_escape(p)}</span>'
)
else:
out.append(html_escape(p))
return ''.join(out)
def _emphasize_multiline(text: str) -> str:
return '<br>'.join(_emphasize(line) for line in str(text).split('\n'))
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>'
lis = []
for it in items:
s = _strip_bullet(str(it).strip())
if not s:
continue
# 불릿 앞 '🔥' = 국내 증시 영향 큰 항목 → 강조 배경 밴드
if s.startswith('🔥'):
s = s[1:].strip()
lis.append(
'<li style="margin:6px 0;padding:4px 7px;line-height:1.7;color:#1a1a1a;'
'font-size:13.5px;background:#fff7f6;border-radius:4px;">'
f'🔥 {_emphasize(s)}</li>'
)
else:
lis.append(
'<li style="margin:6px 0;padding-left:3px;line-height:1.7;color:#1a1a1a;'
f'font-size:13.5px;">{_emphasize(s)}</li>'
)
return f'<ul style="margin:2px 0 0;padding-left:19px;">{chr(10).join(lis)}</ul>'
def _us_card(summary, us_market: dict) -> str:
"""미국증시 요약 카드.
_US_PALETTE = {'header': '#0d47a1', 'group': '#1565c0', 'accent': '#1565c0',
'bg': '#eef4ff', 'border': '#c5d4f0', 'rule': '#cfd8ea'}
_BEHIVE_PALETTE = {'header': '#1b5e20', 'group': '#2e7d32', 'accent': '#2e7d32',
'bg': '#eef7ee', 'border': '#c5e0c5', 'rule': '#cfe3cf'}
_CLOSE_PALETTE = {'header': '#5d4037', 'group': '#8d6e63', 'accent': '#8d6e63',
'bg': '#f7f1ec', 'border': '#e0d3c8', 'rule': '#e6dbd1'}
def _video_summary_card(heading: str, summary, market: dict, pal: dict) -> str:
"""시황 영상 요약 카드 (미국증시·국내증시 공용).
summary: 카테고리 그룹 리스트 [{"label": "지수", "items": [...]}, ...] 또는
하위호환용 평문 문자열(줄바꿈으로 불릿 구분).
pal: 색상 팔레트(_US_PALETTE / _BEHIVE_PALETTE).
"""
video = (us_market or {}).get('video') or {}
video = (market or {}).get('video') or {}
blocks = []
if isinstance(summary, list):
first = True
@@ -963,7 +1038,7 @@ def _us_card(summary, us_market: dict) -> str:
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>'
f'color:{pal["group"]};letter-spacing:-0.2px;">{_us_group_icon(label)} {html_escape(label)}</div>'
)
blocks.append(_us_items_html(items))
first = False
@@ -975,17 +1050,17 @@ def _us_card(summary, us_market: dict) -> str:
if video.get('url'):
title = html_escape(video.get('title', '영상'))
src = (
'<div style="margin-top:11px;padding-top:9px;border-top:1px dashed #cfd8ea;'
f'<div style="margin-top:11px;padding-top:9px;border-top:1px dashed {pal["rule"]};'
'color:#8a93a6;font-size:12px;">출처: '
f'<a href="{html_escape(video["url"])}" style="color:#1565c0;text-decoration:none;" '
f'<a href="{html_escape(video["url"])}" style="color:{pal["accent"]};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;'
f'<div style="margin-bottom:20px;border:1px solid {pal["border"]};'
f'border-left:4px solid {pal["accent"]};border-radius:8px;background-color:{pal["bg"]};'
'padding:14px 16px 13px;">'
'<div style="font-size:15px;font-weight:700;color:#0d47a1;margin-bottom:6px;'
'letter-spacing:-0.2px;">🇺🇸 미국증시 요약</div>'
f'<div style="font-size:15px;font-weight:700;color:{pal["header"]};margin-bottom:6px;'
f'letter-spacing:-0.2px;">{heading}</div>'
f'{"".join(blocks)}'
f'{src}</div>'
)
@@ -1006,15 +1081,26 @@ def _article_card(a: dict) -> str:
f' | <a href="{html_escape(link)}" style="{S["link"]}" target="_blank">원문 ↗</a>'
if link else ''
)
parts = [f'<div style="{S["article"]}">']
# 국내 증시에 큰 영향을 줄 뉴스: 빨강 강조 테두리 + 🔥 배지
impact = bool(a.get('kr_impact'))
article_style = S['article']
badge = ''
if impact:
article_style += 'border-left:4px solid #c62828;background:#fff7f6;'
badge = (
'<span style="display:inline-block;background:#fdecea;color:#c62828;'
'font-size:11px;font-weight:700;padding:2px 8px;border-radius:10px;'
'margin-right:6px;vertical-align:middle;">🔥 국내증시 영향</span>'
)
parts = [f'<div style="{article_style}">']
parts.append(
f'<div><span style="{S["category"]}">{cat}</span>'
f'<div>{badge}<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>'
f'<div style="{S["summary"]}">{_emphasize_multiline(summary)}</div>'
)
parts.append('</div>')
return '\n'.join(parts)
@@ -1033,6 +1119,11 @@ def build_html_body(data: dict) -> str:
indices = market.get('indices', []) or []
futures = market.get('futures', []) or []
msci = market.get('msci', []) or []
# 비하이브 장전 시황이 말한 값으로 교체 (스크랩값은 전날 것일 수 있음). 없으면 스크랩 유지.
if data.get('kospi_futures_override'):
futures = [data['kospi_futures_override']]
if data.get('msci_override'):
msci = [data['msci_override']]
if mode == 'morning':
greet_line1 = '관리자님, 좋은 아침입니다.'
@@ -1053,15 +1144,30 @@ 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))
if ipo:
out.append(_list_card('공모일정 변경', ipo))
out.append(_market_card(market_title, market_lines))
if mode == 'morning':
overseas = data.get('overseas_summary')
if overseas:
# 해외 카드 출처: @futuresnow 우선, 없으면 비하이브
ov_market = data.get('us_market') or {}
if not (ov_market.get('video')):
ov_market = data.get('behive_market') or {}
out.append(_video_summary_card('🌎 해외 증시·이슈', overseas, ov_market, _US_PALETTE))
domestic = data.get('domestic_summary')
if domestic:
out.append(_video_summary_card('🇰🇷 국내 증시·이슈', domestic,
data.get('behive_market') or {}, _BEHIVE_PALETTE))
elif mode == 'evening':
close_summary = data.get('close_summary')
if close_summary:
out.append(_video_summary_card('📕 마감시황', close_summary,
data.get('behive_close') or {}, _CLOSE_PALETTE))
if articles:
out.append(f'<div style="{S["news_section"]}">주요 뉴스</div>')
for a in articles:
@@ -1,39 +1,61 @@
#!/usr/bin/env python3
"""선의 미국 증시 라이브(@futuresnow) '오늘의 요약' 최신 영상 감지 + 자막 수집.
"""전 브리핑용 유튜브 시황 영상 감지 + 자막 수집.
오전 브리핑(briefing_mail.py) import 해서 메일에 '미국증시 요약' 카드를 넣는다.
미국 정규장 마감( 05:00 KST) 매일 아침 07:20~08:30 KST 사이에 한국어 요약 영상이
올라온다. 영상 제목 날짜는 '미국장 세션일'이라 헷갈리므로, '오늘 올라온 영상인지'
watch 페이지의 publishDate(절대시각) KST로 변환해 판정한다.
채널을 같은 로직으로 다룬다 (briefing_mail.py가 import):
- 오선의 미국 증시 라이브(@futuresnow) '오늘의 요약' 🇺🇸 미국증시 요약 카드
- 비하이브 투자자문 '주식시황'(장전 시황) 📈 국내 증시 시황 카드
공통 흐름: 채널 영상 페이지 `lockupViewModel` 스크랩(RSS 피드는 IP 404 잦아 미사용)으로
제목필터에 맞는 최신 영상을 찾고, watch 페이지 `publishDate`(절대시각 PTKST 변환)
'오늘 게시여부' 판정한다(제목 날짜는 신뢰 불가). 이어 자막을 수집한다.
CLI:
python3 us_market_digest.py latest 최신 요약 영상 메타 + 자막 + 오늘게시여부 JSON
python3 us_market_digest.py transcript 최신 영상 자막만 출력
python3 youtube_briefing_digest.py latest [us|behive] 최신 영상 메타+자막+오늘게시여부 JSON
python3 youtube_briefing_digest.py transcript [us|behive] 최신 영상 자막만 출력
"""
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'
)
TRANSCRIPT_LANGS = ['ko', 'ko-KR', 'en']
TRANSCRIPT_CHAR_LIMIT = 8000
WATCH_URL = 'https://www.youtube.com/watch?v={vid}'
# 채널 설정. expected_weekdays = 시황 영상이 올라올 예정인 KST 요일 (월=0 … 일=6)
CHANNELS = {
'us': {
'name': '오선의 미국 증시 라이브',
'videos_url': 'https://www.youtube.com/@futuresnow/videos',
'title_filter': '오늘의 요약', # 공지/광고 제외
# 미국장(월~금 ET) 요약은 다음날 KST 아침(화~토)에 게시
'expected_weekdays': {1, 2, 3, 4, 5},
},
'behive': {
'name': '비하이브 투자자문',
'videos_url': 'https://www.youtube.com/channel/UCHTRF5r154igU2gXjudUMzg/videos',
'title_filter': '주식시황', # '마감시황'·'주간시황'·'종목분석'·'공모청약' 제외
# 국내 장전 시황은 평일(월~금) 새벽 KST에 게시
'expected_weekdays': {0, 1, 2, 3, 4},
},
'behive_close': {
'name': '비하이브 투자자문',
'videos_url': 'https://www.youtube.com/channel/UCHTRF5r154igU2gXjudUMzg/videos',
'title_filter': '마감시황', # 국내 장 마감 해설 (오후 브리핑용)
# 마감 시황은 평일(월~금) 16:45~18:10 KST 게시
'expected_weekdays': {0, 1, 2, 3, 4},
},
}
def _get(url: str) -> str:
@@ -43,7 +65,6 @@ def _get(url: str) -> str:
def _lockup_text(o) -> str:
"""lockupMetadataViewModel title 등에서 평문 텍스트 추출."""
if isinstance(o, dict):
if isinstance(o.get('content'), str):
return o['content']
@@ -53,13 +74,12 @@ def _lockup_text(o) -> str:
return ''
def fetch_channel_videos() -> list[dict]:
def fetch_channel_videos(videos_url: str) -> list[dict]:
"""채널 영상 페이지를 긁어 최신 영상 목록을 [{video_id, title}]로 반환.
2026-06 YouTube가 videoRenderer lockupViewModel 구조로 바뀌어 그에 맞춰 파싱한다.
RSS 피드(feeds/videos.xml) 현재 IP 차원에서 404 잦아 사용하지 않는다.
"""
html = _get(CHANNEL_VIDEOS_URL)
html = _get(videos_url)
marker = 'var ytInitialData = '
start = html.find(marker)
if start < 0:
@@ -103,8 +123,7 @@ def get_publish_kst(video_id: str) -> datetime | None:
if not m:
return None
try:
dt = datetime.fromisoformat(m.group(1))
return dt.astimezone(KST)
return datetime.fromisoformat(m.group(1)).astimezone(KST)
except Exception:
return None
@@ -134,38 +153,33 @@ def fetch_transcript(video_id: str) -> tuple[str, str]:
return '', f'unavailable: {type(e).__name__}'
def get_us_summary() -> dict:
"""오전 브리핑용 미국증시 요약 데이터.
def get_channel_summary(key: str) -> dict:
"""브리핑용 시 요약 데이터. key는 CHANNELS의 'us' / 'behive'.
반환 :
channel 채널명
recap_expected_today 오늘(KST) 미국장 요약이 올라올 예정인 날인지 (~)
available_today 오늘(KST) 게시된 최신 요약 영상이 실제로 있는지
video {id, title, url, published_kst} (없으면 None)
transcript available_today일 때만 자막 본문
transcript_status ok / unavailable / error...
error 수집 실패 메시지 ( 없음)
channel, recap_expected_today, available_today,
video {id,title,url,published_kst} | None,
transcript (available_today일 때만), transcript_status, [error]
"""
cfg = CHANNELS[key]
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,
'channel': cfg['name'],
'recap_expected_today': now.weekday() in cfg['expected_weekdays'],
'available_today': False,
'video': None,
'transcript': '',
'transcript_status': 'not_fetched',
}
try:
videos = fetch_channel_videos()
videos = fetch_channel_videos(cfg['videos_url'])
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)
latest = next((v for v in videos if cfg['title_filter'] in v.get('title', '')), None)
if not latest:
result['error'] = f"'{TITLE_FILTER}' 영상을 찾지 못함"
result['error'] = f"'{cfg['title_filter']}' 영상을 찾지 못함"
return result
vid = latest['video_id']
@@ -177,7 +191,6 @@ def get_us_summary() -> dict:
'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
@@ -185,10 +198,26 @@ def get_us_summary() -> dict:
return result
def get_us_summary() -> dict:
return get_channel_summary('us')
def get_behive_market_summary() -> dict:
return get_channel_summary('behive')
def get_behive_close_summary() -> dict:
return get_channel_summary('behive_close')
def main() -> int:
cmd = sys.argv[1] if len(sys.argv) > 1 else 'latest'
key = sys.argv[2] if len(sys.argv) > 2 else 'us'
if key not in CHANNELS:
print(f'unknown channel: {key} (us|behive)', file=sys.stderr)
return 2
if cmd == 'transcript':
data = get_us_summary()
data = get_channel_summary(key)
vid = (data.get('video') or {}).get('id')
if not vid:
print(data.get('error', 'no video'), file=sys.stderr)
@@ -199,7 +228,7 @@ def main() -> int:
print(text)
return 0
if cmd == 'latest':
print(json.dumps(get_us_summary(), ensure_ascii=False, indent=2))
print(json.dumps(get_channel_summary(key), ensure_ascii=False, indent=2))
return 0
print(f'unknown command: {cmd}', file=sys.stderr)
return 2