"""外部入口的形状判据与修复计划:目录页、检索页要认得出,直达页不许被误判。"""
import importlib.util
import json
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO / "src"))
sys.path.insert(0, str(REPO / "tests"))
from peach.link_repair import ( # noqa: E402
bare_root, confirms, directory_reason, empty_path_segment, html_entity_leak,
index_candidates, is_search_page, page_title, same_document_path, upgraded_scheme,
without_entities,
)
from support.ledger import fresh_ledger # noqa: E402
def load_script():
spec = importlib.util.spec_from_file_location(
"repair_entity_links", REPO / "scripts" / "repair_entity_links.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class DirectoryShapeTests(unittest.TestCase):
def test_a_site_root_is_not_a_person_page(self):
self.assertEqual(directory_reason("http://lightpro.jp/"), "站点根")
self.assertEqual(directory_reason("http://www.julia-official.com"), "站点根")
def test_a_directory_word_at_the_end_is_a_listing(self):
self.assertEqual(directory_reason("http://ai-p.love/model/"), "路径末段是目录词")
self.assertEqual(directory_reason("http://cmore.jp/official/model.html"),
"路径末段是目录词")
def test_an_index_filename_is_stripped_before_judging(self):
"""`/models/kobatomugi/index.html` 是小鸠むぎ本人的页面。
`index` 是「这一层的首页」,不是一个对象;不先摘掉它,目录词表就只看到 `index`
而根本没看它上面那一段,于是一批本人页被判成目录页。
"""
self.assertEqual(directory_reason("http://www.8man.jp/models/kobatomugi/index.html"), "")
self.assertEqual(directory_reason("http://www.allurepro.com/models/index.html"),
"路径末段是目录词")
self.assertEqual(directory_reason("http://www.l-promotion.com/index.html"), "站点根")
def test_a_link_that_others_hang_below_is_a_listing(self):
"""`/model/` 下面挂着 `/model/12110`,那 `/model/` 讲的是「有哪些人」。"""
self.assertEqual(
directory_reason("http://official.nax-pro.com/model/", ["/model/12110", "/model/"]),
"同站另有更深的链接挂在它下面")
def test_a_named_page_under_a_directory_is_left_alone(self):
self.assertEqual(directory_reason("https://so-agent.jp/model/nagi_hikaru/"), "")
self.assertEqual(
directory_reason("http://bambi.ne.jp/official/model.php?alias=aiga_mizuki&id=176"), "")
def test_a_query_only_address_is_still_the_site_root(self):
"""`bambi.ne.jp` 把 `/official/model.php?alias=…` 整段重定向到 `/?alias=…`。
两头标题完全一致,只比标题就会把一条个人页链接换成站点首页,而「新旧标题相同」
看起来还像是很强的证据。
"""
self.assertTrue(bare_root("https://bambi.ne.jp/?alias=hoshina_ai&id=138"))
self.assertTrue(bare_root("https://www.l-promotion.com/index.html"))
self.assertFalse(bare_root("https://bambi.ne.jp/official/model.php?alias=x&id=1"))
def test_a_redirect_to_another_path_is_a_different_document(self):
"""换协议时跳到另一条路径,两头标题一致证明的是「都落在兜底页上」。
`t-powers.co.jp` 把拼错的 `/telent/…` 跳到 `/release/`,`l-promotion.com` 把
`/naiyou.html` 跳到 `/blog/`;扩展名和末尾斜杠的整理才是同一篇文档。
"""
self.assertTrue(same_document_path(
"https://www.prestige-av.com/special/nagisa_konomi",
"https://www.prestige-av.com/special/nagisa_konomi.php"))
self.assertTrue(same_document_path("https://lightpro.jp/talent/kirarin.html",
"https://lightpro.jp/talent/kirarin.html"))
self.assertFalse(same_document_path("https://www.t-powers.co.jp/release/",
"https://www.t-powers.co.jp/telent/ひなた"))
self.assertFalse(same_document_path("https://www.l-promotion.com/blog/",
"https://www.l-promotion.com/naiyou.html"))
def test_an_empty_path_segment_is_visible(self):
self.assertTrue(empty_path_segment("https://www.t-powers.co.jp/talent//"))
self.assertFalse(empty_path_segment("https://www.t-powers.co.jp/talent/"))
class SearchShapeTests(unittest.TestCase):
def test_the_three_search_forms_on_the_ledger_are_caught(self):
for url in ("https://video.dmm.co.jp/av/list/?key=釈アリス",
"https://www.mgstage.com/search/cSearch.php?actor[]=釈アリス&type=top",
"https://www.av-event.jp/search/?q=釈アリス"):
self.assertTrue(is_search_page(url), url)
def test_an_id_bearing_page_is_not_a_search(self):
"""直达页的查询里装的是站内编号或别名,不是检索词。
把 `actress=`、`actress_id=`、`name=` 一起收进检索词表,就会把本来正确的
直达地址判成问题,然后拿它去「修」。
"""
for url in ("https://video.dmm.co.jp/av/list/?actress=1019076",
"https://www.prestige-av.com/actress/actress_detail.php?name=kiduki&actress_id=4822",
"https://senzai.tv/m_detail.asp?idx=10138",
"https://prime-recruit.com/girlInfo.php?id=120"):
self.assertFalse(is_search_page(url), url)
class UrlTextTests(unittest.TestCase):
def test_an_unescaped_entity_breaks_the_query(self):
url = "http://bambi.ne.jp/official/model.php?alias=aiga_mizuki&id=176"
self.assertTrue(html_entity_leak(url))
self.assertEqual(without_entities(url),
"http://bambi.ne.jp/official/model.php?alias=aiga_mizuki&id=176")
def test_a_clean_url_is_left_alone(self):
self.assertFalse(html_entity_leak("https://x.com/alice_710_"))
self.assertEqual(upgraded_scheme("https://x.com/alice_710_"), "")
self.assertEqual(upgraded_scheme("http://official.nax-pro.com/model/"),
"https://official.nax-pro.com/model/")
def test_the_link_itself_comes_first_when_it_is_the_index(self):
"""目录页的索引就是它自己;照死链那样先砍掉末段,人名全在被砍掉的那一页上。"""
self.assertEqual(index_candidates("http://official.nax-pro.com/model/",
include_self=True)[0],
"http://official.nax-pro.com/model/")
def test_a_listing_title_does_not_confirm_an_individual(self):
self.assertEqual(confirms("
モデル | NAX", ["釈アリス"]), "")
self.assertIn("釈アリス", confirms("釈アリス | NAX公式", ["釈アリス"]))
self.assertEqual(page_title(" 釈アリス\n | NAX "), "釈アリス | NAX")
class ReviewTests(unittest.TestCase):
def setUp(self):
self.module = load_script()
self.directory = Path(tempfile.mkdtemp())
self.db = fresh_ledger(self.directory)
self.connection = sqlite3.connect(self.db)
self.connection.executemany(
"INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
" VALUES(?,?,?,?,'2026-09-22T00:00:00Z','2026-09-22T00:00:00Z')",
[(1, "performer", "释爱丽丝", "释爱丽丝"), (2, "performer", "岬樱", "岬樱")])
self.connection.execute(
"INSERT INTO entity_alias(entity_id,alias,normalized_alias,source,confidence)"
" VALUES(1,'釈アリス','釈アリス','test',1.0)")
self.connection.commit()
def tearDown(self):
self.connection.close()
def add(self, link_id, entity_id, link_kind, url, metadata="{}"):
self.connection.execute(
"INSERT INTO entity_link(id,entity_id,link_kind,label,url,hostname,metadata_json,"
"created_at,updated_at) VALUES(?,?,?,'标签',?,'host',?,"
"'2026-09-22T00:00:00Z','2026-09-22T00:00:00Z')",
(link_id, entity_id, link_kind, url, metadata))
self.connection.commit()
def findings(self):
return {row["link_id"]: row
for row in self.module.review(self.module.load_links(self.connection))}
def test_each_shape_gets_its_own_problem_and_fix(self):
self.add(1, 1, "official", "http://official.nax-pro.com/model/")
self.add(2, 2, "official", "https://official.nax-pro.com/model/14717")
self.add(3, 1, "catalog", "https://video.dmm.co.jp/av/list/?key=釈アリス")
self.add(4, 2, "official",
"http://bambi.ne.jp/official/model.php?alias=aiga_mizuki&id=176")
found = self.findings()
self.assertEqual([name for name, _, _ in found[1]["problems"]],
[self.module.DIRECTORY, self.module.PLAIN_HTTP])
self.assertNotIn(2, found)
self.assertEqual(found[3]["problems"][0][:2],
(self.module.SEARCH, self.module.FIX_KEEP_SEARCH))
self.assertEqual([name for name, _, _ in found[4]["problems"]],
[self.module.ENTITY_LEAK, self.module.PLAIN_HTTP])
def test_a_search_page_becomes_a_direct_page_only_with_a_stored_id(self):
"""没有 id 就标 `保留检索页`。按名字拼一个直达地址,打开是别人才麻烦。"""
self.add(1, 1, "catalog", "https://video.dmm.co.jp/av/list/?key=釈アリス")
self.assertEqual(self.findings()[1]["problems"][0][1], self.module.FIX_KEEP_SEARCH)
self.connection.execute(
"INSERT INTO entity_external_ref(entity_id,provider,external_kind,external_id)"
" VALUES(1,'dmm','performer','1019076')")
self.connection.commit()
self.assertEqual(self.findings()[1]["problems"][0][1], self.module.FIX_DIRECT_ID)
def test_two_links_on_one_site_are_reported_and_not_touched(self):
"""两个账号可能都是她的,也可能有一条是别人的。删哪一条都是替用户做决定。"""
self.add(1, 1, "social", "https://x.com/alice_710_")
self.add(2, 1, "social", "https://x.com/shaku_alice")
found = self.findings()
self.assertEqual([name for name, _, _ in found[1]["problems"]],
[self.module.DUPLICATE])
self.assertIsNone(self.module.primary(found[1]))
def test_a_latin_agency_name_is_still_usable_against_a_title(self):
"""事务所和厂牌的规范名常常就是罗马字,它们的官网首页要靠这个名字确认。
日文站检索把罗马字排除在外是对的;标题比对不受那条规矩管,照搬会让这些实体
一个名字都拿不到。
"""
self.assertEqual(self.module.title_names("T-POWERS", []), ["T-POWERS"])
self.assertEqual(self.module.title_names("释爱丽丝", ["釈アリス", "Alice Shaku"]),
["釈アリス", "释爱丽丝"])
self.assertEqual(self.module.title_names("AI", []), [])
def test_the_deepest_fix_wins_when_a_link_breaks_several_rules(self):
"""重探出来的地址本来就是站点现在给的形态,协议问题跟着一起没了。"""
self.add(1, 1, "official", "http://official.nax-pro.com/model/")
self.assertEqual(self.module.primary(self.findings()[1])[1],
self.module.FIX_REDISCOVER)
class WriteTests(unittest.TestCase):
def setUp(self):
self.module = load_script()
self.directory = Path(tempfile.mkdtemp())
self.db = fresh_ledger(self.directory)
self.connection = sqlite3.connect(self.db)
self.connection.execute(
"INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
" VALUES(1,'performer','释爱丽丝','释爱丽丝','2026-09-22T00:00:00Z',"
"'2026-09-22T00:00:00Z')")
self.connection.executemany(
"INSERT INTO entity_link(id,entity_id,link_kind,label,url,hostname,metadata_json,"
"created_at,updated_at) VALUES(?,1,'official','NAX',?,'official.nax-pro.com',"
"'{\"source\": \"minnano-av\"}','2026-09-22T00:00:00Z','2026-09-22T00:00:00Z')",
[(1, "http://official.nax-pro.com/model/"),
(2, "https://official.nax-pro.com/model/617")])
self.connection.commit()
def tearDown(self):
self.connection.close()
def plan(self, link_id, new_url, verdict="ok"):
return {"link_id": str(link_id), "entity_id": "1", "problem": "目录页",
"url": "http://official.nax-pro.com/model/", "new_url": new_url,
"verdict": verdict, "check": "标题自述该人"}
def test_a_repair_keeps_the_original_provenance_and_records_where_it_came_from(self):
plans = [self.plan(1, "https://official.nax-pro.com/model/14717")]
tally = self.module.write_plan(self.connection, plans, "2026-09-22T10:00:00Z")
self.assertEqual(tally["updated"], 1)
url, host, metadata, updated = self.connection.execute(
"SELECT url, hostname, metadata_json, updated_at FROM entity_link WHERE id=1"
).fetchone()
self.assertEqual(url, "https://official.nax-pro.com/model/14717")
self.assertEqual(host, "official.nax-pro.com")
self.assertEqual(updated, "2026-09-22T10:00:00Z")
stored = json.loads(metadata)
self.assertEqual(stored["source"], "minnano-av")
self.assertEqual(stored["repaired_from"]["url"], "http://official.nax-pro.com/model/")
def test_a_clash_with_the_unique_key_skips_the_whole_row(self):
"""同一实体已经有一条指向那个地址时,这一行整条跳过并报出来。
照写会撞 `UNIQUE(entity_id,url)`,整个事务回滚——那时已经改过的行也一起没了,
而日志上只有一句约束错误。
"""
plans = [self.plan(1, "https://official.nax-pro.com/model/617")]
tally = self.module.write_plan(self.connection, plans, "2026-09-22T10:00:00Z")
self.assertEqual(tally, {"updated": 0, "skipped_unique": 1})
self.assertEqual(plans[0]["verdict"], "撞上唯一约束,整行跳过")
self.assertEqual(
self.connection.execute("SELECT url FROM entity_link WHERE id=1").fetchone()[0],
"http://official.nax-pro.com/model/")
def test_only_confirmed_rows_are_written(self):
plans = [self.plan(1, "", verdict="未取得"),
self.plan(2, "https://official.nax-pro.com/model/617", verdict="已是目标")]
self.assertEqual(self.module.write_plan(self.connection, plans, "2026-09-22T10:00:00Z"),
{"updated": 0, "skipped_unique": 0})
if __name__ == "__main__":
unittest.main()