"""厂牌名录采集:对上账本的那些必须是真的对上了。 这份复核件的失败模式不是报错,是「看着满满当当、一条都不能用」——匹配放宽一点, 351 家会全部对上同一家,人翻到第十行才发现不对。所以这里守的主要是匹配的下限。 另一半是每个名录自己的取条目:三个名录的页面形态互不相同,认错了就是把站头的 自家标识当成某个厂牌的。 """ import importlib.util import json import sqlite3 import sys import tempfile import unittest from pathlib import Path REPO = Path(__file__).resolve().parents[1] def load_module(): sys.path.insert(0, str(REPO / "src")) spec = importlib.util.spec_from_file_location( "harvest_maker_directories", REPO / "scripts" / "harvest_maker_directories.py") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def entry(slug: str, alt: str) -> str: return (f'{alt}') class FakeSite: """按页返回预置 HTML;没预置的页返回空串,等同那一页没有厂牌。""" def __init__(self, pages: dict[str, str]): self.pages = pages self.asked: list[str] = [] def get(self, url: str) -> str: self.asked.append(url) return self.pages.get(url.rsplit("=", 1)[-1], "") class FoldTests(unittest.TestCase): def setUp(self): self.module = load_module() def test_a_purely_japanese_name_folds_to_an_empty_roman_form(self): """纯日文名没有可比的罗马字形,折出来就该是空串。 这条本身不是缺陷,下一条测的才是:调用处必须把空串当不可比。 """ self.assertEqual(self.module.fold_roman("アロマ企画"), "") self.assertEqual(self.module.fold_roman("Aroma Planning"), "aromaplanning") def test_two_unrelated_japanese_names_never_match_through_the_empty_fold(self): """真实事故:`fold` 把每个日文名都折成空串,于是它们互相全等。 首轮探测因此报出 332 个「匹配」,实际只有 29 个。空键必须是不可比, 不是一个所有人共享的桶。 """ by_roman = {} by_text = {self.module.fold_text("ヒビノ"): (1, "ヒビノ")} self.assertIsNone(self.module.match("aroma", "アロマ企画", by_roman, by_text)) def test_text_folding_keeps_japanese_and_drops_only_punctuation(self): """`アリーナ・エンターテインメント` 与 `アリーナエンターテインメント` 是同一家。 中黑、全角括号这类分隔符在两边写法不一,日文字符本身一个都不能丢—— 丢了就等于又造一个空串桶。 """ self.assertEqual(self.module.fold_text("アリーナ・エンターテインメント"), self.module.fold_text("アリーナエンターテインメント")) self.assertNotEqual(self.module.fold_text("ヒビノ"), self.module.fold_text("いんすた")) self.assertNotEqual(self.module.fold_text("ヒビノ"), "") class MatchTests(unittest.TestCase): def setUp(self): self.module = load_module() self.by_roman = { "prestige": (5630, "Prestige"), "aromaplanning": (5566, "Aroma Planning"), "hmjm": (5597, "HMJM"), "waapentertainment": (8555, "Waap Entertainment"), } self.by_text = { self.module.fold_text("Prestige"): (5630, "Prestige"), self.module.fold_text("ヒビノ"): (8624, "ヒビノ"), self.module.fold_text("hmjm"): (5597, "HMJM"), self.module.fold_text("ハマジム"): (5597, "HMJM"), } def test_the_slug_matches_the_canonical_name_directly(self): self.assertEqual(self.module.match("prestige", "プレステージ", self.by_roman, self.by_text), (5630, "Prestige", "slug 归一相等")) def test_the_japanese_listing_name_matches_a_japanese_canonical_name(self): """账本里少数厂牌规范名本身就是日文,那一路只有日文名对得上。""" self.assertEqual(self.module.match("hibino", "ヒビノ", self.by_roman, self.by_text), (8624, "ヒビノ", "日文名相等")) def test_the_slug_matches_a_japanese_alias(self): """`hmjm.gif` 的日文名是 `ハマジム`,账本规范名是 `HMJM`。 slug 一路先命中,这条守的是它命中的确实是同一家。 """ self.assertEqual(self.module.match("hmjm", "ハマジム", self.by_roman, self.by_text), (5597, "HMJM", "slug 归一相等")) def test_an_abbreviated_slug_falls_back_to_a_unique_prefix_candidate(self): """`waap.gif` 对 `Waap Entertainment`:slug 是缩写,只能靠前缀。 判据写进 `how` 列,让人知道这一条比前三路弱。 """ self.assertEqual(self.module.match("waap", "WAAP", self.by_roman, self.by_text), (8555, "Waap Entertainment", "前缀候选")) def test_an_ambiguous_prefix_is_not_a_candidate(self): """前缀撞上两家就没有候选:给人一个五五开的猜测,不如告诉他账本里没有。""" by_roman = dict(self.by_roman, sodcreate=(1, "SOD Create"), sodstar=(2, "SOD Star")) self.assertIsNone(self.module.match("sod", "SOD", by_roman, {})) def test_a_short_slug_never_reaches_the_prefix_path(self): """三个字母以下的 slug 前缀命中太廉价,`vip` 会撞上一串无关厂牌。""" self.assertIsNone(self.module.match("doc", "DOC", {"documentary": (9, "Documentary")}, {})) def test_a_maker_absent_from_the_ledger_returns_no_match(self): """322 家对不上不是失败。它们照样进复核件,是入库时的现成来源。""" self.assertIsNone(self.module.match("kmp", "ケイ・エム・プロデュース", self.by_roman, self.by_text)) class ListingTests(unittest.TestCase): def setUp(self): self.module = load_module() def test_navigation_images_are_not_makers(self): """50 音导航条和厂牌字标是同一批 ``,只能按文件名排掉。""" site = FakeSite({"a": entry("maker_01", "あ行") + entry("50kensaku", "50音検索") + entry("aroma", "アロマ企画")}) self.assertEqual(self.module.makers(site), { "aroma": ("アロマ企画", "https://static.mgstage.com/mgs/img/pc/aroma.gif")}) def test_the_exclusive_badge_is_stripped_from_the_maker_name(self): """`【独占】` 是 MGStage 的销售身份,不是厂牌名的一部分。""" site = FakeSite({"a": entry("luxutv", "【独占】ラグジュTV")}) self.assertEqual(self.module.makers(site)["luxutv"][0], "ラグジュTV") def test_a_maker_listed_on_two_pages_appears_once(self): """`osusume` 是站方推荐位,和音节页整片重合,靠 slug 去重。""" site = FakeSite({"osusume": entry("prestige", "プレステージ"), "ha": entry("prestige", "プレステージ")}) self.assertEqual(list(self.module.makers(site)), ["prestige"]) def test_every_syllabary_page_is_requested(self): site = FakeSite({}) self.module.makers(site) self.assertEqual(len(site.asked), len(self.module.PAGES)) class OnePageSite: """只有一页的名录用的替身:不管问哪个地址都回同一份内容。""" def __init__(self, body: str): self.body = body self.asked: list[str] = [] def get(self, url: str) -> str: self.asked.append(url) return self.body class PrestigeListingTests(unittest.TestCase): def setUp(self): self.module = load_module() def payload(self, *makers) -> str: return json.dumps({"list": list(makers), "count": len(makers)}) def test_the_code_name_is_the_slug_and_the_media_path_is_the_logo(self): site = OnePageSite(self.payload( {"codeName": "nanpatv", "name": "ナンパTV", "media": {"path": "maker/banner-nanpa.jpg"}})) self.assertEqual(self.module.prestige_makers(site), { "nanpatv": ("ナンパTV", "https://www.prestige-av.com/api/media/maker/banner-nanpa.jpg")}) def test_a_maker_without_an_image_is_left_out(self): """没有 `media` 的那条给不出字标地址,出在复核件上只会是一行空证据。""" site = OnePageSite(self.payload( {"codeName": "kanbi", "name": "KANBi", "media": None}, {"codeName": "magic", "name": "MAGIC", "media": {"path": "maker/m.jpg"}})) self.assertEqual(list(self.module.prestige_makers(site)), ["magic"]) class KmpListingTests(unittest.TestCase): def setUp(self): self.module = load_module() def test_the_slug_comes_from_the_label_path(self): site = OnePageSite( 'BAZOOKA') self.assertEqual(self.module.kmp_makers(site), { "bazooka": ("BAZOOKA", "https://www.km-produce.com/img2018/label/bazooka/logo.svg")}) def test_the_site_own_marks_are_not_labels(self): """站头和页脚那两张是 KMP 自己的标识,和 label 图混在同一批 `` 里。""" site = OnePageSite( 'KMP' 'REAL') self.assertEqual(list(self.module.kmp_makers(site)), ["real"]) def test_a_label_without_a_slug_is_keyed_by_its_japanese_name(self): """`file/label_<时间戳>` 这一路没有罗马字 slug,只能靠日文名对账。 拿路径里那串时间戳当键的话,每次厂牌换图它就变成另一家。 """ site = OnePageSite('ヒメゴト') self.assertEqual(list(self.module.kmp_makers(site)), ["ヒメゴト"]) class DirectoryRegistryTests(unittest.TestCase): def setUp(self): self.module = load_module() def test_every_directory_has_its_own_key(self): keys = [entry.key for entry in self.module.DIRECTORIES] self.assertEqual(sorted(keys), sorted(set(keys))) def test_only_mgstage_carries_the_age_gate_cookie(self): """年龄门是 MGStage 一家的要求。把它的 cookie 带到别家去,等于替对方 声明它也有一道门——而那家并没有。 """ gated = {entry.key for entry in self.module.DIRECTORIES if entry.cookies} self.assertEqual(gated, {"mgstage"}) class LedgerTests(unittest.TestCase): def setUp(self): self.module = load_module() def ledger(self, path: Path): connection = sqlite3.connect(path) connection.executescript( "CREATE TABLE entity(id INTEGER PRIMARY KEY, kind TEXT, canonical_name TEXT);" "CREATE TABLE entity_alias(entity_id INTEGER, alias TEXT);" "CREATE TABLE asset_entity(asset_id INTEGER, entity_id INTEGER);") return connection def test_only_studio_entities_are_reconciled(self): """演员和厂牌同住 `entity` 表。不按 kind 过滤,`Jackson` 会对上一个同名演员。""" with tempfile.TemporaryDirectory() as raw: path = Path(raw).resolve() / "ledger.db" connection = self.ledger(path) connection.executemany( "INSERT INTO entity(id,kind,canonical_name) VALUES(?,?,?)", [(1, "studio", "Jackson"), (2, "performer", "Prestige")]) connection.commit() by_roman, _, _ = self.module.ledger_studios(connection) connection.close() self.assertEqual(by_roman.get("jackson"), (1, "Jackson")) self.assertNotIn("prestige", by_roman) def test_asset_counts_come_from_studio_links_only(self): """`assets` 列是复核时的优先级:挂着 425 部片的厂牌值得先看。""" with tempfile.TemporaryDirectory() as raw: path = Path(raw).resolve() / "ledger.db" connection = self.ledger(path) connection.executemany( "INSERT INTO entity(id,kind,canonical_name) VALUES(?,?,?)", [(1, "studio", "Prestige"), (2, "performer", "誰か")]) connection.executemany("INSERT INTO asset_entity(asset_id,entity_id) VALUES(?,?)", [(10, 1), (11, 1), (10, 2)]) connection.commit() _, _, counts = self.module.ledger_studios(connection) connection.close() self.assertEqual(counts, {1: 2}) class LogoStateTests(unittest.TestCase): def setUp(self): self.module = load_module() def test_the_state_column_tells_a_gap_apart_from_a_replacement(self): """补空和替换是两种决定,复核件上必须分得开。""" with tempfile.TemporaryDirectory() as raw: root = Path(raw).resolve() (root / "Prestige.icon.img").write_bytes(b"x") (root / "Prestige.img").write_bytes(b"x") self.assertEqual(self.module.logo_state("Prestige", root), "icon+主位") self.assertEqual(self.module.logo_state("Flower", root), "无图") def test_a_missing_logo_root_reports_no_image_rather_than_raising(self): with tempfile.TemporaryDirectory() as raw: self.assertEqual(self.module.logo_state("Prestige", Path(raw).resolve() / "nope"), "无图") if __name__ == "__main__": unittest.main()