Files
MediaCrawler/media_platform/xhs/media.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

161 lines
5.5 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_platform/xhs/media.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""小红书媒体地址提取。
输入是 ``/api/sns/web/v1/feed`` 返回的原始 ``note_card``snake_case 字段),
输出是下载器可直接消费的 ``MediaItem`` 列表。无 IO(会读取 config 决定国际版策略)。
"""
from __future__ import annotations
from typing import Dict, List
import config
from media_downloader import MediaItem, MediaType
# 小红书视频 CDNorigin_video_key 拼在此域名后可拿到无水印源片
XHS_VIDEO_CDN_HOST = "https://sns-video-bd.xhscdn.com"
def _as_dict(value) -> Dict:
"""接口响应里同名字段可能是字符串或 None,统一收敛为 dict"""
return value if isinstance(value, dict) else {}
def _extract_origin_video_key(video_dict: Dict) -> str:
"""从 video.consumer 中取无水印源片 key(兼容 snake_case 与 camelCase"""
consumer = _as_dict(video_dict.get("consumer"))
return consumer.get("origin_video_key") or consumer.get("originVideoKey") or ""
def extract_video_urls(note_item: Dict) -> List[str]:
"""提取视频地址候选列表,按可用性排序。
优先无水印源片(origin_video_key),其余为带水印的 h264 master_url 备用。
国际版(rednote)的 CDN 域名与国内不同,不能拼接 xhscdn 域名。
"""
if note_item.get("type") != "video":
return []
video_dict = _as_dict(note_item.get("video"))
if not video_dict:
return []
urls: List[str] = []
origin_video_key = _extract_origin_video_key(video_dict)
if origin_video_key and not getattr(config, "XHS_INTERNATIONAL", False):
urls.append(f"{XHS_VIDEO_CDN_HOST}/{origin_video_key}")
stream = _as_dict(_as_dict(video_dict.get("media")).get("stream"))
for item in stream.get("h264") or []:
master_url = item.get("master_url") if isinstance(item, dict) else None
if master_url and master_url not in urls:
urls.append(master_url)
return urls
def extract_image_urls(note_item: Dict) -> List[str]:
"""提取图文笔记的图片地址(按原始顺序)"""
urls: List[str] = []
for image_item in note_item.get("image_list") or []:
if not isinstance(image_item, dict):
continue
url = image_item.get("url_default") or image_item.get("url") or ""
if isinstance(url, str) and url:
urls.append(url)
return urls
def extract_cover_url(note_item: Dict) -> str:
"""提取封面地址。
小红书不同接口返回的封面字段位置不一致,这里按可靠性依次兜底;
视频笔记的 image_list 通常就是封面图,作为最后兜底。
"""
video_dict = _as_dict(note_item.get("video"))
cover_dict = _as_dict(note_item.get("cover"))
video_cover = _as_dict(video_dict.get("cover"))
candidates = [
video_cover.get("url_default"),
video_cover.get("url"),
cover_dict.get("url_default"),
cover_dict.get("url"),
]
for candidate in candidates:
if candidate:
return candidate
images = extract_image_urls(note_item)
return images[0] if images else ""
def build_media_items(note_item: Dict) -> List[MediaItem]:
"""把一个笔记转换成待下载的媒体任务列表。
- 视频笔记:封面 + 视频。其 image_list 通常只有封面一张,不再按图集重复下载
- 图文笔记:各张图片(第一张即封面,因此不再单独下载一份 cover)
"""
if not isinstance(note_item, dict):
return []
content_id = note_item.get("note_id") or note_item.get("id") or ""
if not content_id:
return []
items: List[MediaItem] = []
if note_item.get("type") == "video":
cover_url = extract_cover_url(note_item)
if cover_url:
items.append(
MediaItem(
url=cover_url,
media_type=MediaType.IMAGE,
content_id=content_id,
stem="cover",
)
)
video_urls = extract_video_urls(note_item)
if video_urls:
items.append(
MediaItem(
url=video_urls[0],
backup_urls=tuple(video_urls[1:]),
media_type=MediaType.VIDEO,
content_id=content_id,
stem="video",
)
)
else:
for index, image_url in enumerate(extract_image_urls(note_item), start=1):
items.append(
MediaItem(
url=image_url,
media_type=MediaType.IMAGE,
content_id=content_id,
stem=f"{index:03d}",
)
)
return items