#!/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"([^<]+)", 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()