Files

1 line
72 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "chubbyguan/chubbyskills", "files": {"youtube-transcribe/scripts/transcribe.py": {"56c434bd9f8f5d15d540038073aa44c24df39dbd": {"t": 1785745410.2108395, "n": 10625, "h": "d816535a31e0d0de", "c": "#!/usr/bin/env python3\n\"\"\"\nYouTube 视频转录 + 翻译工具\n\n用法:\n python transcribe.py \"https://www.youtube.com/watch?v=xxxxx\"\n python transcribe.py \"https://www.youtube.com/watch?v=xxxxx\" --no-translate\n\"\"\"\n\nimport sys\nimport os\nimport re\nimport time\nimport json\nimport subprocess\nimport tempfile\nimport shutil\nimport glob\nimport urllib.request\nfrom datetime import datetime\n\n\nSUB_LANGS = \"en,en-US,en-orig,zh-Hans,zh-CN,zh,zh-Hant\"\n\n\ndef get_video_info(url: str) -> dict:\n \"\"\"Get video title and duration.\"\"\"\n result = subprocess.run(\n [\"yt-dlp\", \"--get-title\", \"--get-duration\", \"--no-check-certificates\", url],\n capture_output=True, text=True, timeout=30\n )\n lines = result.stdout.strip().split('\\n')\n return {\n 'title': lines[0] if lines else 'Untitled',\n 'duration': lines[1] if len(lines) > 1 else '',\n }\n\n\ndef download_audio(url: str, output_dir: str) -> str:\n \"\"\"Download audio from YouTube.\"\"\"\n print(\" ⬇️ Downloading audio...\", file=sys.stderr)\n\n audio_path = os.path.join(output_dir, \"audio.mp3\")\n subprocess.run(\n [\"yt-dlp\",\n \"--extract-audio\", \"--audio-format\", \"mp3\", \"--audio-quality\", \"128K\",\n \"-o\", audio_path,\n \"--no-check-certificates\",\n url],\n timeout=600, check=True\n )\n\n size_mb = os.path.getsize(audio_path) / (1024 * 1024)\n print(f\" ✅ Audio: {size_mb:.1f} MB\", file=sys.stderr)\n return audio_path\n\n\ndef parse_vtt(path: str) -> str:\n \"\"\"把 vtt 字幕解析成纯文本,去时间轴、去标签、去连续重复行。\"\"\"\n with open(path, encoding=\"utf-8\", errors=\"replace\") as f:\n lines = f.read().splitlines()\n out = []\n for line in lines:\n line = line.strip()\n if not line or line == \"WEBVTT\" or \"-->\" in line:\n continue\n if line.isdigit() or line.startswith((\"Kind:\", \"Language:\", \"NOTE\")):\n continue\n line = re.sub(r\"<[^>]+>\", \"\", line).replace(\"&nbsp;\", \" \").strip()\n if not line or (out and out[-1] == line):\n continue\n out.append(line)\n return \"\\n\".join(out)\n\n\ndef try_subtitles(url: str, tmpdir: str):\n \"\"\"字幕优先:抓官方/自动字幕,免下载+转录。返回 (text, language) 或 None。\"\"\"\n print(\" 💬 尝试抓取字幕...\", file=sys.stderr)\n try:\n subprocess.run(\n [\"yt-dlp\", \"--skip-download\", \"--write-subs\", \"--write-auto-subs\",\n \"--sub-langs\", SUB_LANGS, \"--sub-format\", \"vtt\",\n \"--no-check-certificates\",\n \"-o\", os.path.join(tmpdir, \"sub.%(ext)s\"), url],\n capture_output=True, text=True, timeout=120,\n )\n except Exception:\n return None\n files = glob.glob(os.path.join(tmpdir, \"*.vtt\"))\n if not files:\n print(\" 💬 无字幕,回退音频转录\", file=sys.stderr)\n return None\n\n def rank(p):\n name = os.path.basename(p).lower()\n for i, tag in enumerate([\"en\", \"zh-hans\", \"zh-cn\", \"zh\"]):\n if tag in name:\n return i\n return 99\n chosen = sorted(files, key=rank)[0]\n text = parse_vtt(chosen)\n if len(text) < 50:\n return None\n chinese = len(re.findall(r\"[一-鿿]\", text))\n language = \"zh\" if chinese / max(len(text), 1) > 0.3 else \"en\"\n print(f\" ✅ 命中字幕({language},{len(text)} 字)\", file=sys.stderr)\n return text, language\n\n\ndef transcribe_audio(audio_path: str) -> tuple:\n \"\"\"Transcribe audio using SenseVoice-Small. Returns (text, language).\"\"\"\n from funasr import AutoModel\n from funasr.utils.postprocess_utils import rich_transcription_postprocess\n\n print(\" 🎙️ Loading model...\", file=sys.stderr)\n model = AutoModel(\n model=\"iic/SenseVoiceSmall\",\n trust_remote_code=True,\n vad_model=\"fsmn-vad\",\n vad_kwargs={\"max_single_segment_time\": 30000},\n device=\"cpu\",\n )\n\n print(\" 🎙️ Transcribing...\", file=sys.stderr)\n start = time.time()\n\n # Auto detect language\n result = model.generate(\n input=audio_path,\n language=\"auto\", # Auto detect\n use_itn=True,\n batch_size_s=60\n )\n elapsed = time.time() - start\n\n # Extract text\n text = \"\"\n if result and len(result) > 0:\n for r in result:\n if \"text\" in r:\n text += rich_transcription_postprocess(r[\"text\"]) + \"\\n\\n\"\n\n # Detect language (simple heuristic)\n chinese_chars = len(re.findall(r'[\\u4e00-\\u9fff]', text))\n total_chars = len(text.strip())\n language = \"zh\" if chinese_chars / max(total_chars, 1) > 0.3 else \"en\"\n\n print(f\" ✅ Transcribed in {elapsed:.1f}s (detected: {language})\", file=sys.stderr)\n return text.strip(), language\n\n\ndef translate_text(text: str, api_key: str = None) -> str:\n \"\"\"Translate English to Chinese using DeepSeek API.\"\"\"\n if not api_key:\n api_key = os.environ.get(\"DEEPSEEK_API_KEY\")\n\n if not api_key:\n print(\" ⚠️ No DEEPSEEK_API_KEY, skipping translation\", file=sys.stderr)\n return None\n\n print(\" 🌐 Translating to Chinese...\", file=sys.stderr)\n\n # Split into chunks if too long\n chunks = []\n if len(text) > 4000:\n paragraphs = text.split('\\n\\n')\n current_chunk = \"\"\n for para in paragraphs:\n if len(current_chunk) + len(para) > 3500:\n chunks.append(current_chunk)\n current_chunk = para\n else:\n current_chunk += \"\\n\\n\" + para if current_chunk else para\n if current_chunk:\n chunks.append(current_chunk)\n else:\n chunks = [text]\n\n translated_chunks = []\n for i, chunk in enumerate(chunks):\n if len(chunks) > 1:\n print(f\" 🌐 Translating chunk {i+1}/{len(chunks)}...\", file=sys.stderr)\n\n try:\n payload = json.dumps({\n \"model\": \"deepseek-chat\",\n \"messages\": [\n {\"role\": \"system\", \"content\": \"你是专业翻译。将英文翻译成中文,保持原文风格和格式。不要添加额外解释。\"},\n {\"role\": \"user\", \"content\": f\"翻译以下英文为中文:\\n\\n{chunk}\"}\n ],\n \"temperature\": 0.3\n }).encode(\"utf-8\")\n\n req = urllib.request.Request(\n \"https://api.deepseek.com/v1/chat/completions\",\n data=payload,\n headers={\n \"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {api_key}\",\n },\n )\n with urllib.request.urlopen(req, timeout=120) as resp:\n response = json.loads(resp.read())\n\n translated = response['choices'][0]['message']['content']\n translated_chunks.append(translated)\n except Exception as e:\n print(f\" ⚠️ Translation failed: {e}\", file=sys.stderr)\n return None\n\n print(\" ✅ Translation done\", file=sys.stderr)\n return \"\\n\\n\".join(translated_chunks)\n\n\ndef generate_markdown(title: str, original: str, translated: str, language: str, url: str, transcriber: str = \"SenseVoice-Small\") -> str:\n \"\"\"Generate bilingual Markdown.\"\"\"\n now = datetime.now().strftime(\"%Y-%m-%d\")\n\n if language == \"en\" and translated:\n return f\"\"\"---\ntitle: {title}\ntype: note\nplatform: youtube\ntags: [YouTube]\ncreated: {now}\nsource: {url}\nauthor:\nlanguage: en\ntranslated: true\ntranscriber: {transcriber}\n---\n\n# {title}\n\n> 🌐 英文视频,已翻译为中文\n\n---\n\n## 中文翻译\n\n{translated}\n\n---\n\n## English Original\n\n{original}\n\"\"\"\n else:\n return f\"\"\"---\ntitle: {title}\ntype: note\nplatform: youtube\ntags: [YouTube]\ncreated: {now}\nsource: {url}\nauthor:\nlanguage: {language}\ntranscriber: {transcriber}\n---\n\n# {title}\n\n{original}\n\"\"\"\n\n\ndef sanitize_filename(name: str) -> str:\n \"\"\"Clean filename.\"\"\"\n s = re.sub(r'[<>:\"/\\\\|?*]', '', name)\n s = re.sub(r'\\s+', '-', s)\n return s[:50]\n\n\ndef main():\n import argparse\n parser = argparse.ArgumentParser(description='YouTube 视频转录 + 翻译')\n parser.add_argument('url', help='YouTube URL')\n parser.add_argument('--output', '-o', default='.', help='输出目录')\n parser.add_argument('--no-translate', action='store_true', help='不翻译')\n parser.add_argument('--no-subtitle', action='store_true', help='跳过字幕,强制音频转录')\n args = parser.parse_args()\n\n # Step 1: Get video info\n print(\"=\" * 50, file=sys.stderr)\n print(\"Step 1: Getting video info...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n\n info = get_video_info(args.url)\n title = info['title']\n print(f\" 📺 Title: {title}\", file=sys.stderr)\n print(f\" ⏱️ Duration: {info['duration']}\", file=sys.stderr)\n\n # Step 2: Get transcript (subtitle-first, fallback to audio)\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 2: Getting transcript...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n\n tmpdir = tempfile.mkdtemp(prefix=\"youtube-\")\n try:\n transcriber = \"SenseVoice-Small\"\n sub = None if args.no_subtitle else try_subtitles(args.url, tmpdir)\n if sub:\n transcriber = \"字幕\"\n else:\n audio_path = download_audio(args.url, tmpdir)\n\n # Step 3: Transcribe\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 3: Building transcript...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n\n if sub:\n text, language = sub\n else:\n text, language = transcribe_audio(audio_path)\n\n # Step 4: Translate if English\n translated = None\n if language == \"en\" and not args.no_translate:\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 4: Translating...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n translated = translate_text(text)\n\n # Step 5: Generate Markdown\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 5: Generating Markdown...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n\n markdown = generate_markdown(title, text, translated, language, args.url, transcriber)\n\n # Save\n safe_title = sanitize_filename(title)\n output_path = os.path.join(args.output, f\"{safe_title}.md\")\n os.makedirs(args.output, exist_ok=True)\n\n with open(output_path, 'w', encoding='utf-8') as f:\n f.write(markdown)\n\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"✅ Done!\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n print(f\" Title: {title}\", file=sys.stderr)\n print(f\" Language: {language}\", file=sys.stderr)\n print(f\" Translated: {'Yes' if translated else 'No'}\", file=sys.stderr)\n print(f\" Output: {output_path}\", file=sys.stderr)\n\n print(output_path)\n\n finally:\n shutil.rmtree(tmpdir, ignore_errors=True)\n\n\nif __name__ == \"__main__\":\n main()\n"}, "820b293dc0df12270e8f6ab23c28fae1b4f11f02": {"t": 1785752935.469398, "n": 10625, "h": "d816535a31e0d0de", "c": "#!/usr/bin/env python3\n\"\"\"\nYouTube 视频转录 + 翻译工具\n\n用法:\n python transcribe.py \"https://www.youtube.com/watch?v=xxxxx\"\n python transcribe.py \"https://www.youtube.com/watch?v=xxxxx\" --no-translate\n\"\"\"\n\nimport sys\nimport os\nimport re\nimport time\nimport json\nimport subprocess\nimport tempfile\nimport shutil\nimport glob\nimport urllib.request\nfrom datetime import datetime\n\n\nSUB_LANGS = \"en,en-US,en-orig,zh-Hans,zh-CN,zh,zh-Hant\"\n\n\ndef get_video_info(url: str) -> dict:\n \"\"\"Get video title and duration.\"\"\"\n result = subprocess.run(\n [\"yt-dlp\", \"--get-title\", \"--get-duration\", \"--no-check-certificates\", url],\n capture_output=True, text=True, timeout=30\n )\n lines = result.stdout.strip().split('\\n')\n return {\n 'title': lines[0] if lines else 'Untitled',\n 'duration': lines[1] if len(lines) > 1 else '',\n }\n\n\ndef download_audio(url: str, output_dir: str) -> str:\n \"\"\"Download audio from YouTube.\"\"\"\n print(\" ⬇️ Downloading audio...\", file=sys.stderr)\n\n audio_path = os.path.join(output_dir, \"audio.mp3\")\n subprocess.run(\n [\"yt-dlp\",\n \"--extract-audio\", \"--audio-format\", \"mp3\", \"--audio-quality\", \"128K\",\n \"-o\", audio_path,\n \"--no-check-certificates\",\n url],\n timeout=600, check=True\n )\n\n size_mb = os.path.getsize(audio_path) / (1024 * 1024)\n print(f\" ✅ Audio: {size_mb:.1f} MB\", file=sys.stderr)\n return audio_path\n\n\ndef parse_vtt(path: str) -> str:\n \"\"\"把 vtt 字幕解析成纯文本,去时间轴、去标签、去连续重复行。\"\"\"\n with open(path, encoding=\"utf-8\", errors=\"replace\") as f:\n lines = f.read().splitlines()\n out = []\n for line in lines:\n line = line.strip()\n if not line or line == \"WEBVTT\" or \"-->\" in line:\n continue\n if line.isdigit() or line.startswith((\"Kind:\", \"Language:\", \"NOTE\")):\n continue\n line = re.sub(r\"<[^>]+>\", \"\", line).replace(\"&nbsp;\", \" \").strip()\n if not line or (out and out[-1] == line):\n continue\n out.append(line)\n return \"\\n\".join(out)\n\n\ndef try_subtitles(url: str, tmpdir: str):\n \"\"\"字幕优先:抓官方/自动字幕,免下载+转录。返回 (text, language) 或 None。\"\"\"\n print(\" 💬 尝试抓取字幕...\", file=sys.stderr)\n try:\n subprocess.run(\n [\"yt-dlp\", \"--skip-download\", \"--write-subs\", \"--write-auto-subs\",\n \"--sub-langs\", SUB_LANGS, \"--sub-format\", \"vtt\",\n \"--no-check-certificates\",\n \"-o\", os.path.join(tmpdir, \"sub.%(ext)s\"), url],\n capture_output=True, text=True, timeout=120,\n )\n except Exception:\n return None\n files = glob.glob(os.path.join(tmpdir, \"*.vtt\"))\n if not files:\n print(\" 💬 无字幕,回退音频转录\", file=sys.stderr)\n return None\n\n def rank(p):\n name = os.path.basename(p).lower()\n for i, tag in enumerate([\"en\", \"zh-hans\", \"zh-cn\", \"zh\"]):\n if tag in name:\n return i\n return 99\n chosen = sorted(files, key=rank)[0]\n text = parse_vtt(chosen)\n if len(text) < 50:\n return None\n chinese = len(re.findall(r\"[一-鿿]\", text))\n language = \"zh\" if chinese / max(len(text), 1) > 0.3 else \"en\"\n print(f\" ✅ 命中字幕({language},{len(text)} 字)\", file=sys.stderr)\n return text, language\n\n\ndef transcribe_audio(audio_path: str) -> tuple:\n \"\"\"Transcribe audio using SenseVoice-Small. Returns (text, language).\"\"\"\n from funasr import AutoModel\n from funasr.utils.postprocess_utils import rich_transcription_postprocess\n\n print(\" 🎙️ Loading model...\", file=sys.stderr)\n model = AutoModel(\n model=\"iic/SenseVoiceSmall\",\n trust_remote_code=True,\n vad_model=\"fsmn-vad\",\n vad_kwargs={\"max_single_segment_time\": 30000},\n device=\"cpu\",\n )\n\n print(\" 🎙️ Transcribing...\", file=sys.stderr)\n start = time.time()\n\n # Auto detect language\n result = model.generate(\n input=audio_path,\n language=\"auto\", # Auto detect\n use_itn=True,\n batch_size_s=60\n )\n elapsed = time.time() - start\n\n # Extract text\n text = \"\"\n if result and len(result) > 0:\n for r in result:\n if \"text\" in r:\n text += rich_transcription_postprocess(r[\"text\"]) + \"\\n\\n\"\n\n # Detect language (simple heuristic)\n chinese_chars = len(re.findall(r'[\\u4e00-\\u9fff]', text))\n total_chars = len(text.strip())\n language = \"zh\" if chinese_chars / max(total_chars, 1) > 0.3 else \"en\"\n\n print(f\" ✅ Transcribed in {elapsed:.1f}s (detected: {language})\", file=sys.stderr)\n return text.strip(), language\n\n\ndef translate_text(text: str, api_key: str = None) -> str:\n \"\"\"Translate English to Chinese using DeepSeek API.\"\"\"\n if not api_key:\n api_key = os.environ.get(\"DEEPSEEK_API_KEY\")\n\n if not api_key:\n print(\" ⚠️ No DEEPSEEK_API_KEY, skipping translation\", file=sys.stderr)\n return None\n\n print(\" 🌐 Translating to Chinese...\", file=sys.stderr)\n\n # Split into chunks if too long\n chunks = []\n if len(text) > 4000:\n paragraphs = text.split('\\n\\n')\n current_chunk = \"\"\n for para in paragraphs:\n if len(current_chunk) + len(para) > 3500:\n chunks.append(current_chunk)\n current_chunk = para\n else:\n current_chunk += \"\\n\\n\" + para if current_chunk else para\n if current_chunk:\n chunks.append(current_chunk)\n else:\n chunks = [text]\n\n translated_chunks = []\n for i, chunk in enumerate(chunks):\n if len(chunks) > 1:\n print(f\" 🌐 Translating chunk {i+1}/{len(chunks)}...\", file=sys.stderr)\n\n try:\n payload = json.dumps({\n \"model\": \"deepseek-chat\",\n \"messages\": [\n {\"role\": \"system\", \"content\": \"你是专业翻译。将英文翻译成中文,保持原文风格和格式。不要添加额外解释。\"},\n {\"role\": \"user\", \"content\": f\"翻译以下英文为中文:\\n\\n{chunk}\"}\n ],\n \"temperature\": 0.3\n }).encode(\"utf-8\")\n\n req = urllib.request.Request(\n \"https://api.deepseek.com/v1/chat/completions\",\n data=payload,\n headers={\n \"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {api_key}\",\n },\n )\n with urllib.request.urlopen(req, timeout=120) as resp:\n response = json.loads(resp.read())\n\n translated = response['choices'][0]['message']['content']\n translated_chunks.append(translated)\n except Exception as e:\n print(f\" ⚠️ Translation failed: {e}\", file=sys.stderr)\n return None\n\n print(\" ✅ Translation done\", file=sys.stderr)\n return \"\\n\\n\".join(translated_chunks)\n\n\ndef generate_markdown(title: str, original: str, translated: str, language: str, url: str, transcriber: str = \"SenseVoice-Small\") -> str:\n \"\"\"Generate bilingual Markdown.\"\"\"\n now = datetime.now().strftime(\"%Y-%m-%d\")\n\n if language == \"en\" and translated:\n return f\"\"\"---\ntitle: {title}\ntype: note\nplatform: youtube\ntags: [YouTube]\ncreated: {now}\nsource: {url}\nauthor:\nlanguage: en\ntranslated: true\ntranscriber: {transcriber}\n---\n\n# {title}\n\n> 🌐 英文视频,已翻译为中文\n\n---\n\n## 中文翻译\n\n{translated}\n\n---\n\n## English Original\n\n{original}\n\"\"\"\n else:\n return f\"\"\"---\ntitle: {title}\ntype: note\nplatform: youtube\ntags: [YouTube]\ncreated: {now}\nsource: {url}\nauthor:\nlanguage: {language}\ntranscriber: {transcriber}\n---\n\n# {title}\n\n{original}\n\"\"\"\n\n\ndef sanitize_filename(name: str) -> str:\n \"\"\"Clean filename.\"\"\"\n s = re.sub(r'[<>:\"/\\\\|?*]', '', name)\n s = re.sub(r'\\s+', '-', s)\n return s[:50]\n\n\ndef main():\n import argparse\n parser = argparse.ArgumentParser(description='YouTube 视频转录 + 翻译')\n parser.add_argument('url', help='YouTube URL')\n parser.add_argument('--output', '-o', default='.', help='输出目录')\n parser.add_argument('--no-translate', action='store_true', help='不翻译')\n parser.add_argument('--no-subtitle', action='store_true', help='跳过字幕,强制音频转录')\n args = parser.parse_args()\n\n # Step 1: Get video info\n print(\"=\" * 50, file=sys.stderr)\n print(\"Step 1: Getting video info...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n\n info = get_video_info(args.url)\n title = info['title']\n print(f\" 📺 Title: {title}\", file=sys.stderr)\n print(f\" ⏱️ Duration: {info['duration']}\", file=sys.stderr)\n\n # Step 2: Get transcript (subtitle-first, fallback to audio)\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 2: Getting transcript...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n\n tmpdir = tempfile.mkdtemp(prefix=\"youtube-\")\n try:\n transcriber = \"SenseVoice-Small\"\n sub = None if args.no_subtitle else try_subtitles(args.url, tmpdir)\n if sub:\n transcriber = \"字幕\"\n else:\n audio_path = download_audio(args.url, tmpdir)\n\n # Step 3: Transcribe\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 3: Building transcript...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n\n if sub:\n text, language = sub\n else:\n text, language = transcribe_audio(audio_path)\n\n # Step 4: Translate if English\n translated = None\n if language == \"en\" and not args.no_translate:\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 4: Translating...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n translated = translate_text(text)\n\n # Step 5: Generate Markdown\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 5: Generating Markdown...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n\n markdown = generate_markdown(title, text, translated, language, args.url, transcriber)\n\n # Save\n safe_title = sanitize_filename(title)\n output_path = os.path.join(args.output, f\"{safe_title}.md\")\n os.makedirs(args.output, exist_ok=True)\n\n with open(output_path, 'w', encoding='utf-8') as f:\n f.write(markdown)\n\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"✅ Done!\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n print(f\" Title: {title}\", file=sys.stderr)\n print(f\" Language: {language}\", file=sys.stderr)\n print(f\" Translated: {'Yes' if translated else 'No'}\", file=sys.stderr)\n print(f\" Output: {output_path}\", file=sys.stderr)\n\n print(output_path)\n\n finally:\n shutil.rmtree(tmpdir, ignore_errors=True)\n\n\nif __name__ == \"__main__\":\n main()\n"}, "cdbe58ee3f989712c208082d4938504fc7cc08c3": {"t": 1785752937.4726965, "n": 10757, "h": "d924c2cd574214dc", "c": "#!/usr/bin/env python3\n\"\"\"\nYouTube 视频转录 + 翻译工具\n\n用法:\n python transcribe.py \"https://www.youtube.com/watch?v=xxxxx\"\n python transcribe.py \"https://www.youtube.com/watch?v=xxxxx\" --no-translate\n\"\"\"\n\nimport sys\nimport os\nimport re\nimport time\nimport json\nimport subprocess\nimport tempfile\nimport shutil\nimport glob\nimport urllib.request\nfrom datetime import datetime\n\n\nSUB_LANGS = \"en,en-US,en-orig,zh-Hans,zh-CN,zh,zh-Hant\"\n\n\ndef get_video_info(url: str) -> dict:\n \"\"\"Get video title and duration.\"\"\"\n result = subprocess.run(\n [\"yt-dlp\", \"--get-title\", \"--get-duration\", \"--no-check-certificates\", url],\n capture_output=True, text=True, timeout=30\n )\n lines = result.stdout.strip().split('\\n')\n return {\n 'title': lines[0] if lines else 'Untitled',\n 'duration': lines[1] if len(lines) > 1 else '',\n }\n\n\ndef download_audio(url: str, output_dir: str) -> str:\n \"\"\"Download audio from YouTube.\"\"\"\n print(f\" ⬇️ Downloading audio...\", file=sys.stderr)\n \n audio_path = os.path.join(output_dir, \"audio.mp3\")\n subprocess.run(\n [\"yt-dlp\",\n \"--extract-audio\", \"--audio-format\", \"mp3\", \"--audio-quality\", \"128K\",\n \"-o\", audio_path,\n \"--no-check-certificates\",\n url],\n timeout=600, check=True\n )\n \n size_mb = os.path.getsize(audio_path) / (1024 * 1024)\n print(f\" ✅ Audio: {size_mb:.1f} MB\", file=sys.stderr)\n return audio_path\n\n\ndef parse_vtt(path: str) -> str:\n \"\"\"把 vtt 字幕解析成纯文本,去时间轴、去标签、去连续重复行。\"\"\"\n with open(path, encoding=\"utf-8\", errors=\"replace\") as f:\n lines = f.read().splitlines()\n out = []\n for line in lines:\n line = line.strip()\n if not line or line == \"WEBVTT\" or \"-->\" in line:\n continue\n if line.isdigit() or line.startswith((\"Kind:\", \"Language:\", \"NOTE\")):\n continue\n line = re.sub(r\"<[^>]+>\", \"\", line).replace(\"&nbsp;\", \" \").strip()\n if not line or (out and out[-1] == line):\n continue\n out.append(line)\n return \"\\n\".join(out)\n\n\ndef try_subtitles(url: str, tmpdir: str):\n \"\"\"字幕优先:抓官方/自动字幕,免下载+转录。返回 (text, language) 或 None。\"\"\"\n print(\" 💬 尝试抓取字幕...\", file=sys.stderr)\n try:\n subprocess.run(\n [\"yt-dlp\", \"--skip-download\", \"--write-subs\", \"--write-auto-subs\",\n \"--sub-langs\", SUB_LANGS, \"--sub-format\", \"vtt\",\n \"--no-check-certificates\",\n \"-o\", os.path.join(tmpdir, \"sub.%(ext)s\"), url],\n capture_output=True, text=True, timeout=120,\n )\n except Exception:\n return None\n files = glob.glob(os.path.join(tmpdir, \"*.vtt\"))\n if not files:\n print(\" 💬 无字幕,回退音频转录\", file=sys.stderr)\n return None\n\n def rank(p):\n name = os.path.basename(p).lower()\n for i, tag in enumerate([\"en\", \"zh-hans\", \"zh-cn\", \"zh\"]):\n if tag in name:\n return i\n return 99\n chosen = sorted(files, key=rank)[0]\n text = parse_vtt(chosen)\n if len(text) < 50:\n return None\n chinese = len(re.findall(r\"[一-鿿]\", text))\n language = \"zh\" if chinese / max(len(text), 1) > 0.3 else \"en\"\n print(f\" ✅ 命中字幕({language},{len(text)} 字)\", file=sys.stderr)\n return text, language\n\n\ndef transcribe_audio(audio_path: str) -> tuple:\n \"\"\"Transcribe audio using SenseVoice-Small. Returns (text, language).\"\"\"\n from funasr import AutoModel\n from funasr.utils.postprocess_utils import rich_transcription_postprocess\n\n print(\" 🎙️ Loading model...\", file=sys.stderr)\n model = AutoModel(\n model=\"iic/SenseVoiceSmall\",\n trust_remote_code=True,\n vad_model=\"fsmn-vad\",\n vad_kwargs={\"max_single_segment_time\": 30000},\n device=\"cpu\",\n )\n\n print(\" 🎙️ Transcribing...\", file=sys.stderr)\n start = time.time()\n \n # Auto detect language\n result = model.generate(\n input=audio_path,\n language=\"auto\", # Auto detect\n use_itn=True,\n batch_size_s=60\n )\n elapsed = time.time() - start\n\n # Extract text\n text = \"\"\n if result and len(result) > 0:\n for r in result:\n if \"text\" in r:\n text += rich_transcription_postprocess(r[\"text\"]) + \"\\n\\n\"\n \n # Detect language (simple heuristic)\n chinese_chars = len(re.findall(r'[\\u4e00-\\u9fff]', text))\n total_chars = len(text.strip())\n language = \"zh\" if chinese_chars / max(total_chars, 1) > 0.3 else \"en\"\n \n print(f\" ✅ Transcribed in {elapsed:.1f}s (detected: {language})\", file=sys.stderr)\n return text.strip(), language\n\n\ndef translate_text(text: str, api_key: str = None) -> str:\n \"\"\"Translate English to Chinese using DeepSeek API.\"\"\"\n if not api_key:\n api_key = os.environ.get(\"DEEPSEEK_API_KEY\")\n\n if not api_key:\n print(\" ⚠️ No DEEPSEEK_API_KEY, skipping translation\", file=sys.stderr)\n return None\n\n print(\" 🌐 Translating to Chinese...\", file=sys.stderr)\n\n # Split into chunks if too long\n chunks = []\n if len(text) > 4000:\n paragraphs = text.split('\\n\\n')\n current_chunk = \"\"\n for para in paragraphs:\n if len(current_chunk) + len(para) > 3500:\n chunks.append(current_chunk)\n current_chunk = para\n else:\n current_chunk += \"\\n\\n\" + para if current_chunk else para\n if current_chunk:\n chunks.append(current_chunk)\n else:\n chunks = [text]\n\n translated_chunks = []\n for i, chunk in enumerate(chunks):\n if len(chunks) > 1:\n print(f\" 🌐 Translating chunk {i+1}/{len(chunks)}...\", file=sys.stderr)\n\n try:\n payload = json.dumps({\n \"model\": \"deepseek-chat\",\n \"messages\": [\n {\"role\": \"system\", \"content\": \"你是专业翻译。将英文翻译成中文,保持原文风格和格式。不要添加额外解释。\"},\n {\"role\": \"user\", \"content\": f\"翻译以下英文为中文:\\n\\n{chunk}\"}\n ],\n \"temperature\": 0.3\n }).encode(\"utf-8\")\n\n req = urllib.request.Request(\n \"https://api.deepseek.com/v1/chat/completions\",\n data=payload,\n headers={\n \"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {api_key}\",\n },\n )\n with urllib.request.urlopen(req, timeout=120) as resp:\n response = json.loads(resp.read())\n\n translated = response['choices'][0]['message']['content']\n translated_chunks.append(translated)\n except Exception as e:\n print(f\" ⚠️ Translation failed: {e}\", file=sys.stderr)\n return None\n\n print(f\" ✅ Translation done\", file=sys.stderr)\n return \"\\n\\n\".join(translated_chunks)\n\n\ndef generate_markdown(title: str, original: str, translated: str, language: str, url: str, transcriber: str = \"SenseVoice-Small\") -> str:\n \"\"\"Generate bilingual Markdown.\"\"\"\n now = datetime.now().strftime(\"%Y-%m-%d\")\n elapsed_note = \"\"\n \n if language == \"en\" and translated:\n return f\"\"\"---\ntitle: {title}\ntype: note\nplatform: youtube\ntags: [YouTube]\ncreated: {now}\nsource: {url}\nauthor:\nlanguage: en\ntranslated: true\ntranscriber: {transcriber}\n---\n\n# {title}\n\n> 🌐 英文视频,已翻译为中文\n\n---\n\n## 中文翻译\n\n{translated}\n\n---\n\n## English Original\n\n{original}\n\"\"\"\n else:\n return f\"\"\"---\ntitle: {title}\ntype: note\nplatform: youtube\ntags: [YouTube]\ncreated: {now}\nsource: {url}\nauthor:\nlanguage: {language}\ntranscriber: {transcriber}\n---\n\n# {title}\n\n{original}\n\"\"\"\n\n\ndef sanitize_filename(name: str) -> str:\n \"\"\"Clean filename.\"\"\"\n s = re.sub(r'[<>:\"/\\\\|?*]', '', name)\n s = re.sub(r'\\s+', '-', s)\n return s[:50]\n\n\ndef main():\n import argparse\n parser = argparse.ArgumentParser(description='YouTube 视频转录 + 翻译')\n parser.add_argument('url', help='YouTube URL')\n parser.add_argument('--output', '-o', default='.', help='输出目录')\n parser.add_argument('--no-translate', action='store_true', help='不翻译')\n parser.add_argument('--no-subtitle', action='store_true', help='跳过字幕,强制音频转录')\n args = parser.parse_args()\n \n # Step 1: Get video info\n print(\"=\" * 50, file=sys.stderr)\n print(\"Step 1: Getting video info...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n \n info = get_video_info(args.url)\n title = info['title']\n print(f\" 📺 Title: {title}\", file=sys.stderr)\n print(f\" ⏱️ Duration: {info['duration']}\", file=sys.stderr)\n \n # Step 2: Get transcript (subtitle-first, fallback to audio)\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 2: Getting transcript...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n \n tmpdir = tempfile.mkdtemp(prefix=\"youtube-\")\n try:\n transcriber = \"SenseVoice-Small\"\n sub = None if args.no_subtitle else try_subtitles(args.url, tmpdir)\n if sub:\n transcriber = \"字幕\"\n else:\n audio_path = download_audio(args.url, tmpdir)\n \n # Step 3: Transcribe\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 3: Building transcript...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n\n if sub:\n text, language = sub\n else:\n text, language = transcribe_audio(audio_path)\n \n # Step 4: Translate if English\n translated = None\n if language == \"en\" and not args.no_translate:\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 4: Translating...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n translated = translate_text(text)\n \n # Step 5: Generate Markdown\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 5: Generating Markdown...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n \n markdown = generate_markdown(title, text, translated, language, args.url, transcriber)\n \n # Save\n safe_title = sanitize_filename(title)\n output_path = os.path.join(args.output, f\"{safe_title}.md\")\n os.makedirs(args.output, exist_ok=True)\n \n with open(output_path, 'w', encoding='utf-8') as f:\n f.write(markdown)\n \n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"✅ Done!\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n print(f\" Title: {title}\", file=sys.stderr)\n print(f\" Language: {language}\", file=sys.stderr)\n print(f\" Translated: {'Yes' if translated else 'No'}\", file=sys.stderr)\n print(f\" Output: {output_path}\", file=sys.stderr)\n \n print(output_path)\n \n finally:\n shutil.rmtree(tmpdir, ignore_errors=True)\n\n\nif __name__ == \"__main__\":\n main()\n"}, "7f3f850580da96b43752127d5e17abb4848c7cde": {"t": 1785752938.856805, "n": 8358, "h": "8332f69a9ac928cd", "c": "#!/usr/bin/env python3\n\"\"\"\nYouTube 视频转录 + 翻译工具\n\n用法:\n python transcribe.py \"https://www.youtube.com/watch?v=xxxxx\"\n python transcribe.py \"https://www.youtube.com/watch?v=xxxxx\" --no-translate\n\"\"\"\n\nimport sys\nimport os\nimport re\nimport time\nimport json\nimport subprocess\nimport tempfile\nimport shutil\nimport urllib.request\nfrom datetime import datetime\n\n\ndef get_video_info(url: str) -> dict:\n \"\"\"Get video title and duration.\"\"\"\n result = subprocess.run(\n [\"yt-dlp\", \"--get-title\", \"--get-duration\", \"--no-check-certificates\", url],\n capture_output=True, text=True, timeout=30\n )\n lines = result.stdout.strip().split('\\n')\n return {\n 'title': lines[0] if lines else 'Untitled',\n 'duration': lines[1] if len(lines) > 1 else '',\n }\n\n\ndef download_audio(url: str, output_dir: str) -> str:\n \"\"\"Download audio from YouTube.\"\"\"\n print(f\" ⬇️ Downloading audio...\", file=sys.stderr)\n \n audio_path = os.path.join(output_dir, \"audio.mp3\")\n subprocess.run(\n [\"yt-dlp\",\n \"--extract-audio\", \"--audio-format\", \"mp3\", \"--audio-quality\", \"128K\",\n \"-o\", audio_path,\n \"--no-check-certificates\",\n url],\n timeout=600, check=True\n )\n \n size_mb = os.path.getsize(audio_path) / (1024 * 1024)\n print(f\" ✅ Audio: {size_mb:.1f} MB\", file=sys.stderr)\n return audio_path\n\n\ndef transcribe_audio(audio_path: str) -> tuple:\n \"\"\"Transcribe audio using SenseVoice-Small. Returns (text, language).\"\"\"\n from funasr import AutoModel\n from funasr.utils.postprocess_utils import rich_transcription_postprocess\n\n print(\" 🎙️ Loading model...\", file=sys.stderr)\n model = AutoModel(\n model=\"iic/SenseVoiceSmall\",\n trust_remote_code=True,\n vad_model=\"fsmn-vad\",\n vad_kwargs={\"max_single_segment_time\": 30000},\n device=\"cpu\",\n )\n\n print(\" 🎙️ Transcribing...\", file=sys.stderr)\n start = time.time()\n \n # Auto detect language\n result = model.generate(\n input=audio_path,\n language=\"auto\", # Auto detect\n use_itn=True,\n batch_size_s=60\n )\n elapsed = time.time() - start\n\n # Extract text\n text = \"\"\n if result and len(result) > 0:\n for r in result:\n if \"text\" in r:\n text += rich_transcription_postprocess(r[\"text\"]) + \"\\n\\n\"\n \n # Detect language (simple heuristic)\n chinese_chars = len(re.findall(r'[\\u4e00-\\u9fff]', text))\n total_chars = len(text.strip())\n language = \"zh\" if chinese_chars / max(total_chars, 1) > 0.3 else \"en\"\n \n print(f\" ✅ Transcribed in {elapsed:.1f}s (detected: {language})\", file=sys.stderr)\n return text.strip(), language\n\n\ndef translate_text(text: str, api_key: str = None) -> str:\n \"\"\"Translate English to Chinese using DeepSeek API.\"\"\"\n if not api_key:\n api_key = os.environ.get(\"DEEPSEEK_API_KEY\")\n\n if not api_key:\n print(\" ⚠️ No DEEPSEEK_API_KEY, skipping translation\", file=sys.stderr)\n return None\n\n print(\" 🌐 Translating to Chinese...\", file=sys.stderr)\n\n # Split into chunks if too long\n chunks = []\n if len(text) > 4000:\n paragraphs = text.split('\\n\\n')\n current_chunk = \"\"\n for para in paragraphs:\n if len(current_chunk) + len(para) > 3500:\n chunks.append(current_chunk)\n current_chunk = para\n else:\n current_chunk += \"\\n\\n\" + para if current_chunk else para\n if current_chunk:\n chunks.append(current_chunk)\n else:\n chunks = [text]\n\n translated_chunks = []\n for i, chunk in enumerate(chunks):\n if len(chunks) > 1:\n print(f\" 🌐 Translating chunk {i+1}/{len(chunks)}...\", file=sys.stderr)\n\n try:\n payload = json.dumps({\n \"model\": \"deepseek-chat\",\n \"messages\": [\n {\"role\": \"system\", \"content\": \"你是专业翻译。将英文翻译成中文,保持原文风格和格式。不要添加额外解释。\"},\n {\"role\": \"user\", \"content\": f\"翻译以下英文为中文:\\n\\n{chunk}\"}\n ],\n \"temperature\": 0.3\n }).encode(\"utf-8\")\n\n req = urllib.request.Request(\n \"https://api.deepseek.com/v1/chat/completions\",\n data=payload,\n headers={\n \"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {api_key}\",\n },\n )\n with urllib.request.urlopen(req, timeout=120) as resp:\n response = json.loads(resp.read())\n\n translated = response['choices'][0]['message']['content']\n translated_chunks.append(translated)\n except Exception as e:\n print(f\" ⚠️ Translation failed: {e}\", file=sys.stderr)\n return None\n\n print(f\" ✅ Translation done\", file=sys.stderr)\n return \"\\n\\n\".join(translated_chunks)\n\n\ndef generate_markdown(title: str, original: str, translated: str, language: str, url: str) -> str:\n \"\"\"Generate bilingual Markdown.\"\"\"\n now = datetime.now().strftime(\"%Y-%m-%d\")\n elapsed_note = \"\"\n \n if language == \"en\" and translated:\n return f\"\"\"---\ntitle: {title}\ntype: note\ntags: [YouTube]\ncreated: {now}\nsource: {url}\nlanguage: en\ntranslated: true\n---\n\n# {title}\n\n> 🌐 英文视频,已翻译为中文\n\n---\n\n## 中文翻译\n\n{translated}\n\n---\n\n## English Original\n\n{original}\n\"\"\"\n else:\n return f\"\"\"---\ntitle: {title}\ntype: note\ntags: [YouTube]\ncreated: {now}\nsource: {url}\nlanguage: {language}\n---\n\n# {title}\n\n{original}\n\"\"\"\n\n\ndef sanitize_filename(name: str) -> str:\n \"\"\"Clean filename.\"\"\"\n s = re.sub(r'[<>:\"/\\\\|?*]', '', name)\n s = re.sub(r'\\s+', '-', s)\n return s[:50]\n\n\ndef main():\n import argparse\n parser = argparse.ArgumentParser(description='YouTube 视频转录 + 翻译')\n parser.add_argument('url', help='YouTube URL')\n parser.add_argument('--output', '-o', default='.', help='输出目录')\n parser.add_argument('--no-translate', action='store_true', help='不翻译')\n args = parser.parse_args()\n \n # Step 1: Get video info\n print(\"=\" * 50, file=sys.stderr)\n print(\"Step 1: Getting video info...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n \n info = get_video_info(args.url)\n title = info['title']\n print(f\" 📺 Title: {title}\", file=sys.stderr)\n print(f\" ⏱️ Duration: {info['duration']}\", file=sys.stderr)\n \n # Step 2: Download audio\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 2: Downloading audio...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n \n tmpdir = tempfile.mkdtemp(prefix=\"youtube-\")\n try:\n audio_path = download_audio(args.url, tmpdir)\n \n # Step 3: Transcribe\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 3: Transcribing...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n \n text, language = transcribe_audio(audio_path)\n \n # Step 4: Translate if English\n translated = None\n if language == \"en\" and not args.no_translate:\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 4: Translating...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n translated = translate_text(text)\n \n # Step 5: Generate Markdown\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 5: Generating Markdown...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n \n markdown = generate_markdown(title, text, translated, language, args.url)\n \n # Save\n safe_title = sanitize_filename(title)\n output_path = os.path.join(args.output, f\"{safe_title}.md\")\n os.makedirs(args.output, exist_ok=True)\n \n with open(output_path, 'w', encoding='utf-8') as f:\n f.write(markdown)\n \n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"✅ Done!\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n print(f\" Title: {title}\", file=sys.stderr)\n print(f\" Language: {language}\", file=sys.stderr)\n print(f\" Translated: {'Yes' if translated else 'No'}\", file=sys.stderr)\n print(f\" Output: {output_path}\", file=sys.stderr)\n \n print(output_path)\n \n finally:\n shutil.rmtree(tmpdir, ignore_errors=True)\n\n\nif __name__ == \"__main__\":\n main()\n"}, "53000c11c2758c068cfb9564b1c3e4332a782999": {"t": 1785752940.1471877, "n": 8397, "h": "6b26965692b7cd0c", "c": "#!/usr/bin/env python3\n\"\"\"\nYouTube 视频转录 + 翻译工具\n\n用法:\n python transcribe.py \"https://www.youtube.com/watch?v=xxxxx\"\n python transcribe.py \"https://www.youtube.com/watch?v=xxxxx\" --no-translate\n\"\"\"\n\nimport sys\nimport os\nimport re\nimport time\nimport json\nimport subprocess\nimport tempfile\nimport shutil\nfrom datetime import datetime\n\n\ndef get_video_info(url: str) -> dict:\n \"\"\"Get video title and duration.\"\"\"\n result = subprocess.run(\n [\"yt-dlp\", \"--get-title\", \"--get-duration\", \"--no-check-certificates\", url],\n capture_output=True, text=True, timeout=30\n )\n lines = result.stdout.strip().split('\\n')\n return {\n 'title': lines[0] if lines else 'Untitled',\n 'duration': lines[1] if len(lines) > 1 else '',\n }\n\n\ndef download_audio(url: str, output_dir: str) -> str:\n \"\"\"Download audio from YouTube.\"\"\"\n print(f\" ⬇️ Downloading audio...\", file=sys.stderr)\n \n audio_path = os.path.join(output_dir, \"audio.mp3\")\n subprocess.run(\n [\"yt-dlp\",\n \"--extract-audio\", \"--audio-format\", \"mp3\", \"--audio-quality\", \"128K\",\n \"-o\", audio_path,\n \"--no-check-certificates\",\n url],\n timeout=600, check=True\n )\n \n size_mb = os.path.getsize(audio_path) / (1024 * 1024)\n print(f\" ✅ Audio: {size_mb:.1f} MB\", file=sys.stderr)\n return audio_path\n\n\ndef transcribe_audio(audio_path: str) -> tuple:\n \"\"\"Transcribe audio using SenseVoice-Small. Returns (text, language).\"\"\"\n from funasr import AutoModel\n from funasr.utils.postprocess_utils import rich_transcription_postprocess\n\n print(\" 🎙️ Loading model...\", file=sys.stderr)\n model = AutoModel(\n model=\"iic/SenseVoiceSmall\",\n trust_remote_code=True,\n vad_model=\"fsmn-vad\",\n vad_kwargs={\"max_single_segment_time\": 30000},\n device=\"cpu\",\n )\n\n print(\" 🎙️ Transcribing...\", file=sys.stderr)\n start = time.time()\n \n # Auto detect language\n result = model.generate(\n input=audio_path,\n language=\"auto\", # Auto detect\n use_itn=True,\n batch_size_s=60\n )\n elapsed = time.time() - start\n\n # Extract text\n text = \"\"\n if result and len(result) > 0:\n for r in result:\n if \"text\" in r:\n text += rich_transcription_postprocess(r[\"text\"]) + \"\\n\\n\"\n \n # Detect language (simple heuristic)\n chinese_chars = len(re.findall(r'[\\u4e00-\\u9fff]', text))\n total_chars = len(text.strip())\n language = \"zh\" if chinese_chars / max(total_chars, 1) > 0.3 else \"en\"\n \n print(f\" ✅ Transcribed in {elapsed:.1f}s (detected: {language})\", file=sys.stderr)\n return text.strip(), language\n\n\ndef translate_text(text: str, api_key: str = None) -> str:\n \"\"\"Translate English to Chinese using DeepSeek API.\"\"\"\n # Try to get API key from environment\n if not api_key:\n api_key = os.environ.get(\"DEEPSEEK_API_KEY\")\n \n if not api_key:\n print(\" ⚠️ No DEEPSEEK_API_KEY, skipping translation\", file=sys.stderr)\n return None\n \n print(\" 🌐 Translating to Chinese...\", file=sys.stderr)\n \n # Split into chunks if too long\n chunks = []\n if len(text) > 4000:\n # Split by paragraphs\n paragraphs = text.split('\\n\\n')\n current_chunk = \"\"\n for para in paragraphs:\n if len(current_chunk) + len(para) > 3500:\n chunks.append(current_chunk)\n current_chunk = para\n else:\n current_chunk += \"\\n\\n\" + para if current_chunk else para\n if current_chunk:\n chunks.append(current_chunk)\n else:\n chunks = [text]\n \n translated_chunks = []\n for i, chunk in enumerate(chunks):\n if len(chunks) > 1:\n print(f\" 🌐 Translating chunk {i+1}/{len(chunks)}...\", file=sys.stderr)\n \n try:\n result = subprocess.run(\n [\"curl\", \"-s\", \"https://api.deepseek.com/v1/chat/completions\",\n \"-H\", \"Content-Type: application/json\",\n \"-H\", f\"Authorization: Bearer {api_key}\",\n \"-d\", json.dumps({\n \"model\": \"deepseek-chat\",\n \"messages\": [\n {\"role\": \"system\", \"content\": \"你是专业翻译。将英文翻译成中文,保持原文风格和格式。不要添加额外解释。\"},\n {\"role\": \"user\", \"content\": f\"翻译以下英文为中文:\\n\\n{chunk}\"}\n ],\n \"temperature\": 0.3\n })],\n capture_output=True, text=True, timeout=120\n )\n \n response = json.loads(result.stdout)\n translated = response['choices'][0]['message']['content']\n translated_chunks.append(translated)\n except Exception as e:\n print(f\" ⚠️ Translation failed: {e}\", file=sys.stderr)\n return None\n \n print(f\" ✅ Translation done\", file=sys.stderr)\n return \"\\n\\n\".join(translated_chunks)\n\n\ndef generate_markdown(title: str, original: str, translated: str, language: str, url: str) -> str:\n \"\"\"Generate bilingual Markdown.\"\"\"\n now = datetime.now().strftime(\"%Y-%m-%d\")\n elapsed_note = \"\"\n \n if language == \"en\" and translated:\n return f\"\"\"---\ntitle: {title}\ntype: note\ntags: [YouTube]\ncreated: {now}\nsource: {url}\nlanguage: en\ntranslated: true\n---\n\n# {title}\n\n> 🌐 英文视频,已翻译为中文\n\n---\n\n## 中文翻译\n\n{translated}\n\n---\n\n## English Original\n\n{original}\n\"\"\"\n else:\n return f\"\"\"---\ntitle: {title}\ntype: note\ntags: [YouTube]\ncreated: {now}\nsource: {url}\nlanguage: {language}\n---\n\n# {title}\n\n{original}\n\"\"\"\n\n\ndef sanitize_filename(name: str) -> str:\n \"\"\"Clean filename.\"\"\"\n s = re.sub(r'[<>:\"/\\\\|?*]', '', name)\n s = re.sub(r'\\s+', '-', s)\n return s[:50]\n\n\ndef main():\n import argparse\n parser = argparse.ArgumentParser(description='YouTube 视频转录 + 翻译')\n parser.add_argument('url', help='YouTube URL')\n parser.add_argument('--output', '-o', default='.', help='输出目录')\n parser.add_argument('--no-translate', action='store_true', help='不翻译')\n args = parser.parse_args()\n \n # Step 1: Get video info\n print(\"=\" * 50, file=sys.stderr)\n print(\"Step 1: Getting video info...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n \n info = get_video_info(args.url)\n title = info['title']\n print(f\" 📺 Title: {title}\", file=sys.stderr)\n print(f\" ⏱️ Duration: {info['duration']}\", file=sys.stderr)\n \n # Step 2: Download audio\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 2: Downloading audio...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n \n tmpdir = tempfile.mkdtemp(prefix=\"youtube-\")\n try:\n audio_path = download_audio(args.url, tmpdir)\n \n # Step 3: Transcribe\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 3: Transcribing...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n \n text, language = transcribe_audio(audio_path)\n \n # Step 4: Translate if English\n translated = None\n if language == \"en\" and not args.no_translate:\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 4: Translating...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n translated = translate_text(text)\n \n # Step 5: Generate Markdown\n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"Step 5: Generating Markdown...\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n \n markdown = generate_markdown(title, text, translated, language, args.url)\n \n # Save\n safe_title = sanitize_filename(title)\n output_path = os.path.join(args.output, f\"{safe_title}.md\")\n os.makedirs(args.output, exist_ok=True)\n \n with open(output_path, 'w', encoding='utf-8') as f:\n f.write(markdown)\n \n print(\"\\n\" + \"=\" * 50, file=sys.stderr)\n print(\"✅ Done!\", file=sys.stderr)\n print(\"=\" * 50, file=sys.stderr)\n print(f\" Title: {title}\", file=sys.stderr)\n print(f\" Language: {language}\", file=sys.stderr)\n print(f\" Translated: {'Yes' if translated else 'No'}\", file=sys.stderr)\n print(f\" Output: {output_path}\", file=sys.stderr)\n \n print(output_path)\n \n finally:\n shutil.rmtree(tmpdir, ignore_errors=True)\n\n\nif __name__ == \"__main__\":\n main()\n"}}, "setup.sh": {"56c434bd9f8f5d15d540038073aa44c24df39dbd": {"t": 1785745936.3126185, "n": 4234, "h": "15e582c45be8a9db", "c": "#!/usr/bin/env bash\n# Chubby Skills - staged installer\n#\n# Usage:\n# bash setup.sh # light mode: zero/low-dependency tools\n# bash setup.sh light # same as default\n# bash setup.sh video # video transcription stack\n# bash setup.sh podcast # podcast transcription stack\n# bash setup.sh wechat # WeChat/PDF extraction stack\n# bash setup.sh all # everything\n# bash setup.sh doctor # environment check only\n# bash setup.sh bilibili xhs # aliases are accepted\n\nset -euo pipefail\n\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m'\n\ninfo() { echo -e \"${GREEN}[✓]${NC} $*\"; }\nwarn() { echo -e \"${YELLOW}[!]${NC} $*\"; }\nerror() { echo -e \"${RED}[✗]${NC} $*\"; }\n\nhas_cmd() {\n command -v \"$1\" >/dev/null 2>&1\n}\n\nrequire_cmd() {\n if has_cmd \"$1\"; then\n info \"已安装 $1\"\n else\n error \"未找到 $1:$2\"\n return 1\n fi\n}\n\nrun_doctor() {\n python3 tools/check_env.py\n}\n\npip_install() {\n python3 -m pip install \"$@\"\n}\n\ninstall_ytdlp() {\n if has_cmd yt-dlp; then\n info \"yt-dlp 已安装\"\n return\n fi\n warn \"未找到 yt-dlp,正在安装...\"\n if [[ \"$(uname)\" == \"Darwin\" ]] && has_cmd brew; then\n brew install yt-dlp || pip_install yt-dlp\n else\n pip_install yt-dlp\n fi\n}\n\ninstall_light() {\n echo \"\"\n echo \"--- light:零/轻依赖能力 ---\"\n require_cmd python3 \"请先安装 Python 3.9+\"\n info \"可直接使用:X 图文、小红书图文、情报雷达、知识库健康检查、content-enrich\"\n warn \"content-enrich / 学习笔记 / 爆款拆解需要按需设置 DEEPSEEK_API_KEY\"\n warn \"小红书采集建议按需设置 XHS_COOKIE\"\n}\n\ninstall_video() {\n echo \"\"\n echo \"--- video:视频转录依赖 ---\"\n require_cmd python3 \"请先安装 Python 3.9+\"\n require_cmd ffmpeg \"macOS: brew install ffmpeg | Ubuntu: sudo apt install ffmpeg\"\n install_ytdlp\n warn \"即将安装 funasr / modelscope / torch / torchaudio,首次安装体积较大。\"\n pip_install funasr modelscope torch torchaudio\n info \"视频转录依赖安装完成\"\n}\n\ninstall_podcast() {\n echo \"\"\n echo \"--- podcast:播客转录依赖 ---\"\n require_cmd python3 \"请先安装 Python 3.9+\"\n require_cmd ffmpeg \"macOS: brew install ffmpeg | Ubuntu: sudo apt install ffmpeg\"\n pip_install faster-whisper\n info \"播客转录依赖安装完成\"\n}\n\ninstall_wechat() {\n echo \"\"\n echo \"--- wechat:公众号/PDF 处理依赖 ---\"\n require_cmd python3 \"请先安装 Python 3.9+\"\n pip_install beautifulsoup4 markitdown pymupdf\n info \"公众号/PDF 处理依赖安装完成\"\n}\n\nnormalize_target() {\n case \"$1\" in\n light|x|twitter|xhs|xiaohongshu|content|radar|knowledge|kb)\n echo \"light\" ;;\n video|douyin|bilibili|youtube|tiktok|weibo|zhihu)\n echo \"video\" ;;\n podcast|rss)\n echo \"podcast\" ;;\n wechat|pdf|article)\n echo \"wechat\" ;;\n all)\n echo \"all\" ;;\n doctor|check)\n echo \"doctor\" ;;\n *)\n echo \"unknown\" ;;\n esac\n}\n\necho \"\"\necho \"=========================================\"\necho \" Chubby Skills 安装助手\"\necho \"=========================================\"\n\nif [[ $# -eq 0 ]]; then\n set -- light\nfi\n\nwant_light=false\nwant_video=false\nwant_podcast=false\nwant_wechat=false\nwant_all=false\nwant_doctor=false\n\nfor raw in \"$@\"; do\n target=\"$(normalize_target \"$raw\")\"\n if [[ \"$target\" == \"unknown\" ]]; then\n error \"未知安装目标:$raw\"\n echo \"可用目标:light / video / podcast / wechat / all / doctor\"\n exit 2\n fi\n case \"$target\" in\n light) want_light=true ;;\n video) want_video=true ;;\n podcast) want_podcast=true ;;\n wechat) want_wechat=true ;;\n all) want_all=true ;;\n doctor) want_doctor=true ;;\n esac\ndone\n\nif $want_doctor; then\n run_doctor\n exit 0\nfi\n\nif $want_all; then\n install_light\n install_video\n install_podcast\n install_wechat\nelse\n $want_light && install_light\n $want_video && install_video\n $want_podcast && install_podcast\n $want_wechat && install_wechat\nfi\n\necho \"\"\necho \"=========================================\"\necho \" 安装步骤完成\"\necho \"=========================================\"\necho \"\"\necho \"下一步建议:\"\necho \" python3 tools/check_env.py\"\necho \" python3 tools/chubby.py quickstart\"\necho \" python3 tools/platform_smoke.py --mode all --check\"\necho \" python3 tools/golden_outputs.py examples/outputs\"\n"}, "88701e7ef4cac72c606bc5d356011c2e08810cfc": {"t": 1785752935.4563684, "n": 4234, "h": "15e582c45be8a9db", "c": "#!/usr/bin/env bash\n# Chubby Skills - staged installer\n#\n# Usage:\n# bash setup.sh # light mode: zero/low-dependency tools\n# bash setup.sh light # same as default\n# bash setup.sh video # video transcription stack\n# bash setup.sh podcast # podcast transcription stack\n# bash setup.sh wechat # WeChat/PDF extraction stack\n# bash setup.sh all # everything\n# bash setup.sh doctor # environment check only\n# bash setup.sh bilibili xhs # aliases are accepted\n\nset -euo pipefail\n\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m'\n\ninfo() { echo -e \"${GREEN}[✓]${NC} $*\"; }\nwarn() { echo -e \"${YELLOW}[!]${NC} $*\"; }\nerror() { echo -e \"${RED}[✗]${NC} $*\"; }\n\nhas_cmd() {\n command -v \"$1\" >/dev/null 2>&1\n}\n\nrequire_cmd() {\n if has_cmd \"$1\"; then\n info \"已安装 $1\"\n else\n error \"未找到 $1:$2\"\n return 1\n fi\n}\n\nrun_doctor() {\n python3 tools/check_env.py\n}\n\npip_install() {\n python3 -m pip install \"$@\"\n}\n\ninstall_ytdlp() {\n if has_cmd yt-dlp; then\n info \"yt-dlp 已安装\"\n return\n fi\n warn \"未找到 yt-dlp,正在安装...\"\n if [[ \"$(uname)\" == \"Darwin\" ]] && has_cmd brew; then\n brew install yt-dlp || pip_install yt-dlp\n else\n pip_install yt-dlp\n fi\n}\n\ninstall_light() {\n echo \"\"\n echo \"--- light:零/轻依赖能力 ---\"\n require_cmd python3 \"请先安装 Python 3.9+\"\n info \"可直接使用:X 图文、小红书图文、情报雷达、知识库健康检查、content-enrich\"\n warn \"content-enrich / 学习笔记 / 爆款拆解需要按需设置 DEEPSEEK_API_KEY\"\n warn \"小红书采集建议按需设置 XHS_COOKIE\"\n}\n\ninstall_video() {\n echo \"\"\n echo \"--- video:视频转录依赖 ---\"\n require_cmd python3 \"请先安装 Python 3.9+\"\n require_cmd ffmpeg \"macOS: brew install ffmpeg | Ubuntu: sudo apt install ffmpeg\"\n install_ytdlp\n warn \"即将安装 funasr / modelscope / torch / torchaudio,首次安装体积较大。\"\n pip_install funasr modelscope torch torchaudio\n info \"视频转录依赖安装完成\"\n}\n\ninstall_podcast() {\n echo \"\"\n echo \"--- podcast:播客转录依赖 ---\"\n require_cmd python3 \"请先安装 Python 3.9+\"\n require_cmd ffmpeg \"macOS: brew install ffmpeg | Ubuntu: sudo apt install ffmpeg\"\n pip_install faster-whisper\n info \"播客转录依赖安装完成\"\n}\n\ninstall_wechat() {\n echo \"\"\n echo \"--- wechat:公众号/PDF 处理依赖 ---\"\n require_cmd python3 \"请先安装 Python 3.9+\"\n pip_install beautifulsoup4 markitdown pymupdf\n info \"公众号/PDF 处理依赖安装完成\"\n}\n\nnormalize_target() {\n case \"$1\" in\n light|x|twitter|xhs|xiaohongshu|content|radar|knowledge|kb)\n echo \"light\" ;;\n video|douyin|bilibili|youtube|tiktok|weibo|zhihu)\n echo \"video\" ;;\n podcast|rss)\n echo \"podcast\" ;;\n wechat|pdf|article)\n echo \"wechat\" ;;\n all)\n echo \"all\" ;;\n doctor|check)\n echo \"doctor\" ;;\n *)\n echo \"unknown\" ;;\n esac\n}\n\necho \"\"\necho \"=========================================\"\necho \" Chubby Skills 安装助手\"\necho \"=========================================\"\n\nif [[ $# -eq 0 ]]; then\n set -- light\nfi\n\nwant_light=false\nwant_video=false\nwant_podcast=false\nwant_wechat=false\nwant_all=false\nwant_doctor=false\n\nfor raw in \"$@\"; do\n target=\"$(normalize_target \"$raw\")\"\n if [[ \"$target\" == \"unknown\" ]]; then\n error \"未知安装目标:$raw\"\n echo \"可用目标:light / video / podcast / wechat / all / doctor\"\n exit 2\n fi\n case \"$target\" in\n light) want_light=true ;;\n video) want_video=true ;;\n podcast) want_podcast=true ;;\n wechat) want_wechat=true ;;\n all) want_all=true ;;\n doctor) want_doctor=true ;;\n esac\ndone\n\nif $want_doctor; then\n run_doctor\n exit 0\nfi\n\nif $want_all; then\n install_light\n install_video\n install_podcast\n install_wechat\nelse\n $want_light && install_light\n $want_video && install_video\n $want_podcast && install_podcast\n $want_wechat && install_wechat\nfi\n\necho \"\"\necho \"=========================================\"\necho \" 安装步骤完成\"\necho \"=========================================\"\necho \"\"\necho \"下一步建议:\"\necho \" python3 tools/check_env.py\"\necho \" python3 tools/chubby.py quickstart\"\necho \" python3 tools/platform_smoke.py --mode all --check\"\necho \" python3 tools/golden_outputs.py examples/outputs\"\n"}, "820b293dc0df12270e8f6ab23c28fae1b4f11f02": {"t": 1785752936.7973084, "n": 4190, "h": "23b32d620771dcae", "c": "#!/usr/bin/env bash\n# Chubby Skills - staged installer\n#\n# Usage:\n# bash setup.sh # light mode: zero/low-dependency tools\n# bash setup.sh light # same as default\n# bash setup.sh video # video transcription stack\n# bash setup.sh podcast # podcast transcription stack\n# bash setup.sh wechat # WeChat/PDF extraction stack\n# bash setup.sh all # everything\n# bash setup.sh doctor # environment check only\n# bash setup.sh bilibili xhs # aliases are accepted\n\nset -euo pipefail\n\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m'\n\ninfo() { echo -e \"${GREEN}[✓]${NC} $*\"; }\nwarn() { echo -e \"${YELLOW}[!]${NC} $*\"; }\nerror() { echo -e \"${RED}[✗]${NC} $*\"; }\n\nhas_cmd() {\n command -v \"$1\" >/dev/null 2>&1\n}\n\nrequire_cmd() {\n if has_cmd \"$1\"; then\n info \"已安装 $1\"\n else\n error \"未找到 $1:$2\"\n return 1\n fi\n}\n\nrun_doctor() {\n python3 tools/check_env.py\n}\n\npip_install() {\n python3 -m pip install \"$@\"\n}\n\ninstall_ytdlp() {\n if has_cmd yt-dlp; then\n info \"yt-dlp 已安装\"\n return\n fi\n warn \"未找到 yt-dlp,正在安装...\"\n if [[ \"$(uname)\" == \"Darwin\" ]] && has_cmd brew; then\n brew install yt-dlp || pip_install yt-dlp\n else\n pip_install yt-dlp\n fi\n}\n\ninstall_light() {\n echo \"\"\n echo \"--- light:零/轻依赖能力 ---\"\n require_cmd python3 \"请先安装 Python 3.9+\"\n info \"可直接使用:X 图文、小红书图文、情报雷达、知识库健康检查、content-enrich\"\n warn \"content-enrich / 学习笔记 / 爆款拆解需要按需设置 DEEPSEEK_API_KEY\"\n warn \"小红书采集建议按需设置 XHS_COOKIE\"\n}\n\ninstall_video() {\n echo \"\"\n echo \"--- video:视频转录依赖 ---\"\n require_cmd python3 \"请先安装 Python 3.9+\"\n require_cmd ffmpeg \"macOS: brew install ffmpeg | Ubuntu: sudo apt install ffmpeg\"\n install_ytdlp\n warn \"即将安装 funasr / modelscope / torch / torchaudio,首次安装体积较大。\"\n pip_install funasr modelscope torch torchaudio\n info \"视频转录依赖安装完成\"\n}\n\ninstall_podcast() {\n echo \"\"\n echo \"--- podcast:播客转录依赖 ---\"\n require_cmd python3 \"请先安装 Python 3.9+\"\n require_cmd ffmpeg \"macOS: brew install ffmpeg | Ubuntu: sudo apt install ffmpeg\"\n pip_install faster-whisper\n info \"播客转录依赖安装完成\"\n}\n\ninstall_wechat() {\n echo \"\"\n echo \"--- wechat:公众号/PDF 处理依赖 ---\"\n require_cmd python3 \"请先安装 Python 3.9+\"\n pip_install beautifulsoup4 markitdown pymupdf\n info \"公众号/PDF 处理依赖安装完成\"\n}\n\nnormalize_target() {\n case \"$1\" in\n light|x|twitter|xhs|xiaohongshu|content|radar|knowledge|kb)\n echo \"light\" ;;\n video|douyin|bilibili|youtube|tiktok|weibo|zhihu)\n echo \"video\" ;;\n podcast|rss)\n echo \"podcast\" ;;\n wechat|pdf|article)\n echo \"wechat\" ;;\n all)\n echo \"all\" ;;\n doctor|check)\n echo \"doctor\" ;;\n *)\n echo \"unknown\" ;;\n esac\n}\n\necho \"\"\necho \"=========================================\"\necho \" Chubby Skills 安装助手\"\necho \"=========================================\"\n\nif [[ $# -eq 0 ]]; then\n set -- light\nfi\n\nwant_light=false\nwant_video=false\nwant_podcast=false\nwant_wechat=false\nwant_all=false\nwant_doctor=false\n\nfor raw in \"$@\"; do\n target=\"$(normalize_target \"$raw\")\"\n if [[ \"$target\" == \"unknown\" ]]; then\n error \"未知安装目标:$raw\"\n echo \"可用目标:light / video / podcast / wechat / all / doctor\"\n exit 2\n fi\n case \"$target\" in\n light) want_light=true ;;\n video) want_video=true ;;\n podcast) want_podcast=true ;;\n wechat) want_wechat=true ;;\n all) want_all=true ;;\n doctor) want_doctor=true ;;\n esac\ndone\n\nif $want_doctor; then\n run_doctor\n exit 0\nfi\n\nif $want_all; then\n install_light\n install_video\n install_podcast\n install_wechat\nelse\n $want_light && install_light\n $want_video && install_video\n $want_podcast && install_podcast\n $want_wechat && install_wechat\nfi\n\necho \"\"\necho \"=========================================\"\necho \" 安装步骤完成\"\necho \"=========================================\"\necho \"\"\necho \"下一步建议:\"\necho \" python3 tools/check_env.py\"\necho \" python3 tools/chubby_ingest.py '<链接>' -o output/\"\necho \" python3 tools/validate_outputs.py examples/outputs\"\n"}, "7f3f850580da96b43752127d5e17abb4848c7cde": {"t": 1785752938.2242327, "n": 3471, "h": "f22481f19ef3f8dd", "c": "#!/usr/bin/env bash\n# Chubby Skills - 一键安装脚本\n# 用法: bash setup.sh [skill-name ...]\n# 不带参数 → 安装所有依赖\n# 带参数 → 只安装指定 skill 的依赖\n#\n# 示例:\n# bash setup.sh # 全部安装\n# bash setup.sh douyin-transcribe # 只装抖音转录\n# bash setup.sh podcast wechat # 装播客 + 公众号\n\nset -euo pipefail\n\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m'\n\ninfo() { echo -e \"${GREEN}[✓]${NC} $*\"; }\nwarn() { echo -e \"${YELLOW}[!]${NC} $*\"; }\nerror() { echo -e \"${RED}[✗]${NC} $*\"; }\n\ncheck_cmd() {\n if ! command -v \"$1\" &>/dev/null; then\n error \"未找到 $1,请先安装\"\n return 1\n fi\n info \"已安装 $1\"\n}\n\n# ── 基础检查 ──────────────────────────────────────────\necho \"\"\necho \"=========================================\"\necho \" Chubby Skills 安装助手\"\necho \"=========================================\"\necho \"\"\n\ncheck_cmd python3\ncheck_cmd ffmpeg\ncheck_cmd curl\n\n# ── 解析参数 ──────────────────────────────────────────\nINSTALL_ALL=true\nSKILLS=()\nif [[ $# -gt 0 ]]; then\n INSTALL_ALL=false\n SKILLS=(\"$@\")\nfi\n\nneeds() {\n # 检查是否需要安装某个 skill 的依赖\n if $INSTALL_ALL; then return 0; fi\n for s in \"${SKILLS[@]}\"; do\n case \"$1\" in\n *\"$s\"*) return 0 ;;\n esac\n done\n return 1\n}\n\n# ── 视频转录依赖 (funasr + torch) ────────────────────\ninstall_video_deps() {\n echo \"\"\n echo \"--- 视频转录依赖 (funasr / torch) ---\"\n if needs \"douyin\" || needs \"bilibili\" || needs \"tiktok\" || needs \"weibo\" || needs \"zhihu\" || needs \"youtube\" || needs \"video\"; then\n info \"安装 funasr + torch(首次较慢,约 2-3 GB)...\"\n pip install funasr modelscope torch torchaudio\n info \"视频转录依赖安装完成\"\n fi\n}\n\n# ── yt-dlp ────────────────────────────────────────────\ninstall_ytdlp() {\n echo \"\"\n echo \"--- yt-dlp ---\"\n if needs \"bilibili\" || needs \"tiktok\" || needs \"weibo\" || needs \"zhihu\" || needs \"youtube\"; then\n if command -v yt-dlp &>/dev/null; then\n info \"yt-dlp 已安装\"\n else\n warn \"未找到 yt-dlp,正在安装...\"\n if [[ \"$(uname)\" == \"Darwin\" ]]; then\n brew install yt-dlp 2>/dev/null || pip install yt-dlp\n else\n pip install yt-dlp\n fi\n info \"yt-dlp 安装完成\"\n fi\n fi\n}\n\n# ── 播客转录依赖 (faster-whisper) ─────────────────────\ninstall_podcast_deps() {\n echo \"\"\n echo \"--- 播客转录依赖 (faster-whisper) ---\"\n if needs \"podcast\"; then\n pip install faster-whisper\n info \"播客转录依赖安装完成\"\n fi\n}\n\n# ── 公众号处理依赖 ────────────────────────────────────\ninstall_wechat_deps() {\n echo \"\"\n echo \"--- 公众号处理依赖 ---\"\n if needs \"wechat\"; then\n pip install beautifulsoup4 markitdown pymupdf\n info \"公众号处理依赖安装完成\"\n fi\n}\n\n# ── 翻译功能检查 ──────────────────────────────────────\ncheck_translation() {\n if needs \"youtube\" || needs \"learning\"; then\n if [[ -z \"${DEEPSEEK_API_KEY:-}\" ]]; then\n warn \"翻译/闪卡功能需要 DEEPSEEK_API_KEY\"\n warn \" export DEEPSEEK_API_KEY=your-key-here\"\n else\n info \"DEEPSEEK_API_KEY 已设置\"\n fi\n fi\n}\n\n# ── 执行 ──────────────────────────────────────────────\ninstall_video_deps\ninstall_ytdlp\ninstall_podcast_deps\ninstall_wechat_deps\ncheck_translation\n\necho \"\"\necho \"=========================================\"\necho \" 安装完成!\"\necho \"=========================================\"\necho \"\"\necho \"快速验证:\"\necho \" python3 industry-intelligence-radar/scripts/scan.py --hours 1\"\necho \" python3 podcast-transcribe/scripts/transcribe.py --help\"\necho \"\"\n"}}}}