480 lines
18 KiB
Python
480 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
YouTube Analyzer — анализ видео как Perplexity Deep Research.
|
|
|
|
Три режима:
|
|
1. По URL: youtube_analyzer.py --url "https://youtube.com/watch?v=..."
|
|
2. Поиск: youtube_analyzer.py --search "LLM агенты 2026"
|
|
3. Пакет: youtube_analyzer.py --search "AI" --limit 3 --compare (сравнение нескольких)
|
|
|
|
Алгоритм:
|
|
- Получение субтитров через youtube-transcript-api
|
|
- Если субтитров нет — pytube + whisper
|
|
- LLM-синтез → structured report с таймкодами и цитатами
|
|
|
|
Использование:
|
|
python3 scripts/youtube_analyzer.py --url "https://youtube.com/watch?v=..."
|
|
python3 scripts/youtube_analyzer.py --search "термоядерный синтез 2026" --lang ru
|
|
python3 scripts/youtube_analyzer.py --search "LLM agents" --limit 3 --compare --lang en
|
|
python3 scripts/youtube_analyzer.py --url "..." --transcript-only
|
|
|
|
Опции:
|
|
--url URL видео
|
|
--search Поисковый запрос для поиска видео
|
|
--limit 1-5 (default: 1, для --search и --compare)
|
|
--compare Сравнить несколько видео по теме
|
|
--lang Язык субтитров: ru (default) | en
|
|
--transcript-only Только текст без LLM-анализа
|
|
--output Сохранить в файл
|
|
|
|
Зависимости: youtube-transcript-api, pytube, openai-whisper (установлены)
|
|
"""
|
|
|
|
import argparse
|
|
import html
|
|
import json
|
|
import os
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
import urllib.parse
|
|
from datetime import datetime, timedelta
|
|
|
|
import requests
|
|
|
|
# Utils для LLM — ищем в scripts/ плагина или в ~/.hermes/scripts/
|
|
import importlib.util as _importlib_util
|
|
import sys as _sys
|
|
_utils_paths = [
|
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "research_llm_utils.py"),
|
|
"/home/neo/.hermes/scripts/research_llm_utils.py",
|
|
]
|
|
_utils_path = None
|
|
for _up in _utils_paths:
|
|
if os.path.exists(_up):
|
|
_utils_path = _up
|
|
break
|
|
if _utils_path is None:
|
|
raise ImportError("research_llm_utils.py не найден. Установите плагин research-agent полностью.")
|
|
_spec = _importlib_util.spec_from_file_location("research_llm_utils", _utils_path)
|
|
_utils_mod = _importlib_util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(_utils_mod)
|
|
llm_call_safe = _utils_mod.llm_call_safe
|
|
validate_and_warn = _utils_mod.validate_and_warn
|
|
REASON_FIRST_PREFIX = _utils_mod.REASON_FIRST_PREFIX
|
|
log = _utils_mod.log
|
|
apply_cli_overrides = _utils_mod.apply_cli_overrides
|
|
|
|
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://localhost:8888")
|
|
LLM_URL = os.environ.get("LLM_URL", "http://192.168.1.152:80/v1/chat/completions")
|
|
LLM_MODEL = os.environ.get("LLM_MODEL", "Qwen3.6-35B-A3B-UD-IQ4_NL.gguf")
|
|
LLM_TEMPERATURE = float(os.environ.get("LLM_TEMPERATURE", "0.3"))
|
|
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
|
|
|
|
|
|
def log(msg):
|
|
print(f"[youtube-analyzer] {msg}", file=sys.stderr)
|
|
|
|
|
|
def llm_call(system, user, max_tokens=2048, temperature=None):
|
|
if temperature is None:
|
|
temperature = LLM_TEMPERATURE
|
|
payload = {
|
|
"model": LLM_MODEL,
|
|
"messages": [
|
|
{"role": "system", "content": system},
|
|
{"role": "user", "content": user},
|
|
],
|
|
"max_tokens": max_tokens,
|
|
"temperature": temperature,
|
|
"stream": False,
|
|
}
|
|
try:
|
|
resp = requests.post(LLM_URL, json=payload, timeout=180)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
content = data["choices"][0]["message"]["content"].strip()
|
|
if not content:
|
|
log("LLM error: empty response")
|
|
return None
|
|
return content
|
|
except Exception as e:
|
|
log(f"LLM error: {e}")
|
|
return None
|
|
|
|
|
|
def extract_video_id(url):
|
|
"""Извлечь video ID из URL YouTube."""
|
|
patterns = [
|
|
r"(?:v=|/v/|youtu\.be/|/embed/|/shorts/)([a-zA-Z0-9_-]{11})",
|
|
r"youtube\.com/watch\?.*v=([a-zA-Z0-9_-]{11})",
|
|
]
|
|
for p in patterns:
|
|
m = re.search(p, url)
|
|
if m:
|
|
return m.group(1)
|
|
return None
|
|
|
|
|
|
def get_video_title(video_id):
|
|
"""Получить название видео через оython youtube-транскрипт-api или прямой запрос."""
|
|
# Пробуем через страницу
|
|
try:
|
|
resp = requests.get(
|
|
f"https://www.youtube.com/watch?v={video_id}",
|
|
headers={"User-Agent": USER_AGENT},
|
|
timeout=10,
|
|
)
|
|
m = re.search(r"<title>([^<]+)</title>", resp.text)
|
|
if m:
|
|
title = html.unescape(m.group(1))
|
|
title = re.sub(r"\s*-\s*YouTube\s*$", "", title)
|
|
return title.strip()
|
|
except Exception:
|
|
pass
|
|
return f"Video {video_id}"
|
|
|
|
|
|
def get_transcript(video_id, lang="ru"):
|
|
"""Получить транскрипт видео (API youtube-transcript-api v2.x)."""
|
|
try:
|
|
from youtube_transcript_api import YouTubeTranscriptApi
|
|
|
|
lang_list = [lang, "en", "a.en", "ru", "es", "de", "fr"]
|
|
|
|
api = YouTubeTranscriptApi()
|
|
transcript = api.fetch(video_id, languages=lang_list)
|
|
raw = transcript.to_raw_data()
|
|
|
|
segments = [
|
|
{
|
|
"text": seg.get("text", ""),
|
|
"start": seg.get("start", 0),
|
|
"duration": seg.get("duration", 0),
|
|
}
|
|
for seg in raw
|
|
]
|
|
return segments, None
|
|
except ImportError:
|
|
return None, "youtube-transcript-api не установлен"
|
|
except Exception as e:
|
|
return None, str(e)
|
|
|
|
|
|
def format_timestamp(seconds):
|
|
"""MM:SS или HH:MM:SS."""
|
|
s = int(seconds)
|
|
h, r = divmod(s, 3600)
|
|
m, s = divmod(r, 60)
|
|
if h:
|
|
return f"{h}:{m:02d}:{s:02d}"
|
|
return f"{m}:{s:02d}"
|
|
|
|
|
|
def transcript_to_text(segments, with_timestamps=True):
|
|
"""Транскрипт в читаемый текст с/без таймкодов."""
|
|
lines = []
|
|
for seg in segments:
|
|
text = seg["text"].strip()
|
|
if not text:
|
|
continue
|
|
if with_timestamps:
|
|
ts = format_timestamp(seg["start"])
|
|
lines.append(f"[{ts}] {text}")
|
|
else:
|
|
lines.append(text)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def search_youtube_videos(query, lang="ru", limit=5):
|
|
"""Поиск видео через SearXNG."""
|
|
params = {
|
|
"q": f"{query} site:youtube.com",
|
|
"format": "json",
|
|
"categories": "general",
|
|
"language": lang,
|
|
"pageno": 1,
|
|
}
|
|
try:
|
|
resp = requests.get(
|
|
SEARXNG_URL, params=params, headers={"User-Agent": USER_AGENT}, timeout=20
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
results = data.get("results", [])
|
|
|
|
# Фильтр: только YouTube ссылки
|
|
videos = []
|
|
for r in results:
|
|
url = r.get("url", "")
|
|
vid = extract_video_id(url)
|
|
if vid:
|
|
videos.append({
|
|
"title": r.get("title", "?"),
|
|
"url": url,
|
|
"video_id": vid,
|
|
"snippet": r.get("content", ""),
|
|
})
|
|
if len(videos) >= limit:
|
|
break
|
|
return videos
|
|
except Exception as e:
|
|
log(f"Search error: {e}")
|
|
return []
|
|
|
|
|
|
def transcribe_with_whisper(video_id):
|
|
"""Скачать аудио + whisper транскрибация (fallback)."""
|
|
try:
|
|
from pytube import YouTube
|
|
|
|
log("Скачивание аудио через pytube...")
|
|
yt = YouTube(f"https://www.youtube.com/watch?v={video_id}")
|
|
stream = yt.streams.filter(only_audio=True).first()
|
|
if not stream:
|
|
return None, "Нет аудиопотока"
|
|
|
|
tmp_path = f"/tmp/yta_{video_id}.mp4"
|
|
stream.download(output_path="/tmp", filename=f"yta_{video_id}.mp4")
|
|
|
|
if not os.path.exists(tmp_path):
|
|
return None, "Не удалось скачать аудио"
|
|
|
|
log("Транскрибация через whisper...")
|
|
import whisper
|
|
|
|
model = whisper.load_model("small") # small — баланс скорость/качество
|
|
result = model.transcribe(tmp_path, language="ru")
|
|
os.remove(tmp_path)
|
|
|
|
segments = []
|
|
for seg in result.get("segments", []):
|
|
segments.append({
|
|
"text": seg.get("text", "").strip(),
|
|
"start": seg.get("start", 0),
|
|
"duration": seg.get("end", 0) - seg.get("start", 0),
|
|
})
|
|
return segments, None
|
|
except ImportError as e:
|
|
return None, f"pytube/whisper не установлен: {e}"
|
|
except Exception as e:
|
|
return None, str(e)
|
|
|
|
|
|
def analyze_single_video(video_id, title, lang="ru", transcript_only=False):
|
|
"""Анализ одного видео — полный цикл."""
|
|
result = {
|
|
"video_id": video_id,
|
|
"title": title,
|
|
"url": f"https://youtube.com/watch?v={video_id}",
|
|
"segments": None,
|
|
"error": None,
|
|
"transcript_text": "",
|
|
"report": "",
|
|
}
|
|
|
|
# Фаза 1: получение транскрипта
|
|
log(f"Получение транскрипта ({lang})...")
|
|
segments, error = get_transcript(video_id, lang)
|
|
used_whisper = False
|
|
|
|
if segments is None:
|
|
log(f"Транскрипт не найден: {error}")
|
|
log("Fallback: whisper транскрибация...")
|
|
segments, error = transcribe_with_whisper(video_id)
|
|
used_whisper = True
|
|
|
|
if segments is None:
|
|
result["error"] = f"Не удалось получить транскрипт: {error}"
|
|
return result
|
|
|
|
result["segments"] = segments
|
|
result["transcript_text"] = transcript_to_text(segments, with_timestamps=True)
|
|
result["used_whisper"] = used_whisper
|
|
|
|
log(f"Транскрипт: {len(segments)} сегментов, "
|
|
f"{len(result['transcript_text'])} символов")
|
|
|
|
if transcript_only:
|
|
return result
|
|
|
|
# Фаза 2: LLM-синтез
|
|
log("Синтез отчёта...")
|
|
text_for_llm = transcript_to_text(segments, with_timestamps=True)
|
|
# Ограничение для LLM
|
|
if len(text_for_llm) > 12000:
|
|
text_for_llm = text_for_llm[:12000] + "\n[...truncated]"
|
|
|
|
lang_system = "ru" if lang == "ru" else "en"
|
|
system = (
|
|
"Ты — аналитик контента. Проанализируй транскрипт видео и составь "
|
|
"структурированный отчёт.\n\n"
|
|
"Формат отчёта СТРОГО:\n\n"
|
|
"## [Название видео]\n\n"
|
|
"**Ключевой вывод:** [1-2 предложения]\n\n"
|
|
"### Краткое содержание\n[2-3 абзаца с ключевыми идеями и таймкодами [MM:SS]]\n\n"
|
|
"### Ключевые тезисы\n- **Тезис 1** (таймкод [MM:SS]): [формулировка]\n"
|
|
"- **Тезис 2** (таймкод [MM:SS]): [формулировка]\n\n"
|
|
"### Яркие цитаты\n> \"[цитата]\" — [MM:SS]\n\n"
|
|
"### Оценка\n[Кому будет полезно, качество аргументации, факты/мнение]\n"
|
|
)
|
|
|
|
report = llm_call(system, text_for_llm, max_tokens=2048, temperature=0.2)
|
|
if report:
|
|
result["report"] = report
|
|
else:
|
|
result["report"] = "Ошибка синтеза отчёта."
|
|
|
|
return result
|
|
|
|
|
|
def synthesize_compare(video_analyses, topic):
|
|
"""Сравнительный анализ нескольких видео."""
|
|
ctx_parts = []
|
|
for va in video_analyses:
|
|
if va["report"]:
|
|
ctx_parts.append(
|
|
f"## {va['title']}\n{va['report'][:2000]}"
|
|
)
|
|
elif va["error"]:
|
|
ctx_parts.append(f"## {va['title']}\nОшибка: {va['error']}")
|
|
|
|
context = "\n\n---\n\n".join(ctx_parts)
|
|
|
|
system = (
|
|
"Ты — аналитик. Составь сравнительный обзор нескольких видео на одну тему.\n\n"
|
|
"Формат:\n\n"
|
|
"## Сравнительный анализ: [тема]\n\n"
|
|
"**Ключевой вывод:** [общий вывод по всем видео]\n\n"
|
|
"### Обзор видео\n"
|
|
"- [Видео 1]: кратко (1-2 предложения)\n"
|
|
"- [Видео 2]: кратко\n\n"
|
|
"### Сходства\n[что общего в подаче / фактах / аргументах]\n\n"
|
|
"### Различия\n[чем отличаются: угол подачи, глубина, качество]\n\n"
|
|
"### Рейтинг (от лучшего к худшему)\n"
|
|
"1. [Видео] — почему\n"
|
|
"2. [Видео] — почему\n\n"
|
|
"### Итог\n[какое видео лучше всего раскрывает тему]\n"
|
|
)
|
|
|
|
prompt = (
|
|
f"Тема: {topic}\n\n"
|
|
f"Анализы видео:\n\n{context}\n\n"
|
|
"Составь сравнительный отчёт."
|
|
)
|
|
|
|
report = llm_call(system, prompt, max_tokens=2048, temperature=0.2)
|
|
return report or "Ошибка синтеза."
|
|
|
|
|
|
# ─── Main ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="YouTube Analyzer — анализ видео как Perplexity"
|
|
)
|
|
parser.add_argument("--url", help="URL видео YouTube")
|
|
parser.add_argument("--search", help="Поисковый запрос для поиска видео")
|
|
parser.add_argument("--limit", type=int, default=1, choices=range(1, 6),
|
|
help="Количество видео для поиска/сравнения")
|
|
parser.add_argument("--compare", action="store_true",
|
|
help="Сравнить несколько видео")
|
|
parser.add_argument("--lang", default="ru", choices=["ru", "en"])
|
|
parser.add_argument("--model", help="Имя LLM модели (env: LLM_MODEL)")
|
|
parser.add_argument("--temperature", type=float, help="Temperature (env: LLM_TEMPERATURE)")
|
|
parser.add_argument("--transcript-only", action="store_true",
|
|
help="Только текст транскрипта")
|
|
parser.add_argument("--output", "-o", help="Сохранить в файл")
|
|
args = parser.parse_args()
|
|
|
|
if args.model:
|
|
LLM_MODEL = args.model
|
|
if args.temperature is not None:
|
|
LLM_TEMPERATURE = args.temperature
|
|
|
|
if not args.url and not args.search:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
start_time = time.time()
|
|
|
|
# ─── Режим: поиск видео ─────────────────────────────────────────
|
|
videos = []
|
|
if args.search:
|
|
log(f"Поиск видео: {args.search}")
|
|
videos = search_youtube_videos(args.search, args.lang, args.limit)
|
|
if not videos:
|
|
print("Видео не найдены.")
|
|
sys.exit(1)
|
|
log(f"Найдено видео: {len(videos)}")
|
|
for v in videos:
|
|
log(f" {v['title'][:80]}")
|
|
if not v.get("title"):
|
|
v["title"] = get_video_title(v["video_id"])
|
|
elif args.url:
|
|
vid = extract_video_id(args.url)
|
|
if not vid:
|
|
print("Не удалось извлечь ID видео из URL")
|
|
sys.exit(1)
|
|
title = get_video_title(vid)
|
|
videos = [{"video_id": vid, "title": title, "url": args.url}]
|
|
log(f"Видео: {title}")
|
|
|
|
# ─── Анализ видео ───────────────────────────────────────────────
|
|
analyses = []
|
|
for i, v in enumerate(videos):
|
|
log(f"\nАнализ [{i+1}/{len(videos)}]: {v['title'][:60]}")
|
|
result = analyze_single_video(
|
|
v["video_id"],
|
|
v.get("title", v.get("title", "?")),
|
|
args.lang,
|
|
args.transcript_only,
|
|
)
|
|
analyses.append(result)
|
|
|
|
# ─── Вывод результатов ──────────────────────────────────────────
|
|
output_parts = []
|
|
elapsed = time.time() - start_time
|
|
|
|
if args.compare and len(analyses) > 1:
|
|
# Сравнительный отчёт
|
|
log("Синтез сравнительного отчёта...")
|
|
topic = args.search or "сравнение видео"
|
|
compare_report = synthesize_compare(analyses, topic)
|
|
output_parts.append(f"# Сравнительный анализ видео\n\n{compare_report}")
|
|
output_parts.append(f"\n\n---\n*Проанализировано {len(analyses)} видео за {elapsed:.0f} сек*")
|
|
else:
|
|
for a in analyses:
|
|
title_display = a["title"]
|
|
if a.get("error"):
|
|
output_parts.append(f"## {title_display}\n\n**Ошибка:** {a['error']}\n\n---")
|
|
continue
|
|
|
|
if args.transcript_only:
|
|
# Только текст транскрипта
|
|
header = f"## {title_display}\nURL: {a['url']}\n\n"
|
|
output_parts.append(header + a["transcript_text"])
|
|
else:
|
|
output_parts.append(a["report"])
|
|
|
|
if a.get("used_whisper"):
|
|
output_parts.append("\n\n*Транскрипция через whisper (субтитры отсутствовали)*")
|
|
|
|
if not args.transcript_only:
|
|
output_parts.append(f"\n\n---\n*Проанализировано {len(analyses)} видео за {elapsed:.0f} сек*")
|
|
|
|
output = "\n\n".join(output_parts)
|
|
|
|
if args.output:
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
f.write(output)
|
|
log(f"Отчёт сохранён: {args.output}")
|
|
else:
|
|
print(output)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|