Ctrl K
ComunidadGuías…

tests/test_performer_name_harvest.py

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

Archivo original ↗Preguntar a KiBot
"""javdatabase 名字候选:页面解析、番号入口、匹配门槛与整体产物。"""
from __future__ import annotations

import argparse
import importlib.util
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))

from peach.review_csv import read_rows   # noqa: E402


def load_script():
    path = ROOT / "scripts" / "harvest_javdatabase_names.py"
    spec = importlib.util.spec_from_file_location("harvest_javdatabase_names", path)
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


MODULE = load_script()


def movie_page(*slugs: str) -> str:
    links = "".join(
        f'<a href="https://www.javdatabase.com/idols/{slug}/">idol</a>' for slug in slugs)
    return f"<html><body><h1>CEMD-517</h1>{links}</body></html>"


def idol_page(romaji: str, japanese: str = "", alternate: str = "") -> str:
    """照抄真实 idol 页的资料行写法,标签名和名字之间隔着 `</b>`。

    固定件必须是抓回来的那份 HTML,不是照着记忆重画的:按 `JP: 名字` 写的固定件
    能让测试全绿,而线上一个日文名都采不到。
    """
    facts = ""
    if japanese:
        facts += f"\n\t\t\t\t<b>JP:</b> {japanese} "
        facts += f" - <b>Alt:</b> {alternate}<br>" if alternate else "<br>"
    elif alternate:
        facts += f"\n\t\t\t\t<b>Alt:</b> {alternate}<br>"
    return (f"<html><head><title>{romaji} - JAV Database</title></head><body>"
            f'<h1 class="h3">{romaji} - JAV Profile</h1><b>Age:</b> 27{facts}'
            "</body></html>")


class FakeSite:
    def __init__(self, pages, error=RuntimeError):
        self.pages = pages
        self.error = error
        self.asked = []
        self.fetched = self.cached = self.retried = 0

    def get(self, url, refresh=False):
        self.asked.append(url)
        page = self.pages.get(url)
        if page is None:
            raise self.error(404)
        self.fetched += 1
        return page

    def close(self):
        pass


class ParseTests(unittest.TestCase):
    def test_the_profile_line_yields_japanese_and_alternate_names(self):
        page = idol_page("Rin Oka", "凰華りん", "Rin Natsuki")
        self.assertEqual(MODULE.idol_names(page), [
            ("romaji", "Rin Oka"), ("jp", "凰華りん"), ("alt", "Rin Natsuki"),
        ])

    def test_the_column_name_is_not_taken_as_part_of_the_name(self):
        """标题写成 `Rin Oka - JAV Profile`,后半截是栏目名。"""
        names = MODULE.idol_names(idol_page("Rin Oka"))
        self.assertEqual(names, [("romaji", "Rin Oka")])

    def test_several_alternate_names_are_split(self):
        names = MODULE.idol_names(idol_page("Yua Mikami", "三上悠亜", "Momona Kito, Moa Hoshizora"))
        self.assertEqual([value for field, value in names if field == "alt"],
                         ["Momona Kito", "Moa Hoshizora"])

    def test_a_romaji_name_with_a_space_is_not_split(self):
        self.assertIn(("alt", "Rin Natsuki"),
                      MODULE.idol_names(idol_page("Rin Oka", "凰華りん", "Rin Natsuki")))

    def test_the_captured_markup_parses(self):
        """2026-09-02 从 `/idols/arina-arata/` 抓下来的原文,逐字保留。"""
        captured = ('<h1 class="h3">Arina Arata - JAV Profile</h1>'
                    'class="btn btn-primary btn-sm">Suggest Tags</a></br>\n\t\t\t\t'
                    "<b>JP:</b> 新ありな  - <b>Alt:</b> Iwatani Shiki, Arina Hashimoto,"
                    " Mana Kaminogi<br>\n\t\t\t\t"
                    '<a href="https://twitter.com/Arina_aa9" target="_blank"></a>')
        self.assertEqual(MODULE.idol_names(captured), [
            ("romaji", "Arina Arata"), ("jp", "新ありな"), ("alt", "Iwatani Shiki"),
            ("alt", "Arina Hashimoto"), ("alt", "Mana Kaminogi"),
        ])

    def test_the_separator_between_the_two_fields_is_not_part_of_the_name(self):
        names = dict(MODULE.idol_names(idol_page("Rin Oka", "凰華りん", "Rin Natsuki")))
        self.assertEqual(names["jp"], "凰華りん")

    def test_idol_links_come_out_deduplicated_and_in_page_order(self):
        page = movie_page("rin-oka", "yua-mikami", "rin-oka")
        self.assertEqual(MODULE.idol_links(page), [
            "https://www.javdatabase.com/idols/rin-oka/",
            "https://www.javdatabase.com/idols/yua-mikami/",
        ])


