Files
CIBank/backend/video_downloader.py
2026-07-20 19:49:27 +08:00

237 lines
8.8 KiB
Python
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.
#!/usr/bin/env python3
"""视频号批量下载管理器。
基于 wx_channels_download 工具的 API 接口,实现:
1. 检查工具是否运行
2. 获取视频号创作者的视频列表
3. 与本地已下载文件比对,发现新视频
4. 触发批量下载
5. 下载完成后自动触发 ASR 转写入库
工具 GitHub: https://github.com/ltaoo/wx_channels_download
API 基地址: http://127.0.0.1:2022
使用前提:
- wx_channels_download 以 sudo 运行
- IPv6 已禁用(sudo networksetup -setv6off Wi-Fi
- VPN 已关闭
- 微信保持运行
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")
TOOL_API = os.getenv("WX_VIDEO_API", "http://127.0.0.1:2022")
VIDEO_CONFIG_PATH = BASE_DIR / "video_config.json"
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def api_get(endpoint: str, params: dict[str, str] | None = None, timeout: int = 30) -> dict[str, Any]:
"""调用 wx_channels_download 工具的 API。"""
url = f"{TOOL_API}{endpoint}"
if params:
qs = "&".join(f"{k}={v}" for k, v in params.items())
url += f"?{qs}"
req = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"工具 API 返回 {exc.code}{detail}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"无法连接工具 API{TOOL_API}):{exc.reason}\n请确认 wx_channels_download 已以 sudo 启动。") from exc
def check_tool_running() -> bool:
"""检查下载工具是否在运行。"""
try:
result = api_get("/api/channels/version", timeout=5)
return bool(result.get("version") or result.get("data"))
except Exception:
return False
def get_video_list(finder: str) -> list[dict[str, Any]]:
"""获取视频号创作者的视频列表。
需要微信客户端已连接到工具(WebSocket)。
"""
result = api_get("/api/channels/contact/feed/list", {"finder": finder}, timeout=60)
# 适配不同版本的返回格式
if isinstance(result, list):
return result
if isinstance(result, dict):
return result.get("data", result.get("list", result.get("feeds", [])))
return []
def list_local_videos(directory: Path) -> set[str]:
"""列出本地已下载的视频文件名(不含扩展名)。"""
if not directory.is_dir():
return set()
suffixes = {".mp4", ".mov", ".m4v", ".mkv", ".webm"}
return {f.stem for f in directory.iterdir() if f.is_file() and f.suffix.lower() in suffixes}
def normalize_title(title: str) -> str:
"""将视频标题规范化,用于与本地文件名比对。"""
# 去除 wx_channels_download 添加的 _xWT 后缀和画质标记
value = re.sub(r"[_ ]?xWT\d+$", "", title, flags=re.I)
value = re.sub(r"[\x00-\x1f/:]", "_", value).strip(" .")
return value[:180] or "untitled"
def find_new_videos(video_list: list[dict[str, Any]], local_dir: Path) -> list[dict[str, Any]]:
"""比对线上视频列表与本地已下载文件,返回未下载的视频。"""
local_names = list_local_videos(local_dir)
new_videos = []
for video in video_list:
title = video.get("title", video.get("desc", ""))
normalized = normalize_title(title)
# 检查本地是否已有该视频(模糊匹配)
if not any(normalized in local_name or local_name in normalized for local_name in local_names):
new_videos.append(video)
return new_videos
def create_batch_download(finder: str, video_ids: list[str]) -> dict[str, Any]:
"""创建批量下载任务。"""
params = {"finder": finder, "ids": ",".join(video_ids)}
return api_get("/api/task/create_batch", params, timeout=120)
def wait_for_downloads(local_dir: Path, expected_count: int, timeout: int = 600) -> int:
"""等待下载完成,返回实际新增的文件数。"""
initial = list_local_videos(local_dir)
deadline = time.time() + timeout
while time.time() < deadline:
time.sleep(10)
current = list_local_videos(local_dir)
new_files = current - initial
if len(new_files) >= expected_count:
return len(new_files)
return len(list_local_videos(local_dir) - initial)
def run_download(auto_transcribe: bool = True) -> int:
"""主流程:检查工具 → 获取视频列表 → 发现新视频 → 触发下载。"""
config = json.loads(VIDEO_CONFIG_PATH.read_text(encoding="utf-8")) if VIDEO_CONFIG_PATH.exists() else {"sources": []}
sources = config.get("sources", [])
if not sources:
print(json.dumps({"status": "skipped", "reason": "video_config.json 中未配置视频源"}, ensure_ascii=False))
return 0
# 1. 检查工具是否运行
if not check_tool_running():
print(json.dumps({
"status": "error",
"reason": f"wx_channels_download 工具未运行({TOOL_API}",
"hint": "请执行: sudo <工具路径>/wx_video_download,并确保 IPv6 已禁用、VPN 已关闭"
}, ensure_ascii=False))
return 1
print(json.dumps({"status": "tool_running", "api": TOOL_API}, ensure_ascii=False), flush=True)
total_new = 0
total_downloaded = 0
for source in sources:
account = source.get("account", "未知")
finder = source.get("finder", "")
download_dir = Path(source.get("directory", ""))
if not finder:
print(f"SKIP {account}: 未配置 finder", file=sys.stderr)
continue
# 2. 获取视频列表
try:
video_list = get_video_list(finder)
except Exception as exc:
print(f"FAIL {account}: 获取视频列表失败 - {exc}", file=sys.stderr, flush=True)
continue
print(f"INFO {account}: 线上视频 {len(video_list)}", flush=True)
# 3. 发现新视频
new_videos = find_new_videos(video_list, download_dir)
total_new += len(new_videos)
if not new_videos:
print(f"INFO {account}: 无新视频", flush=True)
continue
print(f"INFO {account}: 发现 {len(new_videos)} 个新视频", flush=True)
# 4. 触发批量下载
video_ids = [v.get("id", v.get("objectId", "")) for v in new_videos if v.get("id") or v.get("objectId")]
if video_ids:
try:
result = create_batch_download(finder, video_ids)
print(f"INFO {account}: 批量下载任务已创建 - {result}", flush=True)
# 5. 等待下载完成
downloaded = wait_for_downloads(download_dir, len(new_videos), timeout=600)
total_downloaded += downloaded
print(f"INFO {account}: 新增下载 {downloaded} 个文件", flush=True)
except Exception as exc:
print(f"FAIL {account}: 下载失败 - {exc}", file=sys.stderr, flush=True)
else:
# 如果无法获取 video_id,提示用户手动下载
print(f"WARN {account}: 无法自动获取视频ID,请通过微信内批量下载或 Web 管理页面手动下载", flush=True)
print(f" Web 管理页面: {TOOL_API}/download", flush=True)
print(f" 下载目录: {download_dir}", flush=True)
print(json.dumps({
"status": "success",
"total_new": total_new,
"total_downloaded": total_downloaded,
}, ensure_ascii=False), flush=True)
# 6. 自动触发 ASR 转写入库
if auto_transcribe and total_downloaded > 0:
print("INFO 自动触发视频号 ASR 转写...", flush=True)
import subprocess
subprocess.run(
[sys.executable, str(BASE_DIR / "crawler.py"), "--source-type", "视频号"],
cwd=str(BASE_DIR),
)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="视频号批量下载管理器")
parser.add_argument("--no-transcribe", action="store_true", help="仅下载,不自动触发 ASR 转写")
parser.add_argument("--check", action="store_true", help="仅检查工具状态")
return parser
if __name__ == "__main__":
args = build_parser().parse_args()
if args.check:
running = check_tool_running()
print(json.dumps({"tool_running": running, "api": TOOL_API}, ensure_ascii=False))
raise SystemExit(0 if running else 1)
raise SystemExit(run_download(auto_transcribe=not args.no_transcribe))