#!/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()