Ctrl K
ComunidadGuías…

tests/test_community_catalog.py

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

Archivo original ↗Preguntar a KiBot
"""社区来源的解析与封面比对:AVBase、JavBus、javdb 各自的页面形状,以及图源印证与只有一个图源时的退路。"""
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

import httpx
from PIL import Image

from peach.community_catalog import COMMUNITY_SOURCES, IMAGE_LIMIT, _download, community_sources_for, verified_cover
from peach.http import HttpResponse
from peach.jav_cover_fetch import Candidate, NotFound, Unavailable, _fetch
from peach.javdb_genres import page_genres
from peach.javdb_public import (PUBLIC_API, PublicIdentityAmbiguous, PublicNotFound, PublicSourcePaused,
                                PublicSourceUnavailable)
from peach.library_processing import _previous_order
from peach.scraping_access import SourcePaused
from peach.sources import (SITE_SOURCES, AVBaseSource, FailureReason, JavBusSource, JavDBPublicSource, JavDBSource,
                           Page, Session, SourceFailure)
from peach.sources.javdb import actresses as javdb_actresses, public_record


def gradient(width, height, rising=True):
    """横向渐变。dHash 只看相邻像素谁亮:升序与降序是两张完全不同的图。"""
    image = Image.new("L", (width, height))
    row = [int(255 * (x if rising else width - 1 - x) / (width - 1)) for x in range(width)]
    image.putdata(row * height)
    buffer = io.BytesIO()
    image.convert("RGB").save(buffer, format="JPEG")
    return buffer.getvalue()


def serve(pages):
    def call(request, timeout, limit):
        found = request.url in pages
        return HttpResponse(200 if found else 404, {}, pages.get(request.url, b""), request.url)
    return call


def work(site, pages, code):
    """走完取页与解析,交出来源快照那份 dict:采集任务与 `scrape_codes` 拿到的就是它。"""
    return site.query(code, session=Session(serve(pages))).payload()


TITLE = "涼森れむ流 HOW TO SEX!!"
DMM_COVER = "https://pics.dmm.co.jp/mono/movie/adult/118abw358/118abw358pl.jpg"
MGS_COVER = "https://image.mgstage.com/images/prestige/abw/358/pb_e_abw-358.jpg"
JAVDB_COVER = "https://c0.jdbstatic.com/covers/zb/Zb7mX.jpg"
JAVBUS_COVER = "https://www.javbus.com/pics/cover/9x2a_b.jpg"
AVBASE_DATA = {"props": {"pageProps": {"works": [
    {"work_id": "ABW-3580", "title": "別の作品", "products": []},
    {"prefix": "prestige", "work_id": "ABW-358", "title": TITLE, "actors": [{"name": "涼森れむ"}], "products": [
        # 收录本作的合集:标题和番号都对不上,封面和厂牌都不该带进来。
        {"source": "duga", "title": "総集編 8時間", "product_id": "prestige-9999",
         "image_url": "https://pic.duga.jp/unsecure/prestige/9999/noauth/jacket.jpg", "maker": {"name": "別メーカー"}},
        {"source": "mgstage", "title": TITLE, "product_id": "ABW-358", "image_url": MGS_COVER,
         "maker": {"name": "プレステージ"}, "date": "Tue May 23 2023 09:00:00 GMT+0900"},
        {"source": "fanza", "title": TITLE, "product_id": "118abw358", "image_url": DMM_COVER,
         "maker": {"name": "プレステージ"}, "label": {"name": "ABSOLUTELY WONDERFUL"},
         "date": "Fri May 26 2023 09:00:00 GMT+0900", "iteminfo": {"director": "監督A"}},
    ]},
]}}}
AVBASE_SEARCH = "https://www.avbase.net/works?q=ABW-358"
#: 形状取自 259LUXU-1514 的实测页:名寄せ的作品标题是 FANZA 合集那一件的标题,
#: 单卖本作的只有 MGStage 那条。
LUXU_TITLE = "ラグジュTV 1485 綺麗で笑顔が素敵な看護師さんが「普通のセックスでは物足りない…」と刺激を求めて登場!"
LUXU_COVER = "https://image.mgstage.com/images/luxutv/sp/259luxu/1514/pake-03_sp-259luxu-1514.jpg"
LUXU_DATA = {"props": {"pageProps": {"works": [
    {"work_id": "259LUXU-1514", "title": "FIRST CLASS ファーストクラス File/006",
     "actors": [{"name": "東條なつ"}], "products": [
         {"source": "fanza", "title": "FIRST CLASS ファーストクラス File/006", "product_id": "118sng013",
          "image_url": "https://pics.dmm.co.jp/mono/movie/adult/118sng013/118sng013pl.jpg",
          "maker": {"name": "プレステージ"}, "label": {"name": "SINGLE"},
          "series": {"name": "FIRST CLASS ファーストクラス"},
          "date": "Fri Aug 16 2024 09:00:00 GMT+0900"},
         {"source": "mgstage", "title": LUXU_TITLE, "product_id": "259LUXU-1514", "image_url": LUXU_COVER,
          "maker": {"name": "ラグジュTV"}, "series": {"name": "ラグジュTV"},
          "date": "Fri Nov 19 2021 09:00:00 GMT+0900"},
     ]},
]}}}
LUXU_SEARCH = "https://www.avbase.net/works?q=259LUXU-1514"
JAVBUS_WORK = "https://www.javbus.com/ABW-358"
JAVDB_SEARCH = "https://javdb.com/search?q=ABW-358&f=all"
JAVDB_DETAIL = "https://javdb.com/v/Zb7mX?locale=zh"


