mirror of
https://github.com/NanmiCoder/MediaCrawler.git
synced 2026-09-20 03:17:55 +08:00
小红书把 DASH 分档名从按编码命名(h264/h265/av1)改成了按内部档位命名 (EF4/EF5/EF6/EF7),同时 video.consumer 里的 origin_video_key 也没了。 extract_video_urls 写死 stream["h264"],两条路都取不到地址,于是静默返回 空列表:笔记照常入库,但 video_url 为空、媒体只下到封面,全程不报错。 - media.py: 遍历 stream 下所有列表型分档,不再写死分档名;同一分档内含多个 分辨率(720P→4K),按 (height, avg_bitrate) 降序,最高档作主地址,其余作为 备用地址交给下载器回退;origin_video_key 分支保留 - 测试 fixture 换成真实响应结构,旧的 h264 结构另存一份确保兼容性不被改坏 验证:真实响应下旧实现取到 0 条、新实现 5 条;真实下载得到 4K 视频 (54079413 字节 / hevc 3840x2160 / 281.87s),旧结构与图文笔记行为不变。
823 lines
28 KiB
Python
823 lines
28 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/tests/test_media_extractors.py
|
||
# GitHub: https://github.com/NanmiCoder
|
||
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
|
||
#
|
||
|
||
"""各平台媒体地址提取器单测。
|
||
|
||
每个平台的 ``media.py`` 都是纯函数,这里用最小化的真实结构 fixture 直接断言
|
||
产出的 ``MediaItem`` 列表,不发起任何网络请求。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import config
|
||
import pytest
|
||
|
||
from media_downloader import MediaType
|
||
from media_platform.bilibili import media as bili_media
|
||
from media_platform.douyin import media as douyin_media
|
||
from media_platform.kuaishou import media as kuaishou_media
|
||
from media_platform.weibo import media as weibo_media
|
||
from media_platform.xhs import media as xhs_media
|
||
|
||
# --------------------------------------------------------------------------- xhs fixture
|
||
|
||
# 旧结构:分档按编码命名(h264),consumer 带无水印源片 key。
|
||
# 保留这份 fixture 是为了确保兼容性不被改坏。
|
||
XHS_VIDEO_NOTE = {
|
||
"note_id": "video-note-1",
|
||
"type": "video",
|
||
"image_list": [{"url_default": "https://sns-webpic.xhscdn.com/img-cover"}],
|
||
"cover": {"url_default": "https://sns-webpic.xhscdn.com/top-cover"},
|
||
"video": {
|
||
"consumer": {"origin_video_key": "spec/abc/def"},
|
||
"media": {"stream": {"h264": [{"master_url": "https://sns-video-hw.xhscdn.com/master"}]}},
|
||
"cover": {"url_default": "https://sns-webpic.xhscdn.com/video-cover"},
|
||
},
|
||
}
|
||
|
||
# 上游当前结构(2026-09 真实响应裁剪):分档改按内部档位命名(EF4/EF5/EF6/EF7),
|
||
# 同一分档内含多个分辨率,consumer 里不再有 origin_video_key。
|
||
# 回归背景:写死 stream["h264"] 时这里的视频地址会被静默取空,只下到封面。
|
||
XHS_VIDEO_NOTE_NEW_SHAPE = {
|
||
"note_id": "video-note-2",
|
||
"type": "video",
|
||
"image_list": [{"url_default": "https://sns-webpic.xhscdn.com/img-cover-2"}],
|
||
"video": {
|
||
"consumer": {"chapters": []},
|
||
"media": {
|
||
"stream": {
|
||
"EF4": [
|
||
{
|
||
"master_url": "https://sns-video-v4.xhscdn.com/720p-ef4",
|
||
"backup_urls": ["https://sns-bak-v1.xhscdn.com/720p-ef4"],
|
||
"width": 1280,
|
||
"height": 720,
|
||
"avg_bitrate": 414724,
|
||
}
|
||
],
|
||
"EF5": [
|
||
{
|
||
"master_url": "https://sns-video-v4.xhscdn.com/1080p-ef5",
|
||
"width": 1920,
|
||
"height": 1080,
|
||
"avg_bitrate": 635797,
|
||
},
|
||
{
|
||
"master_url": "https://sns-video-v4.xhscdn.com/4k-ef5",
|
||
"width": 3840,
|
||
"height": 2160,
|
||
"avg_bitrate": 1534891,
|
||
},
|
||
],
|
||
"EF6": [],
|
||
"EF7": [],
|
||
}
|
||
},
|
||
},
|
||
}
|
||
|
||
XHS_IMAGE_NOTE = {
|
||
"note_id": "image-note-1",
|
||
"type": "normal",
|
||
"image_list": [
|
||
{"url_default": "https://sns-webpic.xhscdn.com/1"},
|
||
{"url_default": "https://sns-webpic.xhscdn.com/2"},
|
||
{"url_default": "https://sns-webpic.xhscdn.com/3"},
|
||
],
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------- xhs 用例
|
||
|
||
|
||
def test_xhs_video_note_yields_cover_and_video():
|
||
items = xhs_media.build_media_items(XHS_VIDEO_NOTE)
|
||
|
||
assert [item.stem for item in items] == ["cover", "video"]
|
||
assert [item.media_type for item in items] == [MediaType.IMAGE, MediaType.VIDEO]
|
||
|
||
cover, video = items
|
||
assert cover.url == "https://sns-webpic.xhscdn.com/video-cover"
|
||
assert video.content_id == "video-note-1"
|
||
# 优先无水印源片,带水印的 master_url 作为备用
|
||
assert video.url == "https://sns-video-bd.xhscdn.com/spec/abc/def"
|
||
assert video.backup_urls == ("https://sns-video-hw.xhscdn.com/master",)
|
||
|
||
|
||
def test_xhs_video_note_falls_back_to_top_level_cover():
|
||
note = {**XHS_VIDEO_NOTE, "video": {**XHS_VIDEO_NOTE["video"], "cover": {}}}
|
||
note.pop("cover", None)
|
||
note["cover"] = {"url_default": "https://sns-webpic.xhscdn.com/top-cover"}
|
||
|
||
items = xhs_media.build_media_items(note)
|
||
|
||
assert items[0].url == "https://sns-webpic.xhscdn.com/top-cover"
|
||
|
||
|
||
def test_xhs_video_note_falls_back_to_first_image_when_no_cover():
|
||
note = {
|
||
**XHS_VIDEO_NOTE,
|
||
"video": {**XHS_VIDEO_NOTE["video"], "cover": {}},
|
||
"cover": {},
|
||
}
|
||
|
||
items = xhs_media.build_media_items(note)
|
||
|
||
assert items[0].stem == "cover"
|
||
assert items[0].url == "https://sns-webpic.xhscdn.com/img-cover"
|
||
|
||
|
||
def test_xhs_video_note_without_any_cover_still_yields_video():
|
||
note = {
|
||
**XHS_VIDEO_NOTE,
|
||
"video": {**XHS_VIDEO_NOTE["video"], "cover": {}},
|
||
"cover": {},
|
||
"image_list": [],
|
||
}
|
||
|
||
items = xhs_media.build_media_items(note)
|
||
|
||
assert [item.stem for item in items] == ["video"]
|
||
|
||
|
||
def test_xhs_video_note_without_origin_key_uses_master_url():
|
||
note = {
|
||
**XHS_VIDEO_NOTE,
|
||
"video": {"media": {"stream": {"h264": [{"master_url": "https://sns-video-hw.xhscdn.com/master"}]}}},
|
||
}
|
||
|
||
items = xhs_media.build_media_items(note)
|
||
|
||
video = next(item for item in items if item.media_type == MediaType.VIDEO)
|
||
assert video.url == "https://sns-video-hw.xhscdn.com/master"
|
||
assert video.backup_urls == ()
|
||
|
||
|
||
def test_xhs_video_note_new_stream_buckets():
|
||
"""上游把分档名从 h264 换成 EF4/EF5/... 之后仍要能取到地址(本次回归核心)"""
|
||
urls = xhs_media.extract_video_urls(XHS_VIDEO_NOTE_NEW_SHAPE)
|
||
|
||
# 分辨率高的排前面作主地址,其余按清晰度降序作为备用
|
||
assert urls == [
|
||
"https://sns-video-v4.xhscdn.com/4k-ef5",
|
||
"https://sns-video-v4.xhscdn.com/1080p-ef5",
|
||
"https://sns-video-v4.xhscdn.com/720p-ef4",
|
||
]
|
||
|
||
|
||
def test_xhs_video_note_new_shape_builds_video_item():
|
||
"""新结构下必须产出 video 任务,而不是只剩封面"""
|
||
items = xhs_media.build_media_items(XHS_VIDEO_NOTE_NEW_SHAPE)
|
||
|
||
assert [item.stem for item in items] == ["cover", "video"]
|
||
video = items[1]
|
||
assert video.media_type == MediaType.VIDEO
|
||
assert video.url == "https://sns-video-v4.xhscdn.com/4k-ef5"
|
||
assert video.backup_urls == (
|
||
"https://sns-video-v4.xhscdn.com/1080p-ef5",
|
||
"https://sns-video-v4.xhscdn.com/720p-ef4",
|
||
)
|
||
|
||
|
||
def test_xhs_video_note_missing_dimensions_keeps_all_urls():
|
||
"""缺 height/avg_bitrate 时不能抛异常,也不能丢地址"""
|
||
note = {
|
||
"type": "video",
|
||
"video": {
|
||
"media": {"stream": {"EF5": [{"master_url": "https://a"}, {"master_url": "https://b"}]}}
|
||
},
|
||
}
|
||
|
||
assert sorted(xhs_media.extract_video_urls(note)) == ["https://a", "https://b"]
|
||
|
||
|
||
def test_xhs_international_does_not_build_xhscdn_url(monkeypatch):
|
||
monkeypatch.setattr(config, "XHS_INTERNATIONAL", True)
|
||
|
||
items = xhs_media.build_media_items(XHS_VIDEO_NOTE)
|
||
|
||
video = next(item for item in items if item.media_type == MediaType.VIDEO)
|
||
assert video.url == "https://sns-video-hw.xhscdn.com/master"
|
||
assert "sns-video-bd" not in video.url
|
||
|
||
|
||
def test_xhs_image_note_yields_numbered_images_without_duplicate_cover():
|
||
items = xhs_media.build_media_items(XHS_IMAGE_NOTE)
|
||
|
||
assert [item.stem for item in items] == ["001", "002", "003"]
|
||
assert [item.url for item in items] == [
|
||
"https://sns-webpic.xhscdn.com/1",
|
||
"https://sns-webpic.xhscdn.com/2",
|
||
"https://sns-webpic.xhscdn.com/3",
|
||
]
|
||
assert all(item.media_type is MediaType.IMAGE for item in items)
|
||
assert not any(item.stem == "cover" for item in items)
|
||
|
||
|
||
def test_xhs_image_note_skips_entries_without_url():
|
||
note = {
|
||
"note_id": "n1",
|
||
"type": "normal",
|
||
"image_list": [{"url_default": ""}, {"url": "https://cdn/x"}, {"no_url": True}],
|
||
}
|
||
|
||
items = xhs_media.build_media_items(note)
|
||
|
||
assert [item.url for item in items] == ["https://cdn/x"]
|
||
|
||
|
||
def test_xhs_missing_note_id_returns_empty():
|
||
assert xhs_media.build_media_items({"type": "video"}) == []
|
||
|
||
|
||
@pytest.mark.parametrize("payload", [None, "not-a-dict", [], 123])
|
||
def test_xhs_non_dict_input_is_ignored(payload):
|
||
assert xhs_media.build_media_items(payload) == []
|
||
|
||
|
||
# --------------------------------------------------------------------------- douyin fixture
|
||
|
||
DY_VIDEO_ITEM = {
|
||
"aweme_id": "7300000000000000001",
|
||
"desc": "video",
|
||
"video": {
|
||
"play_addr_h264": {"url_list": ["https://cdn-a/h264", "https://cdn-b/h264"]},
|
||
"play_addr": {"url_list": ["https://cdn-a/play"]},
|
||
"raw_cover": {"url_list": ["https://cdn-a/cover", "https://cdn-b/cover"]},
|
||
},
|
||
}
|
||
|
||
DY_IMAGE_ITEM = {
|
||
"aweme_id": "7300000000000000002",
|
||
"desc": "images",
|
||
"images": [
|
||
{"url_list": ["https://cdn-a/img1-small", "https://cdn-b/img1"]},
|
||
{"url_list": ["https://cdn-a/img2-small", "https://cdn-b/img2"]},
|
||
],
|
||
"video": {"raw_cover": {"url_list": ["https://cdn/cover"]}},
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------- douyin 用例
|
||
|
||
|
||
def test_douyin_video_item_yields_cover_and_video():
|
||
items = douyin_media.build_media_items(DY_VIDEO_ITEM)
|
||
|
||
assert [item.stem for item in items] == ["cover", "video"]
|
||
cover, video = items
|
||
assert cover.url == "https://cdn-b/cover"
|
||
# url_list 最后一个通常无水印,其余作为备用
|
||
assert video.url == "https://cdn-b/h264"
|
||
assert video.backup_urls == ("https://cdn-a/h264",)
|
||
assert video.media_type is MediaType.VIDEO
|
||
|
||
|
||
def test_douyin_video_falls_back_to_lower_quality_addr():
|
||
item = {
|
||
"aweme_id": "1",
|
||
"video": {"play_addr_256": {"url_list": ["https://cdn/only", "https://cdn/best"]}},
|
||
}
|
||
|
||
items = douyin_media.build_media_items(item)
|
||
|
||
video = next(entry for entry in items if entry.media_type is MediaType.VIDEO)
|
||
assert video.url == "https://cdn/best"
|
||
assert video.backup_urls == ("https://cdn/only",)
|
||
|
||
|
||
def test_douyin_video_falls_back_to_bit_rate_list():
|
||
item = {
|
||
"aweme_id": "1",
|
||
"video": {
|
||
"bit_rate": [
|
||
{"bit_rate": 100, "play_addr": {"url_list": ["https://cdn/low"]}},
|
||
{"bit_rate": 900, "play_addr": {"url_list": ["https://cdn/high"]}},
|
||
]
|
||
},
|
||
}
|
||
|
||
items = douyin_media.build_media_items(item)
|
||
|
||
video = next(entry for entry in items if entry.media_type is MediaType.VIDEO)
|
||
assert video.url == "https://cdn/high"
|
||
|
||
|
||
def test_douyin_image_item_yields_numbered_images_without_cover():
|
||
items = douyin_media.build_media_items(DY_IMAGE_ITEM)
|
||
|
||
assert [item.stem for item in items] == ["001", "002"]
|
||
assert [item.url for item in items] == ["https://cdn-b/img1", "https://cdn-b/img2"]
|
||
assert not any(item.stem == "cover" for item in items)
|
||
|
||
|
||
def test_douyin_empty_video_yields_nothing():
|
||
assert douyin_media.build_media_items({"aweme_id": "1", "video": {}}) == []
|
||
|
||
|
||
def test_douyin_cover_key_priority_is_locked():
|
||
"""封面字段有明确优先级,不能只钉住第一个键"""
|
||
detail = {
|
||
"aweme_id": "1",
|
||
"video": {
|
||
"raw_cover": {"url_list": ["https://cdn/raw"]},
|
||
"origin_cover": {"url_list": ["https://cdn/origin"]},
|
||
"cover": {"url_list": ["https://cdn/cover"]},
|
||
"dynamic_cover": {"url_list": ["https://cdn/dynamic"]},
|
||
},
|
||
}
|
||
|
||
assert douyin_media.extract_cover_url(detail) == "https://cdn/raw"
|
||
del detail["video"]["raw_cover"]
|
||
assert douyin_media.extract_cover_url(detail) == "https://cdn/origin"
|
||
del detail["video"]["origin_cover"]
|
||
assert douyin_media.extract_cover_url(detail) == "https://cdn/cover"
|
||
del detail["video"]["cover"]
|
||
assert douyin_media.extract_cover_url(detail) == "https://cdn/dynamic"
|
||
|
||
|
||
def test_douyin_video_addr_key_priority_is_locked():
|
||
"""h264 > 256 > play_addr 的降级顺序必须被钉住"""
|
||
detail = {
|
||
"aweme_id": "1",
|
||
"video": {
|
||
"play_addr_h264": {"url_list": ["https://cdn/h264"]},
|
||
"play_addr_256": {"url_list": ["https://cdn/256"]},
|
||
"play_addr": {"url_list": ["https://cdn/plain"]},
|
||
},
|
||
}
|
||
|
||
assert douyin_media.extract_video_urls(detail) == ["https://cdn/h264"]
|
||
del detail["video"]["play_addr_h264"]
|
||
assert douyin_media.extract_video_urls(detail) == ["https://cdn/256"]
|
||
del detail["video"]["play_addr_256"]
|
||
assert douyin_media.extract_video_urls(detail) == ["https://cdn/plain"]
|
||
|
||
|
||
def test_bilibili_falls_back_to_lowest_quality_keeping_codec_priority(monkeypatch):
|
||
"""所有档位都超过用户设置时取最低清晰度,但编码优先级仍要生效(AVC 优先)"""
|
||
monkeypatch.setattr(config, "BILI_QN", 16)
|
||
play_info = {
|
||
"dash": {
|
||
"video": [
|
||
{"id": 32, "codecid": 12, "bandwidth": 300000, "base_url": "https://upos/hevc.m4s"},
|
||
{"id": 32, "codecid": 7, "bandwidth": 200000, "base_url": "https://upos/avc.m4s"},
|
||
{"id": 80, "codecid": 7, "bandwidth": 900000, "base_url": "https://upos/80.m4s"},
|
||
]
|
||
}
|
||
}
|
||
|
||
assert bili_media.pick_video_stream(play_info)["base_url"] == "https://upos/avc.m4s"
|
||
|
||
|
||
def test_bilibili_tolerates_string_typed_ids():
|
||
"""接口偶发把 id/bandwidth 返回成字符串,不能因此整条媒体被跳过"""
|
||
play_info = {
|
||
"dash": {
|
||
"video": [
|
||
{"id": "80", "codecid": "12", "bandwidth": "900000", "base_url": "https://upos/hevc.m4s"},
|
||
{"id": "80", "codecid": "7", "bandwidth": "800000", "base_url": "https://upos/avc.m4s"},
|
||
],
|
||
"audio": [{"id": "30280", "bandwidth": "192000", "base_url": "https://upos/a.m4s"}],
|
||
}
|
||
}
|
||
|
||
item = bili_media.build_dash_item(play_info, "BV1")
|
||
|
||
assert item is not None
|
||
assert item.url == "https://upos/avc.m4s"
|
||
|
||
|
||
def test_bilibili_durl_stem_differs_from_dash_stem():
|
||
"""直链降级产物必须与 DASH 产物区分,否则低清文件会永久阻塞高清路径"""
|
||
dash_item = bili_media.build_dash_item(BILI_DASH_PLAY_INFO, "BV1")
|
||
durl_item = bili_media.build_durl_item(BILI_DURL_PLAY_INFO, "BV1")
|
||
|
||
assert dash_item.stem == "video"
|
||
assert durl_item.stem == "video-durl"
|
||
|
||
|
||
# --------------------------------------------------------------------------- kuaishou fixture
|
||
|
||
KS_VIDEO_ITEM = {
|
||
"photo": {
|
||
"id": "3xabcdefg",
|
||
"caption": "hello",
|
||
"photoH265Url": "https://ks/h265.mp4",
|
||
"photoUrl": "https://ks/h264.mp4",
|
||
"coverUrl": "https://ks/cover.jpg",
|
||
},
|
||
"author": {"id": "u1"},
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------- kuaishou 用例
|
||
|
||
|
||
def test_kuaishou_video_item_yields_cover_and_video():
|
||
items = kuaishou_media.build_media_items(KS_VIDEO_ITEM)
|
||
|
||
assert [item.stem for item in items] == ["cover", "video"]
|
||
cover, video = items
|
||
assert cover.url == "https://ks/cover.jpg"
|
||
assert video.url == "https://ks/h265.mp4"
|
||
assert video.backup_urls == ("https://ks/h264.mp4",)
|
||
assert video.content_id == "3xabcdefg"
|
||
|
||
|
||
def test_kuaishou_falls_back_to_video_resource_representations():
|
||
item = {
|
||
"photo": {
|
||
"id": "1",
|
||
"videoResource": {
|
||
"h264": {
|
||
"adaptationSet": [
|
||
{"representation": [{"url": "https://ks/rep.mp4"}]}
|
||
]
|
||
}
|
||
},
|
||
}
|
||
}
|
||
|
||
items = kuaishou_media.build_media_items(item)
|
||
|
||
video = next(entry for entry in items if entry.media_type is MediaType.VIDEO)
|
||
assert video.url == "https://ks/rep.mp4"
|
||
|
||
|
||
def test_kuaishou_cover_falls_back_to_cover_urls_list():
|
||
item = {"photo": {"id": "1", "coverUrls": [{"url": "https://ks/c1.jpg"}], "photoUrl": "https://ks/v.mp4"}}
|
||
|
||
items = kuaishou_media.build_media_items(item)
|
||
|
||
cover = next(entry for entry in items if entry.stem == "cover")
|
||
assert cover.url == "https://ks/c1.jpg"
|
||
|
||
|
||
def test_kuaishou_missing_photo_id_yields_nothing():
|
||
assert kuaishou_media.build_media_items({"photo": {"photoUrl": "https://ks/v.mp4"}}) == []
|
||
|
||
|
||
# --------------------------------------------------------------------------- weibo fixture
|
||
|
||
WB_IMAGE_MBLOG = {
|
||
"id": "5000000000000000",
|
||
"pics": [
|
||
{"url": "https://wx1.sinaimg.cn/orj360/abc.jpg", "pid": "abc"},
|
||
"https://wx2.sinaimg.cn/thumbnail/def.jpg",
|
||
],
|
||
}
|
||
|
||
WB_VIDEO_MBLOG = {
|
||
"id": "5000000000000001",
|
||
"page_info": {
|
||
"type": "video",
|
||
"page_pic": {"url": "https://wx1.sinaimg.cn/orj360/cover.jpg"},
|
||
"media_info": {
|
||
"mp4_hd_mp4": "https://f.video.weibocdn.com/hd.mp4",
|
||
"mp4_ld_mp4": "https://f.video.weibocdn.com/ld.mp4",
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------- weibo 用例
|
||
|
||
|
||
def test_weibo_image_url_is_rewritten_to_large_via_agent_host():
|
||
assert (
|
||
weibo_media.rewrite_image_url("https://wx1.sinaimg.cn/orj360/abc.jpg")
|
||
== "https://i1.wp.com/wx1.sinaimg.cn/large/abc.jpg"
|
||
)
|
||
|
||
|
||
def test_weibo_image_url_rewrite_ignores_query_string():
|
||
assert (
|
||
weibo_media.rewrite_image_url("https://wx1.sinaimg.cn/orj360/abc.jpg?v=1")
|
||
== "https://i1.wp.com/wx1.sinaimg.cn/large/abc.jpg"
|
||
)
|
||
|
||
|
||
def test_weibo_image_note_yields_numbered_images():
|
||
items = weibo_media.build_media_items(WB_IMAGE_MBLOG)
|
||
|
||
assert [item.stem for item in items] == ["001", "002"]
|
||
assert [item.url for item in items] == [
|
||
"https://i1.wp.com/wx1.sinaimg.cn/large/abc.jpg",
|
||
"https://i1.wp.com/wx2.sinaimg.cn/large/def.jpg",
|
||
]
|
||
|
||
|
||
def test_weibo_video_note_yields_cover_and_video():
|
||
items = weibo_media.build_media_items(WB_VIDEO_MBLOG)
|
||
|
||
assert [item.stem for item in items] == ["cover", "video"]
|
||
cover, video = items
|
||
assert cover.url == "https://wx1.sinaimg.cn/orj360/cover.jpg"
|
||
assert video.url == "https://f.video.weibocdn.com/hd.mp4"
|
||
assert video.backup_urls == ("https://f.video.weibocdn.com/ld.mp4",)
|
||
|
||
|
||
def test_weibo_plain_note_yields_nothing():
|
||
assert weibo_media.build_media_items({"id": "1", "text": "just text"}) == []
|
||
|
||
|
||
def test_weibo_retweet_uses_original_media():
|
||
"""转发微博的媒体在 retweeted_status 里,顶层没有;不处理会一条都下不到"""
|
||
retweet = {
|
||
"id": "6000000000000000",
|
||
"text": "转发内容",
|
||
"retweeted_status": {
|
||
"id": "5000000000000009",
|
||
"pics": [{"url": "https://wx1.sinaimg.cn/orj360/original.jpg"}],
|
||
},
|
||
}
|
||
|
||
items = weibo_media.build_media_items(retweet)
|
||
|
||
assert [item.stem for item in items] == ["001"]
|
||
assert items[0].url == "https://i1.wp.com/wx1.sinaimg.cn/large/original.jpg"
|
||
assert items[0].content_id == "6000000000000000", "目录应按转发帖自身 id 组织"
|
||
|
||
|
||
def test_weibo_retweet_with_video():
|
||
retweet = {
|
||
"id": "6000000000000001",
|
||
"retweeted_status": {
|
||
"page_info": {
|
||
"type": "video",
|
||
"page_pic": {"url": "https://wx1.sinaimg.cn/orj360/cover.jpg"},
|
||
"media_info": {"mp4_hd_mp4": "https://f.video.weibocdn.com/hd.mp4"},
|
||
}
|
||
},
|
||
}
|
||
|
||
items = weibo_media.build_media_items(retweet)
|
||
|
||
assert [item.stem for item in items] == ["cover", "video"]
|
||
assert items[1].url == "https://f.video.weibocdn.com/hd.mp4"
|
||
|
||
|
||
def test_weibo_own_media_wins_over_retweeted_status():
|
||
"""自己带媒体时不应去看被转发的内容"""
|
||
mblog = {
|
||
"id": "6000000000000002",
|
||
"pics": [{"url": "https://wx1.sinaimg.cn/orj360/mine.jpg"}],
|
||
"retweeted_status": {"pics": [{"url": "https://wx1.sinaimg.cn/orj360/theirs.jpg"}]},
|
||
}
|
||
|
||
items = weibo_media.build_media_items(mblog)
|
||
|
||
assert items[0].url == "https://i1.wp.com/wx1.sinaimg.cn/large/mine.jpg"
|
||
|
||
|
||
def test_kuaishou_falls_back_to_manifest_representations():
|
||
"""search 接口的 photo 字段更少,manifest 是最后一道兜底"""
|
||
item = {
|
||
"photo": {
|
||
"id": "1",
|
||
"manifest": {
|
||
"adaptationSet": [{"representation": [{"url": "https://ks/manifest.mp4"}]}]
|
||
},
|
||
}
|
||
}
|
||
|
||
items = kuaishou_media.build_media_items(item)
|
||
|
||
video = next(entry for entry in items if entry.media_type is MediaType.VIDEO)
|
||
assert video.url == "https://ks/manifest.mp4"
|
||
|
||
|
||
@pytest.mark.parametrize("scalar_video_resource", ["https://ks/raw.mp4", 123, None])
|
||
def test_kuaishou_tolerates_scalar_video_resource(scalar_video_resource):
|
||
"""GraphQL 里 videoResource 可能是 scalar 而非对象,不能因此抛异常"""
|
||
item = {"photo": {"id": "1", "videoResource": scalar_video_resource, "photoUrl": "https://ks/plain.mp4"}}
|
||
|
||
video_items = [
|
||
entry for entry in kuaishou_media.build_media_items(item)
|
||
if entry.media_type is MediaType.VIDEO
|
||
]
|
||
|
||
assert [entry.url for entry in video_items] == ["https://ks/plain.mp4"]
|
||
|
||
|
||
# --------------------------------------------------------------------------- bilibili fixture
|
||
|
||
BILI_VIEW = {
|
||
"aid": 114514,
|
||
"cid": 1919810,
|
||
"bvid": "BV1dwuKzmE26",
|
||
"pic": "https://i0.hdslb.com/bfs/archive/cover.jpg",
|
||
}
|
||
|
||
BILI_DASH_PLAY_INFO = {
|
||
"dash": {
|
||
"video": [
|
||
{"id": 32, "bandwidth": 300000, "base_url": "https://upos/32.m4s", "backup_url": ["https://upos-b/32.m4s"]},
|
||
{"id": 80, "bandwidth": 900000, "base_url": "https://upos/80.m4s"},
|
||
{"id": 116, "bandwidth": 2000000, "baseUrl": "https://upos/116.m4s"},
|
||
],
|
||
"audio": [
|
||
{"id": 30216, "bandwidth": 64000, "base_url": "https://upos/a64.m4s"},
|
||
{"id": 30280, "bandwidth": 192000, "base_url": "https://upos/a192.m4s", "backup_url": ["https://upos-b/a192.m4s"]},
|
||
],
|
||
}
|
||
}
|
||
|
||
BILI_DURL_PLAY_INFO = {
|
||
"durl": [
|
||
{"url": "https://upos/seg1.mp4", "size": 1000, "backup_url": ["https://upos-b/seg1.mp4"]},
|
||
{"url": "https://upos/seg2.mp4", "size": 5000},
|
||
]
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------- bilibili 用例
|
||
|
||
|
||
def test_bilibili_cover_item_from_view_pic():
|
||
item = bili_media.build_cover_item(BILI_VIEW, "BV1dwuKzmE26")
|
||
|
||
assert item is not None
|
||
assert item.url == "https://i0.hdslb.com/bfs/archive/cover.jpg"
|
||
assert item.stem == "cover"
|
||
assert item.content_id == "BV1dwuKzmE26"
|
||
|
||
|
||
def test_bilibili_cover_item_missing_pic():
|
||
assert bili_media.build_cover_item({"aid": 1}, "BV1") is None
|
||
assert bili_media.build_cover_item({}, "BV1") is None
|
||
|
||
|
||
def test_bilibili_pick_video_stream_respects_configured_quality(monkeypatch):
|
||
monkeypatch.setattr(config, "BILI_QN", 80)
|
||
|
||
stream = bili_media.pick_video_stream(BILI_DASH_PLAY_INFO)
|
||
|
||
assert stream["id"] == 80
|
||
|
||
|
||
def test_bilibili_pick_video_stream_falls_back_to_lowest_when_all_exceed(monkeypatch):
|
||
monkeypatch.setattr(config, "BILI_QN", 16)
|
||
|
||
stream = bili_media.pick_video_stream(BILI_DASH_PLAY_INFO)
|
||
|
||
assert stream["id"] == 32
|
||
|
||
|
||
def test_bilibili_pick_video_stream_picks_highest_when_all_below(monkeypatch):
|
||
monkeypatch.setattr(config, "BILI_QN", 127)
|
||
|
||
stream = bili_media.pick_video_stream(BILI_DASH_PLAY_INFO)
|
||
|
||
assert stream["id"] == 116
|
||
|
||
|
||
def test_bilibili_pick_audio_stream_takes_highest_bandwidth():
|
||
stream = bili_media.pick_audio_stream(BILI_DASH_PLAY_INFO)
|
||
|
||
assert stream["id"] == 30280
|
||
|
||
|
||
def test_bilibili_prefers_avc_over_hevc_at_same_quality(monkeypatch):
|
||
"""同一清晰度下应选兼容性最好的 AVC,而不是码率更高但兼容性差的 HEVC"""
|
||
monkeypatch.setattr(config, "BILI_QN", 80)
|
||
play_info = {
|
||
"dash": {
|
||
"video": [
|
||
{"id": 80, "codecid": 12, "bandwidth": 2000000, "base_url": "https://upos/hevc.m4s"},
|
||
{"id": 80, "codecid": 7, "bandwidth": 1500000, "base_url": "https://upos/avc.m4s"},
|
||
{"id": 80, "codecid": 13, "bandwidth": 1200000, "base_url": "https://upos/av1.m4s"},
|
||
],
|
||
"audio": [{"id": 30280, "base_url": "https://upos/a.m4s"}],
|
||
}
|
||
}
|
||
|
||
assert bili_media.pick_video_stream(play_info)["base_url"] == "https://upos/avc.m4s"
|
||
assert bili_media.build_dash_item(play_info, "BV1").url == "https://upos/avc.m4s"
|
||
|
||
|
||
def test_bilibili_falls_back_to_hevc_when_avc_unavailable(monkeypatch):
|
||
monkeypatch.setattr(config, "BILI_QN", 80)
|
||
play_info = {
|
||
"dash": {
|
||
"video": [
|
||
{"id": 80, "codecid": 12, "bandwidth": 2000000, "base_url": "https://upos/hevc.m4s"},
|
||
],
|
||
"audio": [{"id": 30280, "base_url": "https://upos/a.m4s"}],
|
||
}
|
||
}
|
||
|
||
assert bili_media.pick_video_stream(play_info)["base_url"] == "https://upos/hevc.m4s"
|
||
|
||
|
||
def test_bilibili_build_dash_item(monkeypatch):
|
||
monkeypatch.setattr(config, "BILI_QN", 80)
|
||
|
||
item = bili_media.build_dash_item(BILI_DASH_PLAY_INFO, "BV1dwuKzmE26")
|
||
|
||
assert item is not None
|
||
assert item.is_dash is True
|
||
assert item.url == "https://upos/80.m4s"
|
||
assert item.audio_url == "https://upos/a192.m4s"
|
||
assert item.audio_backup_urls == ("https://upos-b/a192.m4s",)
|
||
assert item.media_type is MediaType.VIDEO
|
||
|
||
|
||
def test_bilibili_build_dash_item_supports_camel_case_base_url(monkeypatch):
|
||
monkeypatch.setattr(config, "BILI_QN", 127)
|
||
|
||
item = bili_media.build_dash_item(BILI_DASH_PLAY_INFO, "BV1")
|
||
|
||
assert item.url == "https://upos/116.m4s"
|
||
|
||
|
||
def test_bilibili_build_dash_item_requires_audio():
|
||
play_info = {"dash": {"video": [{"id": 80, "base_url": "https://upos/v.m4s"}]}}
|
||
|
||
assert bili_media.build_dash_item(play_info, "BV1") is None
|
||
|
||
|
||
def test_bilibili_build_durl_item_takes_largest_segment():
|
||
item = bili_media.build_durl_item(BILI_DURL_PLAY_INFO, "BV1")
|
||
|
||
assert item is not None
|
||
assert item.url == "https://upos/seg2.mp4"
|
||
assert item.is_dash is False
|
||
|
||
|
||
def test_bilibili_build_durl_item_keeps_backup_urls():
|
||
play_info = {"durl": [{"url": "https://upos/only.mp4", "size": 1, "backup_url": ["https://upos-b/only.mp4"]}]}
|
||
|
||
item = bili_media.build_durl_item(play_info, "BV1")
|
||
|
||
assert item.backup_urls == ("https://upos-b/only.mp4",)
|
||
|
||
|
||
@pytest.mark.parametrize("play_info", [{}, {"durl": []}, None])
|
||
def test_bilibili_build_durl_item_empty(play_info):
|
||
assert bili_media.build_durl_item(play_info or {}, "BV1") is None
|
||
|
||
|
||
def test_bilibili_counts_durl_segments():
|
||
assert bili_media.count_durl_segments(BILI_DURL_PLAY_INFO) == 2
|
||
assert bili_media.count_durl_segments(BILI_DASH_PLAY_INFO) == 0
|
||
assert bili_media.count_durl_segments(None) == 0
|
||
assert bili_media.count_durl_segments({"durl": ["oops", {"no_url": 1}]}) == 0
|
||
|
||
|
||
@pytest.mark.parametrize("payload", ["oops", 123, [], None])
|
||
def test_douyin_extractors_tolerate_malformed_payload(payload):
|
||
"""接口偶发返回非 dict 结构时不能抛异常,否则会中断整轮爬取"""
|
||
assert douyin_media.build_media_items(payload) == []
|
||
|
||
|
||
@pytest.mark.parametrize("payload", ["oops", 123, [], None])
|
||
def test_kuaishou_extractors_tolerate_malformed_payload(payload):
|
||
assert kuaishou_media.build_media_items(payload) == []
|
||
|
||
|
||
@pytest.mark.parametrize("payload", ["oops", 123, [], None])
|
||
def test_bilibili_extractors_tolerate_malformed_payload(payload):
|
||
assert bili_media.pick_video_stream(payload or {}) is None
|
||
assert bili_media.pick_audio_stream(payload or {}) is None
|
||
assert bili_media.build_dash_item(payload or {}, "BV1") is None
|
||
assert bili_media.build_durl_item(payload or {}, "BV1") is None
|
||
|
||
|
||
@pytest.mark.parametrize("payload", ["oops", 123, [], None])
|
||
def test_weibo_extractors_tolerate_malformed_payload(payload):
|
||
assert weibo_media.build_media_items(payload) == []
|
||
|
||
|
||
def test_xhs_extractors_tolerate_malformed_video_field():
|
||
"""实测:video 字段是字符串时旧逻辑会 AttributeError"""
|
||
assert xhs_media.build_media_items({"note_id": "n1", "type": "video", "video": "oops"}) == []
|
||
assert xhs_media.build_media_items({"note_id": "n1", "type": "video", "video": None}) == []
|
||
assert (
|
||
xhs_media.build_media_items(
|
||
{"note_id": "n1", "type": "video", "video": {"media": "oops", "cover": "oops"}}
|
||
)
|
||
== []
|
||
)
|
||
|
||
|
||
def test_bilibili_tolerates_non_dict_stream_entries():
|
||
play_info = {
|
||
"dash": {
|
||
"video": ["oops", {"id": 80, "base_url": "https://upos/ok.m4s"}],
|
||
"audio": [{"id": 30280, "base_url": "https://upos/a.m4s"}],
|
||
}
|
||
}
|
||
|
||
item = bili_media.build_dash_item(play_info, "BV1")
|
||
|
||
assert item is not None
|
||
assert item.url == "https://upos/ok.m4s"
|