mirror of
https://github.com/NanmiCoder/MediaCrawler.git
synced 2026-09-23 06:28:12 +08:00
旧实现只覆盖 4 个平台,且把整个文件读进内存、无重试与完整性校验,
代码按平台复制粘贴了 4 份。本次用统一下载器替换:
- 新增 media_downloader/:流式写入、Range 续传、指数退避重试、大小校验、
路径穿越防护;B 站 DASH 音视频分轨下载后交由 ffmpeg 无损合流
- 新增 media_platform/<平台>/media.py:从平台原始响应提取媒体地址,
与下载器解耦;快手首次接入下载能力
- 开关:config.ENABLE_GET_MEDIA 与 --get_media,并打通 API/WebUI;
同时修正旧配置项 ENABLE_GET_MEIDAS 的拼写
- 落盘按帖子聚合:{SAVE_DATA_PATH 或 data}/{platform}/media/{内容ID}/
- B 站装好 ffmpeg 时走 DASH 最高画质,否则降级 mp4 直链(产物 video-durl.mp4,
避免低清文件阻塞后续的高清路径)
- 删除 4 个 *_store_media.py、AbstractStoreImage/Video 及各 client 的媒体 GET 方法
媒体下载失败只记录日志,不中断爬取主流程。
198 lines
6.4 KiB
Python
198 lines
6.4 KiB
Python
# -*- coding: utf-8 -*-
|
||
# Copyright (c) 2025 relakkes@gmail.com
|
||
#
|
||
# This file is part of MediaCrawler project.
|
||
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_downloader/paths.py
|
||
# GitHub: https://github.com/NanmiCoder
|
||
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
|
||
#
|
||
|
||
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
|
||
# 1. 不得用于任何商业用途。
|
||
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
|
||
# 3. 不得进行大规模爬取或对平台造成运营干扰。
|
||
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
|
||
# 5. 不得用于任何非法或不当的用途。
|
||
#
|
||
# 详细许可条款请参阅项目根目录下的LICENSE文件。
|
||
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
|
||
|
||
"""媒体落盘路径规则:目录布局、文件名清洗、扩展名推断。
|
||
|
||
本模块全部为纯函数,不发起任何 IO(``ensure_within`` 会做路径解析)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import re
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
from urllib.parse import urlsplit
|
||
|
||
# 允许落盘的扩展名白名单,白名单之外的推断结果一律丢弃
|
||
ALLOWED_EXTENSIONS = frozenset(
|
||
{
|
||
".jpg",
|
||
".jpeg",
|
||
".png",
|
||
".gif",
|
||
".webp",
|
||
".mp4",
|
||
".m4s",
|
||
".m4a",
|
||
".mp3",
|
||
".flv",
|
||
".mov",
|
||
}
|
||
)
|
||
|
||
# Content-Type -> 扩展名(只取主类型,忽略 charset 等参数)
|
||
CONTENT_TYPE_EXTENSIONS = {
|
||
"image/jpeg": ".jpg",
|
||
"image/jpg": ".jpg",
|
||
"image/png": ".png",
|
||
"image/gif": ".gif",
|
||
"image/webp": ".webp",
|
||
"video/mp4": ".mp4",
|
||
"video/quicktime": ".mov",
|
||
"video/x-flv": ".flv",
|
||
"video/x-m4v": ".mp4",
|
||
"audio/mp4": ".m4a",
|
||
"audio/x-m4a": ".m4a",
|
||
"audio/mpeg": ".mp3",
|
||
"audio/aac": ".m4a",
|
||
}
|
||
|
||
_ILLEGAL_CHARS = re.compile(r"[^A-Za-z0-9._-]")
|
||
# 从 URL 路径里提取扩展名,兼容 "img.jpg!large" 这类 CDN 后缀写法
|
||
_URL_EXTENSION = re.compile(r"\.([A-Za-z0-9]{2,5})(?:!.*)?$")
|
||
_WINDOWS_RESERVED = frozenset(
|
||
{
|
||
"CON",
|
||
"PRN",
|
||
"AUX",
|
||
"NUL",
|
||
*(f"COM{index}" for index in range(1, 10)),
|
||
*(f"LPT{index}" for index in range(1, 10)),
|
||
}
|
||
)
|
||
MAX_COMPONENT_LENGTH = 64
|
||
MEDIA_DIR_NAME = "media"
|
||
|
||
|
||
def url_fingerprint(url: str) -> str:
|
||
"""URL 的短指纹,用于命名下载中的临时文件,URL 变化时自动作废旧片段"""
|
||
return hashlib.sha1(url.encode("utf-8")).hexdigest()[:8]
|
||
|
||
|
||
def redact_url(url) -> str:
|
||
"""日志安全的 URL 表示:去掉 query(媒体直链常带签名参数)。
|
||
|
||
对非字符串输入返回占位符——它常被用在异常处理路径里,自身抛异常会掩盖原始错误。
|
||
"""
|
||
if not isinstance(url, str):
|
||
return "<invalid-url>"
|
||
try:
|
||
parts = urlsplit(url)
|
||
except ValueError:
|
||
return "<invalid-url>"
|
||
if not parts.scheme or not parts.netloc:
|
||
return "<invalid-url>"
|
||
return f"{parts.scheme}://{parts.netloc}{parts.path}"
|
||
|
||
|
||
def sanitize_component(value: str, fallback: str = "unknown") -> str:
|
||
"""清洗单个路径片段,防止路径穿越与非法字符。
|
||
|
||
- 只保留 ``[A-Za-z0-9._-]``,其余替换为 ``_``
|
||
- 去掉开头的 ``.``,避免隐藏文件与 ``..``
|
||
- 命中 Windows 保留设备名时加前缀
|
||
- 超长截断
|
||
"""
|
||
cleaned = _ILLEGAL_CHARS.sub("_", (value or "").strip())
|
||
cleaned = cleaned.lstrip(".")
|
||
cleaned = cleaned.replace("..", "_")
|
||
cleaned = cleaned[:MAX_COMPONENT_LENGTH]
|
||
if not cleaned:
|
||
return fallback
|
||
if cleaned.split(".")[0].upper() in _WINDOWS_RESERVED:
|
||
cleaned = f"_{cleaned}"
|
||
return cleaned
|
||
|
||
|
||
def guess_extension(
|
||
url: str = "",
|
||
content_type: Optional[str] = None,
|
||
explicit: Optional[str] = None,
|
||
default: str = ".bin",
|
||
) -> str:
|
||
"""推断文件扩展名,优先级:显式指定 > URL 后缀 > Content-Type > 默认值。
|
||
|
||
只返回白名单内的扩展名(default 除外,由调用方保证合理)。
|
||
"""
|
||
if explicit:
|
||
normalized = explicit if explicit.startswith(".") else f".{explicit}"
|
||
normalized = normalized.lower()
|
||
if normalized in ALLOWED_EXTENSIONS:
|
||
return normalized
|
||
|
||
if url:
|
||
path = urlsplit(url).path
|
||
match = _URL_EXTENSION.search(path)
|
||
if match:
|
||
candidate = f".{match.group(1).lower()}"
|
||
if candidate in ALLOWED_EXTENSIONS:
|
||
return candidate
|
||
|
||
if content_type:
|
||
main_type = content_type.split(";")[0].strip().lower()
|
||
candidate = CONTENT_TYPE_EXTENSIONS.get(main_type)
|
||
if candidate:
|
||
return candidate
|
||
|
||
return default
|
||
|
||
|
||
def build_media_dir(base_dir: Path, platform: str, content_id) -> Path:
|
||
"""媒体目录:``{base}/{platform}/media/{content_id}``
|
||
|
||
content_id 来自外部响应,可能是 int 或 None,先归一成字符串再清洗。
|
||
"""
|
||
safe_platform = sanitize_component(platform, "unknown")
|
||
raw_content_id = "" if content_id is None else str(content_id)
|
||
safe_content_id = sanitize_component(
|
||
raw_content_id, fallback=hashlib.sha1(raw_content_id.encode("utf-8")).hexdigest()[:16]
|
||
)
|
||
return Path(base_dir) / safe_platform / MEDIA_DIR_NAME / safe_content_id
|
||
|
||
|
||
def build_file_path(directory: Path, stem: str, extension: str) -> Path:
|
||
"""目录内的媒体文件路径:``{directory}/{stem}{ext}``"""
|
||
safe_stem = sanitize_component(stem, "media")
|
||
normalized_ext = extension if extension.startswith(".") else f".{extension}"
|
||
return Path(directory) / f"{safe_stem}{normalized_ext.lower()}"
|
||
|
||
|
||
def build_media_path(
|
||
base_dir: Path,
|
||
platform: str,
|
||
content_id: str,
|
||
stem: str,
|
||
extension: str,
|
||
) -> Path:
|
||
"""媒体文件最终路径:``{base}/{platform}/media/{content_id}/{stem}{ext}``"""
|
||
return build_file_path(build_media_dir(base_dir, platform, content_id), stem, extension)
|
||
|
||
|
||
def ensure_within(base_dir: Path, target: Path) -> Path:
|
||
"""校验目标路径位于 base_dir 之内,返回解析后的绝对路径。
|
||
|
||
防止外部响应里的 content_id 构造出越界路径。
|
||
"""
|
||
base_resolved = Path(base_dir).resolve()
|
||
target_resolved = Path(target).resolve()
|
||
if not target_resolved.is_relative_to(base_resolved):
|
||
raise ValueError(f"媒体落盘路径越界: {target_resolved} 不在 {base_resolved} 之内")
|
||
return target_resolved
|