305 lines
14 KiB
Python
305 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Channel Full Analysis — comprehensive overview of your YouTube channel.
|
|
|
|
Input: just YOUTUBE_API_KEY + MY_CHANNEL_ID/HANDLE from youtube_account.json.
|
|
No additional config needed. Output: full report with stats, patterns, and
|
|
data-driven recommendations.
|
|
"""
|
|
import os, json, sys, time, datetime, statistics, re
|
|
from collections import Counter
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
ACCOUNT = os.path.join(HERE, "youtube_account.json")
|
|
REPORT = os.path.join(HERE, "channel_full_analysis_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 _parse_iso_duration(d):
|
|
"""ISO 8601 duration (PT4M30S) → seconds."""
|
|
m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", d or "")
|
|
if not m: return 0
|
|
h, mi, s = m.groups()
|
|
return int(h or 0) * 3600 + int(mi or 0) * 60 + int(s or 0)
|
|
|
|
def _fmt_duration(sec):
|
|
if sec < 60: return f"{sec}s"
|
|
if sec < 3600: return f"{sec//60}m {sec%60}s"
|
|
return f"{sec//3600}h {(sec%3600)//60}m"
|
|
|
|
def _resolve_telegram(account):
|
|
"""Same fallback chain as my_videos_check.py."""
|
|
import 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, "..", "..", ".."))
|
|
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
|
|
return token, chat
|
|
|
|
def _push_telegram(account, text):
|
|
token, chat = _resolve_telegram(account)
|
|
if not token or not chat:
|
|
return
|
|
try:
|
|
import requests
|
|
requests.post(
|
|
f"https://api.telegram.org/bot{token}/sendMessage",
|
|
json={"chat_id": chat, "text": text, "parse_mode": "Markdown"},
|
|
timeout=10,
|
|
)
|
|
print("📨 텔레그램으로 보고 전송")
|
|
except Exception as e:
|
|
print(f"⚠️ 텔레그램 전송 실패: {e}")
|
|
|
|
def main():
|
|
if not os.path.exists(ACCOUNT):
|
|
print("❌ youtube_account.json이 없어요. 외부 연결 패널에서 YouTube API 키와 채널 ID 입력해주세요.")
|
|
sys.exit(1)
|
|
acct = _load(ACCOUNT)
|
|
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 Data API 카드에 입력해주세요.")
|
|
sys.exit(1)
|
|
if not (handle or chan_id):
|
|
print("❌ MY_CHANNEL_HANDLE 또는 MY_CHANNEL_ID 필요. 외부 연결 패널 → 채널 ID 입력해주세요.")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
from googleapiclient.discovery import build
|
|
except ImportError:
|
|
print("❌ google-api-python-client 미설치.")
|
|
print(" 터미널에서 한 줄: pip3 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를 찾지 못했어요. 외부 연결 패널의 채널 ID 확인.")
|
|
sys.exit(1)
|
|
|
|
print(f"📈 [채널 완전 분석] 채널 {handle or cid} 분석 중...")
|
|
print()
|
|
|
|
# 1. 채널 메타
|
|
ch = youtube.channels().list(part="snippet,statistics,brandingSettings", id=cid).execute()
|
|
if not ch.get("items"):
|
|
print("❌ 채널 데이터를 가져오지 못했어요. API 키·할당량 확인.")
|
|
sys.exit(1)
|
|
c = ch["items"][0]
|
|
sn = c.get("snippet", {})
|
|
st = c.get("statistics", {})
|
|
title = sn.get("title", "(이름 없음)")
|
|
subs = int(st.get("subscriberCount", 0))
|
|
total_views = int(st.get("viewCount", 0))
|
|
video_count = int(st.get("videoCount", 0))
|
|
pub_at = sn.get("publishedAt", "")[:10]
|
|
|
|
print("─── 1. 채널 개요 ───")
|
|
print(f" 채널: {title}")
|
|
print(f" 핸들: {sn.get('customUrl', handle or '(없음)')}")
|
|
print(f" 구독자: {subs:,}명")
|
|
print(f" 총 조회수: {total_views:,}회")
|
|
print(f" 업로드 영상: {video_count}개")
|
|
print(f" 채널 가입: {pub_at}")
|
|
avg_per_video = total_views // max(1, video_count)
|
|
print(f" 영상당 평균 조회: {avg_per_video:,}회")
|
|
print()
|
|
|
|
# 2. 최근 30일 영상 분석 (uploads playlist 사용 — search보다 quota 절약)
|
|
uploads = c.get("contentDetails", {}).get("relatedPlaylists", {}).get("uploads") if "contentDetails" in c else None
|
|
if not uploads:
|
|
# contentDetails 없으면 search로 폴백
|
|
cd = youtube.channels().list(part="contentDetails", id=cid).execute()
|
|
if cd.get("items"):
|
|
uploads = cd["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"]
|
|
|
|
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=30)
|
|
recent_video_ids = []
|
|
if uploads:
|
|
next_token = None
|
|
while len(recent_video_ids) < 50:
|
|
args = {"part": "snippet,contentDetails", "playlistId": uploads, "maxResults": 50}
|
|
if next_token: args["pageToken"] = next_token
|
|
pi = youtube.playlistItems().list(**args).execute()
|
|
for item in pi.get("items", []):
|
|
pub = item["snippet"]["publishedAt"]
|
|
pub_dt = datetime.datetime.fromisoformat(pub.replace("Z", "+00:00"))
|
|
if pub_dt < cutoff:
|
|
break
|
|
recent_video_ids.append(item["contentDetails"]["videoId"])
|
|
next_token = pi.get("nextPageToken")
|
|
if not next_token: break
|
|
if recent_video_ids and datetime.datetime.fromisoformat(pi["items"][-1]["snippet"]["publishedAt"].replace("Z", "+00:00")) < cutoff:
|
|
break
|
|
|
|
if not recent_video_ids:
|
|
print("⚠️ 최근 30일 동안 업로드한 영상이 없어요. 영상 업로드 후 다시 분석해주세요.")
|
|
sys.exit(0)
|
|
|
|
# 3. 영상별 통계 (50개씩 나눠서)
|
|
all_vids = []
|
|
for i in range(0, len(recent_video_ids), 50):
|
|
chunk = recent_video_ids[i:i+50]
|
|
st_resp = youtube.videos().list(part="snippet,statistics,contentDetails", id=",".join(chunk)).execute()
|
|
for v in st_resp.get("items", []):
|
|
stats = v.get("statistics", {})
|
|
sn_v = v.get("snippet", {})
|
|
cd_v = v.get("contentDetails", {})
|
|
views = int(stats.get("viewCount", 0))
|
|
likes = int(stats.get("likeCount", 0))
|
|
comments = int(stats.get("commentCount", 0))
|
|
duration_sec = _parse_iso_duration(cd_v.get("duration", ""))
|
|
pub = sn_v.get("publishedAt", "")
|
|
pub_dt = datetime.datetime.fromisoformat(pub.replace("Z", "+00:00"))
|
|
all_vids.append({
|
|
"id": v["id"],
|
|
"title": sn_v.get("title", ""),
|
|
"views": views,
|
|
"likes": likes,
|
|
"comments": comments,
|
|
"duration_sec": duration_sec,
|
|
"pub_dt": pub_dt,
|
|
"engagement_rate": (likes + comments) / views if views > 0 else 0,
|
|
})
|
|
|
|
all_vids.sort(key=lambda x: x["views"], reverse=True)
|
|
views_list = [v["views"] for v in all_vids]
|
|
median_views = statistics.median(views_list) if views_list else 0
|
|
mean_views = statistics.mean(views_list) if views_list else 0
|
|
|
|
print("─── 2. 최근 30일 업로드 패턴 ───")
|
|
print(f" 업로드 횟수: {len(all_vids)}개 (월평균 {len(all_vids):.1f}개)")
|
|
weekday_counts = Counter(v["pub_dt"].strftime("%A") for v in all_vids)
|
|
weekday_kr = {"Monday":"월","Tuesday":"화","Wednesday":"수","Thursday":"목","Friday":"금","Saturday":"토","Sunday":"일"}
|
|
top_day = weekday_counts.most_common(1)
|
|
if top_day:
|
|
print(f" 주로 업로드한 요일: {weekday_kr.get(top_day[0][0], top_day[0][0])}요일 ({top_day[0][1]}회)")
|
|
avg_duration = sum(v["duration_sec"] for v in all_vids) / len(all_vids)
|
|
print(f" 평균 영상 길이: {_fmt_duration(int(avg_duration))}")
|
|
print()
|
|
|
|
print("─── 3. 성과 통계 ───")
|
|
print(f" 중간값 조회수: {int(median_views):,}회")
|
|
print(f" 평균 조회수: {int(mean_views):,}회")
|
|
avg_eng = sum(v["engagement_rate"] for v in all_vids) / len(all_vids) * 100 if all_vids else 0
|
|
print(f" 평균 참여율 (좋아요+댓글)/조회: {avg_eng:.2f}%")
|
|
print()
|
|
|
|
# 떡상 / 부진 분류
|
|
hot = [v for v in all_vids if v["views"] >= median_views * 1.5]
|
|
cold = [v for v in all_vids if v["views"] < median_views * 0.5]
|
|
|
|
print("─── 4. 🔥 떡상 영상 (중간값 × 1.5 이상) ───")
|
|
if not hot:
|
|
print(" (없음 — 모든 영상이 평균 근처)")
|
|
else:
|
|
for v in hot[:5]:
|
|
print(f" 🔥 {v['views']:>8,}회 · 참여 {v['engagement_rate']*100:.2f}% · {_fmt_duration(v['duration_sec'])} · {v['title'][:50]}")
|
|
print()
|
|
|
|
print("─── 5. 🥶 부진 영상 (중간값 × 0.5 미만) ───")
|
|
if not cold:
|
|
print(" (없음 — 모든 영상이 평균 근처)")
|
|
else:
|
|
for v in cold[:5]:
|
|
print(f" 🥶 {v['views']:>8,}회 · 참여 {v['engagement_rate']*100:.2f}% · {_fmt_duration(v['duration_sec'])} · {v['title'][:50]}")
|
|
print()
|
|
|
|
# 6. 패턴 비교 — 떡상 vs 부진의 차이
|
|
print("─── 6. 떡상 vs 부진 — 패턴 비교 ───")
|
|
if hot and cold:
|
|
hot_avg_dur = sum(v["duration_sec"] for v in hot) / len(hot)
|
|
cold_avg_dur = sum(v["duration_sec"] for v in cold) / len(cold)
|
|
hot_avg_title = sum(len(v["title"]) for v in hot) / len(hot)
|
|
cold_avg_title = sum(len(v["title"]) for v in cold) / len(cold)
|
|
print(f" 떡상 영상 평균 길이: {_fmt_duration(int(hot_avg_dur))}")
|
|
print(f" 부진 영상 평균 길이: {_fmt_duration(int(cold_avg_dur))}")
|
|
if abs(hot_avg_dur - cold_avg_dur) > 60:
|
|
longer = "떡상" if hot_avg_dur > cold_avg_dur else "부진"
|
|
print(f" → {longer} 영상이 평균 {abs(int(hot_avg_dur - cold_avg_dur))}초 더 길어요")
|
|
print(f" 떡상 영상 평균 제목 길이: {hot_avg_title:.0f}자")
|
|
print(f" 부진 영상 평균 제목 길이: {cold_avg_title:.0f}자")
|
|
else:
|
|
print(" (떡상 또는 부진 데이터 부족 — 영상이 더 쌓이면 다시 분석)")
|
|
print()
|
|
|
|
# 7. 자동 추천 (LLM 없이 데이터만으로)
|
|
print("─── 7. 🧭 다음 액션 추천 (데이터 기반) ───")
|
|
actions = []
|
|
if hot:
|
|
actions.append(f"🔥 떡상한 {len(hot)}개 영상의 제목·후크 패턴을 다음 영상에 적용 — 가장 잘 된 후크는 \"{hot[0]['title'][:50]}\"")
|
|
if cold:
|
|
actions.append(f"🥶 부진한 {len(cold)}개는 썸네일 A/B 테스트 또는 제목 리네이밍 후보")
|
|
if avg_eng < 2.0:
|
|
actions.append(f"💗 평균 참여율 {avg_eng:.2f}% — 영상 끝에 명확한 CTA(좋아요·구독) 추가 추천 (보통 3% 이상이 건강함)")
|
|
elif avg_eng > 5.0:
|
|
actions.append(f"💗 참여율 {avg_eng:.2f}% — 매우 좋음. 시청자와 강한 연결 구축됨, 상품·멤버십 도입 고려 시점")
|
|
if len(all_vids) < 4:
|
|
actions.append("📅 월 4개 미만 업로드 — 알고리즘 노출 위해 최소 주 1회 권장")
|
|
elif len(all_vids) > 12:
|
|
actions.append("📅 월 12개 이상 업로드 — 양은 충분, 영상별 품질·후크에 집중 추천")
|
|
if not actions:
|
|
actions.append("✅ 채널 상태 안정적 — 현재 패턴 유지하며 시청자 댓글에서 다음 콘텐츠 아이디어 수집")
|
|
for a in actions:
|
|
print(f" • {a}")
|
|
print()
|
|
|
|
# 8. 보고서 .md 저장
|
|
summary_lines = [
|
|
f"# 📈 채널 완전 분석 — {time.strftime('%Y-%m-%d %H:%M')}",
|
|
f"채널: **{title}** · 구독자 **{subs:,}** · 영상 **{video_count}**개",
|
|
"",
|
|
"## 최근 30일 통계",
|
|
f"- 업로드: {len(all_vids)}개",
|
|
f"- 조회수 중간값: **{int(median_views):,}**",
|
|
f"- 평균 참여율: **{avg_eng:.2f}%**",
|
|
f"- 평균 영상 길이: **{_fmt_duration(int(avg_duration))}**",
|
|
"",
|
|
f"## 🔥 떡상 영상 ({len(hot)}개)",
|
|
]
|
|
for v in hot[:5]:
|
|
summary_lines.append(f"- {v['views']:,}회 · {v['title']}")
|
|
summary_lines.append(f"\n## 🥶 부진 영상 ({len(cold)}개)")
|
|
for v in cold[:5]:
|
|
summary_lines.append(f"- {v['views']:,}회 · {v['title']}")
|
|
summary_lines.append("\n## 🧭 다음 액션 (자동 추천)")
|
|
for a in actions:
|
|
summary_lines.append(f"- {a}")
|
|
|
|
summary = "\n".join(summary_lines)
|
|
with open(REPORT, "a", encoding="utf-8") as f:
|
|
f.write("\n\n" + summary + "\n\n---\n")
|
|
print(f"✅ 보고서: {REPORT}")
|
|
_push_telegram(acct, summary)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|