def avbase_page(data):
    return ('<html><script id="__NEXT_DATA__" type="application/json">'
            + json.dumps(data, ensure_ascii=False) + "</script></html>").encode()


#: 形状取自厂牌回查缓存的 JavBus 作品页(PRED-340):字段行之间有换行与缩进,演员不在字段行里。
JAVBUS_PAGE = (f"<h3>ABW-358 {TITLE}</h3>\n"
               '<a class="bigImage" href="/pics/cover/9x2a_b.jpg"><img src="/pics/cover/9x2a_b.jpg"></a>\n'
               '<p><span class="header">識別碼:</span> <span style="color:#CC0000;">ABW-358</span>\n</p>\n'
               '<p><span class="header">發行日期:</span> 2023-05-26</p>\n'
               '<p><span class="header">長度:</span> 210分鐘</p>\n'
               '<p><span class="header">製作商:</span> <a href="https://www.javbus.com/studio/x">プレステージ</a>\n'
               '            </p>            <p><span class="header">發行商:</span> '
               '<a href="https://www.javbus.com/label/y">ABSOLUTELY WONDERFUL</a>\n</p>'
               '<p class="header">類別:<span id="genre-toggle"></span></p>\n'
               '<div class="star-name"><a href="https://www.javbus.com/star/z" title="涼森れむ">涼森れむ</a></div>').encode()
JAVDB_RESULTS = ('<a href="/v/Q1" class="box" title="別"><div class="video-title"><strong>ABW-3580</strong></div></a>'
                 '<a href="/v/Zb7mX" class="box" title="涼森れむ流"><div class="video-title"><strong>ABW-358</strong> '
                 + TITLE + "</div></a>").encode()
#: 站上并存的两种封面标签写法:一种 class 在前、src 在后、中间夹着三个属性;另一种 src 在前。
#: 解析器对两种都要认。
JAVDB_COVER_TAG = (f'<div class="column column-video-cover"><img class="video-cover" width="600" '
                   f'height="404" fetchpriority="high" src="{JAVDB_COVER}" /></div>')
JAVDB_COVER_TAG_EARLIER = f'<img src="{JAVDB_COVER}" class="video-cover" alt="">'
JAVDB_PAGE = (f'<strong class="current-title">{TITLE}</strong>'
              + JAVDB_COVER_TAG
              + '<div class="panel-block first-block"><strong>番號:</strong>&nbsp;<span class="value">'
              '<a href="/video_codes/ABW">ABW</a>-358</span></div>'
              '<div class="panel-block"><strong>日期:</strong>&nbsp;<span class="value">2023-05-23</span></div>'
              '<div class="panel-block"><strong>時長:</strong>&nbsp;<span class="value"> 210 分鍾</span></div>'
              '<div class="panel-block"><strong>片商:</strong>&nbsp;<span class="value">'
              '<a href="/makers/x">PRESTIGE,プレステージ</a></span></div>'
              '<div class="panel-block"><strong>演員:</strong>&nbsp;<span class="value">'
              '<a href="/actors/a" class="actor-female">涼森れむ</a><a href="/actors/b">男優</a></span></div>').encode()