class LedgerTests(unittest.TestCase):
    def setUp(self):
        self.connection = sqlite3.connect(":memory:")
        self.connection.executescript(
            "CREATE TABLE entity (id INTEGER PRIMARY KEY, kind TEXT, canonical_name TEXT);"
            "CREATE TABLE entity_alias (entity_id INTEGER, alias TEXT, confidence REAL);"
            "CREATE TABLE asset (id INTEGER PRIMARY KEY, code TEXT, medium TEXT);"
            "CREATE TABLE asset_entity (asset_id INTEGER, entity_id INTEGER, source TEXT);"
        )

    def add(self, name, codes, aliases=()):
        cursor = self.connection.execute(
            "INSERT INTO entity (kind, canonical_name) VALUES ('performer',?)", (name,))
        entity_id = cursor.lastrowid
        for alias in aliases:
            self.connection.execute(
                "INSERT INTO entity_alias (entity_id, alias, confidence) VALUES (?,?,1.0)",
                (entity_id, alias))
        for code in codes:
            asset = self.connection.execute(
                "INSERT INTO asset (code, medium) VALUES (?, 'video')", (code,)).lastrowid
            self.connection.execute(
                "INSERT INTO asset_entity (asset_id, entity_id, source) VALUES (?,?,'r18:performer')",
                (asset, entity_id))
        return entity_id

    def test_romaji_aliases_stay_in_the_held_set(self):
        """`name_chain` 按设计剔掉罗马字;判断「账本有没有」必须连罗马字一起看。"""
        entity_id = self.add("夏木铃", ["FSDSS-249"], ["Rin Natsuki", "凰華りん"])
        self.assertEqual(
            set(MODULE.held_names(self.connection)[entity_id]),
            {"夏木铃", "Rin Natsuki", "凰華りん"},
        )

    def test_one_code_per_movie_no_matter_how_many_files(self):
        entity_id = self.add("夏木铃", ["FSDSS-249", "FSDSS-249", "PPT-123"])
        self.assertEqual(MODULE.codes_of(self.connection, entity_id, 2),
                         ["FSDSS-249", "PPT-123"])

    def test_non_jav_codes_never_become_an_entry_point(self):
        entity_id = self.add("皮皮娘", ["", "2024-01-01-vlog"])
        self.assertEqual(MODULE.codes_of(self.connection, entity_id, 2), [])

    def test_a_name_shared_by_two_performers_is_indexed_to_both(self):
        first = self.add("なぎさ", ["ABC-001"])
        second = self.add("Nagisa", ["DEF-002"], ["なぎさ"])
        index = MODULE.index_names(MODULE.held_names(self.connection))
        self.assertEqual(index["なぎさ"], {first, second})

    def tearDown(self):
        self.connection.close()


