Full upload: MCNVideo AI project with .workbuddy

This commit is contained in:
maogeigei committed 2026-08-06 23:44:59 +08:00
commit 782e06c1ab
760 files changed
+170975

No files matched your search

@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""
analyze_titles.py - 分析YouTube频道视频标题模式
分析维度:
- 标题长度分布
- 数字使用频率与类型
- 高频词汇(去除停用词)
- 标题公式分类(挑战型/数字型/悬念型/对比型/情感型)
- 标点符号与大写模式
用法:
python analyze_titles.py titles.txt
python analyze_titles.py titles.txt -o report.md
python analyze_titles.py titles.txt --top 30
输入格式: 纯文本文件,每行一个标题
输出格式: Markdown分析报告
"""
import argparse
import re
import sys
from collections import Counter
from pathlib import Path
# 英文停用词(轻量级,不依赖nltk)
STOP_WORDS = {
"i", "me", "my", "we", "our", "you", "your", "he", "him", "his", "she",
"her", "it", "its", "they", "them", "their", "a", "an", "the", "and",
"but", "or", "for", "nor", "on", "at", "to", "from", "by", "in", "of",
"with", "is", "am", "are", "was", "were", "be", "been", "being", "have",
"has", "had", "do", "does", "did", "will", "would", "shall", "should",
"may", "might", "must", "can", "could", "not", "no", "so", "if", "then",
"than", "that", "this", "these", "those", "what", "which", "who", "whom",
"how", "when", "where", "why", "all", "each", "every", "both", "few",
"more", "most", "other", "some", "such", "only", "same", "too", "very",
"just", "about", "above", "after", "again", "also", "any", "because",
"before", "between", "during", "into", "through", "up", "down", "out",
"over", "under", "here", "there", "now", "get", "got", "go", "going",
"went", "make", "made", "like", "even", "still", "back", "us",
}
# 标题公式分类的关键词/模式
TITLE_PATTERNS = {
"挑战型": [
r"\b(challenge|survive|last|endure|spent|living|tried|attempt)\b",
r"\b(\d+)\s*(hours?|days?|minutes?)\b",
r"\bi\s+(survived|built|made|ate|bought|opened|spent)\b",
],
"数字型": [
r"^\$[\d,]+",
r"\$[\d,]+\s+vs\.?\s+\$[\d,]+",
r"\b\d{2,}\b",
r"\b(100|1000|10000|million|billion)\b",
],
"悬念型": [
r"\.\.\.",
r"\?$",
r"\b(mystery|secret|hidden|never|impossible|insane|unbelievable)\b",
r"\b(what happens|you won't believe|no one|nobody)\b",
],
"对比型": [
r"\bvs\.?\b",
r"\bversus\b",
r"\$[\d,]+\s+vs\.?\s+\$[\d,]+",
r"\b(cheap|expensive|worst|best|biggest|smallest)\b.*\bvs\.?\b",
r"\b(world'?s?\s+(largest|smallest|most|least|biggest|cheapest))\b",
],
"情感型": [
r"\b(emotional|crying|tears|heartwarming|giving|donated|surprise)\b",
r"!{2,}",
r"\b(amazing|incredible|insane|crazy|epic|extreme)\b",
],
}
def load_titles(filepath: str) -> list[str]:
"""从文本文件加载标题,每行一个"""
path = Path(filepath)
if not path.exists():
print(f"[ERROR] 文件不存在: {filepath}", file=sys.stderr)
sys.exit(1)
titles = [
line.strip()
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
if not titles:
print(f"[ERROR] 文件为空: {filepath}", file=sys.stderr)
sys.exit(1)
return titles
def analyze_length(titles: list[str]) -> dict:
"""分析标题长度分布"""
lengths = [len(t) for t in titles]
word_counts = [len(t.split()) for t in titles]
return {
"char_avg": sum(lengths) / len(lengths),
"char_min": min(lengths),
"char_max": max(lengths),
"char_median": sorted(lengths)[len(lengths) // 2],
"word_avg": sum(word_counts) / len(word_counts),
"word_min": min(word_counts),
"word_max": max(word_counts),
"brackets": [
sum(1 for l in lengths if l <= 30),
sum(1 for l in lengths if 30 < l <= 50),
sum(1 for l in lengths if 50 < l <= 70),
sum(1 for l in lengths if l > 70),
],
}
def analyze_numbers(titles: list[str]) -> dict:
"""分析数字使用情况"""
has_number = [t for t in titles if re.search(r"\d", t)]
has_dollar = [t for t in titles if "$" in t]
numbers_found = []
for t in titles:
numbers_found.extend(int(n.replace(",", "")) for n in re.findall(r"[\d,]+", t) if n.replace(",", "").isdigit())
return {
"with_number_pct": len(has_number) / len(titles) * 100,
"with_dollar_pct": len(has_dollar) / len(titles) * 100,
"common_numbers": Counter(numbers_found).most_common(10),
}
def analyze_words(titles: list[str], top_n: int = 20) -> list[tuple[str, int]]:
"""提取高频词汇(去除停用词)"""
words = []
for t in titles:
tokens = re.findall(r"[a-zA-Z]+", t.lower())
words.extend(w for w in tokens if w not in STOP_WORDS and len(w) > 1)
return Counter(words).most_common(top_n)
def classify_titles(titles: list[str]) -> dict[str, list[str]]:
"""按标题公式分类"""
results = {cat: [] for cat in TITLE_PATTERNS}
for t in titles:
for cat, patterns in TITLE_PATTERNS.items():
if any(re.search(p, t, re.IGNORECASE) for p in patterns):
results[cat].append(t)
break # 每个标题只归入第一个匹配的类别
results["其他"] = [
t for t in titles
if not any(t in v for v in results.values())
]
return results
def analyze_punctuation(titles: list[str]) -> dict:
"""分析标点和大写模式"""
return {
"ends_exclamation": sum(1 for t in titles if t.endswith("!")),
"ends_question": sum(1 for t in titles if t.endswith("?")),
"ends_ellipsis": sum(1 for t in titles if t.endswith("...")),
"has_all_caps_word": sum(1 for t in titles if re.search(r"\b[A-Z]{2,}\b", t)),
"has_emoji": sum(1 for t in titles if re.search(r"[\U0001F600-\U0001F9FF]", t)),
}
def generate_report(titles: list[str], top_n: int) -> str:
"""生成Markdown分析报告"""
total = len(titles)
length_stats = analyze_length(titles)
number_stats = analyze_numbers(titles)
top_words = analyze_words(titles, top_n)
categories = classify_titles(titles)
punct_stats = analyze_punctuation(titles)
lines = []
lines.append(f"# YouTube标题分析报告\n")
lines.append(f"共分析 **{total}** 个标题\n")
# 长度分布
lines.append("## 1. 标题长度分布\n")
lines.append("| 指标 | 字符数 | 词数 |")
lines.append("|------|--------|------|")
lines.append(f"| 平均 | {length_stats['char_avg']:.1f} | {length_stats['word_avg']:.1f} |")
lines.append(f"| 最短 | {length_stats['char_min']} | {length_stats['word_min']} |")
lines.append(f"| 最长 | {length_stats['char_max']} | {length_stats['word_max']} |")
lines.append(f"| 中位数 | {length_stats['char_median']} | - |")
lines.append("")
b = length_stats["brackets"]
lines.append(f"- 30字符以内: {b[0]} ({b[0]/total*100:.1f}%)")
lines.append(f"- 31-50字符: {b[1]} ({b[1]/total*100:.1f}%)")
lines.append(f"- 51-70字符: {b[2]} ({b[2]/total*100:.1f}%)")
lines.append(f"- 70字符以上: {b[3]} ({b[3]/total*100:.1f}%)")
lines.append("")
# 数字使用
lines.append("## 2. 数字使用\n")
lines.append(f"- 含数字的标题: {number_stats['with_number_pct']:.1f}%")
lines.append(f"- 含$金额的标题: {number_stats['with_dollar_pct']:.1f}%")
if number_stats["common_numbers"]:
lines.append("\n常见数字:")
for num, count in number_stats["common_numbers"]:
lines.append(f" - {num:,}: 出现 {count} 次")
lines.append("")
# 高频词汇
lines.append(f"## 3. 高频词汇 (Top {top_n})\n")
lines.append("| 排名 | 词汇 | 出现次数 |")
lines.append("|------|------|----------|")
for i, (word, count) in enumerate(top_words, 1):
lines.append(f"| {i} | {word} | {count} |")
lines.append("")
# 标题公式分类
lines.append("## 4. 标题公式分类\n")
lines.append("| 类型 | 数量 | 占比 | 示例 |")
lines.append("|------|------|------|------|")
for cat in ["挑战型", "数字型", "悬念型", "对比型", "情感型", "其他"]:
items = categories.get(cat, [])
pct = len(items) / total * 100 if total else 0
example = items[0][:50] + "..." if items and len(items[0]) > 50 else (items[0] if items else "-")
lines.append(f"| {cat} | {len(items)} | {pct:.1f}% | {example} |")
lines.append("")
# 标点与格式
lines.append("## 5. 标点与格式特征\n")
lines.append(f"- 感叹号结尾: {punct_stats['ends_exclamation']} ({punct_stats['ends_exclamation']/total*100:.1f}%)")
lines.append(f"- 问号结尾: {punct_stats['ends_question']} ({punct_stats['ends_question']/total*100:.1f}%)")
lines.append(f"- 省略号结尾: {punct_stats['ends_ellipsis']} ({punct_stats['ends_ellipsis']/total*100:.1f}%)")
lines.append(f"- 含全大写词: {punct_stats['has_all_caps_word']} ({punct_stats['has_all_caps_word']/total*100:.1f}%)")
lines.append("")
# 洞察
lines.append("## 6. 关键洞察\n")
# 自动生成一些洞察
if number_stats["with_number_pct"] > 60:
lines.append("- **数字驱动**: 超过60%的标题使用数字,数字是核心吸引力元素")
if number_stats["with_dollar_pct"] > 30:
lines.append("- **金钱叙事**: 大量使用$金额,制造价值感和规模感")
dominant_cat = max(
[(cat, len(items)) for cat, items in categories.items() if cat != "其他"],
key=lambda x: x[1],
)
lines.append(f"- **主导公式**: 「{dominant_cat[0]}」是使用最多的标题类型 ({dominant_cat[1]}/{total})")
if length_stats["char_avg"] < 50:
lines.append("- **简洁风格**: 平均标题长度不到50字符,倾向短标题")
else:
lines.append("- **详细风格**: 平均标题超过50字符,倾向描述性标题")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="分析YouTube频道视频标题模式",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="示例:\n python analyze_titles.py mrbeast_titles.txt\n python analyze_titles.py titles.txt -o report.md --top 30",
)
parser.add_argument("input", help="标题文本文件(每行一个标题)")
parser.add_argument("-o", "--output", help="输出报告文件路径(默认打印到终端)")
parser.add_argument("--top", type=int, default=20, help="显示的高频词数量(默认20)")
args = parser.parse_args()
titles = load_titles(args.input)
report = generate_report(titles, args.top)
if args.output:
Path(args.output).write_text(report, encoding="utf-8")
print(f"[OK] 报告已保存到: {args.output}")
else:
print(report)
if __name__ == "__main__":
main()
@@ -0,0 +1,101 @@
#!/bin/bash
#
# fetch_youtube_subtitles.sh - 下载YouTube视频字幕
#
# 使用yt-dlp下载YouTube视频或频道的字幕文件(手动字幕优先,自动生成字幕兜底)
#
# 用法:
# ./fetch_youtube_subtitles.sh <URL> [语言代码] [输出目录]
#
# 参数:
# URL - YouTube视频URL或频道URL(必需)
# 语言代码 - 字幕语言,默认 en(可选)
# 输出目录 - 字幕保存位置,默认当前目录(可选)
#
# 示例:
# ./fetch_youtube_subtitles.sh "https://youtube.com/watch?v=xxx"
# ./fetch_youtube_subtitles.sh "https://youtube.com/watch?v=xxx" zh-Hans ./subs
# ./fetch_youtube_subtitles.sh "https://youtube.com/@MrBeast" en ./mrbeast_subs
#
set -euo pipefail
# ---------- 参数解析 ----------
URL="${1:-}"
LANG="${2:-en}"
OUTDIR="${3:-.}"
if [ -z "$URL" ]; then
echo "用法: $0 <YouTube URL> [语言代码] [输出目录]"
echo ""
echo "示例:"
echo " $0 'https://youtube.com/watch?v=xxx'"
echo " $0 'https://youtube.com/watch?v=xxx' zh-Hans ./subs"
echo " $0 'https://youtube.com/@MrBeast' en ./mrbeast_subs"
exit 1
fi
# ---------- 检查/安装 yt-dlp ----------
if ! command -v yt-dlp &> /dev/null; then
echo "[INFO] yt-dlp 未安装,正在通过 pip 安装..."
pip install -q yt-dlp
if ! command -v yt-dlp &> /dev/null; then
echo "[ERROR] yt-dlp 安装失败,请手动安装: pip install yt-dlp 或 brew install yt-dlp"
exit 1
fi
echo "[INFO] yt-dlp 安装完成"
fi
# ---------- 创建输出目录 ----------
mkdir -p "$OUTDIR"
echo "========================================="
echo " YouTube字幕下载器"
echo "========================================="
echo "URL: $URL"
echo "语言: $LANG"
echo "输出: $OUTDIR"
echo ""
# ---------- 先列出可用字幕 ----------
echo "[INFO] 正在查询可用字幕..."
yt-dlp --list-subs --skip-download "$URL" 2>/dev/null | head -50 || true
echo ""
# ---------- 下载字幕 ----------
# 策略:先尝试手动字幕,失败后回退到自动生成字幕
echo "[INFO] 尝试下载手动字幕 (${LANG})..."
if yt-dlp \
--write-sub \
--sub-lang "$LANG" \
--sub-format "srt/vtt/best" \
--skip-download \
--no-overwrites \
-o "${OUTDIR}/%(title)s.%(ext)s" \
"$URL" 2>/dev/null; then
echo "[OK] 手动字幕下载成功"
else
echo "[INFO] 无手动字幕,尝试下载自动生成字幕..."
if yt-dlp \
--write-auto-sub \
--sub-lang "$LANG" \
--sub-format "srt/vtt/best" \
--skip-download \
--no-overwrites \
-o "${OUTDIR}/%(title)s.%(ext)s" \
"$URL" 2>/dev/null; then
echo "[OK] 自动生成字幕下载成功"
else
echo "[ERROR] 未找到任何 ${LANG} 字幕"
echo "[提示] 尝试其他语言代码,或用 --list-subs 查看可用字幕"
exit 1
fi
fi
echo ""
echo "[INFO] 下载的字幕文件:"
find "$OUTDIR" -maxdepth 1 \( -name "*.srt" -o -name "*.vtt" \) -newer "$0" 2>/dev/null | head -20 || \
ls -la "$OUTDIR"/*.{srt,vtt} 2>/dev/null || echo " (无新文件)"
echo ""
echo "完成!"
@@ -0,0 +1,388 @@
#!/usr/bin/env python3
"""
retention_curve_checker.py - 基于MrBeast方法论的视频脚本retention检查器
检查维度(基于MrBeast公开分享的retention理论):
1. 前30秒hook:是否有明确的hook抓住观众?
2. Re-engagement moments:每3-5分钟是否有升级/转折?
3. 结尾CTA/悬念:结尾是否有行动号召或悬念?
4. Boring parts:是否有长段落无动作的「死区」?
5. 递进结构:内容是否持续升级(MrBeast的核心理念)
用法:
python retention_curve_checker.py script.txt
python retention_curve_checker.py script.txt -o report.md
python retention_curve_checker.py script.txt --duration 15
输入格式: 纯文本脚本文件
"""
import argparse
import re
import sys
from pathlib import Path
# ---------- 常量 ----------
# 平均语速(中文约250字/分钟,英文约150词/分钟)
WORDS_PER_MINUTE_EN = 150
CHARS_PER_MINUTE_ZH = 250
# Hook检查的关键词/模式
HOOK_PATTERNS = [
r"\b(today|right now|in this video|let me show|watch what happens)\b",
r"\b(challenge|bet|dare|surprise|secret|reveal|biggest|craziest)\b",
r"\$[\d,]+",
r"\b\d+\s*(hours?|days?|people|dollars)\b",
r"[!?]{1,}",
# 中文hook
r"(今天|现在|接下来|你绝对|不敢相信|挑战|赌|秘密|最大的|最疯狂的)",
]
# Re-engagement信号词
REENGAGEMENT_PATTERNS = [
r"\b(but wait|it gets (better|worse|crazier)|plot twist|here'?s where)\b",
r"\b(next|now|then|suddenly|finally|the (biggest|craziest|best) part)\b",
r"\b(level \d|round \d|phase \d|stage \d|part \d)\b",
r"\b(upgrade|double|triple|10x|100x|even more|even bigger)\b",
# 中文
r"(但是等等|更疯狂的是|转折来了|接下来|突然|最关键的|升级|加倍|翻倍)",
r"(第[一二三四五六七八九十\d]+[轮关回])",
]
# CTA和悬念模式
CTA_PATTERNS = [
r"\b(subscribe|like|comment|share|click|next video|see you)\b",
r"\b(what do you think|let me know|tell me)\b",
r"\b(next time|coming soon|stay tuned|part \d|to be continued)\b",
# 中文
r"(关注|点赞|评论|分享|下期|下次|下一个视频|你觉得呢|告诉我|敬请期待)",
]
# Boring part检测:连续长段落无动作词
ACTION_WORDS = [
r"\b(explode|run|jump|scream|crash|build|destroy|open|reveal|surprise)\b",
r"\b(win|lose|fail|succeed|break|smash|launch|drop|fly|race)\b",
r"[!?]",
r"\$[\d,]+",
# 中文
r"(爆炸|跑|跳|尖叫|建造|打开|揭晓|惊喜|赢|输|失败|打破|发射)",
]
def detect_language(text: str) -> str:
"""简单检测文本主要语言"""
zh_chars = len(re.findall(r"[\u4e00-\u9fff]", text))
en_words = len(re.findall(r"[a-zA-Z]+", text))
return "zh" if zh_chars > en_words else "en"
def estimate_duration(text: str, lang: str) -> float:
"""估算脚本时长(分钟)"""
if lang == "zh":
char_count = len(re.findall(r"[\u4e00-\u9fff]", text))
return char_count / CHARS_PER_MINUTE_ZH
else:
word_count = len(text.split())
return word_count / WORDS_PER_MINUTE_EN
def split_into_segments(text: str, segment_minutes: float, lang: str) -> list[str]:
"""将脚本按时长切成段落"""
lines = text.splitlines()
if lang == "zh":
chars_per_seg = int(CHARS_PER_MINUTE_ZH * segment_minutes)
segments = []
current = []
current_len = 0
for line in lines:
line_len = len(re.findall(r"[\u4e00-\u9fff]", line))
if current_len + line_len > chars_per_seg and current:
segments.append("\n".join(current))
current = [line]
current_len = line_len
else:
current.append(line)
current_len += line_len
if current:
segments.append("\n".join(current))
return segments
else:
words_per_seg = int(WORDS_PER_MINUTE_EN * segment_minutes)
segments = []
current = []
current_len = 0
for line in lines:
line_len = len(line.split())
if current_len + line_len > words_per_seg and current:
segments.append("\n".join(current))
current = [line]
current_len = line_len
else:
current.append(line)
current_len += line_len
if current:
segments.append("\n".join(current))
return segments
def check_hook(text: str, first_n_chars: int = 500) -> dict:
"""检查前30秒是否有hook"""
opening = text[:first_n_chars]
matches = []
for pattern in HOOK_PATTERNS:
found = re.findall(pattern, opening, re.IGNORECASE)
if found:
matches.extend(found)
score = min(len(matches), 5) # 0-5分
return {
"score": score,
"max": 5,
"matches": matches[:5],
"opening_preview": opening[:200].replace("\n", " "),
}
def check_reengagement(segments: list[str]) -> dict:
"""检查每个段落是否有re-engagement moment"""
results = []
for i, seg in enumerate(segments):
matches = []
for pattern in REENGAGEMENT_PATTERNS:
found = re.findall(pattern, seg, re.IGNORECASE)
if found:
matches.extend(found)
results.append({
"segment": i + 1,
"has_reengagement": len(matches) > 0,
"matches": matches[:3],
})
segments_with = sum(1 for r in results if r["has_reengagement"])
total = len(results)
score = round(segments_with / total * 5) if total > 0 else 0
return {
"score": score,
"max": 5,
"segments_with": segments_with,
"total_segments": total,
"details": results,
}
def check_ending(text: str, last_n_chars: int = 500) -> dict:
"""检查结尾是否有CTA或悬念"""
ending = text[-last_n_chars:]
matches = []
for pattern in CTA_PATTERNS:
found = re.findall(pattern, ending, re.IGNORECASE)
if found:
matches.extend(found)
score = min(len(matches), 5)
return {
"score": score,
"max": 5,
"matches": matches[:5],
"ending_preview": ending[-200:].replace("\n", " "),
}
def check_boring_parts(segments: list[str]) -> dict:
"""检查是否有无动作的「死区」"""
boring_segments = []
for i, seg in enumerate(segments):
action_count = 0
for pattern in ACTION_WORDS:
action_count += len(re.findall(pattern, seg, re.IGNORECASE))
# 一个段落如果动作词密度太低就标记
word_count = max(len(seg.split()), 1)
density = action_count / word_count * 100
if density < 0.5: # 低于0.5%动作词密度
boring_segments.append({
"segment": i + 1,
"preview": seg[:100].replace("\n", " ") + "...",
"action_density": round(density, 2),
})
# 没有boring段落得5分,每有一个扣1分
score = max(0, 5 - len(boring_segments))
return {
"score": score,
"max": 5,
"boring_count": len(boring_segments),
"details": boring_segments,
}
def check_escalation(segments: list[str]) -> dict:
"""检查内容是否持续升级(MrBeast核心理念:每一分钟都要比上一分钟更精彩)"""
escalation_words = [
r"\b(more|bigger|better|crazier|harder|faster|extreme|ultimate|final)\b",
r"\b(upgrade|level up|raise|increase|double|triple|max|peak)\b",
r"(更大|更好|更疯狂|更难|升级|加码|翻倍|终极|最终)",
]
scores_per_seg = []
for seg in segments:
count = 0
for pattern in escalation_words:
count += len(re.findall(pattern, seg, re.IGNORECASE))
scores_per_seg.append(count)
# 理想情况:后半部分的升级词应该多于前半部分
if len(scores_per_seg) >= 2:
mid = len(scores_per_seg) // 2
first_half = sum(scores_per_seg[:mid])
second_half = sum(scores_per_seg[mid:])
is_escalating = second_half >= first_half
else:
is_escalating = True
total_escalation = sum(scores_per_seg)
score = min(total_escalation, 3) + (2 if is_escalating else 0)
return {
"score": min(score, 5),
"max": 5,
"is_escalating": is_escalating,
"escalation_per_segment": scores_per_seg,
}
def generate_report(filepath: str, text: str, duration_override: float = None) -> str:
"""生成完整检查报告"""
lang = detect_language(text)
duration = duration_override or estimate_duration(text, lang)
segments = split_into_segments(text, 3.0, lang) # 每3分钟一段
hook = check_hook(text)
reengagement = check_reengagement(segments)
ending = check_ending(text)
boring = check_boring_parts(segments)
escalation = check_escalation(segments)
total_score = hook["score"] + reengagement["score"] + ending["score"] + boring["score"] + escalation["score"]
max_score = 25
lines = []
lines.append("# Retention检查报告\n")
lines.append(f"**文件**: {filepath}")
lines.append(f"**语言**: {'中文' if lang == 'zh' else '英文'}")
lines.append(f"**预估时长**: {duration:.1f} 分钟")
lines.append(f"**分段数**: {len(segments)} 段(每3分钟)")
lines.append(f"**总分**: {total_score}/{max_score}\n")
# 评级
if total_score >= 20:
grade = "A - 优秀,retention结构扎实"
elif total_score >= 15:
grade = "B - 良好,有改进空间"
elif total_score >= 10:
grade = "C - 及格,需要重点优化"
else:
grade = "D - 需要大幅改写"
lines.append(f"**评级**: {grade}\n")
# 1. Hook检查
lines.append("## 1. 前30秒Hook ({}/{})\n".format(hook["score"], hook["max"]))
if hook["score"] >= 3:
lines.append("开头有明确的hook元素。")
elif hook["score"] >= 1:
lines.append("开头有部分hook,但力度不够。")
else:
lines.append("**警告**: 开头缺少hook,观众可能在前10秒流失。")
if hook["matches"]:
lines.append(f"\n检测到的hook元素: {', '.join(str(m) for m in hook['matches'][:5])}")
lines.append(f"\n> 开头预览: {hook['opening_preview']}")
lines.append("")
lines.append("**MrBeast原则**: 前30秒必须让观众知道「这个视频值得看完」。要么展示最终成果的预告,要么直接抛出不可抗拒的悬念。\n")
# 2. Re-engagement
lines.append("## 2. Re-engagement Moments ({}/{})\n".format(reengagement["score"], reengagement["max"]))
lines.append(f"在 {reengagement['total_segments']} 个段落中,{reengagement['segments_with']} 个有re-engagement信号。\n")
for detail in reengagement["details"]:
status = "有" if detail["has_reengagement"] else "**缺失**"
lines.append(f"- 段落 {detail['segment']}: {status}")
if detail["matches"]:
lines.append(f" 信号词: {', '.join(str(m) for m in detail['matches'])}")
lines.append("")
lines.append("**MrBeast原则**: 每3-5分钟必须有一个「重新抓住观众」的时刻。可以是新的挑战升级、意外转折、或者stakes提高。\n")
# 3. 结尾
lines.append("## 3. 结尾CTA/悬念 ({}/{})\n".format(ending["score"], ending["max"]))
if ending["score"] >= 3:
lines.append("结尾有明确的CTA或悬念。")
elif ending["score"] >= 1:
lines.append("结尾有部分CTA元素,可以更强。")
else:
lines.append("**警告**: 结尾平淡,缺少行动号召或下期预告。")
lines.append(f"\n> 结尾预览: ...{ending['ending_preview']}")
lines.append("")
# 4. Boring Parts
lines.append("## 4. Boring Parts检测 ({}/{})\n".format(boring["score"], boring["max"]))
if boring["boring_count"] == 0:
lines.append("未检测到明显的「死区」段落。")
else:
lines.append(f"检测到 **{boring['boring_count']}** 个低动作密度段落:\n")
for b in boring["details"]:
lines.append(f"- 段落 {b['segment']} (动作密度: {b['action_density']}%): {b['preview']}")
lines.append("")
lines.append("**MrBeast原则**: 「If it's boring, cut it.」没有动作、没有张力的段落就是观众点走的时刻。\n")
# 5. 递进结构
lines.append("## 5. 递进结构 ({}/{})\n".format(escalation["score"], escalation["max"]))
if escalation["is_escalating"]:
lines.append("内容呈现递进趋势,后半段升级词多于前半段。")
else:
lines.append("**警告**: 后半段的升级感不如前半段,可能导致观众中途流失。")
lines.append(f"\n各段落升级词数量: {escalation['escalation_per_segment']}")
lines.append("")
lines.append("**MrBeast原则**: 视频的每一分钟都应该比上一分钟更精彩。观众的期望在持续上升,内容必须跟上。\n")
# 改进建议
lines.append("## 改进建议\n")
if hook["score"] < 3:
lines.append("1. **强化开头**: 考虑用「结果前置」策略,在前5秒展示视频最震撼的画面,然后回到起点讲故事。")
if reengagement["segments_with"] < reengagement["total_segments"] * 0.6:
missing = [d["segment"] for d in reengagement["details"] if not d["has_reengagement"]]
lines.append(f"2. **补充转折点**: 段落 {missing} 缺少re-engagement,考虑加入新挑战、意外事件或stakes升级。")
if ending["score"] < 3:
lines.append("3. **强化结尾**: 加入明确的CTA(关注/点赞)或下期预告,让观众有理由回来。")
if boring["boring_count"] > 0:
lines.append(f"4. **删减死区**: {boring['boring_count']}个段落动作密度过低,考虑压缩或加入视觉/动作元素。")
if not escalation["is_escalating"]:
lines.append("5. **重排结构**: 把最精彩的内容放在后半段,确保观众感受到持续升级。")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="基于MrBeast方法论的视频脚本retention检查器",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="示例:\n python retention_curve_checker.py script.txt\n python retention_curve_checker.py script.txt --duration 15 -o report.md",
)
parser.add_argument("input", help="视频脚本文本文件")
parser.add_argument("-o", "--output", help="输出报告文件路径")
parser.add_argument("--duration", type=float, help="手动指定视频时长(分钟),覆盖自动估算")
args = parser.parse_args()
path = Path(args.input)
if not path.exists():
print(f"[ERROR] 文件不存在: {args.input}", file=sys.stderr)
sys.exit(1)
text = path.read_text(encoding="utf-8")
if not text.strip():
print(f"[ERROR] 文件为空: {args.input}", file=sys.stderr)
sys.exit(1)
report = generate_report(args.input, text, args.duration)
if args.output:
Path(args.output).write_text(report, encoding="utf-8")
print(f"[OK] 报告已保存到: {args.output}")
else:
print(report)
if __name__ == "__main__":
main()
@@ -0,0 +1,353 @@
#!/usr/bin/env python3
"""
thumbnail_audit.py - 基于MrBeast缩略图理论的检查清单脚本
检查维度(基于MrBeast公开分享的缩略图原则):
1. 标题与缩略图互补性:是否「互补而非重复」?
2. 焦点数量:是否只有1-2个视觉焦点?
3. 文字量:缩略图文字是否少于5个词?
4. 情绪表达:是否有明确的面部表情/情绪?
5. 颜色对比:颜色对比是否足够醒目?
如果提供了图片文件,会用PIL分析颜色分布和亮度对比。
用法:
python thumbnail_audit.py --title "I Spent 50 Hours Buried Alive"
python thumbnail_audit.py --title "..." --image thumbnail.jpg
python thumbnail_audit.py --title "..." --image thumbnail.jpg -o report.md
依赖: Pillow(仅图片分析时需要,纯文本检查无依赖)
"""
import argparse
import sys
from pathlib import Path
# ---------- MrBeast缩略图原则 ----------
REDUNDANCY_WORDS = {
# 如果标题中的关键词大量出现在缩略图文字中,说明重复而非互补
"challenge", "survive", "hours", "days", "dollars", "money",
"biggest", "world", "first", "last", "never", "impossible",
}
# 情绪相关词汇(用于标题分析)
EMOTION_WORDS = [
"shocked", "scared", "amazed", "crying", "screaming", "laughing",
"surprised", "angry", "terrified", "excited", "happy", "sad",
"emotional", "insane", "crazy", "unbelievable", "incredible",
# 中文
"震惊", "害怕", "惊讶", "哭", "尖叫", "笑", "疯狂", "不敢相信",
]
def check_title_thumbnail_complementarity(title: str, thumb_text: str = "") -> dict:
"""检查标题与缩略图是否互补(而非重复)"""
title_words = set(title.lower().split())
thumb_words = set(thumb_text.lower().split()) if thumb_text else set()
if not thumb_text:
return {
"score": 3,
"max": 5,
"note": "未提供缩略图文字,无法完整评估。建议:缩略图应补充标题没说的信息。",
}
overlap = title_words & thumb_words & REDUNDANCY_WORDS
overlap_ratio = len(overlap) / max(len(thumb_words), 1)
if overlap_ratio > 0.5:
score = 1
note = f"缩略图文字与标题高度重复(重复词: {', '.join(overlap)})。MrBeast原则:缩略图应该补充标题,而不是重复标题。"
elif overlap_ratio > 0.2:
score = 3
note = f"有部分重复({', '.join(overlap)}),但还可以。考虑让缩略图传递标题没说的信息。"
else:
score = 5
note = "标题与缩略图互补性好,各自传递不同信息。"
return {"score": score, "max": 5, "note": note, "overlap": list(overlap)}
def check_text_amount(thumb_text: str = "") -> dict:
"""检查缩略图文字量"""
if not thumb_text:
return {
"score": 4,
"max": 5,
"word_count": 0,
"note": "未提供缩略图文字。MrBeast的缩略图通常文字极少(0-3词)或不用文字。",
}
word_count = len(thumb_text.split())
if word_count == 0:
score, note = 5, "无文字,干净利落。"
elif word_count <= 3:
score, note = 5, f"仅{word_count}词,符合MrBeast标准。"
elif word_count <= 5:
score, note = 3, f"{word_count}词,接近上限。考虑精简到3词以内。"
else:
score, note = 1, f"{word_count}词,太多了!MrBeast缩略图极少超过3-5个词。文字越少,点击率越高。"
return {"score": score, "max": 5, "word_count": word_count, "note": note}
def check_emotion_in_title(title: str) -> dict:
"""检查标题是否暗示明确的情绪(间接评估缩略图情绪需求)"""
title_lower = title.lower()
found_emotions = [w for w in EMOTION_WORDS if w in title_lower]
# 检查感叹号和问号
has_exclamation = "!" in title or "?" in title or "!" in title
has_question = "?" in title or "?" in title
if found_emotions:
score = 5
note = f"标题有明确情绪暗示({', '.join(found_emotions[:3])})。缩略图应该用面部表情呼应这种情绪。"
elif has_exclamation or has_question:
score = 3
note = "标题有情绪标点,但缺少明确情绪词。缩略图需要用面部表情补充情绪。"
else:
score = 2
note = "标题情绪不明显。MrBeast原则:缩略图必须有一张表情夸张的人脸,或者明确的情绪视觉元素。"
return {
"score": score,
"max": 5,
"emotions_found": found_emotions,
"note": note,
}
def check_title_curiosity_gap(title: str) -> dict:
"""检查标题是否制造好奇心缺口"""
curiosity_patterns = [
("数字对比", ["vs", "versus", "$", "比"]),
("悬念词", ["secret", "mystery", "hidden", "never", "impossible", "秘密", "不可能"]),
("挑战框架", ["challenge", "survive", "last", "endure", "挑战", "坚持"]),
("极端词", ["world", "biggest", "smallest", "most", "least", "最大", "最小", "最"]),
("时间压力", ["hours", "days", "minutes", "seconds", "小时", "天", "分钟"]),
]
found = []
title_lower = title.lower()
for pattern_name, keywords in curiosity_patterns:
if any(k in title_lower for k in keywords):
found.append(pattern_name)
if len(found) >= 3:
score, note = 5, f"标题有{len(found)}个好奇心元素({', '.join(found)}),非常强!"
elif len(found) >= 2:
score, note = 4, f"标题有{len(found)}个好奇心元素({', '.join(found)}),不错。"
elif len(found) == 1:
score, note = 3, f"标题有1个好奇心元素({found[0]}),可以更强。"
else:
score, note = 1, "标题缺少好奇心缺口。MrBeast标题通常至少包含2-3个好奇心元素。"
return {"score": score, "max": 5, "patterns_found": found, "note": note}
def analyze_image(image_path: str) -> dict:
"""用PIL分析图片的颜色和对比度"""
try:
from PIL import Image, ImageStat
except ImportError:
return {
"available": False,
"note": "Pillow未安装,跳过图片分析。安装: pip install Pillow",
}
path = Path(image_path)
if not path.exists():
return {"available": False, "note": f"图片文件不存在: {image_path}"}
try:
img = Image.open(path)
except Exception as e:
return {"available": False, "note": f"无法打开图片: {e}"}
# 转换为RGB
if img.mode != "RGB":
img = img.convert("RGB")
stat = ImageStat.Stat(img)
width, height = img.size
# 平均亮度
avg_brightness = sum(stat.mean) / 3
# 亮度标准差(对比度指标)
avg_stddev = sum(stat.stddev) / 3
# 颜色饱和度分析
hsv_img = img.convert("HSV")
hsv_stat = ImageStat.Stat(hsv_img)
avg_saturation = hsv_stat.mean[1]
# 主色调分析(简化版:取中心区域和边缘区域对比)
center_crop = img.crop((width // 4, height // 4, 3 * width // 4, 3 * height // 4))
center_stat = ImageStat.Stat(center_crop)
center_brightness = sum(center_stat.mean) / 3
# 评估
results = {
"available": True,
"size": f"{width}x{height}",
"brightness": {
"average": round(avg_brightness, 1),
"score": 5 if 80 < avg_brightness < 200 else 3 if 50 < avg_brightness < 230 else 1,
"note": "亮度适中" if 80 < avg_brightness < 200 else "偏暗或偏亮",
},
"contrast": {
"stddev": round(avg_stddev, 1),
"score": 5 if avg_stddev > 60 else 3 if avg_stddev > 40 else 1,
"note": "对比度强" if avg_stddev > 60 else "对比度中等" if avg_stddev > 40 else "对比度不足,缩略图在小尺寸下可能不够醒目",
},
"saturation": {
"average": round(avg_saturation, 1),
"score": 5 if avg_saturation > 100 else 3 if avg_saturation > 60 else 2,
"note": "色彩饱和度高" if avg_saturation > 100 else "色彩饱和度中等" if avg_saturation > 60 else "色彩偏淡,考虑增加饱和度",
},
"center_focus": {
"center_brightness": round(center_brightness, 1),
"edge_contrast": round(abs(center_brightness - avg_brightness), 1),
"note": "中心区域与边缘有明显对比" if abs(center_brightness - avg_brightness) > 15 else "中心与边缘对比不明显,焦点可能不够突出",
},
}
return results
def generate_report(title: str, thumb_text: str = "", image_path: str = None) -> str:
"""生成完整审核报告"""
complementarity = check_title_thumbnail_complementarity(title, thumb_text)
text_amount = check_text_amount(thumb_text)
emotion = check_emotion_in_title(title)
curiosity = check_title_curiosity_gap(title)
image_analysis = analyze_image(image_path) if image_path else None
# 计算总分
scores = [complementarity["score"], text_amount["score"], emotion["score"], curiosity["score"]]
if image_analysis and image_analysis.get("available"):
scores.append(image_analysis["brightness"]["score"])
scores.append(image_analysis["contrast"]["score"])
scores.append(image_analysis["saturation"]["score"])
total = sum(scores)
max_total = len(scores) * 5
lines = []
lines.append("# 缩略图审核报告\n")
lines.append(f"**标题**: {title}")
if thumb_text:
lines.append(f"**缩略图文字**: {thumb_text}")
if image_path:
lines.append(f"**图片**: {image_path}")
lines.append(f"\n**总分**: {total}/{max_total} ({total/max_total*100:.0f}%)\n")
# 评级
pct = total / max_total * 100
if pct >= 80:
grade = "A - 优秀,点击率潜力高"
elif pct >= 60:
grade = "B - 良好,有优化空间"
elif pct >= 40:
grade = "C - 及格,需要重点改进"
else:
grade = "D - 需要重做"
lines.append(f"**评级**: {grade}\n")
# 各项检查
lines.append("## 1. 标题-缩略图互补性 ({}/{})\n".format(complementarity["score"], complementarity["max"]))
lines.append(complementarity["note"])
lines.append("")
lines.append("## 2. 缩略图文字量 ({}/{})\n".format(text_amount["score"], text_amount["max"]))
lines.append(text_amount["note"])
lines.append("")
lines.append("## 3. 情绪表达 ({}/{})\n".format(emotion["score"], emotion["max"]))
lines.append(emotion["note"])
lines.append("")
lines.append("## 4. 好奇心缺口 ({}/{})\n".format(curiosity["score"], curiosity["max"]))
lines.append(curiosity["note"])
lines.append("")
# 图片分析
if image_analysis:
if image_analysis.get("available"):
lines.append(f"## 5. 图片技术分析 (尺寸: {image_analysis['size']})\n")
b = image_analysis["brightness"]
c = image_analysis["contrast"]
s = image_analysis["saturation"]
cf = image_analysis["center_focus"]
lines.append(f"- **亮度** ({b['score']}/5): 平均 {b['average']} - {b['note']}")
lines.append(f"- **对比度** ({c['score']}/5): 标准差 {c['stddev']} - {c['note']}")
lines.append(f"- **饱和度** ({s['score']}/5): 平均 {s['average']} - {s['note']}")
lines.append(f"- **焦点**: 中心-边缘差 {cf['edge_contrast']} - {cf['note']}")
else:
lines.append(f"## 5. 图片分析\n")
lines.append(f"跳过: {image_analysis['note']}")
lines.append("")
# MrBeast缩略图清单
lines.append("## MrBeast缩略图黄金法则\n")
lines.append("- [ ] 缩略图在手机小屏上是否清晰可辨?")
lines.append("- [ ] 是否只有1-2个视觉焦点(不杂乱)?")
lines.append("- [ ] 是否有一张情绪强烈的人脸?")
lines.append("- [ ] 缩略图是否让人产生「我必须点进去看」的冲动?")
lines.append("- [ ] 标题和缩略图组合是否创造了信息缺口?")
lines.append("- [ ] 与同时段其他视频放在一起时是否够醒目?")
lines.append("")
# 改进建议
lines.append("## 改进建议\n")
suggestions = []
if complementarity["score"] < 4:
suggestions.append("让缩略图传递标题没说的信息(比如标题说挑战,缩略图展示结果或最戏剧性的瞬间)")
if text_amount["score"] < 4:
suggestions.append("减少缩略图文字,理想是0-3个词,用视觉而非文字讲故事")
if emotion["score"] < 4:
suggestions.append("缩略图加入表情夸张的人脸照片,情绪越强烈越好")
if curiosity["score"] < 4:
suggestions.append("标题加入数字/极端词/时间压力等好奇心元素")
if image_analysis and image_analysis.get("available"):
if image_analysis["contrast"]["score"] < 4:
suggestions.append("提高图片对比度,确保缩略图在小尺寸下也清晰醒目")
if image_analysis["saturation"]["score"] < 4:
suggestions.append("增加色彩饱和度,让图片在YouTube首页中跳出来")
if suggestions:
for i, s in enumerate(suggestions, 1):
lines.append(f"{i}. {s}")
else:
lines.append("整体表现优秀,继续保持!")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="基于MrBeast缩略图理论的审核工具",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='示例:\n python thumbnail_audit.py --title "I Survived 50 Hours In Antarctica"\n python thumbnail_audit.py --title "..." --thumb-text "50 HOURS" --image thumb.jpg',
)
parser.add_argument("--title", required=True, help="视频标题")
parser.add_argument("--thumb-text", default="", help="缩略图上的文字(如果有)")
parser.add_argument("--image", help="缩略图图片文件路径(可选)")
parser.add_argument("-o", "--output", help="输出报告文件路径")
args = parser.parse_args()
report = generate_report(args.title, args.thumb_text, args.image)
if args.output:
Path(args.output).write_text(report, encoding="utf-8")
print(f"[OK] 报告已保存到: {args.output}")
else:
print(report)
if __name__ == "__main__":
main()