class CommunityCatalogTests(unittest.TestCase):
    def test_avbase_takes_the_fanza_listing_and_skips_compilations(self):
        found = work(AVBaseSource(), {AVBASE_SEARCH: avbase_page(AVBASE_DATA)}, "ABW-358")
        self.assertEqual((found["id"], found["title"], found["maker"], found["label"], found["director"]),
                         ("ABW-358", TITLE, "プレステージ", "ABSOLUTELY WONDERFUL", "監督A"))
        self.assertEqual(found["release_date"], "2023-05-26")
        self.assertEqual(found["actresses"], [{"japanese_name": "涼森れむ"}])
        self.assertEqual(found["cover_urls"], [DMM_COVER, MGS_COVER])
        self.assertEqual(found["source_url"], "https://www.avbase.net/works/prestige:ABW-358")

    def test_avbase_keeps_the_shop_listing_whose_product_number_is_this_code(self):
        """合集顶着作品标题时也不算本作:商品号认得出番号的那条说了算。"""
        found = work(AVBaseSource(), {LUXU_SEARCH: avbase_page(LUXU_DATA)}, "259LUXU-1514")
        self.assertEqual((found["title"], found["maker"], found["series"], found["release_date"]),
                         (LUXU_TITLE, "ラグジュTV", "ラグジュTV", "2021-11-19"))
        self.assertEqual(found["cover_urls"], [LUXU_COVER])

    def test_avbase_tells_an_unknown_code_apart_from_a_page_it_cannot_read(self):
        """搜索无命中是没有;Cloudflare 验证页与站点改版都是「页面结构未识别」,细档分开记。"""
        empty = {"props": {"pageProps": {"works": []}}}
        with self.assertRaises(SourceFailure) as caught:
            work(AVBaseSource(), {AVBASE_SEARCH: avbase_page(empty)}, "ABW-358")
        self.assertEqual((caught.exception.reason, str(caught.exception)),
                         (FailureReason.NOT_FOUND, "AVBase 没有这个番号"))
        with self.assertRaisesRegex(SourceFailure, "AVBase 页面结构未识别") as caught:
            work(AVBaseSource(), {AVBASE_SEARCH: b"<html><title>Just a moment...</title></html>"}, "ABW-358")
        self.assertEqual(caught.exception.reason, FailureReason.CLOUDFLARE_CHALLENGE)
        with self.assertRaisesRegex(SourceFailure, "AVBase 页面结构未识别") as caught:
            work(AVBaseSource(), {AVBASE_SEARCH: b"<html><body>redesigned</body></html>"}, "ABW-358")
        self.assertEqual(caught.exception.reason, FailureReason.PARSE_ERROR)

    def test_avbase_payload_equals_the_snapshot_shape_field_for_field(self):
        """`AVBaseSource` 交出的 dict 逐键钉住。AVBase 不给时长,`runtime` 留 `None`,候选那一路对它按没有处理。"""
        expected = {
            "id": "ABW-358", "source_url": "https://www.avbase.net/works/prestige:ABW-358", "title": TITLE,
            "actresses": [{"japanese_name": "涼森れむ"}], "maker": "プレステージ", "label": "ABSOLUTELY WONDERFUL",
            "series": "", "director": "監督A", "release_date": "2023-05-26", "runtime": None,
            "cover_urls": [DMM_COVER, MGS_COVER], "cover_url": DMM_COVER}
        self.assertEqual(work(AVBaseSource(), {AVBASE_SEARCH: avbase_page(AVBASE_DATA)}, "ABW-358"), expected)
        record = AVBaseSource().parse(Page(AVBASE_SEARCH, avbase_page(AVBASE_DATA)), "ABW-358")
        self.assertEqual((record.source, record.provenance, record.code), ("avbase", "avbase-search", "ABW-358"))
        self.assertEqual(record.payload(), expected, "只喂 parse 一张页面,得到的与走完 fetch 的一样")
        luxu = {
            "id": "259LUXU-1514", "source_url": "https://www.avbase.net/works/259LUXU-1514", "title": LUXU_TITLE,
            "actresses": [{"japanese_name": "東條なつ"}], "maker": "ラグジュTV", "label": "", "series": "ラグジュTV",
            "director": "", "release_date": "2021-11-19", "runtime": None,
            "cover_urls": [LUXU_COVER], "cover_url": LUXU_COVER}
        self.assertEqual(work(AVBaseSource(), {LUXU_SEARCH: avbase_page(LUXU_DATA)}, "259LUXU-1514"), luxu)

    def test_javbus_reads_the_work_page_fields_and_the_big_cover(self):
        found = work(JavBusSource(), {JAVBUS_WORK: JAVBUS_PAGE}, "ABW-358")
        self.assertEqual((found["id"], found["title"], found["maker"], found["label"], found["release_date"], found["runtime"]),
                         ("ABW-358", TITLE, "プレステージ", "ABSOLUTELY WONDERFUL", "2023-05-26", 210))
        self.assertEqual(found["actresses"], [{"japanese_name": "涼森れむ"}])
        self.assertEqual((found["source_url"], found["cover_urls"]), (JAVBUS_WORK, [JAVBUS_COVER]))

    def test_javbus_tells_a_missing_code_apart_from_its_age_gate(self):
        """番号页 404 是没有;年龄门回 200 却没有「識別碼」,要让人去贴 Cookie,而不是记成没有。"""
        with self.assertRaisesRegex(SourceFailure, "JavBus 没有这个番号") as caught:
            work(JavBusSource(), {}, "ABW-358")
        self.assertEqual(caught.exception.reason, FailureReason.NOT_FOUND)
        with self.assertRaisesRegex(SourceFailure, "JavBus 没有这个番号") as caught:
            work(JavBusSource(), {JAVBUS_WORK: JAVBUS_PAGE.replace(b">ABW-358</span>", b">ABW-359</span>")}, "ABW-358")
        self.assertEqual(caught.exception.reason, FailureReason.NOT_FOUND)
        with self.assertRaisesRegex(SourceFailure, "贴上浏览器里的 Cookie") as caught:
            work(JavBusSource(), {JAVBUS_WORK: b"<html><title>Age Verification JavBus</title></html>"}, "ABW-358")
        self.assertEqual(caught.exception.reason, FailureReason.AUTH_REQUIRED)

    def test_javdb_opens_the_exact_code_and_keeps_the_japanese_maker(self):
        found = work(JavDBSource(), {JAVDB_SEARCH: JAVDB_RESULTS, JAVDB_DETAIL: JAVDB_PAGE}, "ABW-358")
        self.assertEqual((found["id"], found["title"], found["maker"], found["release_date"], found["runtime"]),
                         ("ABW-358", TITLE, "プレステージ", "2023-05-23", 210))
        self.assertEqual(
            found["actresses"],
            [{"japanese_name": "涼森れむ", "profile_source": "javdb", "external_id": "a"}],
            "男优不进演员;女优带着她在 javdb 的演员 id")
        self.assertEqual((found["source_url"], found["cover_urls"]), ("https://javdb.com/v/Zb7mX", [JAVDB_COVER]))

    def test_javdb_reads_the_cover_whichever_side_of_the_class_the_src_sits(self):
        """站方在封面那个 img 上改过属性顺序,页面其余部分照常解析,缺的只是封面。"""
        earlier = JAVDB_PAGE.replace(JAVDB_COVER_TAG.encode(), JAVDB_COVER_TAG_EARLIER.encode())
        self.assertNotEqual(earlier, JAVDB_PAGE, '两种写法要真的不一样,否则这条用例什么也没测')
        for page in (JAVDB_PAGE, earlier):
            found = work(JavDBSource(), {JAVDB_SEARCH: JAVDB_RESULTS, JAVDB_DETAIL: page}, "ABW-358")
            self.assertEqual(found["cover_url"], JAVDB_COVER)

    def test_javdb_reads_the_actor_id_whichever_side_of_the_class_it_sits(self):
        """属性的先后由站方模板决定,不该成为拿不到演员 id 的理由。"""
        self.assertEqual(
            javdb_actresses('<a class="actor-female" href="/actors/z9">葵いぶき</a>'),
            [{"japanese_name": "葵いぶき", "profile_source": "javdb", "external_id": "z9"}])
        self.assertEqual(
            javdb_actresses('<a class="actor-female">名字没挂链接</a>'),
            [{"japanese_name": "名字没挂链接", "profile_source": "javdb", "external_id": ""}])

    def test_javdb_categories_keep_source_words_in_the_shared_record_and_snapshot(self):
        categories = ('<div class="panel-block"><strong>類別:</strong><span class="value">'
                      '<a href="/tags/a"> 高跟鞋 </a><a href="/tags/b"><b>絲襪</b></a>'
                      '<a href="/tags/c">アナル</a><a href="/tags/d">未收录分類</a>'
                      '<a href="/tags/a">高跟鞋</a><a href="/tags/empty"> </a></span></div>')
        page = JAVDB_PAGE + categories.encode()
        record = JavDBSource().parse(Page("https://javdb.com/v/Zb7mX", page), "ABW-358")
        self.assertEqual(record.tags, ("高跟鞋", "絲襪", "アナル", "未收录分類"))
        found = work(JavDBSource(), {JAVDB_SEARCH: JAVDB_RESULTS, JAVDB_DETAIL: page}, "ABW-358")
        self.assertEqual(found, record.payload())
        self.assertEqual(found["genres"], ["高跟鞋", "絲襪", "アナル", "未收录分類"])

    def test_javdb_empty_categories_are_an_empty_genres_snapshot(self):
        empty_panel = ('<div class="panel-block"><strong>類別:</strong>'
                       '<span class="value"><a href="/tags/empty"> </a></span></div>')
        for category_panel in ("", empty_panel):
            with self.subTest(category_panel=category_panel):
                record = JavDBSource().parse(
                    Page("https://javdb.com/v/Zb7mX", JAVDB_PAGE + category_panel.encode()), "ABW-358")
                self.assertEqual(record.tags, ())
                self.assertEqual(record.payload()["genres"], [])

    def test_javdb_reports_a_missing_code_and_a_login_wall_differently(self):
        with self.assertRaises(SourceFailure) as caught:
            work(JavDBSource(), {JAVDB_SEARCH: JAVDB_RESULTS.replace(b"<strong>ABW-358</strong>", b"<strong>ABW-359</strong>")},
                 "ABW-358")
        self.assertEqual(caught.exception.reason, FailureReason.NOT_FOUND)
        with self.assertRaisesRegex(SourceFailure, "javdb 要求登录") as caught:
            work(JavDBSource(), {JAVDB_SEARCH: "<title>登入 | JavDB</title>".encode()}, "ABW-358")
        self.assertEqual(caught.exception.reason, FailureReason.AUTH_REQUIRED)

    def test_javbus_payload_equals_the_snapshot_shape_field_for_field(self):
        """`JavBusSource` 交出的 dict 逐键钉住:采集任务与批量脚本认的就是这份。"""
        expected = {
            "id": "ABW-358", "source_url": JAVBUS_WORK, "title": TITLE, "actresses": [{"japanese_name": "涼森れむ"}],
            "maker": "プレステージ", "label": "ABSOLUTELY WONDERFUL", "series": "", "director": "",
            "release_date": "2023-05-26", "runtime": 210, "cover_urls": [JAVBUS_COVER], "cover_url": JAVBUS_COVER}
        self.assertEqual(work(JavBusSource(), {JAVBUS_WORK: JAVBUS_PAGE}, "ABW-358"), expected)
        record = JavBusSource().parse(Page(JAVBUS_WORK, JAVBUS_PAGE), "ABW-358")
        self.assertEqual((record.source, record.provenance, record.code), ("javbus", "javbus-page", "ABW-358"))
        self.assertEqual(record.payload(), expected, "只喂 parse 一张页面,得到的与走完 fetch 的一样")

    def test_javdb_payload_equals_the_snapshot_shape_field_for_field(self):
        expected = {
            "id": "ABW-358", "source_url": "https://javdb.com/v/Zb7mX", "title": TITLE,
            "origin_title": "", "display_title": TITLE, "source_id": "Zb7mX", "javdb_rule": 2,
            "actresses": [{"japanese_name": "涼森れむ", "profile_source": "javdb", "external_id": "a"}],
            "maker": "プレステージ", "label": "", "series": "", "director": "", "release_date": "2023-05-23",
            "runtime": 210, "genres": [], "cover_urls": [JAVDB_COVER], "cover_url": JAVDB_COVER}
        self.assertEqual(work(JavDBSource(), {JAVDB_SEARCH: JAVDB_RESULTS, JAVDB_DETAIL: JAVDB_PAGE}, "ABW-358"), expected)
        record = JavDBSource().parse(Page("https://javdb.com/v/Zb7mX", JAVDB_PAGE), "ABW-358")
        self.assertEqual((record.source, record.provenance, record.code), ("javdb", "javdb-page", "ABW-358"))
        self.assertEqual(record.payload(), expected)

    def test_the_two_sites_report_failures_through_the_shared_reason_table(self):
        """`query` 抛的是细档;`LibraryMetadataProvider` 与 `scrape_codes` 按 `kind` 分「没有」与「未取得」。"""
        with self.assertRaises(SourceFailure) as caught:
            JavBusSource().query("ABW-358", session=Session(serve({JAVBUS_WORK: b"<html>Age Verification</html>"})))
        self.assertEqual(caught.exception.reason, FailureReason.AUTH_REQUIRED)
        with self.assertRaises(SourceFailure) as caught:
            JavBusSource().query("ABW-358", session=Session(serve({})))
        self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NOT_FOUND, "JavBus 没有这个番号"))
        with self.assertRaises(SourceFailure) as caught:
            JavDBSource().query("ABW-358", session=Session(serve({JAVDB_SEARCH: "<title>登入 | JavDB</title>".encode()})))
        self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.AUTH_REQUIRED, "javdb 要求登录"))
        with self.assertRaises(SourceFailure) as caught:
            JavDBSource().query("ABW-358", session=Session(serve({JAVDB_SEARCH: JAVDB_RESULTS})))
        self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NOT_FOUND, "HTTP 404"))
        mismatched = JAVDB_PAGE.replace(b'<a href="/video_codes/ABW">ABW</a>-358', b'<a href="/video_codes/ABW">ABW</a>-359')
        with self.assertRaises(SourceFailure) as caught:
            JavDBSource().parse(Page("https://javdb.com/v/Zb7mX", mismatched), "ABW-358")
        self.assertEqual((caught.exception.reason, caught.exception.kind, str(caught.exception)),
                         (FailureReason.PARSE_ERROR, "unavailable", "javdb 详情页的番号与搜索结果不一致"))

    def test_a_redirect_to_the_now_printing_placeholder_is_not_a_cover(self):
        """DMM 没图时 302 到 590×800 的「准备中」,尺寸够门槛,只能按最终地址认。"""
        placeholder = HttpResponse(200, {}, gradient(590, 800),
                                   "https://pics.dmm.co.jp/mono/movie/adult/now_printing/now_printing.jpg")
        with self.assertRaises(NotFound):
            _fetch(lambda *args: placeholder, DMM_COVER, referer="https://www.dmm.co.jp/", limit=IMAGE_LIMIT)


