Files
chat_rebot-connect-with-one…/scripts/qq_video_download.py
Claw 4068869217 feat(plugin): 集成 OpenClaw Bridge + QQ 操作技能套件
新增 openclaw_bridge 插件,实现 QQ ↔ OpenClaw Gateway 桥接:

src/process.py:
  - QQ 消息自动转发 OpenClaw Gateway
  - 内置安全检测(高危词、管理员白名单、MC指令过滤)
  - 高危词库外部化到 config.toml [security] section
  - 群昵称自动解析
  - Session 隔离(按群×用户)

config/openclawbridge/config.toml:
  - 插件配置模板(已脱敏:占位符替代个人信息)

scripts/qq_*.py (×15):
  - 消息发送/文件传输/群管理/好友管理
  - 信息查询/历史回溯/OCR/点赞
  - 全部脱敏(QQ号→YOUR_ADMIN_QQ, IP→YOUR_NAPCAT_HOST等)

skills/*/SKILL.md (×8):
  - AgentSkills 使用指导
  - qq-messenger / qq-management / qq-resolver / qq-napcat-extras
  - browser / file-process / mc-query / nix-helper

SKILL.md:
  - AgentSkill 入口,完整架构说明和部署步骤
2026-05-04 16:28:12 +08:00

260 lines
8.7 KiB
Python
Raw 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
"""
视频链接解析与下载工具 — 基于 you-get
场景:
AI 在聊天中检测到视频分享链接B站等调用此脚本下载到本地
然后用 qq_upload_group_file.py 发送到对应群/私聊。
用法:
# 查看视频信息(不下载)
python3 qq_video_download.py --url "https://www.bilibili.com/video/BVxxxx" --info
# 下载视频(自动选择最佳可用画质)
python3 qq_video_download.py --url "https://www.bilibili.com/video/BVxxxx"
# 指定画质下载
python3 qq_video_download.py --url "..." --format dash-flv480-AVC
# JSON 输出(供 AI 解析)
python3 qq_video_download.py --url "..." --info --json
"""
import json
import sys
import os
import re
import subprocess
import argparse
import requests
from urllib.parse import urlparse
FILE_DIR = os.path.dirname(os.path.abspath(__file__))
DEFAULT_OUTPUT = os.path.join(FILE_DIR, "..", "files", "videos")
os.makedirs(DEFAULT_OUTPUT, exist_ok=True)
# B站短链接域名
BILIBILI_SHORT_DOMAINS = ["b23.tv", "bili22.cn", "bili33.cn"]
def normalize_url(url: str) -> str:
"""归一化视频链接:自动解析短链接到标准地址"""
url = url.strip().strip('"').strip("'")
# 短链接才需要解析,标准 URL 跳过
parsed = urlparse(url)
if parsed.netloc not in BILIBILI_SHORT_DOMAINS:
return url
try:
resp = requests.head(url, allow_redirects=True, timeout=10, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
final_url = resp.url
if final_url and final_url != url:
return final_url
except Exception:
pass
return url
# 安全文件名:去掉不安全的字符
def safe_filename(name: str) -> str:
name = re.sub(r'[<>:"/\\|?*]', '_', name)
name = re.sub(r'\s+', ' ', name).strip()
return name or "video"
def run_you_get(args: list, timeout=120) -> dict:
"""运行 you-get 并返回结构化结果"""
cmd = ["you-get"] + args
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "下载超时,视频太大或网络太慢"}
stdout = proc.stdout or ""
stderr = proc.stderr or ""
exit_code = proc.returncode
if exit_code != 0:
# 有些 you-get 非零退出但实际成功了,检查输出
error_msg = stderr.strip() or stdout.strip() or f"you-get 退出码 {exit_code}"
return {"ok": False, "error": error_msg}
return {"ok": True, "exit_code": exit_code, "stdout": stdout, "stderr": stderr}
def parse_info_json(raw_json: str) -> dict:
"""解析 you-get --json 输出"""
try:
data = json.loads(raw_json)
except json.JSONDecodeError:
return None
info = {
"site": data.get("site", ""),
"title": data.get("title", ""),
"url": data.get("url", ""),
"streams": []
}
for fmt_id, stream in data.get("streams", {}).items():
info["streams"].append({
"id": fmt_id,
"container": stream.get("container", ""),
"quality": stream.get("quality", ""),
"size": stream.get("size", 0),
"size_human": f"{stream.get('size', 0) / 1024 / 1024:.1f} MB" if stream.get("size", 0) > 0 else "未知"
})
return info
def cmd_info(url: str, json_output: bool) -> dict:
"""获取视频信息"""
url = normalize_url(url)
result = run_you_get(["--json", url], timeout=30)
if not result["ok"]:
return result
info = parse_info_json(result["stdout"])
if not info:
return {"ok": False, "error": "无法解析视频信息"}
result["info"] = info
if not json_output:
# 人类可读格式
lines = [f"🎬 {info['title']}", f" 来源: {info['site']}", f" 链接: {info['url']}", ""]
for s in info["streams"]:
lines.append(f" [{s['id']}] {s['quality']} | {s['container']} | {s['size_human']}")
result["text"] = "\n".join(lines)
return result
def _pick_best_avc_stream(info: dict) -> str | None:
"""从可用流中挑选最佳的 AVC (H.264) 格式,确保 QQ 客户端可播放"""
avc_streams = [s for s in info.get("streams", []) if "AVC" in s.get("id", "")]
if not avc_streams:
return None
# 按质量降序480 比 360 高),取第一个
avc_streams.sort(key=lambda s: s.get("id", ""), reverse=True)
return avc_streams[0]["id"]
def cmd_download(url: str, output_dir: str, filename: str, fmt: str,
no_merge: bool, no_caption: bool, json_output: bool) -> dict:
"""下载视频"""
url = normalize_url(url)
# 未指定格式时,自动选最佳的 AVC (H.264) 流QQ 播放器不支持 AV1/HEVC
if not fmt:
info_result = cmd_info(url, json_output=True)
if info_result.get("ok"):
best = _pick_best_avc_stream(info_result["info"])
if best:
fmt = best
else:
# 没有 AVC 流,用第一个
streams = info_result["info"].get("streams", [])
if streams:
fmt = streams[0]["id"]
args = ["--output-dir", output_dir, "--force"]
if no_merge:
args.append("--no-merge")
if no_caption:
args.append("--no-caption")
if filename:
args.extend(["--output-filename", filename])
if fmt:
args.extend(["--format", fmt])
args.append(url)
result = run_you_get(args, timeout=300) # 5 分钟超时
if not result["ok"]:
return result
# 解析下载后的文件
stderr = result.get("stderr", "")
stdout = result.get("stdout", "")
# you-get 会在 stdout/stderr 输出 Merged into xxx.mp4
merged_match = re.search(r'Merged into (.+\.mp4)', stderr or stdout)
if merged_match:
final_path = os.path.join(output_dir, merged_match.group(1))
else:
# 查找输出目录中最新添加的 mp4
mp4_files = sorted(
[f for f in os.listdir(output_dir) if f.endswith(".mp4")],
key=lambda f: os.path.getmtime(os.path.join(output_dir, f)),
reverse=True
)
final_path = os.path.join(output_dir, mp4_files[0]) if mp4_files else ""
file_size = os.path.getsize(final_path) if final_path and os.path.exists(final_path) else 0
result["file"] = {
"path": final_path,
"filename": os.path.basename(final_path) if final_path else "",
"size": file_size,
"size_human": f"{file_size / 1024 / 1024:.1f} MB" if file_size > 0 else "未知"
}
if not json_output:
lines = [f"✅ 下载完成: {result['file']['filename']}"]
lines.append(f" 大小: {result['file']['size_human']}")
lines.append(f" 路径: {result['file']['path']}")
result["text"] = "\n".join(lines)
return result
def main():
parser = argparse.ArgumentParser(description="视频链接解析与下载")
parser.add_argument("--url", type=str, required=True, help="视频分享链接")
parser.add_argument("--info", action="store_true", help="仅查看信息,不下载")
parser.add_argument("--output", type=str, default=DEFAULT_OUTPUT, help="下载目录")
parser.add_argument("--name", type=str, help="输出文件名(不含扩展名)")
parser.add_argument("--format", type=str, help="画质格式 ID如 dash-flv480-AVC")
parser.add_argument("--json", action="store_true", help="JSON 格式输出")
parser.add_argument("--no-merge", action="store_true", help="不合并 DASH 流")
parser.add_argument("--no-caption", action="store_true", help="不下字幕/弹幕")
args = parser.parse_args()
try:
if args.info:
result = cmd_info(args.url, args.json)
else:
result = cmd_download(args.url, args.output, args.name,
args.format, args.no_merge, args.no_caption, args.json)
if args.json:
# JSON 输出中去掉 stdout/stderr 这种大字段
output = {k: v for k, v in result.items() if k not in ("stdout", "stderr", "text")}
print(json.dumps(output, ensure_ascii=False, indent=2))
else:
if result.get("text"):
print(result["text"])
elif not result.get("ok"):
print(f"{result.get('error', '未知错误')}")
else:
print(result.get("text", "✅ 完成"))
sys.exit(0 if result.get("ok") else 1)
except Exception as e:
if args.json:
print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False))
else:
print(f"❌ 错误: {e}")
sys.exit(1)
if __name__ == "__main__":
main()