tests/test_metadata_r18dev.py
Rama master · réplica de lectura del repositorio original.
"""r18.dev 套进站点解析器契约:作品 JSON 的解析、combined 页的日文补全、payload 逐键钉住,以及失败分档。"""
import json
import tempfile
import unittest
from unittest.mock import Mock, patch
from peach.http import HttpResponse
from peach.jav_cover_fetch import R18_COMBINED, R18_DETAIL, NotFound, Unavailable
from peach.library_processing import LibraryMetadataProvider, describe_failure, is_missing
from peach.metadata import extract_catalog_evidence, extract_peach_fields, original_wording
from peach.scraping_access import SourcePaused
from peach.sources import R18DEV, FailureReason, Page, R18DevSource, Session, SourceFailure
from peach.sources.r18dev import ACTRESS_IMAGE, actresses, with_japanese
DETAIL_URL = "https://r18.dev/videos/vod/movies/detail/-/dvd_id=ABW-358/json"
COMBINED_URL = "https://r18.dev/videos/vod/movies/detail/-/combined=118abw358/json"
JACKET = "https://pics.dmm.co.jp/digital/video/118abw00358/118abw00358pl.jpg"
DETAIL = {"content_id": "118abw358", "dvd_id": "ABW-358", "title": "Remu Style", "release_date": "2023-05-26",
"runtime_minutes": 210, "director": "Charlie Nakata", "maker": {"name": "Prestige"},
"label": {"name": "ABSOLUTELY WONDERFUL"}, "series": {"name": "HOW TO SEX"},
"images": {"jacket_image": {"large": " ", "large2": JACKET}},
"actresses": [{"name": "Remu Suzumori"}], "categories": [{"name": "Slender"}, {"name": "Variety"}]}
COMBINED = {"content_id": "118abw358", "title_ja": "涼森れむ流", "series_name_ja": "保健室の先生",
"label_name_ja": "ABSOLUTELY WONDERFUL", "maker_name_ja": "プレステージ",
"actresses": [{"id": 1051912, "image_url": "suzumori_remu.jpg", "name_kanji": "涼森れむ",
"name_kana": "すずもりれむ", "name_romaji": "Remu Suzumori"}],
"directors": [{"name_kanji": "チャーリー中田"}],
"categories": [{"name_en": "Slender", "name_ja": "スレンダー"}, {"name_en": "Variety", "name_ja": "企画"}]}
JAPANESE_ACTRESS = {"japanese_name": "涼森れむ", "name_kana": "すずもりれむ", "name_romaji": "Remu Suzumori",
"dmm_id": 1051912, "thumb_url": "https://pics.dmm.co.jp/mono/actjpgs/suzumori_remu.jpg",
"profile_source": "r18dev"}
#: `LibraryMetadataProvider.query('ABW-358')` 对这份夹具交出的 dict,逐键钉住。`cover_urls` 是契约统一带的整列,
#: `cover_url` 仍是第一张;`raw` 与 `combined` 原样带着,候选与复核那一路认的就是这份形状。
EXPECTED = {
"id": "ABW-358", "content_id": "118abw358", "source_url": DETAIL_URL, "title": "涼森れむ流", "maker": "Prestige",
"series": "保健室の先生", "release_date": "2023-05-26", "director": "Charlie Nakata", "label": "ABSOLUTELY WONDERFUL",
"runtime": 210, "cover_urls": [JACKET], "cover_url": JACKET, "actresses": [JAPANESE_ACTRESS],
"genres": ["スレンダー", "企画"], "raw": DETAIL,
"translations": [{"language": "ja", "title": "涼森れむ流", "series": "保健室の先生",
"label": "ABSOLUTELY WONDERFUL", "director": "チャーリー中田"}],
"combined": COMBINED}
def serve(pages):
calls = []
def call(request, timeout, limit):
calls.append((request.url, request.headers.get("Referer"), limit))
body = pages.get(request.url)
if isinstance(body, Exception):
raise body
return HttpResponse(200 if body is not None else 404, {}, body or b"", request.url)
call.calls = calls
return call
def encoded(data):
return json.dumps(data, ensure_ascii=False).encode()
class R18DevSourceTests(unittest.TestCase):
def test_the_config_and_the_two_urls_match_the_cover_layer(self):
"""封面层用同一站的同两页(`jav_cover_fetch.R18_DETAIL` / `R18_COMBINED`),地址要对得上。"""
site = R18DevSource()
self.assertIs(site.config, R18DEV)
self.assertEqual((R18DEV.name, R18DEV.label, R18DEV.provider, R18DEV.stage, R18DEV.interval, R18DEV.cookie,
R18DEV.page_limit), ("r18dev", "r18.dev", "r18-json", "official_mirror", 2.0, False, 2 * 1024 * 1024))
self.assertEqual(site.detail_url("ABW-358"), R18_DETAIL.format(code="ABW-358"))
self.assertEqual(site.combined_url("118abw358"), R18_COMBINED.format(content_id="118abw358"))
self.assertEqual(site.detail_url("ABW 358"), DETAIL_URL.replace("ABW-358", "ABW%20358"))
def test_parse_reads_the_english_page_without_any_transport(self):
record = R18DevSource().parse(Page(DETAIL_URL, encoded(DETAIL)), "ABW-358")
self.assertEqual((record.source, record.provenance, record.code, record.source_url),
("r18dev", "r18-json", "ABW-358", DETAIL_URL))
self.assertEqual((record.title, record.studio, record.label, record.series, record.director,
record.release_date, record.runtime, record.cover_urls),
("Remu Style", "Prestige", "ABSOLUTELY WONDERFUL", "HOW TO SEX", "Charlie Nakata",
"2023-05-26", 210, (JACKET,)))
self.assertEqual(record.performers, ({"japanese_name": "Remu Suzumori"},))
self.assertEqual(record.tags, ("Slender", "Variety"))
self.assertEqual((record.extra["content_id"], record.extra["raw"]), ("118abw358", DETAIL))
def test_parse_accepts_the_body_as_str_or_bytes_and_tolerates_missing_fields(self):
"""`_fetch` 交回 bytes;测试桩常给 str,两种都按 JSON 读。站上没给的字段留空,不是 `None`。"""
sparse = {"content_id": "118abw358", "title": "Remu Style"}
record = R18DevSource().parse(Page(DETAIL_URL, json.dumps(sparse)), "ABW-358")
self.assertEqual((record.release_date, record.director, record.cover_urls, record.runtime, record.tags),
("", "", (), None, ()))
payload = record.payload()
self.assertEqual((payload["release_date"], payload["director"], payload["cover_url"], payload["cover_urls"],
payload["genres"], payload["actresses"]), ("", "", "", [], [], []))
def test_with_japanese_replaces_names_and_genres_and_keeps_the_brand_name_maker(self):
record = R18DevSource().parse(Page(DETAIL_URL, encoded(DETAIL)), "ABW-358")
japanese = with_japanese(record, COMBINED)
self.assertEqual(japanese.performers, (JAPANESE_ACTRESS,))
self.assertEqual(japanese.tags, ("スレンダー", "企画"))
self.assertEqual(japanese.studio, "Prestige", "数据库厂牌实体用品牌名,日文写法会另起一个实体")
self.assertEqual(japanese.extra["translations"], EXPECTED["translations"])
self.assertIs(japanese.extra["combined"], COMBINED)
self.assertEqual((japanese.title, japanese.series), ("涼森れむ流", "保健室の先生"))
def test_a_combined_page_for_another_work_counts_as_no_japanese_page(self):
"""英文页的标题、系列与罗马字演员不交出去:落了库,系列与演员就成了英文的实体规范名。"""
record = R18DevSource().parse(Page(DETAIL_URL, encoded(DETAIL)), "ABW-358")
for combined in ({"content_id": "118xyz001", "title_ja": "别的片"}, ["not", "a", "dict"]):
with self.subTest(combined=combined):
bare = with_japanese(record, combined)
self.assertEqual((bare.title, bare.series, bare.performers), ("", "", ()))
self.assertEqual((bare.studio, bare.release_date, bare.cover_urls, bare.tags),
(record.studio, record.release_date, record.cover_urls, record.tags))
def test_an_empty_japanese_side_leaves_title_series_and_cast_empty(self):
record = R18DevSource().parse(Page(DETAIL_URL, encoded(DETAIL)), "ABW-358")
bare = with_japanese(record, {"content_id": "118abw358", "actresses": [{"name_kanji": ""}], "categories": []})
self.assertEqual((bare.title, bare.series, bare.performers, bare.tags), ("", "", (), record.tags))
self.assertEqual(bare.extra["translations"], [{"language": "ja", "title": "", "series": "", "label": "", "director": ""}])
def test_actresses_build_the_official_avatar_only_from_a_bare_filename(self):
rows = [{"id": 7, "image_url": "a.jpg", "name_kanji": "甲"}, {"id": 8, "image_url": "x/y.jpg", "name_romaji": "Otsu"},
{"image_url": "", "name_kanji": "丙"}]
found = actresses(rows)
self.assertEqual([row["thumb_url"] for row in found], [ACTRESS_IMAGE.format(filename="a.jpg"), "", ""])
self.assertEqual([row["japanese_name"] for row in found], ["甲", "", "丙"])
self.assertEqual(found[1]["name_romaji"], "Otsu")
self.assertEqual([row["dmm_id"] for row in found], [7, 8, ""])
self.assertEqual(actresses(None), [])
def test_query_asks_the_two_pages_with_the_site_referer_and_limit(self):
transport = serve({DETAIL_URL: encoded(DETAIL), COMBINED_URL: encoded(COMBINED)})
record = R18DevSource().query("ABW-358", session=Session(transport))
self.assertEqual(transport.calls, [(DETAIL_URL, "https://r18.dev/", 2 * 1024 * 1024),
(COMBINED_URL, "https://r18.dev/", 2 * 1024 * 1024)])
self.assertEqual(record.payload(), EXPECTED)
def test_the_provider_payload_equals_the_snapshot_shape_field_for_field(self):
"""`LibraryMetadataProvider.query` 走的就是这一站;现有用例按 `peach.jav_cover_fetch._fetch` 打桩,桩仍然生效。"""
pages = lambda transport, url, **kwargs: json.dumps(COMBINED if "combined=" in url else DETAIL)
provider = LibraryMetadataProvider.__new__(LibraryMetadataProvider)
provider.transport = Mock()
with patch("peach.jav_cover_fetch._fetch", side_effect=pages) as fetch:
payload = provider.query("ABW-358")
self.assertEqual(payload, EXPECTED)
self.assertEqual([call.args[1] for call in fetch.call_args_list], [DETAIL_URL, COMBINED_URL])
self.assertEqual(fetch.call_args_list[0].kwargs, {"referer": "https://r18.dev/", "limit": 2 * 1024 * 1024, "deadline": None})
def test_without_the_japanese_page_only_the_language_neutral_fields_stand(self):
english = dict(EXPECTED, title="", series="", actresses=[], genres=["Slender", "Variety"])
english.pop("translations")
english.pop("combined")
for body in (Unavailable("HTTP 503"), NotFound("HTTP 404"), b"<html>maintenance</html>"):
with self.subTest(body=body):
transport = serve({DETAIL_URL: encoded(DETAIL), COMBINED_URL: body})
self.assertEqual(R18DevSource().query("ABW-358", session=Session(transport)).payload(), english)
with patch("peach.jav_cover_fetch.NETWORK_RETRY_DELAYS", ()):
import httpx
transport = serve({DETAIL_URL: encoded(DETAIL), COMBINED_URL: httpx.ConnectError("reset")})
self.assertEqual(R18DevSource().query("ABW-358", session=Session(transport)).payload(), english)
def test_failures_come_through_the_shared_reason_table(self):
with self.assertRaises(SourceFailure) as caught:
R18DevSource().query("ABW-358", session=Session(serve({})))
self.assertEqual((caught.exception.reason, caught.exception.kind, str(caught.exception)),
(FailureReason.NOT_FOUND, "not_found", "HTTP 404"))
with self.assertRaises(SourceFailure) as caught:
R18DevSource().parse(Page(DETAIL_URL, encoded(dict(DETAIL, content_id="118abw359"))), "ABW-358")
self.assertEqual((caught.exception.reason, caught.exception.kind, str(caught.exception)),
(FailureReason.PARSE_ERROR, "unavailable", "来源返回的番号不匹配"))
for body in (b"<html>cloudflare</html>", b"[1, 2]"):
with self.subTest(body=body), self.assertRaises(SourceFailure) as caught:
R18DevSource().parse(Page(DETAIL_URL, body), "ABW-358")
self.assertEqual((caught.exception.reason, str(caught.exception)),
(FailureReason.PARSE_ERROR, "r18.dev 返回的不是作品 JSON"))
with self.assertRaises(SourceFailure) as caught:
R18DevSource().query("ABW-358", session=Session(serve({DETAIL_URL: Unavailable("HTTP 503")})))
self.assertEqual((caught.exception.reason, caught.exception.status_code), (FailureReason.SERVER_ERROR, 503))
with self.assertRaises(SourcePaused):
R18DevSource().query("ABW-358", session=Session(serve({DETAIL_URL: SourcePaused("来源正在冷却")})))
with self.assertRaises(SourceFailure) as caught:
R18DevSource().query("ABW-358", session=Session(serve({DETAIL_URL: Unavailable("本趟对外请求配额已用完")})))
self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NETWORK, "本趟对外请求配额已用完"))
class OriginalWordingTests(unittest.TestCase):
"""候选只取原文那一侧:r18 快照顶层的英文标题、系列与罗马字演员进不了候选。"""
ENGLISH = {"source_url": DETAIL_URL, "title": "Remu Style", "maker": "Prestige",
"series": "HOW TO SEX! The Infirmary Teacher", "release_date": "2023-05-26",
"actresses": [{"japanese_name": "Remu Suzumori"}]}
def test_an_empty_japanese_view_does_not_let_the_english_series_through(self):
payload = dict(self.ENGLISH, translations=[{"language": "ja", "title": "", "series": ""}])
fields = extract_peach_fields(payload)
self.assertNotIn("series", fields)
self.assertNotIn("title", fields)
self.assertEqual(fields["studio"]["value"], "Prestige", "厂牌用品牌名,照旧取顶层")
self.assertEqual(fields["release_date"]["value"], "2023-05-26")
def test_an_english_page_snapshot_without_any_japanese_view_gives_no_names(self):
"""日文那一页没取到时的形态,磁盘缓存里也留着这种旧快照。"""
fields = extract_peach_fields(self.ENGLISH)
self.assertEqual({"title", "series", "performers"} & set(fields), set())
self.assertNotIn("title", extract_catalog_evidence(self.ENGLISH))
self.assertEqual(fields["studio"]["value"], "Prestige")
mixed = dict(self.ENGLISH, actresses=[{"japanese_name": "胡桃さくら"}, {"japanese_name": "Remu Suzumori"}])
self.assertEqual([row["name"] for row in extract_peach_fields(mixed)["performers"]["value"]], ["胡桃さくら"])
def test_an_english_original_title_in_an_r18_snapshot_is_source_only(self):
fields = extract_peach_fields(dict(self.ENGLISH, original_title='English alternative'))
self.assertNotIn('original_title', fields)
def test_latin_title_candidates_require_same_work_original_language_evidence(self):
from pathlib import Path
from peach.metadata_auto_apply import _original_title_candidate, _evidence_candidates
candidate = {'source':'r18dev', 'value':'BEAUTY VENUS 5', 'display_value':'BEAUTY VENUS 5'}
self.assertFalse(_original_title_candidate('title', candidate, 'IPX-219'))
self.assertEqual(_evidence_candidates({'code':'IPX-219','candidates':[candidate]}, 'title'), [])
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'source.json'
candidate['raw_snapshot'] = str(path)
payload = {'id':'IPX-219', 'source_url':'https://r18.dev/detail', 'title':'BEAUTY VENUS 5'}
path.write_text(json.dumps(payload),encoding='utf-8')
self.assertFalse(_original_title_candidate('title', candidate, 'IPX-219'))
payload['translations'] = [{'language':'ja','title':'BEAUTY VENUS 5'}]
path.write_text(json.dumps(payload),encoding='utf-8')
self.assertTrue(_original_title_candidate('title', candidate, 'IPX-219'))
self.assertFalse(_original_title_candidate('title', candidate, 'IPX-218'))
self.assertTrue(_original_title_candidate('title', dict(candidate,source='makers'), 'IPX-219'))
def test_a_top_level_value_written_in_japanese_script_stays(self):
payload = dict(self.ENGLISH, series="いつでも使えるオナホ後輩",
translations=[{"language": "ja", "series": ""}])
self.assertEqual(original_wording(payload, "series"), "いつでも使えるオナホ後輩")
self.assertEqual(extract_peach_fields(payload)["series"]["value"], "いつでも使えるオナホ後輩")
def test_the_japanese_view_wins_and_other_sources_keep_their_top_level_values(self):
payload = dict(self.ENGLISH, translations=[{"language": "ja", "title": "涼森れむ流", "series": "保健室の先生"}])
self.assertEqual((original_wording(payload, "title"), original_wording(payload, "series")),
("涼森れむ流", "保健室の先生"))
javbus = {"source_url": "https://www.javbus.com/ABW-358", "title": "Remu Style", "series": "HOW TO SEX",
"actresses": [{"japanese_name": "涼森れむ"}]}
fields = extract_peach_fields(javbus)
self.assertEqual((fields["title"]["value"], fields["series"]["value"]), ("Remu Style", "HOW TO SEX"))
self.assertEqual([row["name"] for row in fields["performers"]["value"]], ["涼森れむ"])
class FailureWordingTests(unittest.TestCase):
"""`is_missing` 与 `describe_failure` 对契约异常给出的分档与措辞,和传输层那两个异常一致。"""
def test_is_missing_recognises_the_not_found_tier_only(self):
self.assertTrue(is_missing(NotFound("HTTP 404")))
self.assertTrue(is_missing(SourceFailure(FailureReason.NOT_FOUND, "AVBase 没有这个番号")))
self.assertTrue(is_missing(SourceFailure(FailureReason.GONE, "已下架")))
self.assertFalse(is_missing(SourceFailure(FailureReason.AUTH_REQUIRED, "javdb 要求登录")))
self.assertFalse(is_missing(Unavailable("HTTP 503")))
self.assertFalse(is_missing(ValueError("x")))
def test_describe_failure_words_a_source_failure_like_an_unavailable(self):
self.assertEqual(describe_failure(SourceFailure(FailureReason.SERVER_ERROR, "HTTP 503", status_code=503)),
"来源返回 HTTP 503")
self.assertEqual(describe_failure(SourceFailure(FailureReason.AUTH_REQUIRED, "javdb 要求登录")), "javdb 要求登录")
self.assertEqual(describe_failure(SourceFailure(FailureReason.PARSE_ERROR, "来源返回的番号不匹配")),
"来源返回的番号不匹配")
self.assertEqual(describe_failure(Unavailable("HTTP 503")), "来源返回 HTTP 503")
self.assertEqual(describe_failure(ValueError("x")), "处理出错(ValueError)")
if __name__ == "__main__":
unittest.main()