class VerifiedCoverTests(unittest.TestCase):
    def test_a_community_cover_needs_the_same_picture_from_a_second_origin(self):
        works = [("avbase", {"cover_urls": [DMM_COVER]}), ("javdb", {"cover_urls": [JAVDB_COVER]})]
        pages = {JAVDB_COVER: gradient(800, 534), DMM_COVER: gradient(400, 267)}
        candidate, size, _data, origins = verified_cover(serve(pages), "ABW-358", works)
        self.assertEqual((candidate.url, size, origins), (DMM_COVER, (400, 267), ("dmm", "javdb")))
        self.assertEqual(candidate.referer, "https://www.dmm.co.jp/")
        pages[DMM_COVER] = gradient(400, 267, rising=False)
        with self.assertRaisesRegex(NotFound, "^dmm、javdb 给的封面不是同一张图"):
            verified_cover(serve(pages), "ABW-358", works)

    def test_javbus_is_an_origin_of_its_own(self):
        works = [("javbus", {"cover_urls": [JAVBUS_COVER]}), ("javdb", {"cover_urls": [JAVDB_COVER]})]
        pages = {JAVBUS_COVER: gradient(800, 538), JAVDB_COVER: gradient(800, 534)}
        candidate, _size, _data, origins = verified_cover(serve(pages), "ABW-358", works)
        self.assertEqual((candidate.url, candidate.referer, origins),
                         (JAVBUS_COVER, "https://www.javbus.com/", ("javbus", "javdb")))

    def test_a_javdb_only_cover_waits_for_an_eligible_image(self):
        works = [("avbase", {"cover_urls": []}), ("javdb", {"cover_urls": [JAVDB_COVER]})]
        with self.assertRaisesRegex(NotFound, '无 JavDB 水印'):
            verified_cover(serve({JAVDB_COVER: gradient(800, 534)}), "IPX-060", works)

    def test_a_picture_cut_off_on_the_network_keeps_the_answer_retryable(self):
        """#342:JavBus 那张断在连接、5xx 或冷却上时,剩下的 javdb 图凑不成答复,不能当「没有」记一周。"""
        works = [("javbus", {"cover_urls": [JAVBUS_COVER]}), ("javdb", {"cover_urls": [JAVDB_COVER]})]
        pages = {JAVBUS_COVER: gradient(800, 538), JAVDB_COVER: gradient(800, 534)}

        def failing(failure, pages=pages):
            def call(request, timeout, limit):
                if request.url == JAVBUS_COVER:
                    if isinstance(failure, int):
                        return HttpResponse(failure, {}, b"", request.url)
                    raise failure
                return serve(pages)(request, timeout, limit)
            return call

        conflicting = {**pages, JAVBUS_COVER: gradient(800, 538, rising=False),
                       DMM_COVER: gradient(800, 538, rising=False)}
        failures = ((httpx.ConnectError("proxy down"), Unavailable), (httpx.ReadTimeout("read timeout"), Unavailable),
                    (503, Unavailable), (404, Unavailable), (SourcePaused("来源正在冷却"), SourcePaused))
        for failure, expected in failures:
            with self.subTest(failure=failure), patch("peach.jav_cover_fetch.time.sleep"):
                # 只剩 javdb 带水印的那张;或另两家各给一张对不上:没下到的 JavBus 那张都可能改变结论。
                for served, answered, reason in (
                        (pages, works, "^未取得无 JavDB 水印的封面"),
                        (conflicting, [*works, ("avbase", {"cover_urls": [DMM_COVER]})], "^dmm、javdb 给的封面不是同一张图")):
                    with self.assertRaisesRegex(expected, reason + ".*;另有 1 张封面没下载到$") as caught:
                        verified_cover(failing(failure, served), "ABW-358", answered)
                    self.assertNotIsInstance(caught.exception, NotFound)
                # 下到的图全出自一个图源时照旧用它:别家那张没下到不挡已经能用的封面。
                candidate, _size, _data, origins = verified_cover(
                    failing(failure, {DMM_COVER: gradient(800, 538)}), "ABW-358",
                    [works[0], ("avbase", {"cover_urls": [DMM_COVER]})])
                self.assertEqual((candidate.url, origins), (DMM_COVER, ()))
        with self.assertRaisesRegex(NotFound, "无 JavDB 水印"):
            verified_cover(serve(pages), "ABW-358", [("javbus", {"cover_urls": []}), works[1]])

    def test_a_site_cut_off_while_another_answers_keeps_the_cover_retryable(self):
        """#342:JavBus 作品页断线、AVBase 说没有、javdb 答上;只剩 javdb 带水印的图,不是「来源没有封面」。"""
        from peach.library_processing import LibraryMetadataProvider
        pages = {JAVDB_SEARCH: JAVDB_RESULTS, JAVDB_DETAIL: JAVDB_PAGE, JAVDB_COVER: gradient(800, 534)}

        def transport(request, timeout, limit):
            if request.url == JAVBUS_WORK:
                raise httpx.ConnectError("proxy down")
            return serve(pages)(request, timeout, limit)

        provider = LibraryMetadataProvider.__new__(LibraryMetadataProvider)
        provider.transport = transport
        covers = Path(self.enterContext(tempfile.TemporaryDirectory()))
        with patch("peach.jav_cover_fetch.time.sleep"), \
                patch("peach.jav_cover_fetch.best_cover", side_effect=NotFound("所有渠道都没有候选")), \
                self.assertRaisesRegex(Unavailable, "JavBus") as caught:
            provider.cover("ABW-358", covers)
        self.assertNotIsInstance(caught.exception, NotFound)
        self.assertFalse((covers / "ABW-358.jpg").exists())

    def test_a_small_official_cover_counts_as_the_second_origin(self):
        works = [("javdb", {"cover_urls": [JAVDB_COVER]})]
        small = Candidate("image.mgstage.com", "https://image.mgstage.com/images/x/pf_o1_x.jpg")
        reference = (small, (300, 200), gradient(300, 200))
        candidate, _size, _data, origins = verified_cover(serve({JAVDB_COVER: gradient(900, 600)}), "ORETD-615",
                                                          works, reference=reference)
        self.assertEqual((candidate.url, origins), (small.url, ("javdb", "mgstage")))
        with self.assertRaisesRegex(Unavailable, "下载失败"):
            verified_cover(serve({}), "ORETD-615", works, reference=reference)
        with self.assertRaisesRegex(NotFound, "^javdb、mgstage 给的封面不是同一张图"):
            verified_cover(serve({JAVDB_COVER: gradient(900, 600, rising=False)}), "ORETD-615",
                           works, reference=reference)
        with self.assertRaisesRegex(Unavailable, "下载失败"):
            verified_cover(serve({}), "ORETD-615", works)
        with self.assertRaises(NotFound):
            verified_cover(serve({}), "ORETD-615", [("javdb", {"cover_urls": []})])

    def test_an_animated_picture_or_another_works_picture_is_never_the_cover(self):
        """JavArchive 的图床给 FC2 存 GIF 预览动画,页面上还挂着别的作品的图。"""
        own_gif = "https://img.javstore.net/images/2022/01/03/FC2PPV-2543627.gif"
        other = "https://img.javstore.net/images/2024/01/15/2184960PL.jpg"
        frames = [Image.open(io.BytesIO(gradient(500, 282, rising))) for rising in (True, False)]
        buffer = io.BytesIO()
        frames[0].save(buffer, format="GIF", save_all=True, append_images=frames[1:])
        works = [("javarchive", {"cover_urls": [own_gif, other]})]
        pages = {own_gif: buffer.getvalue(), other: gradient(800, 450)}
        with self.assertRaisesRegex(NotFound, "动图或太小"):
            verified_cover(serve(pages), "FC2-PPV-2543627", works)
        still = "https://img.javstore.net/images/2022/01/03/fc2ppv-2543627.jpg"
        pages[still] = gradient(800, 450)
        works = [("javarchive", {"cover_urls": [own_gif, other, still]})]
        candidate, _size, _data, _origins = verified_cover(serve(pages), "FC2-PPV-2543627", works)
        self.assertEqual(candidate.url, still)


