diff --git a/media_platform/xhs/media.py b/media_platform/xhs/media.py index d772ac3..5a359c9 100644 --- a/media_platform/xhs/media.py +++ b/media_platform/xhs/media.py @@ -45,10 +45,40 @@ def _extract_origin_video_key(video_dict: Dict) -> str: return consumer.get("origin_video_key") or consumer.get("originVideoKey") or "" +def _to_int(value, default: int = 0) -> int: + """接口偶尔把 height/bitrate 返回成字符串,排序前统一转 int""" + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _stream_quality_key(item: Dict) -> tuple: + """分档排序 key:分辨率优先,其次平均码率""" + return (_to_int(item.get("height")), _to_int(item.get("avg_bitrate"))) + + +def _iter_stream_items(stream: Dict) -> List[Dict]: + """摊平 stream 下的全部分档条目。 + + 分档名上游换过两代:早期按编码命名(h264/h265/av1),现在按内部档位命名 + (EF4/EF5/EF6/EF7)。这里刻意不写死任何分档名——凡是列表都当候选, + 以后再改名也不会像 ``stream["h264"]`` 那样静默取空。 + """ + return [ + item + for bucket in stream.values() + if isinstance(bucket, list) + for item in bucket + if isinstance(item, dict) and item.get("master_url") + ] + + def extract_video_urls(note_item: Dict) -> List[str]: """提取视频地址候选列表,按可用性排序。 - 优先无水印源片(origin_video_key),其余为带水印的 h264 master_url 备用。 + 优先无水印源片(origin_video_key),其余按清晰度从高到低排。 + 同一分档内含多个分辨率(720P→4K),取最高的作主地址,其余交给下载器回退。 国际版(rednote)的 CDN 域名与国内不同,不能拼接 xhscdn 域名。 """ if note_item.get("type") != "video": @@ -64,9 +94,10 @@ def extract_video_urls(note_item: Dict) -> List[str]: 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: + ranked = sorted(_iter_stream_items(stream), key=_stream_quality_key, reverse=True) + for item in ranked: + master_url = item["master_url"] + if master_url not in urls: urls.append(master_url) return urls diff --git a/tests/test_media_extractors.py b/tests/test_media_extractors.py index 1bb4ded..5ad4028 100644 --- a/tests/test_media_extractors.py +++ b/tests/test_media_extractors.py @@ -27,6 +27,8 @@ 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", @@ -39,6 +41,47 @@ XHS_VIDEO_NOTE = { }, } +# 上游当前结构(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", @@ -116,6 +159,44 @@ def test_xhs_video_note_without_origin_key_uses_master_url(): 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)