feat(bilibili): 使用 yt-dlp 替换原有下载方案

- 重写 download_video 使用 yt-dlp + Netscape cookie 文件
- 支持 BILIBILI_COOKIE 环境变量
- 更新默认质量为 720P
- 更新 SKILL.md 文档

环境变量已在 ~/.bashrc 中配置
This commit is contained in:
2026-02-13 11:30:59 +08:00
parent 0da1c0fb59
commit 1fa90ac21c
4 changed files with 315 additions and 186 deletions
+53 -10
View File
@@ -22,13 +22,56 @@
1. **使用技能过程中,遇到任何技能没有明确说明的问题,不要自己想办法绕过去,立刻停止,记录到SkillBug.md,并汇报问题。** 1. **使用技能过程中,遇到任何技能没有明确说明的问题,不要自己想办法绕过去,立刻停止,记录到SkillBug.md,并汇报问题。**
2. **安装第三方技能时,优先安装在workspace/skills中** 2. **安装第三方技能时,优先安装在workspace/skills中**
3. **技能有说明的内容,不要擅自做优化决定,如果子代理擅自优化了,打回重做。** 3. **技能有说明的内容,不要擅自做优化决定,如果子代理擅自优化了,打回重做。**
4. **子代理监督规则(2026-02-09)** 4. **子代理监督规则(2026-02-09)** - 详见上方
- 主代理必须监督子代理的执行过程
- 子代理开始工作后,定期检查执行日志 ## 第三方库修改规则(2026-02-13)
- 发现以下违规行为立即打回重做: - **严禁直接修改第三方库代码**(如 pyannote.audio, speechbrain 等)
- 未按技能要求执行步骤 - 如遇到兼容性问题:
- 跳过或省略必做步骤 1. 先记录到 SkillBug.md
- 编造内容(代码、数据、描述等) 2. 向用户汇报情况
- 引用不存在的文件或帧号 3. 寻求替代方案(如使用其他服务、等待官方修复)
- 擅自优化技能规定的流程 - 如果必须修改第三方库代码:
- 打回时说明违规原因,要求严格按照技能重新执行 1. **必须先向用户汇报**,获得批准
2. 记录修改内容到 SkillBug.md
3. 考虑使用 monkey patch 或环境变量等非侵入式方案
## pyannote.audio 兼容性问题(2026-02-13)
- **问题**: pyannote 4.0.4 与 torch 2.10+ 存在兼容性问题
- **症状**:
- `torchaudio.list_audio_backends()` 方法不存在
- `torchaudio.io.StreamReader` 不存在
- **解决方案**: 降级到 torch 2.8.0 + torchaudio 2.8.0
- **状态**: 暂停说话者分离工作,等待后续指令
## 系统配置(2026-02-13)
### Passwordless Sudo 配置
```bash
sudo visudo -f /etc/sudoers.d/nopasswd
# 内容:starmen1265 ALL=(ALL) NOPASSWD: ALL
sudo chmod 0440 /etc/sudoers.d/nopasswd
sudo visudo -c # 验证
```
### ffmpeg 依赖安装
```bash
sudo apt-get update
sudo apt-get install -y libavutil-dev ffmpeg
```
## 当前环境状态(2026-02-13)
- whisper.cpp: ~/whisper.cpp
- whisper 模型: ggml-base.bin
- kb-whisper 脚本: skills/kb-whisper/whisper.py
## ⚠️ 安全规则
### rm 命令使用规则
- **使用 rm 命令之前必须向用户汇报具体操作命令**
- **禁止直接执行 rm 命令**
- 等待用户确认后才能执行
### sudo 命令使用规则
- 需要配置 passwordless sudo 才能执行系统级操作
- 配置路径: `/etc/sudoers.d/nopasswd`
- 配置后需要验证: `sudo visudo -c`
+30 -42
View File
@@ -1,48 +1,36 @@
# 2026-02-13 # 2026-02-13 语音识别工作记录
## 视频音频转文字方案检查 ## 目标
整合说话者分离(speaker diarization)到 kb-whisper 工作流,替换 OCR 为 Whisper 视频转文字
### 方案组件 ## Passwordless Sudo 配置成功
- **kb-whisper**: Whisper.cpp 本地语音识别 ```bash
- **kb-funasr**: 阿里 FunASR 本地语音识别 sudo visudo -f /etc/sudoers.d/nopasswd
# 内容:starmen1265 ALL=(ALL) NOPASSWD: ALL
sudo chmod 0440 /etc/sudoers.d/nopasswd
sudo visudo -c # 验证:parsed OK
```
### 发现 ## 降级操作(解决兼容性问题)
- 两个工具都直接支持视频文件输入 | 库 | 从 | 到 | 原因 |
- 内部自动通过 ffmpeg 处理音频提取和格式转换 |---|---|---|---|
- 无需单独的视频→音频提取步骤 | torch | 2.10.0 | 2.8.0 | speechbrain 需要旧 API |
| torchaudio | 2.10.0 | 2.8.0 | speechbrain 需要 list_audio_backends() |
| NumPy | 2.x | 1.26.4 | pyannote 3.1.1 需要 |
| huggingface_hub | 1.4.1 | 0.19.4 | pyannote 3.1.1 需要 use_auth_token |
| pyannote.audio | 4.0.4 | 3.1.1 | 4.x 与 torch 2.10 兼容性问题 |
### 待确认事项 ## 用户指令
- whisper.cpp 是否已安装(应位于 ~/whisper.cpp) - 只允许修改 `speaker_diarization.py` 中的 segmentation 配置
- CUDA/PyTorch 相关包(torch-2.10.0, torchaudio-2.10.0)已安装完成 - 停止所有其他修改
- 暂停说话者分离工作
--- ## 下一步
- 使用已有的 kb-whisper 方案(Whisper.cpp)
- 暂停说话者分离(speaker diarization)
- 等待后续指令
## Whisper.cpp 迁移与模型下载 ## 当前环境
- whisper.cpp: ~/whisper.cpp
### 执行操作 - whisper 模型: ggml-base.bin
- whisper.cpp 原位于 `~/libs/whisper.cpp` - kb-whisper 脚本: skills/kb-whisper/whisper.py
- 移动到默认位置 `~/whisper.cpp`
- 使用 hf-mirror 下载中文模型 ggml-base.bin(147MB)
### Whisper 编译受阻
- 需要 cmake 编译 whisper.cpp
- 执行 `make` 报错:cmake 未安装
- 需要 sudo 权限安装 cmake(当前环境不支持)
---
## KB Workflow 优化
### 修改内容
- 步骤5:从 OCR(kb-ocr-processor)切换为 Whisper(kb-whisper)
- 移除了 content_subtitles 目录(不再需要)
- 输出格式更新为 `{platform}_{id}.txt` 和 `{platform}_{id}.json`
### 测试视频
- 视频:新加坡分区漫游指南(BV1Z9nXzUEhv)
- 由于 Whisper 未编译,临时使用 FunASR 完成语音识别
- 分析报告已生成并归档
### 待办
- 编译 whisper.cpp:`cd ~/whisper.cpp && make`
- 需要手动执行 sudo apt-get install cmake
+60 -11
View File
@@ -4,21 +4,34 @@
## Features ## Features
- Video download (multiple quality options) - Video download using **yt-dlp** (recommended)
- Subtitle extraction - Subtitle extraction
- Article content extraction - Article content extraction
- Low quality for OCR (480p default) - Multiple quality options (360P/480P/720P/1080P)
- Supports `BILIBILI_COOKIE` environment variable
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `BILIBILI_COOKIE` | No | B站登录 Cookie (for member-only videos) |
## Commands ## Commands
```bash ```bash
# Download video (传入 --output 指定完整路径) # Download video (720P default)
python bilibili_content.py --id "BV1xx411c7mD" --type video --quality 480 --output "/path/to/downloads/BV1xx.mp4" python bilibili_content.py --id "BV1xx411c7mD" --type video --output "/path/to/downloads/"
# Extract subtitle (传入 --output 指定完整路径) # Download video (1080P)
python bilibili_content.py --id "BV1xx411c7mD" --type subtitle --output "/path/to/subtitles/BV1xx.srt" python bilibili_content.py --id "BV1xx411c7mD" --type video --quality 80 --output "/path/to/downloads/"
# Get article content (传入 --output 指定完整路径) # Download video (480P)
python bilibili_content.py --id "BV1xx411c7mD" --type video --quality 32 --output "/path/to/downloads/"
# Extract subtitle
python bilibili_content.py --id "BV1xx411c7mD" --type subtitle --output "/path/to/subtitles/"
# Get article content
python bilibili_content.py --id "cv43780186" --type article --output "/path/to/article.md" python bilibili_content.py --id "cv43780186" --type article --output "/path/to/article.md"
``` ```
@@ -28,15 +41,51 @@ python bilibili_content.py --id "cv43780186" --type article --output "/path/to/a
{ {
"success": true, "success": true,
"type": "video", "type": "video",
"filename": "BV1xx.mp4", "filename": "video_title.mp4",
"filepath": "/path/to/downloads/BV1xx.mp4", "filepath": "/path/to/downloads/video_title.mp4",
"message": "下载完成" "message": "下载完成: video_title.mp4"
} }
``` ```
## Cookie Setup
### Get Cookie from Browser
1. Login to bilibili.com
2. Press F12 → Network → Refresh page
3. Copy Cookie from request headers
### Set Environment Variable
```bash
# Add to ~/.bashrc for persistence
export BILIBILI_COOKIE="SESSDATA=xxx; bili_jct=xxx; DedeUserID=xxx; DedeUserID__ckMd5=xxx"
# Or set temporarily
export BILIBILI_COOKIE="your_cookie_here"
```
## Installation ## Installation
```bash ```bash
cp -r kb-bilibili-content/ ~/skills/ cp -r kb-bilibili-content/ ~/skills/
pip install aiohttp pip install aiohttp playwright
playwright install chromium
``` ```
## How It Works
Video download uses **yt-dlp** with Netscape-format cookie file:
1. Parses `BILIBILI_COOKIE` environment variable
2. Creates temporary Netscape-format cookie file
3. Runs `yt-dlp --cookies <file> -f <format> -o <output>`
4. Cleans up temporary file
## Quality Options
| Quality | ID | Resolution | Notes |
|---------|-----|------------|-------|
| 16 | 360P | 640x360 | Fastest |
| 32 | 480P | 852x480 | Default for low bandwidth |
| 64 | 720P | 1280x720 | **Recommended** |
| 80 | 1080P | 1920x1080 | Best quality |
Default quality: **64 (720P)**
+172 -123
View File
@@ -4,7 +4,7 @@
Bilibili Content Downloader - B站内容获取工具 Bilibili Content Downloader - B站内容获取工具
功能: 功能:
- download_video(bvid, quality=480) - 下载视频 - download_video(bvid, quality=480) - 下载视频(使用 yt-dlp)
- extract_subtitle(bvid) - 提取字幕 - extract_subtitle(bvid) - 提取字幕
- extract_article(cvid) - 获取专栏全文(使用Playwright) - extract_article(cvid) - 获取专栏全文(使用Playwright)
- 命令行参数支持 - 命令行参数支持
@@ -16,8 +16,9 @@ import asyncio
import json import json
import os import os
import re import re
import subprocess
import sys import sys
import aiohttp import tempfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
@@ -25,155 +26,203 @@ from typing import Optional, Dict, Any, List
class BilibiliContent: class BilibiliContent:
"""B站内容获取器""" """B站内容获取器"""
# 完整的浏览器请求头
BROWSER_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Cache-Control': 'max-age=0',
}
def __init__(self, output_dir: str = "./output", cookie: str = None): def __init__(self, output_dir: str = "./output", cookie: str = None):
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True) self.output_dir.mkdir(parents=True, exist_ok=True)
self.cookie = cookie self.cookie = cookie
def _get_headers(self) -> Dict[str, str]: def _create_cookie_file(self) -> str:
"""获取请求头(包含Cookie)""" """创建 Netscape 格式的 Cookie 文件"""
headers = self.BROWSER_HEADERS.copy() if not self.cookie:
if self.cookie: return None
headers['Cookie'] = self.cookie
return headers # 解析 Cookie 字符串
cookies = {}
async def download_video(self, bvid: str, quality: int = 32) -> Dict[str, Any]: for item in self.cookie.split(';'):
"""下载B站视频""" item = item.strip()
if '=' in item:
key, value = item.split('=', 1)
cookies[key.strip()] = value.strip()
# 创建 Netscape 格式的 Cookie 文件
cookie_content = """# Netscape HTTP Cookie File
# Generated by bilibili_content.py
"""
for name, value in cookies.items():
cookie_content += f".bilibili.com\tTRUE\t/\tFALSE\t0\t{name}\t{value}\n"
# 写入临时文件
fd, path = tempfile.mkstemp(suffix='.txt')
with os.fdopen(fd, 'w') as f:
f.write(cookie_content)
return path
async def download_video(self, bvid: str, quality: int = 64) -> Dict[str, Any]:
"""下载B站视频(使用 yt-dlp)"""
result = {"success": False, "bvid": bvid, "message": "", "filepath": None, "metadata": {}} result = {"success": False, "bvid": bvid, "message": "", "filepath": None, "metadata": {}}
try: try:
# API 请求使用自动解压 # 先获取视频信息
async with aiohttp.ClientSession(headers=self._get_headers()) as api_session: print(f"获取视频信息: {bvid}")
# 获取视频信息
print(f"获取视频信息: {bvid}") cookie_file = self._create_cookie_file()
async with api_session.get("https://api.bilibili.com/x/web-interface/view", params={'bvid': bvid}) as resp: url = f"https://www.bilibili.com/video/{bvid}"
data = await resp.json()
try:
if data.get('code') != 0: # 获取视频标题(用于文件名)
result["message"] = f"获取失败: {data.get('message')}" if cookie_file:
return result cmd = ["yt-dlp", "--cookies", cookie_file, "--get-title", url]
else:
video_data = data.get('data', {}) cmd = ["yt-dlp", "--get-title", url]
title = video_data.get('title', '未知标题')
cid = video_data.get('cid') proc = await asyncio.create_subprocess_exec(
*cmd,
result["metadata"] = {"title": title, "cid": cid, "bvid": bvid} stdout=asyncio.subprocess.PIPE,
print(f"标题: {title}") stderr=asyncio.subprocess.PIPE
)
# 获取播放地址 stdout, stderr = await proc.communicate()
print(f"获取视频流...")
async with api_session.get("https://api.bilibili.com/x/player/playurl", title = stdout.decode('utf-8').strip()
params={'bvid': bvid, 'cid': cid, 'qn': quality}) as resp: if not title or 'error' in title.lower():
play_data = await resp.json() # 尝试备用方法
title = f"video_{bvid}"
if play_data.get('code') != 0: except Exception as e:
result["message"] = f"获取失败: {play_data.get('message')}" title = f"video_{bvid}"
return result
# 构建 yt-dlp 命令
durl = play_data.get('data', {}).get('durl', []) safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_', '丨')).strip()[:50]
if not durl:
result["message"] = "未找到视频流" # 选择视频格式
return result if quality == 80:
# 1080P
video_url = durl[0].get('url') format_str = "30112+30280"
print(f"下载视频...") elif quality == 64:
# 720P
# 下载 format_str = "30064+30280"
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()[:30] elif quality == 32:
output_path = self.output_dir / f"{safe_title}_{bvid}.flv" # 480P
format_str = "30032+30280"
# 视频下载使用禁用自动解压的 session else:
async with aiohttp.ClientSession(headers=self._get_headers(), auto_decompress=False) as download_session: # 360P
async with download_session.get(video_url) as resp: format_str = "30016+30280"
with open(output_path, 'wb') as f:
async for chunk in resp.content.iter_chunked(81920): print(f"下载视频: {title}")
f.write(chunk) print(f"格式: {format_str}")
print(f"\r下载中...", end="", flush=True)
output_template = str(self.output_dir / f"{safe_title}.%(ext)s")
print(f"\n✅ 已保存: {output_path}")
cmd = [
"yt-dlp",
"-f", format_str,
"-o", output_template,
"--no-download-archive",
"-q",
url
]
if cookie_file:
cmd.extend(["--cookies", cookie_file])
print(f"执行命令: {' '.join(cmd[:4])}...")
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
# 查找下载的文件
downloaded_files = list(self.output_dir.glob(f"{safe_title}.*"))
if downloaded_files:
video_file = downloaded_files[0]
result["success"] = True result["success"] = True
result["filepath"] = str(output_path) result["filepath"] = str(video_file)
result["message"] = f"下载完成" result["message"] = f"下载完成: {video_file.name}"
result["metadata"] = {"title": title, "bvid": bvid}
print(f"✅ {result['message']}")
else:
result["message"] = "未找到下载的文件"
print(f"❌ {result['message']}")
# 清理临时 Cookie 文件
if cookie_file and os.path.exists(cookie_file):
os.remove(cookie_file)
except Exception as e: except Exception as e:
result["message"] = f"错误: {str(e)}" result["message"] = f"错误: {str(e)}"
print(f"❌ {result['message']}")
return result return result
async def extract_subtitle(self, bvid: str) -> Dict[str, Any]: async def extract_subtitle(self, bvid: str) -> Dict[str, Any]:
"""提取字幕""" """提取字幕"""
result = {"success": False, "bvid": bvid, "subtitles": [], "message": ""} result = {"success": False, "bvid": bvid, "subtitles": [], "message": ""}
try: try:
async with aiohttp.ClientSession(headers=self._get_headers()) as session: import aiohttp
async with session.get("https://api.bilibili.com/x/player/pagelist", params={'bvid': bvid}) as resp:
api_url = f"https://api.bilibili.com/x/player/pagelist?bvid={bvid}"
async with aiohttp.ClientSession() as session:
async with session.get(api_url) as resp:
data = await resp.json() data = await resp.json()
pages = data.get('data', []) pages = data.get('data', [])
if not pages: if not pages:
result["message"] = "未找到字幕" result["message"] = "未找到分P"
return result return result
cid = pages[0].get('cid', '') cid = pages[0].get('cid', '')
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={cid}") as resp: async with session.get(f"https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={cid}") as resp:
sub_data = await resp.json() sub_data = await resp.json()
subtitles = sub_data.get('data', {}).get('subtitle', {}).get('list', []) subtitles = sub_data.get('data', {}).get('subtitle', {}).get('list', [])
if not subtitles: if not subtitles:
result["message"] = "未找到字幕" result["message"] = "未找到字幕"
return result return result
result["success"] = True result["success"] = True
result["subtitles"] = [{"lan": s.get('lan'), "title": s.get('lan_doc')} for s in subtitles] result["subtitles"] = [{"lan": s.get('lan'), "title": s.get('lan_doc')} for s in subtitles]
result["message"] = f"找到 {len(subtitles)} 个字幕" result["message"] = f"找到 {len(subtitles)} 个字幕"
print(result["message"]) print(result["message"])
except Exception as e: except Exception as e:
result["message"] = f"错误: {str(e)}" result["message"] = f"错误: {str(e)}"
return result return result
async def extract_article(self, cvid: str) -> Dict[str, Any]: async def extract_article(self, cvid: str) -> Dict[str, Any]:
"""获取专栏全文(使用Playwright异步API)""" """获取专栏全文(使用Playwright异步API)"""
result = {"success": False, "cvid": cvid, "title": "", "content": "", "message": ""} result = {"success": False, "cvid": cvid, "title": "", "content": "", "message": ""}
try: try:
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
cvid_clean = cvid.replace('cv', '') cvid_clean = cvid.replace('cv', '')
url = f"https://www.bilibili.com/read/cv{cvid_clean}" url = f"https://www.bilibili.com/read/cv{cvid_clean}"
print(f"获取专栏: {url}") print(f"获取专栏: {url}")
async with async_playwright() as p: async with async_playwright() as p:
browser = await p.chromium.launch(headless=True) browser = await p.chromium.launch(headless=True)
page = await browser.new_page() page = await browser.new_page()
await page.goto(url, wait_until="networkidle") await page.goto(url, wait_until="networkidle")
await page.wait_for_timeout(3000) await page.wait_for_timeout(3000)
# 提取标题 # 提取标题
title_elem = await page.query_selector('h1') title_elem = await page.query_selector('h1')
if title_elem: if title_elem:
result["title"] = (await title_elem.inner_text()).strip() result["title"] = (await title_elem.inner_text()).strip()
print(f"标题: {result['title'][:50]}...") print(f"标题: {result['title'][:50]}...")
# 提取正文内容 # 提取正文内容
content_elem = await page.query_selector('.read-article-holder') content_elem = await page.query_selector('.read-article-holder')
if content_elem: if content_elem:
@@ -185,18 +234,18 @@ class BilibiliContent:
print(f"内容长度: {len(result['content'])} 字符") print(f"内容长度: {len(result['content'])} 字符")
else: else:
result["message"] = "未找到专栏内容" result["message"] = "未找到专栏内容"
await browser.close() await browser.close()
if result["success"]: if result["success"]:
result["message"] = f"✅ 获取成功: {result['title'][:30]}..." result["message"] = f"✅ 获取成功: {result['title'][:30]}..."
else: else:
result["message"] = f"获取失败: {result.get('message', '未知错误')}" result["message"] = f"获取失败: {result.get('message', '未知错误')}"
except Exception as e: except Exception as e:
result["message"] = f"错误: {str(e)}" result["message"] = f"错误: {str(e)}"
print(f"错误: {e}") print(f"错误: {e}")
return result return result
@@ -205,20 +254,20 @@ async def main():
parser.add_argument('--id', '-i', required=True, help='B站内容ID') parser.add_argument('--id', '-i', required=True, help='B站内容ID')
parser.add_argument('--type', '-t', required=True, choices=['video', 'subtitle', 'article'], help='内容类型') parser.add_argument('--type', '-t', required=True, choices=['video', 'subtitle', 'article'], help='内容类型')
parser.add_argument('--output', '-o', default='./output', help='输出目录') parser.add_argument('--output', '-o', default='./output', help='输出目录')
parser.add_argument('--quality', '-q', type=int, default=32, help='视频质量: 16=360P, 32=480P, 64=720P, 80=1080P') parser.add_argument('--quality', '-q', type=int, default=64, help='视频质量: 16=360P, 32=480P, 64=720P, 80=1080P')
args = parser.parse_args() args = parser.parse_args()
cookie = os.environ.get('BILIBILI_COOKIE') cookie = os.environ.get('BILIBILI_COOKIE')
downloader = BilibiliContent(output_dir=args.output, cookie=cookie) downloader = BilibiliContent(output_dir=args.output, cookie=cookie)
if args.type == 'video': if args.type == 'video':
result = await downloader.download_video(args.id, quality=args.quality) result = await downloader.download_video(args.id, quality=args.quality)
elif args.type == 'subtitle': elif args.type == 'subtitle':
result = await downloader.extract_subtitle(args.id) result = await downloader.extract_subtitle(args.id)
else: else:
result = await downloader.extract_article(args.id.replace('cv', '')) result = await downloader.extract_article(args.id.replace('cv', ''))
# 统一输出格式 # 统一输出格式
if result.get("success"): if result.get("success"):
output = { output = {
@@ -234,7 +283,7 @@ async def main():
"error": result.get('message', '处理失败'), "error": result.get('message', '处理失败'),
"detail": f"ID: {args.id}, 类型: {args.type}" "detail": f"ID: {args.id}, 类型: {args.type}"
} }
print(json.dumps(output, ensure_ascii=False, indent=2)) print(json.dumps(output, ensure_ascii=False, indent=2))
return result return result