From cf513e70a13a20105571987ce4c56e03e9bf9be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 18 Sep 2026 17:00:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(dy):=20=E8=A1=A5=E4=B8=8A=20detail=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E7=9A=84=20uifid/verifyFp=20=E9=A3=8E?= =?UTF-8?q?=E6=8E=A7=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抖音 detail 接口的 Argus 风控要求 uifid / verifyFp / fp 三个参数,缺一则直接 403:响应体为 "Blocked by ArgusSecurityPlugin Uifid Not Found",补上 uifid 但 verifyFp 不对时换成 "... Signature Not Found"。原实现只传 aweme_id,于是 get_aweme_detail 全线失败,详情和媒体都拿不到。 - 三个参数取自浏览器 cookie:uifid 用 UIFID(缺失时退到 UIFID_TEMP), verifyFp/fp 用 s_v_web_id。必须用 cookie 里的 s_v_web_id——实测 uifid 搭配 自生成的 verifyFp 会被判 Signature Not Found,两者需要同源 - 新增 tests/test_douyin_aweme_detail_params.py(不发网络请求) 验证:抓包比对真实浏览器发出的 detail 请求,参数逐项一致;真实爬取中详情与 74MB 视频均下载成功。 --- media_platform/douyin/client.py | 14 ++- tests/test_douyin_aweme_detail_params.py | 107 +++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 tests/test_douyin_aweme_detail_params.py diff --git a/media_platform/douyin/client.py b/media_platform/douyin/client.py index 759920e..cfa095e 100644 --- a/media_platform/douyin/client.py +++ b/media_platform/douyin/client.py @@ -214,7 +214,19 @@ class DouYinClient(AbstractApiClient, ProxyRefreshMixin): :param aweme_id: :return: """ - params = {"aweme_id": aweme_id} + # 抖音 detail 接口的 Argus 风控要求这两个参数成套出现,缺一则直接 403 + # (响应体为 "Blocked by ArgusSecurityPlugin Uifid Not Found"): + # uifid = UIFID cookie,没有时退到 UIFID_TEMP + # verifyFp / fp = s_v_web_id cookie + # 必须用 cookie 里的 s_v_web_id:实测 uifid 搭配自生成的 verifyFp 会被判成 + # "Signature Not Found",两者同源才能通过。 + s_v_web_id = self.cookie_dict.get("s_v_web_id", "") + params = { + "aweme_id": aweme_id, + "uifid": self.cookie_dict.get("UIFID") or self.cookie_dict.get("UIFID_TEMP", ""), + "verifyFp": s_v_web_id, + "fp": s_v_web_id, + } headers = copy.copy(self.headers) del headers["Origin"] res = await self.get("/aweme/v1/web/aweme/detail/", params, headers) diff --git a/tests/test_douyin_aweme_detail_params.py b/tests/test_douyin_aweme_detail_params.py new file mode 100644 index 0000000..dcac1ec --- /dev/null +++ b/tests/test_douyin_aweme_detail_params.py @@ -0,0 +1,107 @@ +# -*- 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_douyin_aweme_detail_params.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# + +"""抖音 aweme detail 接口的风控参数回归测试。 + +回归背景:上游 detail 接口的 Argus 风控要求 ``uifid`` / ``verifyFp`` / ``fp`` +三个参数,缺一时直接 403,响应体为 +``Blocked by ArgusSecurityPlugin Uifid Not Found``(补上 uifid 但 verifyFp 不对时 +换成 ``... Signature Not Found``)。原先只传 aweme_id,于是详情全线失败, +连带媒体也拿不到。 + +这里不发起任何网络请求,只锁定这三个参数确实被带上、且取值来自浏览器 cookie。 +""" + +from __future__ import annotations + +import pytest + +from media_platform.douyin.client import DouYinClient + +AWEME_ID = "7525538910311632128" + + +class _StubPage: + """占位 page,仅用于构造 client;本测试不会走到 playwright 调用""" + + async def evaluate(self, expression): # noqa: ANN001 + return {} + + +def _make_client(cookie_dict: dict) -> DouYinClient: + return DouYinClient( + headers={ + "User-Agent": "test-user-agent", + "Cookie": "a=1", + "Origin": "https://www.douyin.com/", + }, + playwright_page=_StubPage(), + cookie_dict=cookie_dict, + ) + + +@pytest.mark.asyncio +async def test_aweme_detail_sends_uifid_and_verify_fp(monkeypatch): + """uifid 与 verifyFp/fp 必须成套出现,且都取自 cookie""" + captured: dict = {} + + async def fake_get(uri, params=None, headers=None): # noqa: ANN001 + captured["uri"] = uri + captured["params"] = dict(params or {}) + return {"aweme_detail": {"aweme_id": AWEME_ID}} + + client = _make_client( + {"s_v_web_id": "verify_test_fp", "UIFID": "uifid-from-cookie"} + ) + monkeypatch.setattr(client, "get", fake_get) + + await client.get_video_by_id(AWEME_ID) + + assert captured["uri"] == "/aweme/v1/web/aweme/detail/" + assert captured["params"]["aweme_id"] == AWEME_ID + assert captured["params"]["uifid"] == "uifid-from-cookie" + # verifyFp 与 fp 必须同源,且用 cookie 里的 s_v_web_id(自生成的会被判 Signature Not Found) + assert captured["params"]["verifyFp"] == "verify_test_fp" + assert captured["params"]["fp"] == "verify_test_fp" + + +@pytest.mark.asyncio +async def test_aweme_detail_falls_back_to_uifid_temp(monkeypatch): + """没有 UIFID 时退到 UIFID_TEMP""" + captured: dict = {} + + async def fake_get(uri, params=None, headers=None): # noqa: ANN001 + captured["params"] = dict(params or {}) + return {"aweme_detail": {}} + + client = _make_client({"s_v_web_id": "fp", "UIFID_TEMP": "temp-only"}) + monkeypatch.setattr(client, "get", fake_get) + + await client.get_video_by_id(AWEME_ID) + + assert captured["params"]["uifid"] == "temp-only" + + +@pytest.mark.asyncio +async def test_aweme_detail_without_cookies_still_requests(monkeypatch): + """cookie 缺失时不能抛异常,参数退化为空串(由服务端决定是否放行)""" + captured: dict = {} + + async def fake_get(uri, params=None, headers=None): # noqa: ANN001 + captured["params"] = dict(params or {}) + return {"aweme_detail": {}} + + client = _make_client({}) + monkeypatch.setattr(client, "get", fake_get) + + await client.get_video_by_id(AWEME_ID) + + assert captured["params"]["uifid"] == "" + assert captured["params"]["verifyFp"] == "" + assert captured["params"]["fp"] == ""