"""minnano-av 女优链接采集:只采信重定向,只留站外链接。""" import importlib.util 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_performer_links", REPO / "scripts" / "harvest_performer_links.py") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module # 按 2026-09-01 实测的 actress494354 资料表复刻:站内链接是相对路径,站外是绝对 URL。 PROFILE = """
血液型

A型

出身地

東京都

所属事務所

New Actor eXperience

ブログ

http://twitter.com/alice_710_

公式サイト

nax-pro

タグ

長身

""" class Response: def __init__(self, status, body, url): self.status, self.body, self.url = status, body.encode("utf-8"), url class ParseTests(unittest.TestCase): def setUp(self): self.module = load_module() def test_only_external_links_survive_the_profile_table(self): """站内链接是检索入口,不是这个人的链接。 不筛的话每位女优都会挂上「A型」「東京都」「長身」三条站内跳转, 资料页上的「相关链接」就变成了噪音。 """ fields = self.module.profile_fields(PROFILE) self.assertEqual(set(fields), {"ブログ", "公式サイト"}) self.assertEqual(fields["ブログ"], ["https://twitter.com/alice_710_"]) def test_the_agency_is_read_as_text_because_its_link_is_internal(self): """事务所名值得留下,但它的链接指向站内检索,不能当外链装进去。""" self.assertEqual(self.module.profile_text(PROFILE, "所属事務所"), "New Actor eXperience") def test_a_blog_field_holding_twitter_is_classified_as_social(self): """minnano-av 把 Twitter 放在「ブログ」栏里。 按栏目名分类会把它归成博客;按 host 分类才对得上它实际是什么。 """ self.assertEqual(self.module.classify("https://twitter.com/alice_710_"), ("social", "X @alice_710_")) self.assertEqual(self.module.classify("https://x.com/foo"), ("social", "X @foo")) self.assertEqual(self.module.classify("http://official.nax-pro.com/model/"), ("official", "官方网站")) def test_an_agency_page_is_labelled_with_the_agency_name(self): """域名归属未知时才退回事务所名:那时它至少没有和已知事实矛盾。 退回通用的「官方网站」等于把取到的信息扔掉——资料页上一排链接全叫同一个名字, 点之前看不出哪个是事务所。 """ self.assertEqual( self.module.classify("http://www.t-powers.co.jp/official/talent/x", "T-POWERS"), ("official", "T-POWERS")) def test_the_label_follows_the_domain_not_the_agency_field(self): """白石亚子的「所属事務所」是 T-POWERS,「公式サイト」却是 Prestige 的宣传页。 这是资料表里两行不同的事实。把事务所名贴到这条链接上,一个控件就同时替两家公司 说话:文字写着 T-POWERS,图标和落点都是 Prestige。 """ # 归属表的键和 `host_of` 用同一条归一规则,都不带 `www.`。 owners = {"prestige-av.com": "Prestige"} self.assertEqual( self.module.classify("http://www.prestige-av.com/special/shiraishi_ako.php", "T-POWERS", owners.get), ("official", "Prestige")) # 域名确实归事务所时,两条规则给出同一个答案。 self.assertEqual( self.module.classify("http://www.t-powers.co.jp/official/talent/x", "T-POWERS", {"t-powers.co.jp": "T-POWERS"}.get), ("official", "T-POWERS")) def test_a_personal_platform_account_is_not_an_agency_page(self): """Facebook 和 Linktree 此前不在平台表里,走了 official 分支、被贴上事务所名。""" self.assertEqual( self.module.classify("https://www.facebook.com/miho.tanaka.334491", "T-POWERS"), ("social", "Facebook @miho.tanaka.334491")) self.assertEqual( self.module.classify("http://linktr.ee/sawa_sasaki", "GG"), ("social", "Linktree @sawa_sasaki")) self.assertEqual( self.module.classify("http://kawakami-yuu.livedoor.biz/", "Ys Promotion"), ("social", "博客")) def test_a_blog_is_social_not_an_official_site(self): """`blog.livedoor.jp/kaede_fuyutuki/` 是本人的博客,不是公式站。 实测冬月枫的「公式サイト」栏给的就是它;照栏目名当官网会让标签说谎。 """ self.assertEqual( self.module.classify("http://blog.livedoor.jp/kaede_fuyutuki/", "T-POWERS"), ("social", "博客")) def test_a_lookalike_host_is_not_taken_for_the_real_platform(self): """`notx.com` 不是 x.com。后缀匹配必须落在点边界上。""" kind, _ = self.module.classify("https://notx.com/foo") self.assertEqual(kind, "official") class NameChainTests(unittest.TestCase): def setUp(self): self.module = load_module() def test_the_japanese_stage_name_is_tried_before_the_simplified_chinese_one(self): """账本里 performer 的规范名是简体中文,日文站按它一个都搜不到。 实测:拿 `凉森玲梦` 直接搜,12 位里只有 1 位命中;能用的写法 `涼森れむ` 就在别名里。名字链的次序不是风格问题,它决定了这个脚本有没有用。 """ chain = self.module.name_chain("凉森玲梦", ["涼森れむ", "Remu Suzumori", "すずもりれむ"]) self.assertEqual(chain[0], "涼森れむ") self.assertEqual(chain, ["涼森れむ", "すずもりれむ", "凉森玲梦"]) def test_romaji_never_enters_the_chain(self): """罗马字对日文站基本无效。 留着不只是白跑一次往返:它落空后混进未取得,看起来像「这个人查不到」, 而真相是「我们从没用她的日文名查过」。 """ self.assertNotIn("Alice Shaku", self.module.name_chain("释爱丽丝", ["Alice Shaku", "釈アリス"])) def test_duplicates_collapse_and_a_kanji_only_name_still_qualifies(self): """`美谷朱音` 既是账本规范名也是有效日文写法,实测能直接命中。""" self.assertEqual(self.module.name_chain("美谷朱音", ["美谷朱音", "美谷朱里"]), ["美谷朱音", "美谷朱里"]) def test_a_performer_with_only_latin_names_yields_an_empty_chain(self): self.assertEqual(self.module.name_chain("Cola", ["Cola Sauce"]), []) class RedirectTests(unittest.TestCase): def setUp(self): self.module = load_module() def test_a_unique_match_is_taken_from_the_final_url(self): page = f"{PROFILE}" rows, found, agency, note = self.module.scan( lambda *a: Response(200, page, "https://www.minnano-av.com/actress494354.html"), "釈アリス", 5) self.assertEqual(found, "494354") self.assertEqual(agency, "New Actor eXperience") self.assertEqual({row["url"] for row in rows}, {"https://twitter.com/alice_710_", "http://official.nax-pro.com/model/"}) self.assertIn("actress494354", rows[0]["evidence"]) self.assertIn("2 条外链", note) def test_a_search_result_page_is_refused_even_though_it_is_full_of_actress_links(self): """这是本脚本最危险的失败模式。 检索页正文里同样有一堆 `actressNNN.html`——那是「相关女优」。按正文解析会把 别人的社媒安到这个人头上,而且看起来完全正常。信号只在最终地址里, 所以停在 search_result.php 就必须判未取得,哪怕正文看起来很有料。 """ body = ('AB' + PROFILE) rows, found, _, note = self.module.scan( lambda *a: Response(200, body, "https://www.minnano-av.com/search_result.php?search_word=x"), "さくら", 5) self.assertEqual((rows, found), ([], "")) self.assertIn("未唯一命中", note) def test_a_non_200_is_reported_as_未取得(self): rows, _, _, note = self.module.scan( lambda *a: Response(503, "", "https://www.minnano-av.com/"), "誰か", 5) self.assertEqual(rows, []) self.assertIn("503", note) def test_the_search_url_encodes_japanese_as_utf8(self): self.assertTrue(self.module.search_url("釈アリス").endswith( "%E9%87%88%E3%82%A2%E3%83%AA%E3%82%B9")) class SelectionTests(unittest.TestCase): def setUp(self): self.module = load_module() self.tmp = Path(tempfile.mkdtemp()) def ledger(self, rows, aliases=()): db = self.tmp / "ledger.db" connection = sqlite3.connect(db) connection.execute("CREATE TABLE entity(id INTEGER PRIMARY KEY, kind TEXT, canonical_name TEXT)") connection.execute("CREATE TABLE entity_alias(entity_id INTEGER, alias TEXT, confidence REAL DEFAULT 1)") connection.execute("CREATE TABLE asset(id INTEGER PRIMARY KEY, medium TEXT, code TEXT)") connection.execute("CREATE TABLE asset_entity(asset_id INTEGER, entity_id INTEGER)") connection.executemany("INSERT INTO entity_alias(entity_id,alias) VALUES(?,?)", aliases) asset_id = 0 for entity_id, name, codes in rows: connection.execute("INSERT INTO entity VALUES(?,?,?)", (entity_id, "performer", name)) for code in codes: asset_id += 1 connection.execute("INSERT INTO asset VALUES(?,?,?)", (asset_id, "video", code)) connection.execute("INSERT INTO asset_entity VALUES(?,?)", (asset_id, entity_id)) connection.commit() connection.close() return sqlite3.connect(f"file:{db}?mode=ro", uri=True) def test_a_performer_with_no_jav_code_is_skipped(self): """minnano-av 是 JAV 资料库;拿中文素人创作者去查必然落空。 更糟的是落空会混进未取得,把真正查不到的 JAV 女优盖住。 """ connection = self.ledger([(1, "立花美涼", ["ABW-001", "ABW-002", "ABW-003"]), (2, "Cola酱", ["", "", ""])]) try: self.assertEqual([r["name"] for r in self.module.load_performers(connection, 1)], ["立花美涼"]) finally: connection.close() def test_the_selected_performer_carries_its_japanese_name_chain(self): """选人和取名字必须一起出来。 分两步做的话,调用方很容易只拿到 canonical_name 就去查——那就退回到 「拿简体中文搜日文站」的原样,而且不会有任何报错。 """ connection = self.ledger([(1, "凉森玲梦", ["ABW-001"])], aliases=[(1, "涼森れむ"), (1, "Remu Suzumori")]) try: selected = self.module.load_performers(connection, 1) self.assertEqual(selected[0]["chain"], ["涼森れむ", "凉森玲梦"]) finally: connection.close() def test_a_performer_below_the_minimum_is_not_queried(self): connection = self.ledger([(1, "多作", ["ABW-001", "ABW-002"]), (2, "少作", ["ABW-003"])]) try: self.assertEqual([r["name"] for r in self.module.load_performers(connection, 2)], ["多作"]) finally: connection.close() if __name__ == "__main__": unittest.main()