def photo(seed, crop=None, size=None):
    """一张有纹理的「照片」:随机色块再糊一点,特征点才找得到角。`crop` 取其中一块,`size` 缩放。"""
    import random
    from PIL import ImageDraw, ImageFilter
    rng = random.Random(seed)
    image = Image.new("RGB", (800, 900), (128, 128, 128))
    draw = ImageDraw.Draw(image)
    for _ in range(400):
        x, y = rng.randrange(800), rng.randrange(900)
        draw.rectangle((x, y, x + rng.randrange(10, 80), y + rng.randrange(10, 80)),
                       fill=tuple(rng.randrange(256) for _ in range(3)))
    image = image.filter(ImageFilter.GaussianBlur(1))
    if crop:
        image = image.crop(crop)
    if size:
        image = image.resize(size, Image.Resampling.LANCZOS)
    buffer = io.BytesIO()
    image.save(buffer, format="JPEG", quality=90)
    return buffer.getvalue()


@unittest.skipUnless(__import__("importlib").util.find_spec("cv2"), "缺 vision 依赖组")
class SameSceneTests(unittest.TestCase):
    """javdb 给 FC2 的是商品图另取景的方图,JavArchive 转存的是竖版:整图指纹对不上,特征点对得上。"""

    def test_a_square_crop_of_the_same_photo_is_the_same_picture(self):
        from peach.community_catalog import same_scene
        portrait = photo(1, size=(510, 574))
        square = photo(1, crop=(100, 150, 700, 750), size=(276, 276))
        self.assertTrue(same_scene(portrait, square))
        self.assertFalse(same_scene(portrait, photo(2, crop=(100, 150, 700, 750), size=(276, 276))))

    def test_the_official_pages_other_pictures_take_part_in_the_comparison(self):
        """官方那一档按尺寸挑中的是截图;同页那张竖版商品图才和 javdb 的方图对得上。"""
        screenshot = Candidate("img.javstore.net", "https://img.javstore.net/images/FC2PPV-3264420.jpg")
        portrait = "https://img.javstore.net/images/FC2PPV-3264420PS.jpg"
        square = "https://c0.jdbstatic.com/covers/ab/AbCd.jpg"
        pages = {portrait: photo(1, size=(510, 574)),
                 square: photo(1, crop=(100, 150, 700, 750), size=(276, 276))}
        works = [("javdb", {"cover_urls": [square]})]
        reference = (screenshot, (605, 364), photo(3, size=(605, 364)))
        with self.assertRaisesRegex(Unavailable, "不是同一张图"):
            verified_cover(serve(pages), "FC2-PPV-3264420", works, reference=reference)
        candidate, size, _data, origins = verified_cover(
            serve(pages), "FC2-PPV-3264420", works, reference=reference,
            siblings=(screenshot, Candidate("img.javstore.net", portrait)))
        self.assertEqual((candidate.url, size, origins), (portrait, (510, 574), ("img.javstore.net", "javdb")))


