- 新增 operit_runtime.py: 跨平台 Python 运行时 - 实现 Tools.System/Files/Net/UI/Memory/FFmpeg/Workflow/Chat/SoftwareSettings/Tasker - 实现 OkHttp 链式 HTTP 客户端兼容层 - 实现 complete()/exports/console 全局对象 - 实现 OperitScriptLoader 脚本加载器 - 支持 Windows/macOS/Linux 三平台 - 新增 operit_py/ 目录: 47 个 Python 翻译脚本 - 搜索类: duckduckgo, google_search, tavily, zhipu_search, various_search, crossref - 绘图类: openai_draw, qwen_draw, xai_draw, minimax_draw, siliconflow_draw, zhipu_draw, nanobanana_draw - 媒体类: ffmpeg, file_converter, douyin_download, bilibili_tools, jmcomic - 文件类: extended_file_tools, hex_editor, reader, beeimg_image_uploader - 网络类: extended_http_tools, network_test - 自动化类: browser, automatic_ui_base, automatic_ui_subagent, automatic_bilibili_assistant, automatic_xiaohongshu_assistant, automatic_baidu_map_assistant - 系统类: system_tools, super_admin, daily_life, operit_editor - 对话类: ai_chat, extended_chat, qq_intelligent - 工作流类: workflow, vflow_trigger, tasker - 工具类: time, code_runner, quick_start, extended_memory_tools, baidu_map, 12306, pdf_vision_parser - 测试结果: 47/47 语法检查通过, 38 脚本加载成功, 280 个工具注册 - Android 专属功能已降级处理(保留接口,返回不支持提示)
286 lines
12 KiB
Python
286 lines
12 KiB
Python
# METADATA
|
|
# {
|
|
# "name": "douyin_download",
|
|
#
|
|
# "display_name": {
|
|
# "zh": "抖音下载工具",
|
|
# "en": "Douyin Download Tool"
|
|
# },
|
|
# "description": { "zh": "抖音工具包,提供从分享链接或分享口令中提取并下载无水印视频的功能。", "en": "Douyin toolkit for extracting and downloading watermark-free videos from share links or share codes." },
|
|
# "enabledByDefault": true,
|
|
# "category": "Media",
|
|
# "tools": [
|
|
# {
|
|
# "name": "get_douyin_download_link",
|
|
# "description": { "zh": "解析抖音分享链接或口令,下载无水印视频到本地", "en": "Parse a Douyin share link/code and download a watermark-free video to local storage." },
|
|
# "parameters": [
|
|
# {
|
|
# "name": "input",
|
|
# "description": { "zh": "抖音分享链接或包含链接的分享口令文本", "en": "Douyin share link, or share-code text that contains a link." },
|
|
# "type": "string",
|
|
# "required": true
|
|
# }
|
|
# ]
|
|
# },
|
|
# {
|
|
# "name": "get_douyin_video_info",
|
|
# "description": { "zh": "解析抖音分享链接或口令,仅获取视频信息和无水印下载链接,不下载视频", "en": "Parse a Douyin share link/code and return video info + watermark-free download URL (without downloading)." },
|
|
# "parameters": [
|
|
# {
|
|
# "name": "input",
|
|
# "description": { "zh": "抖音分享链接或包含链接的分享口令文本", "en": "Douyin share link, or share-code text that contains a link." },
|
|
# "type": "string",
|
|
# "required": true
|
|
# }
|
|
# ]
|
|
# }
|
|
# ]
|
|
# }
|
|
|
|
import re
|
|
import json
|
|
import traceback
|
|
|
|
client = OkHttp.newClient()
|
|
|
|
# 抖音相关URL模式
|
|
DOUYIN_URL_PATTERNS = [
|
|
re.compile(r'https?://v\.douyin\.com/[^\s]+'),
|
|
re.compile(r'https?://www\.douyin\.com/video/[0-9]+'),
|
|
re.compile(r'https?://www\.douyin\.com/share/video/[0-9]+')
|
|
]
|
|
|
|
|
|
async def douyin_wrap(func, params, successMessage, failMessage):
|
|
"""包装函数调用,提供标准化的成功/错误处理"""
|
|
try:
|
|
console.log(f"开始执行函数: {func.__name__ or '匿名函数'}")
|
|
result = await func(params)
|
|
complete({"success": True, "message": successMessage, "data": result})
|
|
except Exception as error:
|
|
msg = str(error) if isinstance(error, Exception) else str(error)
|
|
console.error(f"函数 {func.__name__ or '匿名函数'} 执行失败! 错误: {msg}")
|
|
stack = traceback.format_exc() if isinstance(error, Exception) else None
|
|
complete({"success": False, "message": f"{failMessage}: {msg}", "error_stack": stack})
|
|
|
|
|
|
def extractDouyinUrl(text):
|
|
"""从文本中提取抖音链接"""
|
|
for pattern in DOUYIN_URL_PATTERNS:
|
|
match = pattern.search(text)
|
|
if match:
|
|
return match.group(0)
|
|
return None
|
|
|
|
|
|
async def resolveDouyinUrl(shareUrl):
|
|
"""解析抖音分享链接,获取视频信息"""
|
|
try:
|
|
console.log(f"正在解析抖音链接: {shareUrl}")
|
|
|
|
request = client.newRequest()
|
|
request = request.url(shareUrl)
|
|
request = request.method("GET")
|
|
request = request.headers({
|
|
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/121.0.2277.107 Version/17.0 Mobile/15E148 Safari/604.1",
|
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
|
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
"Connection": "keep-alive"
|
|
})
|
|
|
|
response = await request.build().execute()
|
|
|
|
if not response.isSuccessful():
|
|
raise Exception(f"链接解析失败 ({response.statusCode})")
|
|
|
|
html = response.content
|
|
|
|
# 尝试从ROUTER_DATA中提取视频信息
|
|
routerDataMatch = re.search(r'window\._ROUTER_DATA\s*=\s*({.*?})</script>', html, re.DOTALL)
|
|
if routerDataMatch and routerDataMatch.group(1):
|
|
try:
|
|
routerData = json.loads(routerDataMatch.group(1))
|
|
# The actual key might vary, let's find it dynamically
|
|
loaderData = routerData.get("loaderData", {}) if isinstance(routerData, dict) else {}
|
|
pageDataKey = next((k for k in loaderData.keys() if "/page" in k), None)
|
|
if not pageDataKey:
|
|
raise Exception("无法在 _ROUTER_DATA 中找到页面数据")
|
|
pageData = loaderData.get(pageDataKey) or {}
|
|
videoInfoRes = pageData.get("videoInfoRes") or {}
|
|
item_list = videoInfoRes.get("item_list") or []
|
|
videoData = item_list[0] if len(item_list) > 0 else None
|
|
|
|
if videoData:
|
|
videoId = videoData.get("aweme_id")
|
|
videoTitle = videoData.get("desc")
|
|
video = videoData.get("video") or {}
|
|
play_addr = video.get("play_addr") or {}
|
|
url_list = play_addr.get("url_list") or []
|
|
watermarkedUrl = url_list[0] if len(url_list) > 0 else None
|
|
|
|
if videoId and videoTitle and watermarkedUrl:
|
|
console.log(f"通过 _ROUTER_DATA 找到视频信息: ID={videoId}")
|
|
downloadUrl = watermarkedUrl.replace("playwm", "play")
|
|
return {"videoId": videoId, "downloadUrl": downloadUrl, "videoTitle": videoTitle}
|
|
except Exception as e:
|
|
msg = str(e) if isinstance(e, Exception) else str(e)
|
|
console.error(f"解析 _ROUTER_DATA JSON 失败: {msg}")
|
|
|
|
raise Exception("无法从HTML中解析出 _ROUTER_DATA")
|
|
|
|
except Exception as error:
|
|
msg = str(error) if isinstance(error, Exception) else str(error)
|
|
console.error(f"解析抖音链接时出错: {msg}")
|
|
raise error
|
|
|
|
|
|
async def get_douyin_download_link(params):
|
|
"""获取抖音视频的无水印下载链接并下载视频"""
|
|
input_ = params.get("input")
|
|
|
|
if not input_:
|
|
raise Exception("输入内容不能为空")
|
|
|
|
console.log("开始获取并下载抖音无水印视频...")
|
|
|
|
try:
|
|
# 1. 提取和解析URL
|
|
douyinUrl = extractDouyinUrl(input_)
|
|
if not douyinUrl:
|
|
raise Exception("未找到有效的抖音链接")
|
|
console.log(f"找到抖音链接: {douyinUrl}")
|
|
|
|
# 2. 解析链接并获取所有需要的视频信息
|
|
console.log("正在解析分享链接...")
|
|
resolved = await resolveDouyinUrl(douyinUrl)
|
|
videoId = resolved["videoId"]
|
|
downloadUrl = resolved["downloadUrl"]
|
|
rawVideoTitle = resolved["videoTitle"]
|
|
console.log(f"解析到视频ID: {videoId}")
|
|
console.log(f"获取到无水印下载链接: {downloadUrl}")
|
|
|
|
# 获取视频标题(用于文件命名)
|
|
videoTitle = (rawVideoTitle or f"douyin_{videoId}").strip()
|
|
# 替换文件名中的非法字符
|
|
videoTitle = re.sub(r'[\\/:*?"<>|]', '_', videoTitle)
|
|
|
|
# 逐级创建目录并下载视频
|
|
baseDir = getPluginConfigDir("douyin_download")
|
|
destinationDir = f"{baseDir}/downloads"
|
|
destinationPath = f"{destinationDir}/{videoTitle}_{videoId}.mp4"
|
|
|
|
console.log(f"确保目录存在: {destinationDir}")
|
|
# 逐级创建目录以确保路径存在
|
|
dirsToCreate = [baseDir, destinationDir]
|
|
for dir_ in dirsToCreate:
|
|
mkdirResult = await Tools.Files.mkdir(dir_)
|
|
if not mkdirResult.get("successful"):
|
|
# 忽略"目录已存在"的错误,但记录其他可能的错误
|
|
console.warn(f"创建目录 '{dir_}' 失败 (可能已存在): {mkdirResult.get('details')}")
|
|
|
|
console.log(f"开始下载视频到: {destinationPath}")
|
|
downloadResult = await Tools.Files.download(downloadUrl, destinationPath)
|
|
|
|
if not downloadResult.get("successful"):
|
|
raise Exception(f"视频下载失败: {downloadResult.get('details')}")
|
|
|
|
successMessage = f'视频"{videoTitle}"成功下载到: {destinationPath}'
|
|
console.log(successMessage)
|
|
return successMessage
|
|
|
|
except Exception as error:
|
|
message = str(error) if isinstance(error, Exception) else str(error)
|
|
console.error(f"处理失败: {message}")
|
|
# 重新抛出错误,以便包装器可以捕获它
|
|
raise Exception(message)
|
|
|
|
|
|
async def get_douyin_video_info(params):
|
|
"""获取抖音视频信息和无水印下载链接(不下载视频)"""
|
|
input_ = params.get("input")
|
|
|
|
if not input_:
|
|
raise Exception("输入内容不能为空")
|
|
|
|
console.log("开始解析抖音视频信息...")
|
|
|
|
try:
|
|
# 1. 提取URL
|
|
douyinUrl = extractDouyinUrl(input_)
|
|
if not douyinUrl:
|
|
raise Exception("未找到有效的抖音链接")
|
|
console.log(f"找到抖音链接: {douyinUrl}")
|
|
|
|
# 2. 解析链接并获取视频信息
|
|
console.log("正在解析分享链接...")
|
|
resolved = await resolveDouyinUrl(douyinUrl)
|
|
videoId = resolved["videoId"]
|
|
downloadUrl = resolved["downloadUrl"]
|
|
videoTitle = resolved["videoTitle"]
|
|
console.log(f"解析到视频ID: {videoId}")
|
|
console.log(f"获取到无水印下载链接: {downloadUrl}")
|
|
console.log(f"视频标题: {videoTitle}")
|
|
|
|
result = {
|
|
"videoId": videoId,
|
|
"videoTitle": videoTitle,
|
|
"downloadUrl": downloadUrl,
|
|
"originalUrl": douyinUrl
|
|
}
|
|
|
|
console.log("视频信息解析成功")
|
|
return result
|
|
|
|
except Exception as error:
|
|
message = str(error) if isinstance(error, Exception) else str(error)
|
|
console.error(f"解析失败: {message}")
|
|
raise Exception(message)
|
|
|
|
|
|
async def main():
|
|
"""抖音工具功能测试主函数"""
|
|
console.log("🚀 开始执行抖音工具功能测试...")
|
|
await test_download_link()
|
|
summary = "抖音下载工具测试完成!\n" + "✅ 下载功能已测试"
|
|
console.log(f"\n{summary}")
|
|
return summary
|
|
|
|
|
|
async def test_download_link():
|
|
"""测试下载功能"""
|
|
testUrl = "3.00 05/24 Z@M.JI TLW:/ 这里有几首melodic dubstep,看你认识几个 # 电子音乐 # 音乐分享 # 顶级旋律 # 热门音乐🔥百听不厌 # 戴上耳机 https://v.douyin.com/AT8AfEbuP_k/ 复制此链接,打开Dou音搜索,直接观看视频!" # 使用真实分享文本进行测试
|
|
console.log("1. 测试视频下载功能 (使用真实分享文本)")
|
|
# 我们预期这个调用会成功提取URL并尝试下载
|
|
try:
|
|
result = await get_douyin_download_link({"input": testUrl})
|
|
console.log(f"✅ 下载功能测试成功, 结果: {result}")
|
|
return result
|
|
except Exception as e:
|
|
errorMessage = str(e) if isinstance(e, Exception) else str(e)
|
|
console.error(f"❌ 下载功能测试失败: {errorMessage}")
|
|
# 在测试中,即使是预期的失败,也应该被视为一个需要注意的问题
|
|
raise e
|
|
|
|
|
|
async def get_douyin_download_link_wrapper(params):
|
|
await douyin_wrap(get_douyin_download_link, params, "抖音视频下载完成", "抖音视频下载失败")
|
|
|
|
|
|
async def get_douyin_video_info_wrapper(params):
|
|
await douyin_wrap(get_douyin_video_info, params, "抖音视频信息获取成功", "抖音视频信息获取失败")
|
|
|
|
|
|
async def main_wrapper(params=None):
|
|
await douyin_wrap(main, params, "抖音工具测试完成", "抖音工具测试失败")
|
|
|
|
|
|
async def test_download_link_wrapper(params=None):
|
|
await douyin_wrap(test_download_link, params, "下载链接获取测试成功", "下载链接获取测试失败")
|
|
|
|
|
|
# 导出所有功能
|
|
exports.get_douyin_download_link = get_douyin_download_link_wrapper
|
|
exports.get_douyin_video_info = get_douyin_video_info_wrapper
|
|
exports.main = main_wrapper
|
|
exports.test_download_link = test_download_link_wrapper
|