chore(corporate): session 2026-05-07T13-32

This commit is contained in:
한예성
2026-05-07 22:33:12 +09:00
parent 293c51c703
commit 3069c8aee5
67 changed files with 2693 additions and 0 deletions
@@ -0,0 +1,478 @@
#!/usr/bin/env python3
"""Professional YouTube Channel Analysis — pro_v4.
채널 메타 · 영상별 상세 (조회수·좋아요율·댓글율·길이·요일) · 상위/하위 영상의 패턴 ·
인기 댓글 샘플 · 발행 요일 분석 · 제목 키워드 · 우선순위 액션 추천. 모든 분석은
실제 YouTube Data API 호출 결과 기반.
Reads YOUTUBE_API_KEY + MY_CHANNEL_HANDLE/ID from youtube_account.json.
Reads LOOKBACK_DAYS / TOP_N / COMMENT_SAMPLES from my_videos_check.json."""
import os, json, sys, time, datetime, re, statistics, warnings, html as html_lib
from collections import Counter
# v2.89.49 — DeprecationWarning(utcnow 등) 노이즈 제거. 사용자 채팅창 출력에 끼면 못생김.
warnings.filterwarnings("ignore", category=DeprecationWarning)
HERE = os.path.dirname(os.path.abspath(__file__))
ACCOUNT = os.path.join(HERE, "youtube_account.json")
CONFIG = os.path.join(HERE, "my_videos_check.json")
REPORT = os.path.join(HERE, "my_videos_check_report.md")
def _load(p):
with open(p, "r", encoding="utf-8") as f:
return json.load(f)
def _resolve_channel_id(youtube, handle, channel_id):
if channel_id:
return channel_id
if not handle:
return None
h = handle.lstrip("@")
try:
r = youtube.search().list(part="snippet", q=h, type="channel", maxResults=1).execute()
items = r.get("items", [])
if items:
return items[0]["snippet"]["channelId"]
except Exception as e:
print(f"⚠️ 채널 ID 조회 실패: {e}")
return None
def _resolve_telegram(account):
"""telegram_v3 — Secretary's tools/telegram_setup.json is the canonical
UI-managed home (input via Skills ⚙️). Fallback chain:
1) youtube_account.json (this tool's local override, back-compat)
2) _agents/secretary/tools/telegram_setup.json (UI-managed, canonical)
3) _agents/secretary/config.md (legacy markdown, back-compat)
"""
import re, json as _json
token = (account.get("TELEGRAM_BOT_TOKEN") or "").strip()
chat = (account.get("TELEGRAM_CHAT_ID") or "").strip()
if token and chat:
return token, chat
brain_root = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
# 2) Secretary's tool JSON
sec_json = os.path.join(brain_root, "_agents", "secretary", "tools", "telegram_setup.json")
if (not token or not chat) and os.path.exists(sec_json):
try:
with open(sec_json, "r", encoding="utf-8") as f:
cfg = _json.load(f)
if not token: token = (cfg.get("TELEGRAM_BOT_TOKEN") or "").strip()
if not chat: chat = (cfg.get("TELEGRAM_CHAT_ID") or "").strip()
except Exception:
pass
# 3) Legacy config.md
sec_cfg = os.path.join(brain_root, "_agents", "secretary", "config.md")
if (not token or not chat) and os.path.exists(sec_cfg):
try:
with open(sec_cfg, "r", encoding="utf-8") as f:
txt = f.read()
if not token:
m = re.search(r"TELEGRAM_BOT_TOKEN\s*[:=]\s*([A-Za-z0-9:_\-]+)", txt)
if m: token = m.group(1).strip()
if not chat:
m = re.search(r"TELEGRAM_CHAT_ID\s*[:=]\s*(-?\d+)", txt)
if m: chat = m.group(1).strip()
except Exception:
pass
return token, chat
def _push_telegram(account, text):
"""v2.89.49 — 마크다운 모드는 *,[,(,),# 같은 특수문자 많은 보고서에서 자주 400 거부.
이전엔 그래도 'sent' print해서 사용자한테 가짜 성공 보고. 이제 plain text 모드로
안전하게 보내고 HTTP status 체크해서 진짜 성공/실패 정확히 알려줌."""
token, chat = _resolve_telegram(account)
if not token or not chat:
print("⚠️ 텔레그램 토큰/chat_id 미설정 — 전송 안 함", file=sys.stderr)
return
try:
import requests
# plain text (parse_mode 없음) — 어떤 특수문자든 통과
r = requests.post(
f"https://api.telegram.org/bot{token}/sendMessage",
json={"chat_id": chat, "text": text[:4000]},
timeout=10,
)
if r.status_code == 200:
print("📨 텔레그램 전송 성공", file=sys.stderr)
else:
try:
err = r.json().get("description", r.text[:200])
except Exception:
err = r.text[:200]
print(f"⚠️ 텔레그램 전송 실패 (HTTP {r.status_code}): {err}", file=sys.stderr)
except Exception as e:
print(f"⚠️ 텔레그램 전송 에러: {e}", file=sys.stderr)
def _fmt_num(n):
if n >= 1_000_000: return f"{n/1_000_000:.1f}M"
if n >= 1_000: return f"{n/1_000:.1f}K"
return f"{n:,}"
def _parse_duration(iso):
"""ISO 8601 duration (PT5M30S) → seconds"""
m = re.match(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?', iso or '')
if not m: return 0
h, mn, s = (int(x) if x else 0 for x in m.groups())
return h * 3600 + mn * 60 + s
def _fmt_duration(secs):
if secs >= 3600: return f"{secs//3600}시간 {(secs%3600)//60}"
if secs >= 60: return f"{secs//60}{secs%60}"
return f"{secs}"
def _korean_weekday(dt):
return ["","","","","","",""][dt.weekday()]
def main():
if not os.path.exists(ACCOUNT):
print("❌ youtube_account.json이 없어요. 직원 에이전트 보기 → YouTube → 도구 ⚙️에서 API 키와 채널 ID를 입력하세요.")
sys.exit(1)
acct = _load(ACCOUNT)
cfg = _load(CONFIG) if os.path.exists(CONFIG) else {}
api_key = (acct.get("YOUTUBE_API_KEY") or "").strip()
handle = (acct.get("MY_CHANNEL_HANDLE") or "").strip()
chan_id = (acct.get("MY_CHANNEL_ID") or "").strip()
if not api_key:
print("❌ YOUTUBE_API_KEY 미설정. youtube_account.json에 채워주세요.")
sys.exit(1)
if not (handle or chan_id):
print("❌ MY_CHANNEL_HANDLE 또는 MY_CHANNEL_ID 필요.")
sys.exit(1)
lookback = int(cfg.get("LOOKBACK_DAYS", 30))
top_n = int(cfg.get("TOP_N", 15))
comment_samples = int(cfg.get("COMMENT_SAMPLES", 5))
try:
from googleapiclient.discovery import build
except ImportError:
print("❌ google-api-python-client 미설치. pip install google-api-python-client requests")
sys.exit(1)
youtube = build("youtube", "v3", developerKey=api_key)
cid = _resolve_channel_id(youtube, handle, chan_id)
if not cid:
print("❌ 채널 ID를 찾지 못했어요. youtube_account.json의 핸들/ID 확인.")
sys.exit(1)
# === 1. 채널 메타 ===
print(f"🔍 채널 정보 가져오는 중...", file=sys.stderr)
cr = youtube.channels().list(part="snippet,statistics,contentDetails,brandingSettings", id=cid).execute()
cit = cr.get("items", [])
if not cit:
print(f"❌ 채널 데이터 없음 (ID: {cid})")
sys.exit(1)
ch = cit[0]
snip = ch.get("snippet", {})
cstats = ch.get("statistics", {})
# v2.89.55 — YouTube API가 가끔 & / ' 같은 HTML entity로 인코딩된 제목 반환.
# 이걸 그대로 출력하면 채팅창에서 "'" 가 literal로 보임. 미리 디코드.
ch_title = html_lib.unescape(snip.get("title", "") or "")
custom_url = snip.get("customUrl", "")
published = (snip.get("publishedAt", "") or "")[:10]
country = snip.get("country", "")
sub_count = int(cstats.get("subscriberCount", 0))
subs_hidden = cstats.get("hiddenSubscriberCount", False)
view_count_total = int(cstats.get("viewCount", 0))
video_count_total = int(cstats.get("videoCount", 0))
if published:
try:
age_days = (datetime.date.today() - datetime.date.fromisoformat(published)).days
except Exception:
age_days = 0
else:
age_days = 0
age_years = age_days / 365.25 if age_days > 0 else 0
avg_views_per_video_alltime = view_count_total // video_count_total if video_count_total else 0
# === 2. 최근 영상 목록 ===
print(f"🔍 최근 {lookback}일 영상 가져오는 중...", file=sys.stderr)
after = (datetime.datetime.utcnow() - datetime.timedelta(days=lookback)).isoformat("T") + "Z"
sr = youtube.search().list(part="snippet", channelId=cid, maxResults=top_n,
order="date", publishedAfter=after, type="video").execute()
vids = [(it["id"]["videoId"], it["snippet"]["title"], it["snippet"]["publishedAt"])
for it in sr.get("items", [])]
if not vids:
# Fallback to most recent regardless of lookback window
sr = youtube.search().list(part="snippet", channelId=cid, maxResults=top_n,
order="date", type="video").execute()
vids = [(it["id"]["videoId"], it["snippet"]["title"], it["snippet"]["publishedAt"])
for it in sr.get("items", [])]
if not vids:
# v2.89.55 — 빈 영상 시 stderr로. stdout이 비어 있어야 TS shortcut이 실패로 정확히 처리.
print(f"⚠️ 업로드된 영상이 없어요.", file=sys.stderr)
sys.exit(0)
# === 3. 영상 상세 통계 ===
print(f"🔍 영상 {len(vids)}개 상세 통계 + 길이·태그 가져오는 중...", file=sys.stderr)
vstats = youtube.videos().list(
part="statistics,contentDetails,snippet",
id=",".join(v[0] for v in vids)
).execute()
sm = {it["id"]: it for it in vstats.get("items", [])}
rows = []
for vid, vtitle, pub in vids:
item = sm.get(vid, {})
s = item.get("statistics", {})
cd = item.get("contentDetails", {})
sn = item.get("snippet", {})
views = int(s.get("viewCount", 0))
likes = int(s.get("likeCount", 0))
comments = int(s.get("commentCount", 0))
dur_sec = _parse_duration(cd.get("duration", "PT0S"))
like_rate = (likes / views * 100) if views > 0 else 0
comment_rate = (comments / views * 100) if views > 0 else 0
try:
pub_dt = datetime.datetime.fromisoformat(pub.replace("Z", "+00:00"))
weekday = _korean_weekday(pub_dt)
hour = pub_dt.hour
except Exception:
weekday, hour = "-", 0
rows.append({
# v2.89.55 — title HTML entity 디코드 (' → ', & → & 등)
"id": vid, "title": html_lib.unescape(vtitle or ""), "pub": pub[:10],
"weekday": weekday, "hour": hour,
"views": views, "likes": likes, "comments": comments,
"duration_sec": dur_sec,
"like_rate": like_rate, "comment_rate": comment_rate,
"tags": sn.get("tags", []) or [],
"is_short": dur_sec <= 60,
})
# === 4. 집계 ===
views_list = [r["views"] for r in rows]
median_views = int(statistics.median(views_list)) if views_list else 0
avg_views = int(statistics.mean(views_list)) if views_list else 0
avg_likes = int(statistics.mean([r["likes"] for r in rows])) if rows else 0
avg_comments = int(statistics.mean([r["comments"] for r in rows])) if rows else 0
avg_duration = int(statistics.mean([r["duration_sec"] for r in rows])) if rows else 0
avg_like_rate = statistics.mean([r["like_rate"] for r in rows]) if rows else 0
avg_comment_rate = statistics.mean([r["comment_rate"] for r in rows]) if rows else 0
title_lengths = [len(r["title"]) for r in rows]
avg_title_len = int(statistics.mean(title_lengths)) if title_lengths else 0
shorts_count = sum(1 for r in rows if r["is_short"])
rows_sorted = sorted(rows, key=lambda r: r["views"], reverse=True)
top_videos = rows_sorted[:3]
bottom_videos = rows_sorted[-3:][::-1] if len(rows_sorted) >= 4 else []
# 요일·시간대 패턴
weekday_views = {}
for r in rows:
weekday_views.setdefault(r["weekday"], []).append(r["views"])
weekday_avg = {wd: int(statistics.mean(vs)) for wd, vs in weekday_views.items()}
# 상위 영상 제목 키워드
top_title_words = Counter()
stop_kr = {'그리고','근데','너무','진짜','정말','내가','지금','이거','저는','제가','우리'}
stop_en = {'this','that','and','the','for','with','have','will','your','from','about'}
for r in top_videos:
words = re.findall(r'[가-힣]+|[a-zA-Z]+', r["title"])
top_title_words.update(w for w in words if len(w) >= 2 and w.lower() not in stop_en and w not in stop_kr)
top_keywords = [w for w, _ in top_title_words.most_common(8)]
# === 5. 인기 댓글 샘플 (상위 3개 영상) ===
print(f"💬 상위 영상의 인기 댓글 가져오는 중...", file=sys.stderr)
comments_by_video = {}
for r in top_videos[:3]:
try:
cr_resp = youtube.commentThreads().list(
part="snippet", videoId=r["id"], maxResults=comment_samples, order="relevance"
).execute()
comments_by_video[r["id"]] = [
{
# v2.89.55 — author/text도 HTML entity 디코드
"author": html_lib.unescape(c["snippet"]["topLevelComment"]["snippet"].get("authorDisplayName", "") or ""),
"text": html_lib.unescape(c["snippet"]["topLevelComment"]["snippet"].get("textOriginal", "") or "")[:200],
"likes": int(c["snippet"]["topLevelComment"]["snippet"].get("likeCount", 0)),
}
for c in cr_resp.get("items", [])
]
except Exception:
comments_by_video[r["id"]] = [] # 댓글 비활성 영상이면 403
# === 6. 종합 보고서 ===
# v2.89.50 — 시각적으로 더 멋진 레이아웃. 블록인용·이모지 평가·시각 분리선 활용.
sub_str = "비공개" if subs_hidden else f"{_fmt_num(sub_count)}"
like_rating = "🟢 좋음" if avg_like_rate >= 2.0 else ("🟡 보통" if avg_like_rate >= 1.0 else "🔴 개선")
comment_rating = "🟢 좋음" if avg_comment_rate >= 0.5 else ("🟡 보통" if avg_comment_rate >= 0.2 else "🔴 개선")
L = []
L.append(f"# 🎬 {ch_title}")
L.append(f"_{time.strftime('%Y-%m-%d %H:%M')} · 최근 {lookback}일 분석 · 영상 {len(rows)}개_")
L.append("")
# 채널 메타 — 인용 블록으로 한눈에
L.append(f"> **{sub_str}** 구독자 · **{_fmt_num(view_count_total)}** 누적 조회 · **{video_count_total:,}개** 영상" + (f" · **{age_years:.1f}년** 운영" if age_years > 0 else ""))
L.append(f"> 핸들 `{custom_url or handle or '-'}`" + (f" · 🌍 {country}" if country else "") + f" · 영상당 평균 **{_fmt_num(avg_views_per_video_alltime)}** 조회")
L.append("")
L.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
L.append("")
# 최근 성과 요약 — 카드 스타일
L.append(f"## 📊 최근 {lookback}일 성과 한눈에")
L.append("")
L.append("| 지표 | 값 | 평가 |")
L.append("|---|---|---|")
pace = (len(rows) * 30 / lookback) if lookback > 0 else 0
pace_rating = "🟢 활발" if pace >= 4 else ("🟡 보통" if pace >= 2 else "🔴 저조")
L.append(f"| 업로드 | {len(rows)}개 (월 {pace:.1f}개) | {pace_rating} |")
if rows:
L.append(f"| 조회수 중간값 | **{_fmt_num(median_views)}** | 최고 {_fmt_num(rows_sorted[0]['views'])} · 최저 {_fmt_num(rows_sorted[-1]['views'])} |")
L.append(f"| 좋아요율 | **{avg_like_rate:.2f}%** | {like_rating} (업계 2~5%) |")
L.append(f"| 댓글율 | **{avg_comment_rate:.2f}%** | {comment_rating} (업계 0.3~1%) |")
L.append(f"| 평균 길이 | {_fmt_duration(avg_duration)} | 제목 평균 {avg_title_len}자 |")
if shorts_count:
L.append(f"| Shorts | {shorts_count}개 / {len(rows)} | - |")
L.append("")
L.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
L.append("")
# 영상별 상세 표
L.append("## 📺 영상별 상세 (조회수 순)")
L.append("| # | 조회수 | 좋아요 (율) | 댓글 (율) | 길이 | 발행 | 제목 |")
L.append("|---|---|---|---|---|---|---|")
for i, r in enumerate(rows_sorted, 1):
marker = "🔥" if r["views"] >= median_views * 1.5 else ("👍" if r["views"] >= median_views else "🥶")
title_short = r['title'].replace('|', '\\|')[:60]
L.append(f"| {i}{marker} | {_fmt_num(r['views'])} | {_fmt_num(r['likes'])} ({r['like_rate']:.1f}%) | {_fmt_num(r['comments'])} ({r['comment_rate']:.1f}%) | {_fmt_duration(r['duration_sec'])} | {r['pub']}({r['weekday']}) | {title_short} |")
L.append("")
# 상위 영상 심층 분석 — 카드 스타일 + 메달 이모지
L.append("## 🏆 TOP 3 — 무엇이 잘 됐나")
L.append("")
medals = ["🥇", "🥈", "🥉"]
for idx, r in enumerate(top_videos):
medal = medals[idx] if idx < 3 else "👍"
L.append(f"### {medal} {_fmt_num(r['views'])}회 · {r['title']}")
L.append("")
L.append(f"> 📅 {r['pub']} ({r['weekday']}요일 {r['hour']:02d}시) · ⏱ {_fmt_duration(r['duration_sec'])} · 👍 {r['like_rate']:.2f}% · 💬 {r['comment_rate']:.2f}%")
if r['tags']:
tag_str = ' '.join(f"`{t}`" for t in r['tags'][:5])
L.append(f"> 🏷 {tag_str}" + ('' if len(r['tags']) > 5 else ''))
L.append(f"> 🔗 [영상 보기](https://youtu.be/{r['id']}) · 🖼 [썸네일](https://i.ytimg.com/vi/{r['id']}/mqdefault.jpg)")
cs = comments_by_video.get(r["id"], [])
if cs:
L.append("")
L.append("**💬 인기 댓글:**")
for c in cs[:3]:
txt = c['text'].replace(chr(10), ' ').replace(chr(13), ' ')[:140]
L.append(f"> _{c['author']}_ (👍{c['likes']}): {txt}")
L.append("")
# 하위 영상 — 시각적으로 부진 강조
if bottom_videos:
L.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
L.append("")
L.append("## 🥶 하위 영상 — 개선 필요")
L.append("")
for r in bottom_videos:
gap_pct = int((1 - r['views'] / median_views) * 100) if median_views else 0
L.append(f"- **{_fmt_num(r['views'])}회** · 중간값 대비 **-{gap_pct}%** ↓")
L.append(f" - {r['title']}")
L.append(f" - 📅 {r['pub']}({r['weekday']}, {r['hour']:02d}시) · ⏱ {_fmt_duration(r['duration_sec'])} · 🔗 [영상](https://youtu.be/{r['id']})")
L.append("")
# 패턴
L.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
L.append("")
L.append("## 🔍 패턴 분석")
L.append("")
if weekday_avg and len(weekday_avg) >= 2:
best_day = max(weekday_avg.items(), key=lambda x: x[1])
worst_day = min(weekday_avg.items(), key=lambda x: x[1])
ratio = best_day[1] / worst_day[1] if worst_day[1] else 1
L.append(f"- 📅 **최고 요일**: {best_day[0]}요일 (평균 {_fmt_num(best_day[1])}회) — 최저 대비 **{ratio:.1f}배**")
L.append(f"- 📅 **최저 요일**: {worst_day[0]}요일 (평균 {_fmt_num(worst_day[1])}회)")
if top_keywords:
L.append(f"- 🔑 **상위 영상 키워드**: {' '.join('`'+k+'`' for k in top_keywords)}")
if title_lengths:
L.append(f"- 📝 **제목 길이**: 평균 {avg_title_len}자 (최단 {min(title_lengths)}자 · 최장 {max(title_lengths)}자)")
if avg_duration > 0:
L.append(f"- ⏱ **영상 길이**: 평균 {_fmt_duration(avg_duration)}" + (f" · Shorts(60초 이하) {shorts_count}/{len(rows)}" if shorts_count else ""))
L.append("")
# 액션 추천 — 카드 스타일
L.append("")
L.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
L.append("")
L.append("## 🎯 다음 액션 (우선순위)")
L.append("")
recs = []
if bottom_videos:
worst = bottom_videos[0]
recs.append(("🔴", f"**부진 영상 살리기** — `{worst['title'][:40]}` ({_fmt_num(worst['views'])}회). 썸네일 A/B 또는 제목 리네이밍."))
if top_videos:
winner = top_videos[0]
recs.append(("🔥", f"**떡상 패턴 복제** — `{winner['title'][:40]}` ({_fmt_num(winner['views'])}회). 같은 후크/포맷으로 후속편."))
if weekday_avg and len(weekday_avg) >= 3:
best_day = max(weekday_avg.items(), key=lambda x: x[1])[0]
recs.append(("📅", f"**발행 요일 최적화** — {best_day}요일 영상이 평균 가장 잘 됨. 다음 업로드 {best_day}요일 추천."))
if avg_like_rate < 2.0 and avg_views > 100:
recs.append(("👍", f"**좋아요율 개선** — 현재 {avg_like_rate:.2f}% (업계 2~5%). 영상 끝 콜아웃 강화."))
if avg_comment_rate < 0.3 and avg_views > 100:
recs.append(("💬", f"**댓글 유도 강화** — 현재 {avg_comment_rate:.2f}% (업계 0.3~1%). 영상 중간 시청자 의견 질문 삽입."))
if top_keywords:
recs.append(("🔑", f"**제목 키워드 활용** — 상위 영상의 `{', '.join(top_keywords[:3])}` 키워드를 다음 제목에 통합."))
if shorts_count == 0 and len(rows) >= 5:
recs.append(("📱", f"**Shorts 시도** — 최근 {lookback}일에 Shorts 0개. 신규 유입 채널로 좋음."))
if pace < 2:
recs.append(("", f"**업로드 빈도 점검** — 월 {pace:.1f}개 페이스. 알고리즘 친화적 페이스는 주 1회+."))
if not recs:
recs.append(("", "데이터 부족 — 더 많은 영상 업로드 후 재분석 권장"))
for i, (icon, rec) in enumerate(recs, 1):
L.append(f"**{i}. {icon} {rec}**" if i == 1 else f"{i}. {icon} {rec}")
L.append("")
# 시청자 반응 키워드 (상위 영상 댓글 기반)
all_comments = []
for cs in comments_by_video.values():
all_comments.extend(c["text"] for c in cs)
if all_comments:
L.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
L.append("")
L.append("## 💬 시청자가 남긴 키워드")
L.append("")
all_text = " ".join(all_comments)
words = re.findall(r'[가-힣]{2,}|[a-zA-Z]{3,}', all_text)
# URL 조각·도메인은 의미 없으니 제외
url_noise = {'https', 'http', 'youtu', 'www', 'com'}
words = [w for w in words if w.lower() not in stop_en and w not in stop_kr and w.lower() not in url_noise and not re.match(r'^[a-zA-Z0-9_]{8,}$', w)]
word_freq = Counter(words).most_common(8)
if word_freq:
kw_line = ' · '.join(f"`{w}`({c})" for w, c in word_freq)
L.append(kw_line)
L.append("")
L.append("> 시청자 머릿속에 남은 단어. 다음 영상 제목·썸네일·후크에 활용.")
L.append("")
summary = chr(10).join(L)
# v2.89.49 — stdout은 보고서 markdown만. 메타·진단 메시지는 stderr로.
print(summary)
with open(REPORT, "a", encoding="utf-8") as f:
f.write(chr(10) + chr(10) + summary + chr(10) + chr(10) + "---" + chr(10))
print(f"\n✅ 보고서 저장: {REPORT}", file=sys.stderr)
# Telegram (4096자 제한 — plain text라 마크다운 특수문자 그대로 보내도 통과)
tg_lines = []
tg_lines.append(f"📊 {ch_title} — 채널 분석")
tg_lines.append(f"({time.strftime('%Y-%m-%d %H:%M')} · 최근 {lookback}일 · 영상 {len(rows)}개)")
tg_lines.append("")
tg_lines.append(f"구독자 {sub_str} · 누적 {_fmt_num(view_count_total)} · 총 {video_count_total}")
if rows:
tg_lines.append(f"중간값 {_fmt_num(median_views)}회 · 최고 {_fmt_num(rows_sorted[0]['views'])} · 최저 {_fmt_num(rows_sorted[-1]['views'])}")
tg_lines.append(f"좋아요율 {avg_like_rate:.2f}% · 댓글율 {avg_comment_rate:.2f}%")
tg_lines.append("")
if top_videos:
tg_lines.append(f"🏆 최고: {_fmt_num(top_videos[0]['views'])} {top_videos[0]['title'][:40]}")
if bottom_videos:
tg_lines.append(f"🥶 부진: {_fmt_num(bottom_videos[0]['views'])} {bottom_videos[0]['title'][:40]}")
tg_lines.append("")
if recs:
tg_lines.append("🎯 액션:")
for i, (icon, rec) in enumerate(recs[:3], 1):
# 마크다운 ** 제거하고 plain text로
clean = re.sub(r'\*\*|`', '', rec.split('')[0] if '' in rec else rec)
tg_lines.append(f"{i}. {icon} {clean[:80]}")
tg_lines.append("")
tg_lines.append("(전체 분석은 IDE 채팅창 확인)")
tg_text = chr(10).join(tg_lines)
_push_telegram(acct, tg_text)
if __name__ == "__main__":
main()