<Project-20 YT-DLP> 给视频网站下载工具 yt-dlp/yt-dlp 加个页面 python web
介绍 yt-dlp
Github 项目:https://github.com/yt-dlp/yt-dlp
A feature-rich command-line audio/video downloader
一个功能丰富的视频与音频命令行下载器
原因与功能
之前我用的 cobalt 因为它不再提供Client Web功能,只能去它的官网使用。 翻 reddit 找到这个 YT-DLP,但它是个命令行工具,考虑参数大多很少用到,给它加个web 壳子,又可以放到docker里面运行。
在网页填入url,只列出含有视频+音频的文件。点下载后,文件可以保存在本地。命令的运行输出也在页面上显示。占用端口: 9012
YT-DLP 程序
代码在 Claude AI 帮助下完成,前端全靠它,Nice~
界面
目录结构
20.YT-DLP/
├── Dockerfile
├── app.py
├── static/
│ ├── css/
│ │ └── style.css
│ └── js/
│ └── script.js
├── templates/
│ └── index.html
└── temp_downloads/
完整代码
1. app.py
# app.py
from flask import Flask, render_template, request, jsonify, send_file
import yt_dlp
import os
import shutil
from werkzeug.utils import secure_filename
import time
import logging
import queue
from datetime import datetime
import sys
app = Flask(__name__)
# 创建固定的临时目录
TEMP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp_downloads')
if not os.path.exists(TEMP_DIR):
os.makedirs(TEMP_DIR)
# 存储下载信息的字典
DOWNLOADS = {}
# 创建日志队列
log_queue = queue.Queue(maxsize=1000)
class QueueHandler(logging.Handler):
def __init__(self, log_queue):
super().__init__()
self.log_queue = log_queue
def emit(self, record):
try:
# 过滤掉 Werkzeug 的常规访问日志
if record.name == 'werkzeug' and any(x in record.getMessage() for x in [
'127.0.0.1',
'GET /api/logs',
'GET /static/',
'"GET / HTTP/1.1"'
]):
return
# 清理消息格式
msg = self.format(record)
if record.name == 'app':
# 移除 "INFO:app:" 等前缀
msg = msg.split(' - ')[-1]
log_entry = {
'timestamp': datetime.fromtimestamp(record.created).isoformat(),
'message': msg,
'level': record.levelname.lower(),
'logger': record.name
}
# 如果队列满了,移除最旧的日志
if self.log_queue.full():
try:
self.log_queue.get_nowait()
except queue.Empty:
pass
self.log_queue.put(log_entry)
except Exception as e:
print(f"Error in QueueHandler: {e}")
# 配置日志格式
log_formatter = logging.Formatter('%(message)s')
# 配置队列处理器
queue_handler = QueueHandler(log_queue)
queue_handler.setFormatter(log_formatter)
# 配置控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(log_formatter)
# 配置 Flask 日志
app.logger.handlers = []
app.logger.addHandler(queue_handler)
app.logger.addHandler(console_handler)
app.logger.setLevel(logging.INFO)
# Werkzeug 日志只输出错误
werkzeug_logger = logging.getLogger('werkzeug')
werkzeug_logger.handlers = []
werkzeug_logger.addHandler(console_handler)
werkzeug_logger.setLevel(logging.WARNING)
def cleanup_old_files():
"""清理超过10分钟的临时文件"""
current_time = time.time()
for token, info in list(DOWNLOADS.items()):
if current_time - info['timestamp'] > 600: # 10分钟
try:
file_path = info['file_path']
if os.path.exists(file_path):
os.remove(file_path)
del DOWNLOADS[token]
except Exception as e:
app.logger.error(f"清理文件失败: {str(e)}")
def get_video_info(url):
"""获取视频信息,包括可用的格式"""
ydl_opts = {
'quiet': True,
'no_warnings': True,
'format': None,
'youtube_include_dash_manifest': True,
'format_sort': [
'res:2160', # 4K
'res:1440', # 2K
'res:1080', # 1080p
'res:720', # 720p
'res:480', # 480p
'fps:60', # 优先60fps
'fps', # 然后是其他fps
'vcodec:h264', # 优先H.264编码
'vcodec:vp9', # 然后是VP9
'acodec' # 最后是音频编码
]
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
info = ydl.extract_info(url, download=False)
formats = []
def safe_number(value, default=0):
try:
return float(value or default)
except (TypeError, ValueError):
return default
# 处理视频格式
for f in info.get('formats', []):
vcodec = f.get('vcodec', 'none')
acodec = f.get('acodec', 'none')
has_video = vcodec != 'none'
has_audio = acodec != 'none'
height = safe_number(f.get('height', 0))
width = safe_number(f.get('width', 0))
fps = safe_number(f.get('fps', 0))
tbr = safe_number(f.get('tbr', 0))
if has_video: # 只处理包含视频的格式
format_notes = []
# 添加分辨率标签
if height >= 2160:
format_notes.append("4K")
elif height >= 1440:
format_notes.append("2K")
# 详细的分辨率信息
if height and width:
format_notes.append(f"{width:.0f}x{height:.0f}p")
# FPS信息
if fps > 0:
format_notes.append(f"{fps:.0f}fps")
# 编码信息
if vcodec != 'none':
codec_name = {
'avc1': 'H.264',
'vp9': 'VP9',
'av01': 'AV1'
}.get(vcodec.split('.')[0], vcodec)
format_notes.append(f"Video: {codec_name}")
# 比特率信息
if tbr > 0:
format_notes.append(f"{tbr:.0f}kbps")
# 音频信息
if has_audio and acodec != 'none':
format_notes.append(f"Audio: {acodec}")
format_data = {
'format_id': f.get('format_id', ''),
'ext': f.get('ext', ''),
'filesize': f.get('filesize', 0),
'format_note': ' - '.join(format_notes),
'vcodec': vcodec,
'acodec': acodec,
'height': height,
'width': width,
'fps': fps,
'resolution_sort': height * 1000 + fps
}
if format_data['format_id']:
formats.append(format_data)
# 按分辨率和FPS排序
formats.sort(key=lambda x: x['resolution_sort'], reverse=True)
# 移除重复的格式
seen_resolutions = set()
unique_formats = []
for fmt in formats:
res_key = f"{fmt['height']:.0f}p-{fmt['fps']:.0f}fps"
if res_key not in seen_resolutions:
seen_resolutions.add(res_key)
unique_formats.append(fmt)
return {
'title': info.get('title', 'Unknown'),
'duration': info.get('duration', 0),
'thumbnail': info.get('thumbnail', ''),
'formats': unique_formats,
'description': info.get('description', ''),
'channel': info.get('channel', 'Unknown'),
'view_count': info.get('view_count', 0),
}
except Exception as e:
app.logger.error(f"获取视频信息失败: {str(e)}")
return {'error': str(e)}
def log_progress(d):
if d['status'] == 'downloading':
try:
percent = d.get('_percent_str', 'N/A').strip()
speed = d.get('_speed_str', 'N/A').strip()
eta = d.get('_eta_str', 'N/A').strip()
# 每5%记录一次进度
if percent != 'N/A' and float(percent.rstrip('%')) % 5 < 1:
app.logger.info(f"下载进度: {percent} | 速度: {speed} | 剩余时间: {eta}")
except Exception:
pass
elif d['status'] == 'finished':
app.logger.info("下载完成,开始处理文件...")
@app.route('/')
def index():
"""渲染主页"""
return render_template('index.html')
@app.route('/api/info', methods=['POST'])
def get_info():
"""获取视频信息的API端点"""
url = request.json.get('url')
if not url:
return jsonify({'error': 'URL is required'}), 400
info = get_video_info(url)
return jsonify(info)
@app.route('/api/download', methods=['POST'])
def download_video():
"""下载视频的API端点"""
url = request.json.get('url')
format_id = request.json.get('format_id')
if not url or not format_id:
app.logger.error('缺少URL或格式ID')
return jsonify({'error': 'URL and format_id are required'}), 400
try:
cleanup_old_files()
temp_file = os.path.join(TEMP_DIR, f'download_{time.time_ns()}')
app.logger.info(f"创建临时文件: {os.path.basename(temp_file)}")
ydl_opts = {
'format': f'{format_id}+bestaudio/best',
'outtmpl': temp_file + '.%(ext)s',
'quiet': True,
'merge_output_format': 'mp4',
'postprocessors': [{
'key': 'FFmpegVideoConvertor',
'preferedformat': 'mp4',
}],
'prefer_ffmpeg': True,
'keepvideo': False,
'progress_hooks': [log_progress],
}
app.logger.info("开始下载视频...")
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
final_file = ydl.prepare_filename(info)
filename = secure_filename(info['title'] + '.mp4')
filesize = os.path.getsize(final_file)
filesize_mb = filesize / (1024 * 1024)
app.logger.info(f"下载完成: {filename} ({filesize_mb:.1f}MB)")
download_token = os.urandom(16).hex()
DOWNLOADS[download_token] = {
'file_path': final_file,
'filename': filename,
'timestamp': time.time()
}
return jsonify({
'status': 'success',
'download_token': download_token,
'filename': filename
})
except Exception as e:
app.logger.error(f"下载失败: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/api/get_file/<token>')
def get_file(token):
"""获取下载文件的API端点"""
if token not in DOWNLOADS:
app.logger.error("无效的下载令牌")
return 'Invalid or expired download token', 400
download_info = DOWNLOADS[token]
file_path = download_info['file_path']
filename = download_info['filename']
if not os.path.exists(file_path):
app.logger.error(f"文件未找到: {filename}")
return 'File not found', 404
try:
filesize = os.path.getsize(file_path)
filesize_mb = filesize / (1024 * 1024)
app.logger.info(f"开始发送: {filename} ({filesize_mb:.1f}MB)")
return send_file(
file_path,
as_attachment=True,
download_name=filename,
mimetype='video/mp4'
)
except Exception as e:
app.logger.error(f"发送文件失败: {str(e)}")
return str(e), 500
finally:
def cleanup():
try:
if token in DOWNLOADS:
os.remove(file_path)
del DOWNLOADS[token]
app.logger.info(f"临时文件已清理: {filename}")
except Exception as e:
app.logger.error(f"清理文件失败: {str(e)}")
import threading
threading.Timer(60, cleanup).start()
@app.route('/api/logs')
def get_logs():
"""获取日志的API端点"""
logs = []
temp_queue = queue.Queue()
try:
while not log_queue.empty():
log = log_queue.get_nowait()
logs.append(log)
temp_queue.put(log)
while not temp_queue.empty():
log_queue.put(temp_queue.get_nowait())
return jsonify(sorted(logs, key=lambda x: x['timestamp'], reverse=True))
except Exception as e:
app.logger.error(f"获取日志失败: {str(e)}")
return jsonify([])
if __name__ == '__main__':
# 确保临时目录存在
os.makedirs(TEMP_DIR, exist_ok=True)
# 启动时清理旧文件
cleanup_old_files()
# 运行应用
app.run(host='0.0.0.0', port=9012, debug=True)
2. index.html
<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YouTube Video Downloader</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="container">
<h1>YouTube Video Downloader</h1>
<div class="input-group">
<input type="text" id="url-input" placeholder="Enter YouTube URL">
<button id="fetch-info">Get Video Info</button>
</div>
<div id="video-info" class="hidden">
<div class="info-container">
<img id="thumbnail" src="" alt="Video thumbnail">
<div class="video-details">
<h2 id="video-title"></h2>
<p id="video-duration"></p>
</div>
</div>
<div class="formats-container">
<h3>Available Formats</h3>
<div id="format-list"></div>
</div>
</div>
<div id="status" class="hidden"></div>
<!-- 日志显示区域 -->
<div class="log-container">
<div class="log-header">
<h3>Operation Logs</h3>
<button id="clear-logs" title="Clear logs">Clear</button>
<label class="auto-scroll">
<input type="checkbox" id="auto-scroll" checked>
Auto-scroll
</label>
</div>
<div id="log-display"></div>
</div>
</div>
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
</body>
</html>
3. style.css
有了 AI 后, style 产生得太简单
/* static/css/style.css */
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 800px;
margin: 0 auto;
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center;
color: #333;
margin-bottom: 20px;
}
.input-group {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
input[type="text"] {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
button {
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:hover {
background-color: #0056b3;
}
.hidden {
display: none;
}
.info-container {
display: flex;
gap: 20px;
margin-bottom: 20px;
padding: 15px;
background-color: #f8f9fa;
border-radius: 4px;
}
#thumbnail {
max-width: 200px;
border-radius: 4px;
}
.video-details {
flex: 1;
}
.video-details h2 {
margin: 0 0 10px 0;
color: #333;
}
.formats-container {
border-top: 1px solid #ddd;
padding-top: 20px;
}
#format-list {
display: grid;
gap: 10px;
}
.format-item {
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
#status {
margin: 20px 0;
padding: 10px;
border-radius: 4px;
text-align: center;
}
#status.success {
background-color: #d4edda;
color: #155724;
}
#status.error {
background-color: #f8d7da;
color: #721c24;
}
/* 日志容器样式 */
.log-container {
margin-top: 20px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #1e1e1e;
}
.log-header {
padding: 10px;
background-color: #2d2d2d;
border-bottom: 1px solid #444;
display: flex;
align-items: center;
gap: 10px;
}
.log-header h3 {
margin: 0;
flex-grow: 1;
color: #fff;
}
.auto-scroll {
display: flex;
align-items: center;
gap: 5px;
font-size: 14px;
color: #fff;
}
#clear-logs {
padding: 5px 10px;
background-color: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
#clear-logs:hover {
background-color: #5a6268;
}
#log-display {
height: 300px;
overflow-y: auto;
padding: 10px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
line-height: 1.4;
background-color: #1e1e1e;
color: #d4d4d4;
}
.log-entry {
margin: 2px 0;
padding: 2px 5px;
border-radius: 2px;
white-space: pre-wrap;
word-wrap: break-word;
}
.log-timestamp {
color: #888;
margin-right: 8px;
font-size: 0.9em;
}
.log-info {
color: #89d4ff;
}
.log-error {
color: #ff8989;
}
.log-warning {
color: #ffd700;
}
/* 滚动条样式 */
#log-display::-webkit-scrollbar {
width: 8px;
}
#log-display::-webkit-scrollbar-track {
background: #2d2d2d;
}
#log-display::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
#log-display::-webkit-scrollbar-thumb:hover {
background: #555;
}
4. script.js
// static/js/script.js
document.addEventListener('DOMContentLoaded', function() {
const urlInput = document.getElementById('url-input');
const fetchButton = document.getElementById('fetch-info');
const videoInfo = document.getElementById('video-info');
const thumbnail = document.getElementById('thumbnail');
const videoTitle = document.getElementById('video-title');
const videoDuration = document.getElementById('video-duration');
const formatList = document.getElementById('format-list');
const status = document.getElementById('status');
// 日志系统
class Logger {
constructor() {
this.logDisplay = document.getElementById('log-display');
this.autoScrollCheckbox = document.getElementById('auto-scroll');
this.clearLogsButton = document.getElementById('clear-logs');
this.lastLogTimestamp = null;
this.setupEventListeners();
}
setupEventListeners() {
this.clearLogsButton.addEventListener('click', () => this.clearLogs());
this.startLogPolling();
}
formatTimestamp(isoString) {
const date = new Date(isoString);
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
}
addLogEntry(entry) {
const logEntry = document.createElement('div');
logEntry.classList.add('log-entry');
if (entry.level === 'error') {
logEntry.classList.add('log-error');
} else if (entry.level === 'warning') {
logEntry.classList.add('log-warning');
} else {
logEntry.classList.add('log-info');
}
const timestamp = document.createElement('span');
timestamp.classList.add('log-timestamp');
timestamp.textContent = this.formatTimestamp(entry.timestamp);
const message = document.createElement('span');
message.classList.add('log-message');
message.textContent = entry.message;
logEntry.appendChild(timestamp);
logEntry.appendChild(message);
this.logDisplay.appendChild(logEntry);
if (this.autoScrollCheckbox.checked) {
this.scrollToBottom();
}
}
clearLogs() {
this.logDisplay.innerHTML = '';
this.lastLogTimestamp = null;
}
scrollToBottom() {
this.logDisplay.scrollTop = this.logDisplay.scrollHeight;
}
async fetchLogs() {
try {
const response = await fetch('/api/logs');
const logs = await response.json();
const newLogs = this.lastLogTimestamp
? logs.filter(log => log.timestamp > this.lastLogTimestamp)
: logs;
if (newLogs.length > 0) {
newLogs.forEach(log => this.addLogEntry(log));
this.lastLogTimestamp = logs[0].timestamp;
}
} catch (error) {
console.error('Failed to fetch logs:', error);
}
}
startLogPolling() {
setInterval(() => this.fetchLogs(), 500);
}
}
// 初始化日志系统
const logger = new Logger();
function formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
function formatFileSize(bytes) {
if (!bytes) return 'Unknown size';
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`;
}
function showStatus(message, isError = false) {
status.textContent = message;
status.className = isError ? 'error' : 'success';
status.classList.remove('hidden');
}
async function downloadVideo(url, formatId) {
try {
logger.addLogEntry({
timestamp: new Date().toISOString(),
level: 'info',
message: `Starting download preparation for format: ${formatId}`
});
showStatus('Preparing download...');
const response = await fetch('/api/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url, format_id: formatId })
});
const data = await response.json();
if (response.ok && data.download_token) {
logger.addLogEntry({
timestamp: new Date().toISOString(),
level: 'success',
message: `Download token received: ${data.download_token}`
});
showStatus('Starting download...');
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = `/api/get_file/${data.download_token}`;
iframe.onload = () => {
logger.addLogEntry({
timestamp: new Date().toISOString(),
level: 'success',
message: `Download started for: ${data.filename}`
});
showStatus('Download started! Check your browser downloads.');
setTimeout(() => document.body.removeChild(iframe), 5000);
};
iframe.onerror = () => {
logger.addLogEntry({
timestamp: new Date().toISOString(),
level: 'error',
message: 'Download failed to start'
});
showStatus('Download failed. Please try again.', true);
document.body.removeChild(iframe);
};
document.body.appendChild(iframe);
} else {
const errorMessage = data.error || 'Download failed';
logger.addLogEntry({
timestamp: new Date().toISOString(),
level: 'error',
message: `Download failed: ${errorMessage}`
});
showStatus(errorMessage, true);
}
} catch (error) {
logger.addLogEntry({
timestamp: new Date().toISOString(),
level: 'error',
message: `Network error: ${error.message}`
});
showStatus('Network error occurred', true);
console.error(error);
}
}
fetchButton.addEventListener('click', async () => {
const url = urlInput.value.trim();
if (!url) {
showStatus('Please enter a valid URL', true);
return;
}
showStatus('Fetching video information...');
try {
const response = await fetch('/api/info', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url })
});
const data = await response.json();
if (response.ok) {
thumbnail.src = data.thumbnail;
videoTitle.textContent = data.title;
videoDuration.textContent = formatDuration(data.duration);
formatList.innerHTML = data.formats
.filter(format => format.format_id && format.ext)
.map(format => `
<div class="format-item">
<span>${format.format_note} (${format.ext}) - ${formatFileSize(format.filesize)}</span>
<button onclick="downloadVideo('${url}', '${format.format_id}')">Download</button>
</div>
`)
.join('');
videoInfo.classList.remove('hidden');
status.classList.add('hidden');
logger.addLogEntry({
timestamp: new Date().toISOString(),
level: 'info',
message: `Video information retrieved: ${data.title}`
});
} else {
showStatus(data.error || 'Failed to fetch video info', true);
logger.addLogEntry({
timestamp: new Date().toISOString(),
level: 'error',
message: `Failed to fetch video info: ${data.error || 'Unknown error'}`
});
}
} catch (error) {
showStatus('Network error occurred', true);
logger.addLogEntry({
timestamp: new Date().toISOString(),
level: 'error',
message: `Network error: ${error.message}`
});
}
});
window.downloadVideo = downloadVideo;
// 支持回车键触发获取视频信息
urlInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
fetchButton.click();
}
});
});
以上文件放到相应目录,库文件参考 requirements.txt 即可。
Docker 部署
1. Dockerfile
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY app.py ./
COPY static/css/style.css ./static/css/
COPY static/js/script.js ./static/js/
COPY templates/index.html ./templates/
RUN pip install --no-cache-dir \
flask \
yt-dlp \
werkzeug
RUN mkdir -p /app/temp_downloads && \
chmod 777 /app/temp_downloads
ENV FLASK_APP=app.py
ENV PYTHONUNBUFFERED=1
ENV FLASK_RUN_HOST=0.0.0.0
ENV FLASK_RUN_PORT=9012
EXPOSE 9012
CMD ["python", "-c", "from app import app; app.run(host='0.0.0.0', port=9012)"]
2. requirements.txt
flask
Werkzeug==3.0.1
yt-dlp==2024.3.10
gunicorn==21.2.0
如果你使用这个 .txt, 可以去掉版本号。我指定版本号,是因我 NAS 的 wheel files 存有多个版本
3. 创建 Image 与 Container
# docker build -t yt-dlp .
# docker run -d -p 9012:9012 --name yt-dlp_container yt-dlp
我使用了与 Github 上面项目的相同名字,只是为了方便,字少。
注:在 docker 命令中没有 加入 --restart always, 要编辑一下容器自己添加。
总结:
yt-dlp 是一个功能超强的工具,可以用 cookie file获取身份认证来下载视频,或通过 Mozila 浏览器直接获得 cookie 内容(只是说明上这么说,我没试过)。 Douyin 有 bug 不能下载 , 其它网站没有试。
我有订阅 youtube ,这个工具只是娱乐,或下载 民国 及以前的,其版权已经放弃的影像内容。
请尊重版权
原文地址:https://blog.csdn.net/davenian/article/details/143587587
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!