Ctrl K
ComunidadGuías…

tests/test_metadata_dmm.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""DMM 的 GraphQL 目录套进站点解析器契约:cid 推导与搜索匹配、发售日换算、payload 逐键钉住,以及失败分档。"""
import json
import re
import unittest
from unittest.mock import Mock, patch

from peach.http import HttpResponse
from peach.jav_cover_fetch import Unavailable
from peach.library_processing import LibraryMetadataProvider
from peach.scraping_access import SourcePaused
from peach.sources import DMM, DmmSource, FailureReason, Page, Session, SourceFailure
from peach.sources.dmm import (ACTRESS_WORKS_QUERY, DETAIL_ATTEMPTS, DETAIL_QUERY, SEARCH_LIMIT, SEARCH_QUERY, code_parts,
                               code_from_cid, guessed_cid, matching_cids, release_date)

ENDPOINT = "https://api.video.dmm.co.jp/graphql"
PAGE_URL = "https://video.dmm.co.jp/av/content/?id=ssis00057"
COVER = "https://awsimgsrc.dmm.co.jp/pics_dig/digital/video/ssis00057/ssis00057pl.jpg"
SAMPLE = "https://awsimgsrc.dmm.co.jp/pics_dig/digital/video/ssis00057/ssis00057-1.jpg"
#: 2026-09-24 实测 `ppvContent(id: "ssis00057")` 的响应,简介截短。
ITEM = {"id": "ssis00057", "title": "パンティーストッキングマニア 三宮つばき", "description": "新人OLの‘つばき’は才色兼備で",
        "makerContentId": "SSIS-057", "makerReleasedAt": "2021-05-06T15:00:00Z", "duration": 9069,
        "maker": {"id": "3152", "name": "エスワン ナンバーワンスタイル"}, "label": {"id": "3474", "name": "S1 NO.1 STYLE"},
        "series": None, "actresses": [{"id": "1062909", "name": "三宮つばき"}], "directors": [{"id": "110733", "name": "肉尊"}],
        "genres": [{"id": "6017", "name": "ギリモザ"}, {"id": "4025", "name": "単体作品"}],
        "packageImage": {"largeUrl": COVER, "mediumUrl": COVER.replace("pl.jpg", "ps.jpg")},
        "sampleImages": [{"imageUrl": SAMPLE}]}
FKOS = {"id": "h_1721fkos00006", "title": "個人撮影6", "makerContentId": "FKOS-006", "makerReleasedAt": "2023-01-19T15:00:00Z",
        "duration": 3600, "maker": {"id": "1", "name": "FKOS"}, "actresses": [], "genres": [], "packageImage": None}
#: `LibraryMetadataProvider.query('SSIS-057', 'dmm')` 对这份夹具交出的 dict,逐键钉住。
EXPECTED = {
    "id": "SSIS-057", "content_id": "ssis00057", "source_url": PAGE_URL, "title": "パンティーストッキングマニア 三宮つばき",
    "maker": "エスワン ナンバーワンスタイル", "label": "S1 NO.1 STYLE", "series": "", "director": "肉尊",
    "release_date": "2021-05-07", "runtime": 151.15, "cover_urls": [COVER], "cover_url": COVER,
    "actresses": [{"japanese_name": "三宮つばき", "dmm_id": "1062909"}], "genres": ["ギリモザ", "単体作品"],
    "description": "新人OLの‘つばき’は才色兼備で", "sample_images": [SAMPLE], "raw": ITEM}


def graphql(contents=None, items=None):
    """假接口:按请求体里的查询与变量答复。`contents` 是搜索结果的 cid 列,`items` 是 cid → 商品或异常。"""
    calls = []
    items = items or {}

    def call(request, timeout, limit):
        payload = json.loads(request.body)
        variables = payload["variables"]
        calls.append((request.method, request.url, request.headers.get("Referer"), request.headers.get("Content-Type"),
                      limit, payload["query"], variables))
        if payload["query"] == SEARCH_QUERY:
            body = {"data": {"legacySearchPPV": {"result": {"contents": [{"id": cid} for cid in contents or []]}}}}
        else:
            item = items.get(variables["id"])
            if isinstance(item, Exception):
                raise item
            body = {"data": {"ppvContent": item}}
        return HttpResponse(200, {}, json.dumps(body, ensure_ascii=False).encode(), request.url)

    call.calls = calls
    return call


def asked(transport):
    """这趟发了哪几条查询:`('detail', cid)` 或 `('search', 关键词)`。"""
    return [("search", variables["queryWord"]) if query == SEARCH_QUERY else ("detail", variables["id"])
            for _, _, _, _, _, query, variables in transport.calls]


class CodeMappingTests(unittest.TestCase):
    def test_dmm_product_identity_keeps_the_release_suffix_and_uses_the_dmm_namespace(self):
        for product, expected in {"1dandy00899": "DANDY-899", "118abw00220": "ABW-220",
                                  "h_086iqqq00026": "IQQQ-026", "1dandy00851c": "DANDY-851C",
                                  "48midv00012ai": "MIDV-012AI"}.items():
            self.assertEqual(code_from_cid(product), expected)
        self.assertEqual(code_from_cid("opaque_cid"), "")

    def test_sod_bonus_edition_maps_to_the_regular_release(self):
        # 特典版(官方番号 SDJS-304-V)是通常版正片加特典映像,番号归到通常版。
        for product, expected in {"1sdjs00304v": "SDJS-304", "1start00644v": "START-644"}.items():
            self.assertEqual(code_from_cid(product), expected)

    def test_code_parts_keep_the_numeric_prefix_and_the_digits_as_written(self):
        self.assertEqual(code_parts("SSIS-057"), ("", "SSIS", "057"))
        self.assertEqual(code_parts("ssis057"), ("", "SSIS", "057"))
        self.assertEqual(code_parts("300MIUM-1239"), ("300", "MIUM", "1239"))
        self.assertEqual(code_parts("MIDV-012ai"), ("", "MIDV", "012AI"))
        for code in ("", None, "FC2-PPV-1812235", "092415_001", "n0780"):
            with self.subTest(code=code):
                self.assertIsNone(code_parts(code))

    def test_the_guess_is_lowercase_letters_and_five_digits(self):
        self.assertEqual(guessed_cid("SSIS-057"), "ssis00057")
        self.assertEqual(guessed_cid("START-640"), "start00640")
        self.assertEqual(guessed_cid("300MIUM-1239"), "300mium01239")
        self.assertEqual(guessed_cid("DANDY-851C"), "dandy00851c")
        self.assertEqual(guessed_cid("092415_001"), "")

    def test_matching_cids_compare_the_letters_and_the_number_not_the_substring(self):
        """搜 `SSIS 057` 前几条是 `ssis00570`;搜 `ERK 116` 会带出 `gerk116`。厂牌数字前缀与 `h_` 前缀都容得下。"""
        self.assertEqual(matching_cids("SSIS-057", ["ssis00570", "ssis00579", "ssis00057", "1ssis00057"]),
                         ["ssis00057", "1ssis00057"])
        self.assertEqual(matching_cids("ERK-116", ["h_1838erkr01116", "gerk116", "erk116"]), ["erk116"])
        self.assertEqual(matching_cids("FKOS-006", ["h_1721fkos00006"]), ["h_1721fkos00006"])
        self.assertEqual(matching_cids("FNS-061", ["1fns00061"]), ["1fns00061"])

    def test_suffixed_variants_sort_after_the_plain_cid(self):
        """`MIDV 012` 同时命中原版与 AI 重制版,原版先核。"""
        self.assertEqual(matching_cids("MIDV-012", ["48midv00012ai", "midv00127", "midv00012", "48midv00012"]),
                         ["midv00012", "48midv00012", "48midv00012ai"])
        self.assertEqual(matching_cids("DANDY-851C", ["1dandy00851", "1dandy00851c", "1dandy00851ai"]), ["1dandy00851c"])

    def test_a_numeric_prefix_in_the_code_must_lead_the_cid(self):
        self.assertEqual(matching_cids("300MIUM-1239", ["300mium01239", "mium01239", "259mium01239"]), ["300mium01239"])
        self.assertEqual(matching_cids("092415_001", ["anything"]), [])

    def test_release_date_is_the_japanese_calendar_day(self):
        """`makerReleasedAt` 以 UTC 写日本时间零点:SSIS-057 答 05-06T15:00Z,账本与 r18.dev 都是 05-07。"""
        self.assertEqual(release_date("2021-05-06T15:00:00Z"), "2021-05-07")
        self.assertEqual(release_date("2026-10-21T15:00:00+00:00"), "2026-10-22")
        self.assertEqual(release_date("2021-05-07"), "2021-05-07")
        self.assertEqual(release_date("2021-05-07T00:00:00"), "2021-05-07")
        self.assertEqual(release_date("not a date at all"), "not a date")
        self.assertEqual(release_date(None), "")


class DmmSourceTests(unittest.TestCase):
    def test_the_config_and_the_endpoint(self):
        site = DmmSource()
        self.assertIs(site.config, DMM)
        self.assertEqual((DMM.name, DMM.label, DMM.provider, DMM.stage, DMM.interval, DMM.cookie, DMM.page_limit),
                         ("dmm", "DMM / FANZA", "dmm-graphql", "official", 2.0, False, 1024 * 1024))
        self.assertEqual(site.endpoint(), ENDPOINT)
        self.assertEqual(site.content_url("ssis00057"), PAGE_URL)
        self.assertEqual(site.content_url("h_1721fkos00006"), PAGE_URL.replace("ssis00057", "h_1721fkos00006"))

    def test_parse_reads_the_content_json_without_any_transport(self):
        record = DmmSource().parse(Page(PAGE_URL, json.dumps(ITEM, ensure_ascii=False)), "SSIS-057")
        self.assertEqual((record.source, record.provenance, record.code, record.source_url),
                         ("dmm", "dmm-graphql", "SSIS-057", PAGE_URL))
        self.assertEqual((record.title, record.studio, record.label, record.series, record.director, record.release_date,
                          record.runtime, record.cover_urls),
                         (ITEM["title"], "エスワン ナンバーワンスタイル", "S1 NO.1 STYLE", "", "肉尊", "2021-05-07", 151.15,
                          (COVER,)))
        self.assertEqual(record.performers, ({"japanese_name": "三宮つばき", "dmm_id": "1062909"},))
        self.assertEqual(record.tags, ("ギリモザ", "単体作品"))
        self.assertEqual((record.extra["content_id"], record.extra["sample_images"], record.extra["raw"]),
                         ("ssis00057", [SAMPLE], ITEM))
        self.assertEqual(record.payload(), EXPECTED)

    def test_parse_tolerates_missing_fields_and_null_objects(self):
        """DMM 对 `series`、`packageImage` 给 null;站上没给的字段留空,不是 `None`。"""
        record = DmmSource().parse(Page(PAGE_URL, json.dumps({"id": "ssis00057", "title": "x", "series": None,
                                                              "packageImage": None, "duration": 0})), "SSIS-057")
        self.assertEqual((record.series, record.director, record.release_date, record.runtime, record.cover_urls,
                          record.tags, record.performers), ("", "", "", None, (), (), ()))
        self.assertEqual(record.payload()["cover_url"], "")

    def test_query_hits_the_guessed_cid_in_one_post(self):
        transport = graphql(items={"ssis00057": ITEM})
        record = DmmSource().query("SSIS-057", session=Session(transport))
        self.assertEqual(record.payload(), EXPECTED)
        self.assertEqual(asked(transport), [("detail", "ssis00057")])
        method, url, referer, content_type, limit, query, variables = transport.calls[0]
        self.assertEqual((method, url, referer, content_type, limit, query, variables),
                         ("POST", ENDPOINT, "https://api.video.dmm.co.jp/", "application/json", 1024 * 1024,
                          DETAIL_QUERY, {"id": "ssis00057"}))

    def test_query_falls_back_to_the_search_when_the_guess_is_not_on_dmm(self):
        """`FKOS-006` 的 cid 带厂牌数字前缀 `h_1721`,猜不出来,只有搜索答得出。"""
        transport = graphql(contents=["h_1721fkos00006"], items={"h_1721fkos00006": FKOS})
        record = DmmSource().query("FKOS-006", session=Session(transport))
        self.assertEqual((record.code, record.extra["content_id"], record.release_date, record.cover_urls),
                         ("FKOS-006", "h_1721fkos00006", "2023-01-20", ()))
        self.assertEqual(asked(transport), [("detail", "fkos00006"), ("search", "FKOS 006"), ("detail", "h_1721fkos00006")])
        self.assertEqual(transport.calls[1][6], {"limit": SEARCH_LIMIT, "sort": "RELEASE_DATE", "queryWord": "FKOS 006"})

    def test_a_detail_that_is_another_film_is_not_trusted(self):
        """接口对一个 cid 答回来的是别的商品(id 与 `makerContentId` 都对不上番号):按没有处理,接着核下一条。"""
        other = dict(ITEM, id="ssis00570", makerContentId="SSIS-570")
        transport = graphql(contents=["1ssis00057", "2ssis00057"],
                            items={"1ssis00057": other, "2ssis00057": dict(ITEM, id="2ssis00057")})
        record = DmmSource().query("SSIS-057", session=Session(transport))
        self.assertEqual(record.extra["content_id"], "2ssis00057")
        self.assertEqual(asked(transport), [("detail", "ssis00057"), ("search", "SSIS 057"),
                                            ("detail", "1ssis00057"), ("detail", "2ssis00057")])

    def test_search_hits_are_capped_and_the_guess_is_not_asked_twice(self):
        contents = ["midv00012", "48midv00012", "48midv00012ai"]
        transport = graphql(contents=contents, items={})
        with self.assertRaises(SourceFailure) as caught:
            DmmSource().query("MIDV-012", session=Session(transport))
        self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NOT_FOUND, "DMM 没有这个番号"))
        self.assertEqual(asked(transport), [("detail", "midv00012"), ("search", "MIDV 012"),
                                            ("detail", "48midv00012"), ("detail", "48midv00012ai")])
        self.assertEqual(len(asked(transport)) - 2, DETAIL_ATTEMPTS)

    def test_the_provider_payload_equals_the_snapshot_shape_field_for_field(self):
        """`LibraryMetadataProvider.query(code, 'dmm')` 走的就是这一站;现有用例按 `peach.jav_cover_fetch._fetch` 打桩,桩仍然生效。"""
        def pages(transport, url, **kwargs):
            payload = json.loads(kwargs["body"])
            self.assertEqual((kwargs["method"], payload["query"], payload["variables"]), ("POST", DETAIL_QUERY, {"id": "ssis00057"}))
            return json.dumps({"data": {"ppvContent": ITEM}}, ensure_ascii=False)

        provider = LibraryMetadataProvider.__new__(LibraryMetadataProvider)
        provider.transport = Mock()
        with patch("peach.jav_cover_fetch._fetch", side_effect=pages) as fetch:
            payload = provider.query("SSIS-057", "dmm")
        self.assertEqual(payload, EXPECTED)
        self.assertEqual(fetch.call_args.args[1], ENDPOINT)
        self.assertEqual(fetch.call_args.kwargs["extra_headers"], {"Content-Type": "application/json", "Accept": "application/json"})

    def test_failures_come_through_the_shared_reason_table(self):
        with self.assertRaises(SourceFailure) as caught:
            DmmSource().query("FC2-PPV-1812235", session=Session(graphql()))
        self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NOT_FOUND, "认不出可以问 DMM 的番号"))
        with self.assertRaises(SourceFailure) as caught:
            DmmSource().parse(Page(PAGE_URL, json.dumps(dict(ITEM, makerContentId="SSIS-570", id="ssis00570"))), "SSIS-057")
        self.assertEqual((caught.exception.reason, caught.exception.kind, str(caught.exception)),
                         (FailureReason.PARSE_ERROR, "unavailable", "来源返回的番号不匹配"))
        for body in (b"<html>maintenance</html>", b"[1, 2]", b'{"title": "no id"}'):
            with self.subTest(body=body), self.assertRaises(SourceFailure) as caught:
                DmmSource().parse(Page(PAGE_URL, body), "SSIS-057")
            self.assertEqual((caught.exception.reason, str(caught.exception)),
                             (FailureReason.PARSE_ERROR, "DMM 返回的不是作品 JSON"))

    def test_a_refused_query_and_a_non_json_reply_are_parse_errors(self):
        """字段名过期时接口回 `errors` 且没有 `data`,措辞带上站方那句话。"""
        refused = lambda request, timeout, limit: HttpResponse(
            200, {}, b'{"errors": [{"message": "Cannot query field \\"largeUrl\\""}]}', request.url)
        with self.assertRaises(SourceFailure) as caught:
            DmmSource().query("SSIS-057", session=Session(refused))
        self.assertEqual((caught.exception.reason, str(caught.exception)),
                         (FailureReason.PARSE_ERROR, 'DMM 拒绝了这条查询:Cannot query field "largeUrl"'))
        cloudflare = lambda request, timeout, limit: HttpResponse(200, {}, b"<html>Just a moment</html>", request.url)
        with self.assertRaises(SourceFailure) as caught:
            DmmSource().query("SSIS-057", session=Session(cloudflare))
        self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.PARSE_ERROR, "DMM 返回的不是作品 JSON"))

    def test_transport_tiers_keep_their_semantics(self):
        with self.assertRaises(SourceFailure) as caught:
            DmmSource().query("SSIS-057", session=Session(graphql(items={"ssis00057": Unavailable("HTTP 503")})))
        self.assertEqual((caught.exception.reason, caught.exception.status_code), (FailureReason.SERVER_ERROR, 503))
        with self.assertRaises(SourceFailure) as caught:
            DmmSource().query("SSIS-057", session=Session(graphql(items={"ssis00057": Unavailable("HTTP 422")})))
        self.assertEqual((caught.exception.reason, caught.exception.status_code), (FailureReason.NETWORK, 422))
        with self.assertRaises(SourcePaused):
            DmmSource().query("SSIS-057", session=Session(graphql(items={"ssis00057": SourcePaused("来源正在冷却")})))


def listed(cid, *, status=None, date="2026-10-30T00:00:00+09:00", cast=("森日向子",), maker="ムーディーズ"):
    """片单里的一行,形状照 2026-11-04 `legacySearchPPV(filter: {actressIds})` 的实测响应写。"""
    return {"id": cid, "title": f"{cid} の作品", "deliveryStartAt": date, "releaseStatus": status,
            "actresses": [{"id": "1061586" if name == "森日向子" else "9", "name": name} for name in cast],
            "maker": {"id": "1", "name": maker}}


def catalogue(pages, maker_codes):
    """假接口:片单按 offset 答 `pages` 里的那一页,别名批量按 cid 答 `maker_codes`。"""
    calls = []

    def call(request, timeout, limit):
        payload = json.loads(request.body)
        if payload["query"] == ACTRESS_WORKS_QUERY:
            variables = payload["variables"]
            calls.append(("list", variables["offset"], variables["filter"]))
            rows, has_next, total = pages[variables["offset"]]
            body = {"data": {"legacySearchPPV": {"result": {
                "pageInfo": {"totalCount": total, "hasNext": has_next}, "contents": rows}}}}
        else:
            cids = re.findall(r'ppvContent\(id: "([^"]+)"\)', payload["query"])
            calls.append(("codes", cids))
            body = {"data": {f"a{index}": ({"id": cid, "makerContentId": maker_codes[cid]}
                                           if cid in maker_codes else None)
                             for index, cid in enumerate(cids)}}
        return HttpResponse(200, {}, json.dumps(body, ensure_ascii=False).encode(), request.url)

    call.calls = calls
    return call


class ActressWorksTests(unittest.TestCase):
    """按女优编号列片单:分页、预购、官方番号与翻页止点(ADR-0135)。"""

    def test_pages_until_the_catalogue_says_there_is_no_more(self):
        first = [listed("mizd00570", status="PRE_ORDER", date="2026-11-27T00:00:00+09:00"),
                 *[listed(f"mide{index:05d}") for index in range(99)]]
        second = [listed("h_068mxdlp00343", date="2026-10-01T10:00:40+09:00")]
        codes = {"mizd00570": "MIZD-570", "h_068mxdlp00343": "MXDLP-0343"}
        transport = catalogue({0: (first, True, 101), 100: (second, False, 101)}, codes)
        works = DmmSource().actress_works("1061586", session=Session(transport))
        self.assertEqual([call[:2] for call in transport.calls if call[0] == "list"], [("list", 0), ("list", 100)])
        self.assertEqual(transport.calls[0][2], {"actressIds": {"ids": [{"id": "1061586"}]}})
        self.assertEqual((works.name, works.total, len(works.works)), ("森日向子", 101, 101))
        head, tail = works.works[0], works.works[-1]
        self.assertEqual((head.cid, head.maker_code, head.pre_order, head.release_date),
                         ("mizd00570", "MIZD-570", True, "2026-11-27"))
        self.assertEqual((tail.maker_code, tail.pre_order, tail.release_date, tail.studio),
                         ("MXDLP-0343", False, "2026-10-01", "ムーディーズ"))
        # 接口没答官方番号的那几部留空,不拿 cid 去推。
        self.assertEqual(works.works[1].maker_code, "")

    def test_a_page_with_a_seen_work_stops_paging_and_skips_its_codes(self):
        first = [listed("mizd00570", status="PRE_ORDER"), listed("mide00001")]
        transport = catalogue({0: (first, True, 500)}, {"mizd00570": "MIZD-570", "mide00001": "MIDE-001"})
        works = DmmSource().actress_works("1061586", session=Session(transport), known={"mide00001"})
        self.assertEqual(transport.calls, [("list", 0, {"actressIds": {"ids": [{"id": "1061586"}]}}),
                                           ("codes", ["mizd00570"])])
        self.assertEqual([work.maker_code for work in works.works], ["MIZD-570", ""])

    def test_the_first_poll_stops_at_the_limit(self):
        page = [listed(f"mide{index:05d}") for index in range(100)]
        transport = catalogue({0: (page, True, 2066), 100: (page, True, 2066)}, {})
        works = DmmSource().actress_works("1008785", session=Session(transport), limit=150)
        self.assertEqual(len(works.works), 150)
        self.assertEqual([call[1] for call in transport.calls if call[0] == "list"], [0, 100])

    def test_an_empty_catalogue_is_zero_works_not_a_failure(self):
        works = DmmSource().actress_works("1051912", session=Session(catalogue({0: ([], False, 0)}, {})))
        self.assertEqual((works.total, works.works), (0, ()))

    def test_failures_are_classified(self):
        silent = Mock()
        with self.assertRaises(SourceFailure) as caught:
            DmmSource().actress_works("森日向子", session=Session(silent))
        self.assertEqual(caught.exception.reason, FailureReason.NOT_FOUND)
        silent.assert_not_called()
        broken = lambda request, timeout, limit: HttpResponse(
            200, {}, json.dumps({"data": {"legacySearchPPV": None}}).encode(), request.url)
        with self.assertRaises(SourceFailure) as caught:
            DmmSource().actress_works("1061586", session=Session(broken))
        self.assertEqual(caught.exception.reason, FailureReason.PARSE_ERROR)

    def test_rate_limits_cool_the_whole_source(self):
        class Limited:
            def __init__(self):
                self.held = []

            def __call__(self, request, timeout, limit):
                return HttpResponse(429, {}, b"", request.url)

            def hold(self, source, action):
                self.held.append((source, action))

        transport, site = Limited(), DmmSource()
        session = Session(transport)
        with self.assertRaises(SourceFailure) as caught, site.holding(session):
            site.actress_works("1061586", session=session)
        self.assertEqual(caught.exception.reason, FailureReason.RATE_LIMITED)
        self.assertEqual(transport.held, [("dmm", "rate_limited")])


if __name__ == "__main__":
    unittest.main()