Files
MediaCrawler/media_downloader/downloader.py
T
程序员阿江(Relakkes) 0ca7b29cf0 feat(media): 重构媒体下载,支持 xhs/dy/ks/bili/wb 五平台
旧实现只覆盖 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 方法

媒体下载失败只记录日志,不中断爬取主流程。
2026-09-17 22:54:22 +08:00

582 lines
26 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.
# -*- 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/downloader.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""统一媒体下载器。
设计要点(与常见实现的关键差异):
1. **零额外请求**:不做 HEAD 探测(大量 CDN 对 HEAD 返回 403/405 或与 GET 不一致),
大小、类型、是否支持续传全部从一次 ``stream GET`` 的响应头判定。
2. **大小校验按 HTTP 语义**:206 响应的 ``Content-Length`` 是"剩余长度",
文件总大小必须取自 ``Content-Range`` 的 ``T``,否则续传场景必然误判失败。
3. **显式 ``Accept-Encoding: identity``**:httpx 默认协商 gzip,若 CDN 压缩,
解码后的字节数与 ``Content-Length`` 不等,大小校验会 100% 误判。
4. **临时文件带 URL 指纹**:URL 变化时自动作废旧片段,避免把新内容拼到旧半成品上。
5. **失败不打断爬虫**:所有异常在 ``download`` 内部收敛为返回值与日志。
"""
from __future__ import annotations
import asyncio
import logging
import os
import random
import re
import shutil
import uuid
from pathlib import Path
from typing import Iterable, Mapping, Optional, Sequence
from urllib.parse import urlsplit
import httpx
from tools.httpx_util import make_async_client
from .ffmpeg import merge_audio_video
from .paths import (
ALLOWED_EXTENSIONS,
build_file_path,
build_media_dir,
build_media_path,
ensure_within,
guess_extension,
redact_url,
sanitize_component,
url_fingerprint,
)
from .types import MediaDownloadError, MediaFatalError, MediaItem, MediaRetryableError, MediaType
logger = logging.getLogger("MediaCrawler.media_downloader")
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
FALLBACK_EXTENSIONS = {MediaType.IMAGE: ".jpg", MediaType.VIDEO: ".mp4"}
_CONTENT_RANGE = re.compile(r"bytes\s+(\d+)-(\d+)/(\d+|\*)", re.IGNORECASE)
# 各媒体类型可接受的 Content-Type 前缀
_EXPECTED_CONTENT_TYPES = {
MediaType.IMAGE: ("image/",),
# 音频单独成轨时素材类型仍是视频(DASH),因此一并接受
MediaType.VIDEO: ("video/", "audio/"),
}
# 这些类型无法用于判定内容是否正常,一律放行
_NEUTRAL_CONTENT_TYPES = (
"application/octet-stream",
"binary/octet-stream",
"application/x-www-form-urlencoded",
# 少数 CDN 会用 application/* 返回真实媒体,不能当成错误页拒掉
"application/mp4",
"application/x-m4a",
)
# 注意:HLS 播放列表(application/x-mpegurl、application/vnd.apple.mpegurl)
# **不能**放进中性名单——m3u8 是文本清单而不是媒体,放行会把它存成几十 KB 的假视频,
# 且会被"已存在即跳过"永久信任。下载器不支持 HLS,交给类型校验直接拒绝。
def is_expected_content_type(content_type: Optional[str], media_type: MediaType) -> bool:
"""判断响应的 Content-Type 是否与媒体类型相容。
CDN 的防盗链页、限流页常以 HTTP 200 + ``text/html`` 返回,
若不拦截会被当作媒体文件落盘,并且因为文件非空而被永久跳过。
"""
if not content_type:
return True
main_type = content_type.split(";")[0].strip().lower()
if not main_type or main_type in _NEUTRAL_CONTENT_TYPES:
return True
expected_prefixes = _EXPECTED_CONTENT_TYPES.get(media_type)
if not expected_prefixes:
return True
return main_type.startswith(expected_prefixes)
class MediaDownloader:
"""把 MediaItem 流式下载到本地磁盘。
平台差异只通过构造参数(proxy/extra_headers)与 MediaItem 字段注入。
``platform`` 仅用于决定落盘目录,下载器不对平台名做任何分支判断
(Referer 等反爬头由平台侧的 ``_media_headers()`` 提供)。
"""
# 这些状态码重试没有意义:403 多为签名过期,404/410 为资源不存在
FATAL_STATUS_CODES = frozenset({400, 401, 403, 404, 405, 410, 451})
def __init__(
self,
platform: str,
*,
proxy: Optional[str] = None,
extra_headers: Optional[Mapping[str, str]] = None,
timeout: float = 60.0,
max_retries: int = 3,
retry_base_delay: float = 1.0,
retry_max_delay: float = 30.0,
max_candidates: int = 3,
base_dir: Optional[Path] = None,
overwrite: bool = False,
) -> None:
self.platform = platform
self.proxy = proxy
self.extra_headers = dict(extra_headers or {})
self.timeout = timeout
self.max_retries = max(0, max_retries)
self.retry_base_delay = retry_base_delay
self.retry_max_delay = retry_max_delay
# 主地址 + 备用地址的总数上限,防止个别平台给出超长 url_list 造成请求放大
self.max_candidates = max(1, max_candidates)
self._base_dir = Path(base_dir) if base_dir is not None else None
self.overwrite = overwrite
# ------------------------------------------------------------------ 对外 API
@property
def base_dir(self) -> Path:
"""媒体根目录,未显式指定时取 config.SAVE_DATA_PATH(为空则 data/)"""
if self._base_dir is None:
import config
self._base_dir = Path(getattr(config, "SAVE_DATA_PATH", "") or "data")
return self._base_dir
async def download_all(self, items: Sequence[MediaItem]) -> list[Path]:
"""串行下载一个帖子的全部媒体,共享同一个 httpx 连接池"""
if not items:
return []
downloaded: list[Path] = []
async with make_async_client(
proxy=self.proxy, follow_redirects=True, timeout=self.timeout
) as client:
for item in items:
path = await self.download(item, client=client)
if path is not None:
downloaded.append(path)
if len(downloaded) < len(items):
logger.warning(
"[download] 本组媒体部分失败: 成功 %d / 共 %d(失败项见上方日志)",
len(downloaded),
len(items),
)
return downloaded
async def download(self, item: MediaItem, client: Optional[httpx.AsyncClient] = None) -> Optional[Path]:
"""下载单个媒体;成功返回最终路径,失败返回 None(不抛异常)。
续传的作用范围是"本次调用内的重试":``.part`` 片段带进程号,
且媒体直链的签名通常跨运行就失效,因此不承诺跨进程续传。
"""
try:
if not self.is_supported_url(item.url):
# 平台响应里偶尔会出现协议相对地址(//cdn/...)或空值,httpx 会直接报错
logger.warning("[download] 非法媒体地址,跳过: %s", redact_url(item.url))
return None
existing = self._find_existing(item)
if existing is not None and not self.overwrite:
logger.info("[download] 已存在,跳过: %s", existing)
return existing
if client is not None:
return await self._download_item(client, item)
async with make_async_client(
proxy=self.proxy, follow_redirects=True, timeout=self.timeout
) as own_client:
return await self._download_item(own_client, item)
except Exception as exc: # 兜底:任何异常都不能打断爬虫
logger.error("[download] 下载失败 %s: %s", redact_url(item.url), exc)
return None
def update_credentials(
self,
proxy: Optional[str] = None,
extra_headers: Optional[Mapping[str, str]] = None,
) -> None:
"""同步平台 client 的最新代理与请求头。
代理池刷新与 Cookie 续期都是就地改 client 的属性,而下载器是惰性创建、
长期复用的;不主动同步就会一直用创建时的旧代理/旧凭证,
表现为 API 请求正常但媒体下载持续失败。
"""
self.proxy = proxy
if extra_headers:
self.extra_headers.update(extra_headers)
def build_path(self, item: MediaItem, extension: Optional[str] = None) -> Path:
"""最终落盘路径(纯函数,便于测试与断言)"""
ext = extension or item.extension or guess_extension(
item.url, default=FALLBACK_EXTENSIONS.get(item.media_type, ".bin")
)
return build_media_path(self.base_dir, self.platform, item.content_id, item.stem, ext)
# ------------------------------------------------------------------ 内部实现
def _find_existing(self, item: MediaItem) -> Optional[Path]:
"""查找已下载完成的同名文件(0 字节视为损坏,重新下载)"""
directory = build_media_dir(self.base_dir, self.platform, item.content_id)
if not directory.is_dir():
return None
if item.extension:
candidate = self.build_path(item, item.extension)
if candidate.is_file() and candidate.stat().st_size > 0:
return candidate
return None
for candidate in sorted(directory.glob(f"{item.stem}.*")):
if candidate.suffix.lower() not in ALLOWED_EXTENSIONS:
continue
if candidate.is_file() and candidate.stat().st_size > 0:
return candidate
return None
async def _download_item(self, client: httpx.AsyncClient, item: MediaItem) -> Optional[Path]:
directory = build_media_dir(self.base_dir, self.platform, item.content_id)
directory.mkdir(parents=True, exist_ok=True)
if item.is_dash:
return await self._download_dash(client, item, directory)
return await self._download_single(
client, item, directory, item.url, item.backup_urls, item.stem, item.extension
)
async def _download_single(
self,
client: httpx.AsyncClient,
item: MediaItem,
target_dir: Path,
url: str,
backup_urls: Iterable[str],
stem: str,
extension: Optional[str] = None,
) -> Optional[Path]:
"""按 主地址 -> 备用地址 的顺序下载到 ``target_dir``,每个地址内部做重试。
``stem`` 与 ``extension`` 由调用方给出:DASH 的两路流会用不同的文件名
落在同一个临时目录里,不能共用 MediaItem 上的 stem。
"""
# 临时文件名也要清洗:stem 来自 MediaItem 的公开契约,未清洗的 stem
# 会把 .part 写到 base_dir 之外(最终路径有 build_file_path 兜底,临时路径没有)
safe_stem = sanitize_component(stem, "media")
# 备用地址数量要有上限:抖音的 url_list 长度不可控,逐个试会放大成几十次请求
candidates = [url, *[candidate for candidate in backup_urls if candidate]][: self.max_candidates]
for candidate in candidates:
if not candidate:
continue
part_path = target_dir / f"{safe_stem}.{url_fingerprint(candidate)}.{os.getpid()}.part"
try:
content_type = await self._fetch_with_retry(client, item, candidate, part_path)
except MediaFatalError as exc:
logger.warning("[download] 放弃地址 %s: %s", redact_url(candidate), exc)
continue
except MediaDownloadError as exc:
logger.warning("[download] 地址重试耗尽 %s: %s", redact_url(candidate), exc)
continue
resolved_extension = extension or guess_extension(
candidate,
content_type=content_type,
explicit=item.extension,
default=FALLBACK_EXTENSIONS.get(item.media_type, ".bin"),
)
try:
final_path = ensure_within(
self.base_dir, build_file_path(target_dir, stem, resolved_extension)
)
except ValueError as exc:
logger.error("[download] %s", exc)
part_path.unlink(missing_ok=True)
return None
os.replace(part_path, final_path)
self._cleanup_parts(target_dir, safe_stem, keep=None)
logger.info(
"[download] 下载完成: %s (%d bytes)",
final_path,
final_path.stat().st_size,
)
return final_path
# 调用内重试会复用 .part 做续传;全部地址都失败后则不再保留,
# 否则每次失败都会在媒体目录里留下一个再也用不上的残片(媒体直链签名每次都会变)
self._cleanup_parts(target_dir, safe_stem)
logger.error("[download] 媒体下载失败,已尝试全部地址: %s", redact_url(url))
return None
@staticmethod
def _cleanup_parts(target_dir: Path, safe_stem: str, keep: Optional[Path] = None) -> None:
"""清掉该 stem 的临时片段。
成功下载后也要清理:某个候选地址中途失败、回退到下一个候选成功时,
前一个候选的残片不会自己消失。
"""
for stale_part in target_dir.glob(f"{safe_stem}.*.part"):
if keep is not None and stale_part == keep:
continue
stale_part.unlink(missing_ok=True)
async def _download_dash(
self, client: httpx.AsyncClient, item: MediaItem, directory: Path
) -> Optional[Path]:
"""DASH 音视频分轨:两路流下载到临时目录,ffmpeg 合流后原子替换"""
tmp_dir = directory / f".tmp-{os.getpid()}-{uuid.uuid4().hex[:8]}"
tmp_dir.mkdir(parents=True, exist_ok=True)
try:
video_path = await self._download_single(
client, item, tmp_dir, item.url, item.backup_urls, "video", ".m4s"
)
if video_path is None:
logger.error("[download] DASH 视频轨下载失败: %s", redact_url(item.url))
return None
audio_url = item.audio_url or ""
audio_path = await self._download_single(
client, item, tmp_dir, audio_url, item.audio_backup_urls, "audio", ".m4s"
)
if audio_path is None:
logger.error("[download] DASH 音频轨下载失败: %s", redact_url(audio_url))
return None
merged_path = tmp_dir / "merged.mp4"
await merge_audio_video(video_path, audio_path, merged_path)
final_path = ensure_within(self.base_dir, self.build_path(item, ".mp4"))
os.replace(merged_path, final_path)
logger.info(
"[download] DASH 合流完成: %s (%d bytes)",
final_path,
final_path.stat().st_size,
)
return final_path
except MediaDownloadError as exc:
logger.error("[download] DASH 合流失败 %s: %s", redact_url(item.url), exc)
return None
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
async def _fetch_with_retry(
self,
client: httpx.AsyncClient,
item: MediaItem,
url: str,
part_path: Path,
) -> Optional[str]:
"""带指数退避重试的单文件抓取,返回 Content-Type"""
last_error: Optional[Exception] = None
for attempt in range(self.max_retries + 1):
try:
return await self._fetch_once(client, item, url, part_path)
except MediaFatalError:
raise
# httpx.InvalidURL 不是 HTTPError 的子类(它是 Exception 的直接子类),
# 漏掉它会让一个畸形地址直接终止整条候选回退链
except (MediaRetryableError, httpx.HTTPError, httpx.InvalidURL, OSError) as exc:
last_error = exc
if attempt < self.max_retries:
delay = self._retry_delay(attempt)
logger.warning(
"[download] 第 %d/%d 次重试 %s(%.1fs 后): %s",
attempt + 1,
self.max_retries,
redact_url(url),
delay,
exc,
)
await asyncio.sleep(delay)
if last_error is None:
raise MediaRetryableError("未知错误")
# 部分网络异常的 str() 为空,带上类型名才有排查线索
raise MediaRetryableError(f"{type(last_error).__name__}: {last_error}")
def _retry_delay(self, attempt: int) -> float:
"""指数退避 + full jitter,避免多个任务同步重试"""
ceiling = min(self.retry_base_delay * (2**attempt), self.retry_max_delay)
return random.uniform(0, ceiling)
async def _fetch_once(
self,
client: httpx.AsyncClient,
item: MediaItem,
url: str,
part_path: Path,
) -> Optional[str]:
"""执行一次流式 GET,成功返回 Content-Type。
续传语义:
- 本地存在 ``.part`` 片段 -> 带 ``Range`` 请求
- 206 -> 追加写;200(服务端忽略 Range)-> 截断重写;416 -> 片段失效,重来
"""
resume_from = part_path.stat().st_size if part_path.exists() else 0
headers = self._build_headers(item)
if resume_from > 0:
headers["Range"] = f"bytes={resume_from}-"
async with client.stream("GET", url, headers=headers) as response:
status = response.status_code
if status in self.FATAL_STATUS_CODES:
raise MediaFatalError(f"HTTP {status}")
if status == 416:
part_path.unlink(missing_ok=True)
raise MediaRetryableError("HTTP 416:本地残留片段已失效,重新下载")
if status not in (200, 206):
raise MediaRetryableError(f"HTTP {status}")
content_type = response.headers.get("content-type")
if not is_expected_content_type(content_type, item.media_type):
# 典型的防盗链/限流错误页:HTTP 200 但返回 text/html。
# 此时尚未读取响应体,本地 .part 仍是合法前缀,保留以便下次续传
raise MediaRetryableError(
f"响应类型 {content_type} 与媒体类型 {item.media_type.value} 不符,疑似错误页"
)
if status == 200 and resume_from > 0:
logger.debug("[download] 服务端不支持 Range,从头下载: %s", redact_url(url))
resume_from = 0
content_range = self._parse_content_range(response)
if status == 206:
# RFC 9110 要求 206 必须带可解析的 Content-Range。缺失时服务端的实际行为
# 不可预期(有 CDN 会对带 Range 的请求回 206 + 全量体),此时若继续 append
# 会把整份内容接到本地片段后面,而"总长"又是按剩余长度反推的,大小校验
# 反而会通过 —— 只能拒绝并丢弃片段重下。
if content_range is None:
part_path.unlink(missing_ok=True)
raise MediaRetryableError("206 响应缺少 Content-Range,丢弃片段重新下载")
if content_range[0] != resume_from:
# 起点不符会得到内容错位或头部缺失的文件;未发 Range(resume_from=0)
# 却收到非 0 起点的 206 同样要拒绝
part_path.unlink(missing_ok=True)
raise MediaRetryableError(
f"服务端返回的 Range 起点 {content_range[0]} 与请求的 {resume_from} 不符"
)
if content_range[2] is None:
# total 为 * 表示服务端只返回了区间的一部分且不告知总长,
# 这种响应无法确认完整性(落盘的就是截断文件),丢弃片段后
# 以无 Range 的方式重新请求完整内容
part_path.unlink(missing_ok=True)
raise MediaRetryableError("206 的 Content-Range 未给出总长(*),重新完整下载")
use_append = status == 206 and resume_from > 0
total_size = self._resolve_total_size(content_range, response, status)
# 服务端若无视 identity 强制压缩,落盘的是解码后的字节,与 Content-Length 不等
encoded = response.headers.get("content-encoding", "").strip().lower() not in ("", "identity")
written = resume_from if use_append else 0
mode = "ab" if use_append else "wb"
with open(part_path, mode) as file_obj:
# 必须使用不带 chunk_size 的 aiter_bytes():传入 chunk_size 时 httpx 会
# 缓冲满该长度才产出数据,响应中途断开会让已接收的字节全部丢失,
# 断点续传与"截断后重试"都会退化成从头再来。
async for chunk in response.aiter_bytes():
if not chunk:
continue
file_obj.write(chunk)
written += len(chunk)
if written == 0:
part_path.unlink(missing_ok=True)
raise MediaRetryableError("响应体为空")
if encoded:
logger.warning("[download] 服务端返回压缩内容,跳过大小校验: %s", redact_url(url))
elif total_size is None:
logger.warning(
"[download] 响应无 Content-Length,跳过大小校验: %s", redact_url(url)
)
elif written != total_size:
raise MediaRetryableError(f"大小不符:实际 {written} 字节,期望 {total_size} 字节")
return content_type
@staticmethod
def _parse_content_range(response: httpx.Response) -> Optional[tuple[int, int, Optional[int]]]:
"""解析 ``Content-Range: bytes 100-999/1000``,返回 (start, end, total)"""
match = _CONTENT_RANGE.search(response.headers.get("content-range", ""))
if not match:
return None
total = None if match.group(3) == "*" else int(match.group(3))
return int(match.group(1)), int(match.group(2)), total
@staticmethod
def _resolve_total_size(
content_range: Optional[tuple[int, int, Optional[int]]],
response: httpx.Response,
status: int,
) -> Optional[int]:
"""解析文件总大小。
206 响应的 Content-Length 只是"剩余长度",文件总长只能取自 Content-Range 的 total。
total 为 ``*``(未知)时不能拿"剩余长度 + 已下载量"反推:服务端有权只返回请求区间的
一部分,反推出来的"总长"会让截断的文件恰好通过校验。
"""
if status == 206:
return content_range[2] if content_range else None
return MediaDownloader._parse_content_length(response)
@staticmethod
def _parse_content_length(response: httpx.Response) -> Optional[int]:
# RFC 7230:存在 Transfer-Encoding 时必须忽略 Content-Length,
# 否则一个 chunked 的完整响应会因为服务端多写的 CL 被误判为"大小不符"
if response.headers.get("transfer-encoding"):
return None
raw = response.headers.get("content-length", "")
if raw.isdigit():
return int(raw)
return None
def _build_headers(self, item: MediaItem) -> dict:
"""拼装请求头:通用 UA + 调用方覆盖 + 单项覆盖。
平台特有的反爬头(Referer 等)由平台侧通过 ``extra_headers`` 注入,
下载器不认识任何平台名。Cookie 同理不会出现在默认头里,避免所有平台的
媒体请求都带上账号凭证;注意 httpx 在重定向时一律剥离 Cookie(同源跳转也会剥),
所以注入的 Cookie 只对首跳生效。
"""
headers = {
"User-Agent": DEFAULT_USER_AGENT,
"Accept": "*/*",
# 必须显式声明不压缩:否则解码后字节数与 Content-Length 不等,大小校验必然误判
"Accept-Encoding": "identity",
}
headers.update(self.extra_headers)
if item.headers:
headers.update(item.headers)
return headers
@staticmethod
def is_supported_url(url) -> bool:
"""URL 是否为可下载的 http(s) 地址。
平台响应里的字段可能是 int/list/dict,``urlsplit`` 对非字符串会抛
TypeError/AttributeError,这里必须一并挡住,否则会击穿 "download 不抛异常" 的契约。
"""
if not isinstance(url, str):
return False
try:
parts = urlsplit(url)
except ValueError:
return False
return parts.scheme in ("http", "https") and bool(parts.netloc)