class RunTests(unittest.TestCase):
    def setUp(self):
        self.tmp = Path(tempfile.mkdtemp()).resolve()
        self.database = self.tmp / "ledger.db"
        connection = sqlite3.connect(self.database)
        connection.executescript(
            "CREATE TABLE entity (id INTEGER PRIMARY KEY, kind TEXT, canonical_name TEXT);"
            "CREATE TABLE entity_alias (entity_id INTEGER, alias TEXT, confidence REAL);"
            "CREATE TABLE asset (id INTEGER PRIMARY KEY, code TEXT, medium TEXT);"
            "CREATE TABLE asset_entity (asset_id INTEGER, entity_id INTEGER, source TEXT);"
        )
        self.ids = {}
        for name, codes, aliases in (
            ("夏木铃", ["CEMD-517"], ["Rin Natsuki", "凰華りん"]),
            ("ナミちゃん", ["300MIUM-1198"], []),
            ("木村さん", ["MIUM-900"], []),
            ("なぎさ", ["ABC-001"], []),
            ("Nagisa", ["ABC-001"], ["なぎさ"]),
        ):
            entity_id = connection.execute(
                "INSERT INTO entity (kind, canonical_name) VALUES ('performer',?)",
                (name,)).lastrowid
            self.ids[name] = entity_id
            for alias in aliases:
                connection.execute(
                    "INSERT INTO entity_alias (entity_id, alias, confidence) VALUES (?,?,1.0)",
                    (entity_id, alias))
            for code in codes:
                asset = connection.execute(
                    "INSERT INTO asset (code, medium) VALUES (?, 'video')", (code,)).lastrowid
                connection.execute(
                    "INSERT INTO asset_entity (asset_id, entity_id, source)"
                    " VALUES (?,?,'r18:performer')", (asset, entity_id))
        connection.commit()
        connection.close()
        self.site = FakeSite({
            "https://www.javdatabase.com/movies/cemd-517/": movie_page("rin-oka", "someone-else"),
            "https://www.javdatabase.com/idols/rin-oka/":
                idol_page("Rin Oka", "凰華りん", "Rin Natsuki"),
            # 同一部片挂着的另一位女优,名字对不上账本这个人。
            "https://www.javdatabase.com/idols/someone-else/": idol_page("Someone Else", "誰か"),
            "https://www.javdatabase.com/movies/mium-900/": movie_page("kimura-san"),
            "https://www.javdatabase.com/idols/kimura-san/": idol_page("Kimura San", "木村さん"),
            "https://www.javdatabase.com/movies/abc-001/": movie_page("nagisa"),
            "https://www.javdatabase.com/idols/nagisa/": idol_page("Nagisa", "なぎさ"),
        }, error=MODULE.HttpStatusError)

    def rows(self, **overrides):
        output = self.tmp / "javdatabase-names.csv"
        args = argparse.Namespace(database=self.database, output=output, min_assets=1,
                                  codes=2, limit=0, only=None, interval=0, timeout=5,
                                  refresh=False, cache_dir=self.tmp / "cache")
        for key, value in overrides.items():
            setattr(args, key, value)
        self.assertEqual(MODULE.run(args, site=self.site), 0)
        return list(read_rows(output))

    def test_a_name_the_ledger_lacks_is_reported_as_a_candidate(self):
        rows = [row for row in self.rows() if row["entity_id"] == str(self.ids["夏木铃"])]
        candidates = {row["candidate"]: row["verdict"] for row in rows}
        self.assertEqual(candidates["Rin Oka"], MODULE.OK)
        self.assertEqual(candidates["凰華りん"], MODULE.HELD)
        self.assertEqual(candidates["Rin Natsuki"], MODULE.HELD)

    def test_the_other_idol_on_the_same_movie_is_not_attributed(self):
        """一部片挂好几位女优,番号对上不等于整页名字都属于这个人。"""
        candidates = {row["candidate"] for row in self.rows()}
        self.assertNotIn("Someone Else", candidates)
        self.assertNotIn("誰か", candidates)

    def test_a_code_javdatabase_does_not_index_is_a_forensic_failure(self):
        row = next(row for row in self.rows()
                   if row["entity_id"] == str(self.ids["ナミちゃん"]))
        self.assertEqual(row["verdict"], MODULE.MISSING)
        self.assertIn("HTTP 404", row["evidence"])
        self.assertEqual(row["candidate"], "")

    def test_a_page_matching_two_performers_is_left_to_a_human(self):
        rows = [row for row in self.rows() if row["verdict"] == MODULE.AMBIGUOUS]
        self.assertEqual({row["entity_id"] for row in rows},
                         {str(self.ids["なぎさ"]), str(self.ids["Nagisa"])})
        self.assertTrue(all(row["candidate"] == "" for row in rows))

    def test_a_matched_page_is_only_fetched_once_across_performers(self):
        self.rows()
        asked = [url for url in self.site.asked if url.endswith("/idols/nagisa/")]
        self.assertEqual(len(asked), 1)

    def test_candidates_sort_before_the_names_we_already_hold(self):
        verdicts = [row["verdict"] for row in self.rows()]
        self.assertEqual(verdicts[0], MODULE.OK)
        self.assertEqual(verdicts[-1], MODULE.HELD)

    def test_only_narrows_the_batch_without_touching_other_performers(self):
        rows = self.rows(only=str(self.ids["木村さん"]))
        self.assertEqual({row["entity_id"] for row in rows}, {str(self.ids["木村さん"])})
        self.assertEqual([url for url in self.site.asked if "cemd-517" in url], [])


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