class SourceChoiceTests(unittest.TestCase):
    def test_an_fc2_product_number_only_goes_to_javdb(self):
        """AVBase 与 JavBus 的目录里没有 FC2,问了只是各撞一次空搜索。"""
        self.assertEqual(community_sources_for("FC2-PPV-1233719"), ("javdb",))
        self.assertEqual(community_sources_for("fc2-ppv-1233719"), ("javdb",))

    def test_a_studio_code_still_goes_to_all_three(self):
        """厂牌番号三家都有产出,顺序也要保持 javdb 最后;三家都登记在契约的 `SITE_SOURCES` 里。"""
        self.assertEqual(community_sources_for("ORETD-615"), ("avbase", "javbus", "javdb"))
        self.assertEqual(COMMUNITY_SOURCES, ("avbase", "javbus", "javdb"))
        self.assertTrue(set(COMMUNITY_SOURCES) <= set(SITE_SOURCES))

    def test_a_row_without_a_code_asks_nobody(self):
        """番号是空的,三家搜什么都一样:搜索页第一条和这一行没有关系。"""
        self.assertEqual(community_sources_for(""), ())

    def test_a_korean_mib_code_asks_nobody(self):
        """MIB 番号与日本片同形,问 JAV 目录站拿回的是别的作品,只能靠人一条条认出来。"""
        self.assertEqual(community_sources_for("AIN-203"), ())


#: 接口类别按 (作品类型, id) 换回详情页原词;测试只登记这几项。
PUBLIC_GENRES = {"0:28": "單體作品", "0:57": "美臀", "0:74": "肛門・肛交", "3:11": "私人攝影"}
PUBLIC_MOVIE = {
    "id": "Zb7mX", "type": 0, "number": "ABW-358", "title": "顯示標題", "origin_title": TITLE,
    "maker_name": "PRESTIGE,プレステージ", "publisher_name": None, "series_name": None, "director_name": None,
    "release_date": "2023-05-23", "duration": 210,
    "cover_url": "https://tp.spfcas.com/rhe951l4q/covers/zb/Zb7mX.jpg",
    "actors": [{"id": "a", "name": "涼森れむ", "gender": 0}, {"id": "m", "name": "男優", "gender": 1}],
    "tags": [{"id": "28", "name": "单体作品"}, {"id": "57", "name": "屁股"}, {"id": "74", "name": "肛交"},
             {"id": "299", "name": "数位马赛克"}]}


class PublicSourceTests(unittest.TestCase):
    """刮削的 javdb 一问走匿名接口:记录与详情页同形,类别按 id 换回原词,失败按缺失、歧义、冷却分档。"""

    def setUp(self):
        for patcher in (patch.dict("peach.javdb_genres.GENRES", PUBLIC_GENRES, clear=True),
                        patch("peach.javdb_genres.HIDDEN", frozenset({"0:299"}))):
            patcher.start()
            self.addCleanup(patcher.stop)

    def test_public_record_has_the_page_snapshot_shape_and_page_genre_words(self):
        payload = public_record(PUBLIC_MOVIE).payload()
        page = JavDBSource().parse(Page("https://javdb.com/v/Zb7mX", JAVDB_PAGE), "ABW-358").payload()
        self.assertEqual(set(payload), set(page))
        self.assertEqual(payload["genres"], ["單體作品", "美臀", "肛門・肛交"], "译名不进词表,详情页不显示的类别不投出")
        self.assertEqual((payload["title"], payload["display_title"], payload["origin_title"]),
                         (TITLE, "顯示標題", TITLE))
        self.assertEqual(payload["actresses"], [{"japanese_name": "涼森れむ", "profile_source": "javdb", "external_id": "a"}])
        self.assertEqual((payload["maker"], payload["label"], payload["source_url"], payload["source_id"]),
                         ("プレステージ", "", "https://javdb.com/v/Zb7mX", "Zb7mX"))
        record = public_record(PUBLIC_MOVIE)
        self.assertEqual((record.source, record.provenance), ("javdb", "javdb-api"))

    def test_an_unknown_genre_id_withholds_tags_instead_of_guessing(self):
        movie = {**PUBLIC_MOVIE, "tags": [*PUBLIC_MOVIE["tags"], {"id": "91", "name": "乳房"}]}
        self.assertIsNone(page_genres(movie))
        self.assertNotIn("genres", public_record(movie).payload())
        self.assertEqual(page_genres({**PUBLIC_MOVIE, "type": 1, "tags": [{"id": "28", "name": "单体作品"}]}), None,
                         "类别 id 按作品类型分开登记")

    def test_fc2_seller_is_evidence_label_not_studio(self):
        movie = {**PUBLIC_MOVIE, "type": 3, "number": "FC2-1108030", "maker_name": "至高ぷれみあ!",
                 "tags": [{"id": "11", "name": "ハメ撮り"}]}
        payload = public_record(movie).payload()
        self.assertEqual((payload["maker"], payload["label"], payload["genres"]), ("", "至高ぷれみあ!", ["私人攝影"]))

    def test_query_maps_public_failures_without_holding_the_page_cooldown(self):
        session = Session(serve({}))
        cases = ((PublicNotFound("没有"), SourceFailure, FailureReason.NOT_FOUND),
                 (PublicIdentityAmbiguous("同番号对应多个作品"), SourceFailure, FailureReason.PARSE_ERROR),
                 (PublicSourceUnavailable("未取得"), SourceFailure, FailureReason.NETWORK),
                 (PublicSourcePaused("暂时不可用"), SourcePaused, None))
        for error, kind, reason in cases:
            with self.subTest(error=type(error).__name__), patch.object(PUBLIC_API, "movie", side_effect=error):
                with self.assertRaises(kind) as caught:
                    JavDBPublicSource().query("ABW-358", session=session)
                if reason is not None:
                    self.assertEqual(caught.exception.reason, reason)
                    self.assertEqual(caught.exception.cooldown_action, "")
        with patch.object(PUBLIC_API, "movie", side_effect=PublicIdentityAmbiguous("x")):
            with self.assertRaises(SourceFailure) as caught:
                JavDBPublicSource().query("ABW-358", session=session)
        self.assertEqual(caught.exception.detail, "identity_ambiguous")
        self.assertIs(SITE_SOURCES["javdb"], JavDBPublicSource)

    def test_query_returns_the_public_record(self):
        with patch.object(PUBLIC_API, "movie", return_value=PUBLIC_MOVIE) as movie:
            record = JavDBPublicSource().query("ABW-358", session=Session(serve({})))
        movie.assert_called_once_with("ABW-358")
        self.assertEqual((record.code, record.studio, record.runtime), ("ABW-358", "プレステージ", 210))

    def test_wrapped_public_cover_is_unwrapped_before_comparison(self):
        body = gradient(800, 538)
        wrapped = bytes(value ^ 0x7F for value in body)
        url = PUBLIC_MOVIE["cover_url"]
        found, outcome = _download(serve({url: wrapped}), Candidate("tp.spfcas.com", url, ""), deadline=None)
        self.assertEqual((outcome, found.size), ("fetched", (800, 538)))

    def test_same_people_in_another_order_keep_the_previous_candidate_order(self):
        first = [{"name": "七沢みあ", "external_id": "NPD3"}, {"name": "初川みなみ", "external_id": "E6MM"}]
        group = {"candidates_json": json.dumps([{"source": "javdb", "value": first}], ensure_ascii=False)}
        swapped = {"value": list(reversed(first)), "display_value": "初川みなみ、七沢みあ", "warnings": []}
        self.assertEqual(_previous_order(group, "javdb", swapped)["value"], first)
        self.assertEqual(_previous_order(group, "javdb", swapped)["display_value"], "七沢みあ、初川みなみ")
        other = {"value": [first[0]], "display_value": "七沢みあ", "warnings": []}
        self.assertIs(_previous_order(group, "javdb", other), other)
        self.assertIs(_previous_order(group, "javbus", swapped), swapped)
        self.assertIs(_previous_order(None, "javdb", swapped), swapped)

    def test_genre_table_aligns_by_position_infers_hidden_genres_and_refuses_conflicts(self):
        from scripts.generate_javdb_genres import align
        rows = [{"type": 0, "api": [{"id": "57", "name": "屁股"}, {"id": "299", "name": "数位马赛克"}],
                 "html": ["美臀"]},
                {"type": 0, "api": [{"id": "57", "name": "美臀"}, {"id": "30", "name": "女大学生"}],
                 "html": ["美臀", "女大學生"]},
                {"type": 1, "api": [{"id": "30", "name": "3P"}], "html": ["3P"]},
                {"type": 0, "api": [{"id": "30", "name": "3P"}], "html": ["3P"]},
                {"type": 0, "api": [{"id": "8", "name": "精选综合"}], "html": []},
                {"type": 0, "api": [{"id": "65", "name": "苗条"}, {"id": "65", "name": "苗条"}], "html": ["苗條"]},
                {"type": 0, "api": [{"id": "57", "name": "美臀"}, {"id": "390", "name": "LTX破解"}], "html": ["美臀"]},
                {"type": 0, "api": [{"id": "65", "name": "苗条"}, {"id": "299", "name": "数位马赛克"}], "html": ["苗條"]}]
        table, hidden, conflicts, skipped = align(rows)
        self.assertEqual(table, {"0:57": "美臀", "0:65": "苗條", "1:30": "3P"},
                         "重复类别先去重;只多出详情页不显示的那项时其余照样对齐")
        self.assertEqual(hidden, ["0:299"], "只有一部作品为证的 390 不算详情页不显示")
        self.assertEqual(conflicts, {"0:30": ["3P", "女大學生"]})
        self.assertEqual(skipped, 2)


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