Ctrl K
ComunidadGuías…

tests/test_ad_judgement.py

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

Archivo original ↗Preguntar a KiBot
"""广告复核队列的判据回归。

样本全部来自 2026-08-15 的真实误判与用户手工标记的真广告:命中推广词本身
不是证据,要看剥掉推广词后还剩不剩内容。
"""
import sqlite3
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
from unittest import mock

from peach import junk, web_batch
from peach.web_contract import (
    BUNDLE_DIR_ASSETS,
    CONTENT_BYTES,
    PART_MARK,
    PROMO_CLUSTER_FILES,
    PROMO_DOMAIN,
    PROMO_PHRASE,
    REAL_CODE,
    WebContract,
    has_sibling_original,
    promo_residue,
    q_ads,
    q_items,
    w_batch,
)
from support.ledger import fresh_ledger


def promo_hit(name: str) -> bool:
    return bool(PROMO_PHRASE.search(name) or PROMO_DOMAIN.search(name))


class AdJudgementTests(unittest.TestCase):
    #: 用户手工标记为待删的真广告,名字剥完应当基本不剩内容。
    REAL_ADS = (
        "点击观看 房间火爆",
        "国产大片",
        "女神在线视频www.55h.me",
        "澳门皇冠赌场~vip0955.com",
        "tuu26.com",
        "极欲鬼滅之刃366gm.com",
        "苍老师强力推荐",
        "全中文AV在线观看【9se.tv】",
        "【91狼友之家91home.cc】4",
    )

    #: 真实内容,只是被打了站点水印或剧情里出现了推广同形词。
    REAL_CONTENT = (
        "1248 - #饭饭吖 NTR剧情_给单男口交深喉,被母狗式爆操,还要微信跟老公汇报战果",
        "236953.xyz 推特新晋4年绿帽美腿淫妻网黄「一个ren」《榨精美足》"
        "谁能在这美妙的裸足压榨下保持不败",
        "No.017 - 서서 펠라_뒤치기【02분 34초】",
    )

    def test_real_ads_have_nothing_left_after_stripping_promo(self):
        for name in self.REAL_ADS:
            with self.subTest(name=name):
                self.assertTrue(promo_hit(name), "应命中推广词或域名")
                self.assertLess(promo_residue(name), 14,
                                "广告名剥完不应还剩大段实质描述")

    def test_watermarked_content_keeps_its_description(self):
        """名字里有域名/推广同形词,但正文是真实描述,必须留下足够残留。"""
        for name in self.REAL_CONTENT:
            with self.subTest(name=name):
                self.assertGreaterEqual(
                    promo_residue(name), 14,
                    "真实内容剥完仍应剩下可观描述,否则会被当成广告")

    def test_plot_word_is_not_a_contact_method(self):
        """“微信跟老公汇报战果”是剧情;“加微信”“微信号”才是联系方式。"""
        self.assertIsNone(PROMO_PHRASE.search("还要微信跟老公汇报战果"))
        self.assertIsNotNone(PROMO_PHRASE.search("加微信看完整版"))
        self.assertIsNotNone(PROMO_PHRASE.search("微信号:abc123"))

    def test_domain_is_detected_when_followed_by_a_word_character(self):
        """`uuc82.com_2` 用 \\b 收尾会漏:m 与 _ 都是词字符,构不成边界。"""
        self.assertIsNotNone(PROMO_DOMAIN.search("妹妹在精彩表演 哥哥快来大饱眼福uuc82.com_2"))
        self.assertIsNotNone(PROMO_DOMAIN.search("星空天使ccc.18my.cc"))
        self.assertIsNone(PROMO_DOMAIN.search("普通标题 没有站点"))

    def test_only_real_codes_drive_the_longer_version_rule(self):
        """RAIKUN325 是被误填进 code 的创作者账号,不能拿来比时长。"""
        self.assertIsNone(REAL_CODE.match("RAIKUN325"))
        self.assertIsNotNone(REAL_CODE.match("ABW-153"))
        self.assertIsNotNone(REAL_CODE.match("259LUXU-1141"))
        self.assertIsNotNone(REAL_CODE.match("FC2-PPV-1234567"))

    def test_disposal_marks_mix_two_different_intents(self):
        """`disposal` 同时承载“这是广告”和“这个我不喜欢”,不能当纯广告标注集。

        用户 2026-08-15 澄清:`1726897607_*` 那批 10 个时间戳命名的长录像被标删,
        是因为不喜欢该来源,与广告无关。判据不该、也不可能识别出这类。
        """
        for name in ("1726897607_1726897607_20250831-214903",
                     "1726897607_1726897607_20250823-215124"):
            with self.subTest(name=name):
                self.assertFalse(promo_hit(name), "时间戳文件名不含推广特征")

    def test_part_markers_still_protect_split_volumes(self):
        for name in ("Movie CD2", "feature part1", "作品 分卷", "title-2", "title (3)"):
            with self.subTest(name=name):
                self.assertIsNotNone(PART_MARK.search(name))

    def test_game_interstitial_padding_does_not_count_as_content(self):
        """手游插页的残留全是固定套话,2026-09-03 之前它们把分数压到门槛以下。

        `WAAA-415-uncensored-HD` 目录里这批图命中「扫码」,可剥完剩下的是
        「免费18禁手游」「安装」——一个内容字都没有,却让残留数越算越高。
        """
        for name in ("爱姬远征-免费18禁手游-扫码安装",
                     "天下布魔-免费18禁手游-扫码安装",
                     "工口.R18-成人遊戲-免费18禁手游-扫码安装",
                     "工口MH-扫描访问",
                     "QR CODE--扫一扫"):
            with self.subTest(name=name):
                self.assertTrue(promo_hit(name), "应命中推广词")
                self.assertLess(promo_residue(name), 6,
                                "剥完只剩游戏名,不该算作实质描述")

    def test_filler_words_alone_are_not_evidence(self):
        """「免费」「手游」「安装」只降低残留,自己不构成命中。

        真片名里也有这些词(`免费` 尤其常见),拿它们当命中依据就等于按题材删片。
        """
        for name in ("免费的午后 上门安装师傅", "手游主播的私拍"):
            with self.subTest(name=name):
                self.assertFalse(promo_hit(name))

    def test_shot_tail_only_strips_the_thumbnail_suffix(self):
        """`MIAD573_02_s` 要退回 `MIAD573_02`,不能退到 `MIAD573`:那是另一条正片。"""
        self.assertTrue(has_sibling_original("MIAD573_02_s", {"miad573_02"}))
        self.assertTrue(has_sibling_original("BNST-033-cover", {"bnst-033"}))
        self.assertFalse(has_sibling_original("MIAD573_02_s", {"miad573"}))
        self.assertFalse(has_sibling_original("别的广告图", {"miad573_02"}))

    def test_poster_suffix_pairs_with_the_disc_it_belongs_to(self):
        """`pl`/`ps` 是 JAV 海报的通用写法,`lxvs006pl` 要配上 `LXVS006-BD`。

        配对时剥掉的载体尾巴(`-BD`)在索引一侧完成,这里只验证海报尾巴。
        """
        self.assertTrue(has_sibling_original("lxvs006pl", {"lxvs006"}))
        self.assertTrue(has_sibling_original("lxvs006ps", {"lxvs006"}))
        self.assertFalse(has_sibling_original("lxvs006pl", {"cawd241"}))

    def test_content_threshold_is_far_above_any_promo_image(self):
        """插页整包加起来不到 10 MB;判据只要不把这类体积算成内容就够。"""
        self.assertGreaterEqual(CONTENT_BYTES, 512 * 1024**2)


class ResourceJunkQueueTests(unittest.TestCase):
    """物理资源都要经过垃圾判断,类型不能成为免检条件。"""

    def setUp(self):
        self.temporary = tempfile.TemporaryDirectory()
        self.addCleanup(self.temporary.cleanup)
        self.db_path = fresh_ledger(Path(self.temporary.name).resolve())
        self.contract = WebContract(self.db_path)

    def add(self, asset_id, location, path, medium, size=1000, duration=None,
            creator=None, disposal=None, code=None):
        connection = sqlite3.connect(self.db_path)
        try:
            connection.execute(
                "INSERT INTO asset(id,location,path,name,medium,size,duration,creator,disposal,code) "
                "VALUES(?,?,?,?,?,?,?,?,?,?)",
                (asset_id, location, path, path.rsplit("\\", 1)[-1], medium,
                 size, duration, creator, disposal, code),
            )
            connection.commit()
        finally:
            connection.close()

    def test_automatic_isolation_requires_a_scope_and_observes_source_and_directory_boundaries(self):
        for aid, location, folder in ((1, 'local', 'media'), (2, 'local', 'media2'), (3, '115', 'media')):
            self.add(aid, location, rf'R:\{folder}\点击进入.url', 'other')
        with self.assertRaises(ValueError):
            junk.isolate(self.db_path)
        self.assertEqual(junk.isolate(self.db_path, asset_ids=[])['isolated'], 0)
        self.assertEqual(junk.isolate(self.db_path, roots=[])['isolated'], 0)
        checked = junk.isolate(self.db_path, roots=[('local', r'r:\MEDIA')])
        self.assertEqual(checked['ids'], [1])
        self.assertEqual(junk.isolate(self.db_path, roots=[('local', r'R:\media')])['isolated'], 0)
        with self.contract.read_connection() as connection:
            self.assertEqual([tuple(row) for row in connection.execute(
                'SELECT id,disposal,disposal_reason FROM asset ORDER BY id')],
                [(1, 'trash', 'junk'), (2, None, None), (3, None, None)])

    def test_automatic_isolation_preserves_user_choices_records_subtitles_and_main_identity(self):
        for aid in range(1, 8):
            self.add(aid, 'local', rf'R:\media\{aid}\点击进入.url', 'other')
        self.add(8, 'local', r'R:\media\123.com-ABC-123.mp4', 'video', 2000, 5)
        with self.contract.database.write_transaction() as connection:
            connection.execute("UPDATE asset SET feedback='like' WHERE id=1")
            connection.execute('UPDATE asset SET play_count=1 WHERE id=2')
            connection.execute("INSERT INTO watch_queue(profile_id,asset_id,added_at) VALUES('local-default',3,'now')")
            connection.execute("INSERT INTO asset_subtitle(asset_id,location,path,name,format,pairing,first_seen,last_seen) "
                               "VALUES(4,'local','R:\\media\\sub.srt','sub.srt','srt','exact','now','now')")
        w_batch(self.contract, {'ids': [5], 'operation': 'dismiss-junk'})
        checked = junk.isolate(self.contract.database, asset_ids=list(range(1, 9)), protected_ids=[6])
        self.assertEqual(checked['ids'], [7])
        w_batch(self.contract, {'ids': [7], 'operation': 'restore'})
        w_batch(self.contract, {'ids': [7], 'operation': 'dismiss-junk'})
        self.assertEqual(junk.isolate(self.db_path, asset_ids=[7])['isolated'], 0)

    def test_isolation_rechecks_evidence_after_scoring(self):
        self.add(1, 'local', r'R:\media\点击进入.url', 'other')
        original = junk.scored_candidates
        def score_then_change(*args, **options):
            result = original(*args, **options)
            with self.contract.database.write_transaction() as connection:
                connection.execute("UPDATE asset SET name='notes.txt' WHERE id=1")
            return result
        with mock.patch.object(junk, 'scored_candidates', side_effect=score_then_change):
            self.assertEqual(junk.isolate(self.db_path, asset_ids=[1])['isolated'], 0)

    def test_unmeasured_and_failed_videos_await_review(self):
        for aid, duration in enumerate((None, 0, -1), 1):
            self.add(aid, 'local', rf'R:\media\{aid}\点击进入.mp4', 'video', 1000, duration)
        result = junk.isolate(self.db_path, asset_ids=[1, 2, 3])
        self.assertEqual((result['isolated'], result['review']), (0, 3))

    def test_short_attachment_structure_stays_in_review_during_automatic_processing(self):
        self.release_with_shorts(r'B:\发行物\正片标题')
        checked = junk.isolate(self.db_path, roots=[('115', r'B:\发行物')])
        self.assertEqual((checked['isolated'], checked['review']), (0, 3))

    def test_video_image_audio_archive_and_url_shortcut_are_all_judged(self):
        self.add(1, "115", r"B:\广告\tuu26.com.mp4", "video",
                 10 * 1024**2, 60)
        self.add(2, "115", r"B:\番号\CAWD-241\亚博体育hayob9.com.jpg", "image", 28725)
        self.add(3, "pikpak", r"A:\资源\点击这里.url", "other", 128)
        self.add(4, "local", r"R:\media\福利群vip0955.com.zip", "archive", 2048)
        self.add(5, "local", r"R:\media\苍老师强力推荐.mp3", "audio", 4096)
        # 域名属于作品出处且仍有长内容描述,不是推广垃圾。
        self.add(
            6, "115",
            r"B:\创作者\Dakota Doll - [LegalPorno.com] - [2024] - Breakfast Sex.mp4.jpg",
            "image", 280000, creator="Dakota Doll",
        )
        # online 的 path 本来就是 URL,不是等待清理的物理快捷方式。
        self.add(7, "online", "https://example.com/post/1.url", "other", 0)

        result = q_ads(self.contract, limit=200)

        self.assertEqual(result["total"], 5)
        self.assertEqual(
            {item["medium"] for item in result["items"]},
            {"video", "image", "audio", "archive", "other"},
        )
        by_id = {item["id"]: item for item in result["items"]}
        self.assertIn("网址快捷方式", by_id[3]["why"])
        self.assertEqual(by_id[3]["junk_kind"], "url")
        self.assertIn("整个名字都是推广语", by_id[2]["why"])
        self.assertNotIn(6, by_id)
        self.assertNotIn(7, by_id)
        self.assertTrue(all("path" not in item for item in result["items"]))

    def test_game_download_promo_name_enters_review_without_automatic_trash(self):
        name = "18+游戏大全(996gg.cc)-七龍珠H版-三國志H版-三國群淫傳等.mp4"
        self.add(80, "115", "B:\\云下载\\MNGS-071\\" + name, "video", 2001226, 11.378005)
        self.add(81, "115", "B:\\云下载\\ROYD-358\\" + name, "video", 2001226)
        self.add(82, "115", "B:\\游戏录像\\" + name, "video", 5 * 1024**2, 11)
        self.add(83, "115", "B:\\游戏录像\\七龍珠H版游戏实况.mp4", "video", 2001226, 11)
        self.add(84, "115", "B:\\完整录像\\" + name, "video", 2001226, 3600)
        candidates = {row["id"]: row for row in q_ads(self.contract)["items"]}
        self.assertEqual(set(candidates), {80, 81})
        for row in candidates.values():
            self.assertIn("游戏下载站推广视频", row["why"])
            self.assertGreaterEqual(row["score"], web_batch.JUNK_REVIEW_SCORE)
            self.assertLess(row["score"], web_batch.AUTO_TRASH_SCORE)
        self.assertEqual(junk.isolate(self.db_path, asset_ids=list(candidates))['isolated'], 0)

    def release_with_shorts(self, base, first_id=300, names=None, **short_values):
        self.add(first_id, "115", base + "\\" + base.rsplit("\\", 1)[-1] + ".mp4",
                 "video", 1103584330, 3264.96)
        for offset, name in enumerate(names or ("附件甲", "附件乙", "附件丙"), 1):
            values = dict(size=24880490, duration=19.319002)
            values.update(short_values)
            self.add(first_id + offset, "115", base + "\\" + name + ".mp4", "video", **values)

    def test_short_attachment_cluster_is_reviewed_without_promo_words(self):
        self.release_with_shorts(r"B:\发行物\正片标题", names=(
            "海贼王3D版 成人欲海狂潮 黄 游", "附件甲", "附件乙", "附件丙", "附件丁", "附件戊"))
        items = q_ads(self.contract)["items"]
        self.assertEqual({row["id"] for row in items}, set(range(301, 307)))
        self.assertTrue(all("6 条极短小附件" in row["why"] for row in items))
        self.assertTrue(all(web_batch.JUNK_REVIEW_SCORE <= row["score"] < web_batch.AUTO_TRASH_SCORE
                            for row in items))
        self.assertEqual(junk.isolate(self.db_path, asset_ids=[row['id'] for row in items])['isolated'], 0)

    def test_a_pair_of_shorts_and_standalone_animation_loops_stay_out(self):
        self.release_with_shorts(r"B:\发行物\正片标题", names=("附件甲", "附件乙"))
        for asset_id in range(310, 316):
            self.add(asset_id, "115", rf"B:\画师作品集\动画{asset_id}.mp4", "video", 24880490, 19.319002)
        self.assertEqual(q_ads(self.contract)["items"], [])

    def test_short_attachment_identity_and_measurements_must_be_eligible(self):
        cases = (
            ({"duration": None}, None), ({"duration": 0}, None), ({"duration": -1}, None),
            ({"duration": 31}, None), ({"size": 0}, None),
            ({"size": 33 * 1024**2}, None), ({"creator": "示例画师"}, None),
            ({}, ("作品 CD1", "作品 part2", "作品 分卷")),
            ({}, ("ABW-153", "ABW-154", "ABW-155")),
        )
        for index, (values, names) in enumerate(cases):
            self.release_with_shorts(rf"B:\发行物\正片{index}", 400 + index * 10,
                                     names=names, **values)
        self.assertEqual(q_ads(self.contract)["items"], [])

    def test_short_attachments_can_have_inherited_directory_codes(self):
        self.release_with_shorts(r"B:\发行物\ABW-153", code="ABW-153")
        self.assertEqual({row["id"] for row in q_ads(self.contract)["items"]}, {301, 302, 303})

    def test_mixed_folder_or_inactive_main_cannot_support_attachment_evidence(self):
        for index, main_values in enumerate((
                {"path": r"B:\发行物\正片0\别的标题.mp4"}, {"disposal": "trash"}, {"location": "online"},
                {"duration": 1100}, {"size": 40 * 1024**2})):
            main_id = 500 + index * 10
            self.release_with_shorts(rf"B:\发行物\正片{index}", main_id)
            with closing(sqlite3.connect(self.db_path)) as connection:
                for column, value in main_values.items():
                    connection.execute(f"UPDATE asset SET {column}=? WHERE id=?", (value, main_id))
                connection.commit()
        self.assertEqual(q_ads(self.contract)["items"], [])

    def test_cross_release_spread_uses_measurements_with_different_names(self):
        for index in range(3):
            self.release_with_shorts(rf"B:\发行物\正片{index}", 600 + index * 10,
                                     names=(f"无推广字样的名字{index}",))
        with closing(sqlite3.connect(self.db_path)) as connection:
            connection.execute("UPDATE asset SET width=1920,height=1080")
            connection.commit()
        items = q_ads(self.contract)["items"]
        self.assertEqual({row["id"] for row in items}, {601, 611, 621})
        self.assertTrue(all("3 个发行目录" in row["why"] for row in items))
        self.assertEqual(junk.isolate(self.db_path, asset_ids=[row['id'] for row in items])['isolated'], 0)

    def test_cross_release_spread_requires_full_matching_measurements(self):
        for index in range(3):
            self.release_with_shorts(rf"B:\发行物\正片{index}", 600 + index * 10, names=("附件",))
        self.assertEqual(q_ads(self.contract)["items"], [])
        with closing(sqlite3.connect(self.db_path)) as connection:
            connection.execute("UPDATE asset SET width=1920,height=1080")
            connection.execute("UPDATE asset SET duration=20 WHERE id=621")
            connection.commit()
        self.assertEqual(q_ads(self.contract)["items"], [])

    def test_dismissed_short_attachment_can_be_reconsidered(self):
        self.release_with_shorts(r"B:\发行物\正片标题")
        w_batch(self.contract, {"ids": [301], "operation": "dismiss-junk"})
        self.assertEqual({row["id"] for row in q_ads(self.contract)["items"]}, {302, 303})
        w_batch(self.contract, {"ids": [301], "operation": "reconsider-junk"})
        self.assertEqual({row["id"] for row in q_ads(self.contract)["items"]}, {301, 302, 303})

    def test_domain_watermarked_release_volumes_are_not_junk(self):
        names = ("www.98T.la@FC2-1529680-CD2.mp4", "www.example.com_ABW-153-part1.mp4",
                 "FC2-PPV-1234567-2.mp4", "ABW-153 (3).mp4")
        for asset_id, name in enumerate(names, 700):
            self.add(asset_id, "115", "B:\\发行物\\" + name, "video", 10 * 1024**2, 400)
        self.assertEqual(q_ads(self.contract)["items"], [])
        self.assertEqual(junk.isolate(self.db_path, asset_ids=range(700, 704))['isolated'], 0)

    def test_numbered_source_watermarks_only_enter_review(self):
        for asset_id, name in enumerate(("【来源站example.com】1", "[example.com]84 (2)"), 710):
            self.add(asset_id, "115", "B:\\短片\\" + name + ".mp4", "video", 5 * 1024**2, 10)
        items = q_ads(self.contract)["items"]
        self.assertEqual({row["id"] for row in items}, {710, 711})
        self.assertTrue(all(row["score"] < web_batch.AUTO_TRASH_SCORE for row in items))
        self.assertEqual(junk.isolate(self.db_path, asset_ids=[row['id'] for row in items])['isolated'], 0)

    def test_non_video_trash_items_remain_visible_and_recoverable(self):
        self.add(10, "115", r"B:\番号\广告图.jpg", "image", disposal="trash")
        self.add(11, "115", r"B:\番号\备用网址.url", "other", disposal="trash")
        self.add(12, "115", r"B:\番号\正片.mp4", "video", 1024, 60)

        result = q_items(self.contract, {
            "state": "trash", "limit": "60", "thumb": "1",
            "exclude_vertical": "1", "dur_min": "3600", "jav": "1",
        })

        self.assertEqual(result["total"], 2)
        self.assertEqual(
            {(item["id"], item["medium"]) for item in result["items"]},
            {(10, "image"), (11, "other")},
        )

    def test_nfo_and_large_same_code_clips_never_enter_the_junk_queue(self):
        """NFO 是资料边车;达到 120 MB 的同番号短版本归重复清理,不归垃圾。"""
        self.add(13, "local", r"R:\番号\点击观看.nfo", "other", 4096)
        self.add(14, "local", r"R:\番号\FC2-PPV-1110408.mp4", "video",
                 4 * 1024**3, 50 * 60, code="FC2-PPV-1110408")
        self.add(15, "local", r"R:\番号\FC2-PPV-1110408-2-4K修复.mp4", "video",
                 488 * 1024**2, 3 * 60, code="FC2-PPV-1110408")

        self.assertEqual(q_ads(self.contract, limit=200)["items"], [])

    def test_mib_archives_can_be_excluded_and_reconsidered_per_asset(self):
        """域名文件名可能是真资源;“不是垃圾”必须可持久排除且能撤销。"""
        self.add(20, "115", r"B:\\MIB\\Mib19.com.zip", "archive", 14 * 1024**3)
        self.add(21, "115", r"B:\\MIB\\Mib19.com(2).zip", "archive", 15 * 1024**3)
        self.add(22, "115", r"B:\\广告\\hayob9.com.jpg", "image", 28000)

        pending = q_ads(self.contract, limit=200)
        self.assertEqual(pending["counts"]["archive"], 2)
        self.assertEqual([item["id"] for item in q_ads(
            self.contract, limit=200, kind="archive")["items"]], [21, 20])

        changed = w_batch(self.contract, {"ids": [20], "operation": "dismiss-junk"})
        self.assertEqual(changed["changed"], 1)
        self.assertEqual(q_ads(self.contract, limit=200)["pending_total"], 2)
        dismissed = q_ads(self.contract, limit=200, status="dismissed")
        self.assertEqual([item["id"] for item in dismissed["items"]], [20])
        self.assertEqual(dismissed["counts"]["archive"], 1)

        w_batch(self.contract, {"ids": [20], "operation": "reconsider-junk"})
        self.assertEqual(q_ads(self.contract, limit=200)["pending_total"], 3)

    def test_one_scoring_serves_every_kind_status_and_page(self):
        """打分与类型、状态、分页无关:算一次,各种切法共用;一页里的条目改不到下一页。"""
        self.add(30, "115", r"B:\\广告\\hayob9.com.jpg", "image", 28000)
        self.add(31, "115", r"B:\\MIB\\Mib19.com.zip", "archive", 14 * 1024**3)
        with mock.patch.object(web_batch, "_scored_junk", wraps=web_batch._scored_junk) as scored:
            first = q_ads(self.contract, limit=1)
            archives = q_ads(self.contract, limit=200, kind="archive")
            dismissed = q_ads(self.contract, limit=200, status="dismissed")
        self.assertEqual(scored.call_count, 1)
        self.assertEqual(first["total"], 2)
        self.assertEqual([item["id"] for item in archives["items"]], [31])
        self.assertEqual(dismissed["items"], [])
        first["items"][0]["why"] = ["被改过"]
        self.assertNotEqual(q_ads(self.contract, limit=1)["items"][0]["why"], ["被改过"])
        self.assertEqual(q_ads(
            self.contract, limit=200, status="dismissed")["items"], [])

    def test_game_interstitials_in_a_release_directory_all_enter_the_queue(self):
        """`WAAA-415-uncensored-HD` 那批插页此前一张都没到门槛,只有正片自己入队。"""
        release = r"B:\番号\Wanz Factory\WAAA-415\WAAA-415-uncensored-HD"
        titles = ("爱姬远征", "天下布魔", "奇迹少女", "星隕計畫", "樱境物语",
                  "欲神幻想", "腥空幻想", "工口.R18-成人遊戲", "三国志侵略版")
        for offset, title in enumerate(titles):
            self.add(30 + offset, "115",
                     rf"{release}\{title}-免费18禁手游-扫码安装.jpg", "image", 1024**2)
        self.add(50, "115", rf"{release}\工口MH-扫描访问.png", "image", 966 * 1024)
        # 正片本体:体积与时长都在候选范围外,进不了队列,只作为目录里的邻居存在。
        self.add(51, "115", rf"{release}\WAAA-415-UNCENSORED.mp4", "video",
                 4 * 1024**3, 8888)

        result = q_ads(self.contract, limit=200)

        self.assertEqual(result["total"], 10)
        self.assertNotIn(51, {item["id"] for item in result["items"]})
        by_id = {item["id"]: item for item in result["items"]}
        # 游戏名有六个字,单看名字只够中间档;同目录另外九个同类推广名补齐证据。
        self.assertIn("同目录另有 9 个同类推广名", by_id[38]["why"])
        self.assertIn("整个名字都是推广语", by_id[30]["why"])

    def test_bundled_photo_set_is_a_resource_not_an_ad(self):
        """域名+序号是打包渠道给整包起的名;几十上百张成套的图是资源。"""
        gallery = r"A:\创作者\森萝财团 整合用\森萝财团 X-019 肉丝换白丝 [103P1V-1.39GB]"
        for offset in range(BUNDLE_DIR_ASSETS):
            self.add(60 + offset, "local", rf"{gallery}\jitumi.pw({offset}).gif",
                     "image", 5 * 1024**2)
        # 同样的名字只有一两张挤在别人的番号目录里,那就是插页。
        self.add(90, "115", r"B:\番号\CAWD-241\jitumi.pw(1).gif", "image", 5 * 1024**2)

        result = q_ads(self.contract, limit=200)

        self.assertEqual([item["id"] for item in result["items"]], [90])

    def test_cover_and_screenshot_outlive_their_promo_directory(self):
        """目录名带域名只说明从哪个站下的,说明不了目录里的图是广告。"""
        # 正片本体的体积在候选范围外,这里只作为截图的对照存在。
        self.add(70, "115", r"B:\云下载\Jav.li_MIAD573_HD\MIAD573_02.wmv", "video",
                 800 * 1024**2, 3600, creator="Jav.li_MIAD573_HD")
        self.add(71, "115", r"B:\云下载\Jav.li_MIAD573_HD\MIAD573_02_s.jpg", "image",
                 40000, creator="Jav.li_MIAD573_HD")
        self.add(72, "115", r"B:\创作者\BNST033\aaxv.xyz-BNST033\BNST-033(1).jpg",
                 "image", 40000, code="BNST-033")
        # 同一个广告包目录里,与番号无关的推广图仍然是广告。
        self.add(73, "115", r"B:\创作者\BNST033\aaxv.xyz-BNST033\点击进入.jpg",
                 "image", 40000)

        result = q_ads(self.contract, limit=200)

        self.assertEqual([item["id"] for item in result["items"]], [73])

    def test_bluray_disc_image_and_its_poster_are_not_ads(self):
        """19.8 GB 的原盘和它的海报都是资源;同目录那个推广 rar 仍然是广告。

        样本是 `B:\\云下载\\javme.me_LXVS-006-BD` 的实际三项。原盘在账本里是
        `other`、没有 code、也没有时长,光看名字和目录只剩「域名+番号」这一条证据。
        """
        pack = r"B:\云下载\javme.me_LXVS-006-BD"
        self.add(91, "115", rf"{pack}\LXVS006-BD.iso", "other", 19778371584)
        self.add(92, "115", rf"{pack}\lxvs006pl.jpg", "image", 134351)
        self.add(93, "115", rf"{pack}\九色腾免费高清五码自拍在线观看不卡。.rar",
                 "archive", 1362754)

        result = q_ads(self.contract, limit=200)

        self.assertEqual([item["id"] for item in result["items"]], [93])

    def test_promo_cluster_needs_more_than_a_couple_of_neighbours(self):
        """两张同类插页不构成成群;`PROMO_CLUSTER_FILES` 是这条判据的下限。"""
        self.assertGreaterEqual(PROMO_CLUSTER_FILES, 3)
        release = r"B:\番号\Faleno\FSDSS-258\FSDSS-258.HD"
        for offset in range(PROMO_CLUSTER_FILES - 1):
            self.add(80 + offset, "115",
                     rf"{release}\逆王传说 - 入侵女兒國{offset}-免费18禁手游-扫码安装.jpg",
                     "image", 1024**2)

        result = q_ads(self.contract, limit=200)

        self.assertEqual(result["items"], [])

    def test_ad_flooded_folder_keeps_video_and_its_cover_only(self):
        """用户实例 `B:\\xxr\\0208 (23)`:整包只有正片和它的同名 PNG 是资源。

        26 条广告各有各的自曝形态:装饰符小图、品牌推广卡、`.mhtml` 网页存档、
        目录名自曝(`-APP`、`一键约炮`、`論壇文宣`)里的推广图。
        """
        base = r"B:\xxr\0208 (23)"
        self.add(100, "115", rf"{base}\梓怡-背著老公.mp4", "video",
                 985 * 1024**2, 1647)
        self.add(101, "115", rf"{base}\梓怡-背著老公.png", "image", 2379 * 1024)
        self.add(102, "115", rf"{base}\❤91短视频❤.jpg", "image", 600 * 1024)
        self.add(103, "115", rf"{base}\全国外围-一键约炮.jpg", "image", 606 * 1024)
        self.add(104, "115", rf"{base}\❤成人游戏-导航-.mhtml", "other", 6615 * 1024)
        self.add(105, "115", rf"{base}\_含羞草APP\❤含 羞 草❤.png", "image", 8 * 1024)
        self.add(106, "115",
                 rf"{base}\論壇文宣\(_0高清国产精品资源获取.png", "image", 18 * 1024)

        result = q_ads(self.contract, limit=200)

        self.assertEqual(
            {item["id"] for item in result["items"]},
            {102, 103, 104, 105, 106},
        )
        by_id = {item["id"]: item for item in result["items"]}
        self.assertIn("装饰符小图", by_id[102]["why"])
        self.assertIn("推广品牌名", by_id[103]["why"])
        self.assertIn("网页存档", by_id[104]["why"])

    def test_brand_word_in_a_video_title_is_not_promo_by_itself(self):
        """`麻豆传媒` 一类品牌词也可以是正片标题的一部分,视频单独命中不算证据。"""
        self.add(110, "115", r"B:\番号\麻豆传媒 背著老公的妻子.mp4", "video",
                 400 * 1024**2, 900)

        result = q_ads(self.contract, limit=200)

        self.assertEqual(result["items"], [])

    def test_invalid_junk_filters_are_rejected(self):
        with self.assertRaisesRegex(ValueError, "invalid junk kind"):
            q_ads(self.contract, kind="document")
        with self.assertRaisesRegex(ValueError, "invalid junk status"):
            q_ads(self.contract, status="deleted")

    def test_small_installer_archive_name_enters_review_with_release_code(self):
        path = r'B:\MVP\Tokyo-Hot n1025-FHD\1024核工厂.rar'
        self.add(211, '115', path, 'archive', 4111)
        with closing(sqlite3.connect(self.db_path)) as db:
            db.execute('UPDATE asset SET code=? WHERE id=?', ('n1025', 211))
            db.commit()
        items = q_ads(self.contract, limit=200)['items']
        self.assertEqual([item['id'] for item in items], [211])
        self.assertIn('推广安装包', items[0]['why'])
        self.assertEqual(junk._promo_name_reason('.rar', path, 128 * 1024), '')
        self.assertEqual(junk._promo_name_reason('.rar', r'B:\MVP\n1025.rar', 4111), '')

    def test_promotional_page_navigation_images_with_inherited_release_code(self):
        base = r'B:\MVP\1pon-092415_001-fhd'
        page = base + r'\花一分钟记住我们的网址 - sex5.xxx_files'
        self.add(201, '115', page + r'\banner_b.jpg', 'image', 312739)
        self.add(202, '115', base + r'\img\001.jpg', 'image', 46234)
        self.add(203, '115', page + r'\001.jpg', 'image', 46234)
        self.add(204, '115', base + r'\movie_files\logo.gif', 'image', 3439)
        with closing(sqlite3.connect(self.db_path)) as db:
            db.execute('UPDATE asset SET code=? WHERE id IN (201,202,203,204)', ('092415-001',))
            db.commit()
        items = q_ads(self.contract, limit=200)['items']
        self.assertEqual([item['id'] for item in items], [201])
        self.assertIn('导航图片', items[0]['why'])
        self.assertEqual(junk._attachment_junk_reason('.gif', page + r'\1.gif', 974),
                         '推广网页存档的横幅、标志或导航图片')
        self.assertEqual(junk._attachment_junk_reason('.gif', page + r'\1.gif', 6000), '')
        self.assertTrue(junk._attachment_junk_reason('.gif', page + r'\1000x90yunding.gif', 97843))

    def add_download(self, task_id, code, folder, main):
        with self.contract.database.write_transaction() as connection:
            connection.execute(
                "INSERT INTO download_task(id,provider,source_uri,target,code,state,ledger_path,asset_id,updated_at) "
                "VALUES(?,'pikpak','magnet:?xt=urn:btih:fixture',?,?,'ingested',?,?,'now')",
                (task_id, f'115-{task_id}', code, folder, main))

    def test_download_package_attachments_are_isolated_while_release_files_stay(self):
        # 样本取自 2026-11-03 的只读盘点:整理把正片移走后,推广附件留在落地目录。
        self.add(1, '115', r'B:\S1\SSIS-839\SSIS-839 标题.mp4', 'video', 5 * 1024**3, 7200)
        self.add_download(1, 'SSIS-839', r'B:\云下载\SSIS-839', 1)
        base = r'B:\云下载\SSIS-839' + '\\'
        self.add(2, '115', base + '人间尤物.mp4', 'video', 78893257, 61)
        self.add(3, '115', base + '好站全在这.txt', 'other', 470)
        self.add(4, '115', base + r'其他作品合集\kpkp3.com_259LUXU-1555.jpg', 'image', 258032)
        self.add(5, '115', base + 'hjd2048.com-0907ssis839pl.jpg', 'image', 176840)
        self.add(6, '115', base + 'poster.jpg', 'image', 180000)
        self.add(7, '115', base + '回家指南综合包.zip', 'archive', 3165927)
        self.add(8, '115', base + '特典映像.mp4', 'video', 90 * 1024**2, 600)
        self.add(9, '115', base + '未探测.mp4', 'video', 4650760)
        # 落地目录就是下载根时,不能把根下所有文件都当成这个种子的附件。
        self.add(10, '115', r'B:\云下载\notes.txt', 'other', 470)
        self.add_download(2, 'ABP-616', r'B:\云下载', 1)
        items = {item['id']: item for item in q_ads(self.contract, limit=200)['items']}
        self.assertEqual(set(items), {2, 3, 4, 7})
        self.assertIn('SSIS-839 下载包里的附带短视频,正片长 120 分', items[2]['why'])
        self.assertIn('附带压缩包', items[7]['why'])
        self.assertLess(items[7]['score'], web_batch.AUTO_TRASH_SCORE)
        checked = junk.isolate(self.contract.database, asset_ids=list(range(1, 11)), protected_ids=[1])
        self.assertEqual(sorted(checked['ids']), [2, 3, 4])

    def test_untracked_folders_under_the_download_root_use_their_own_release_video(self):
        # 下载根由已有任务确定;没有任务行的旧下载按目录名里的番号认正片。
        self.add(1, '115', r'B:\S1\SSIS-839\SSIS-839.mp4', 'video', 5 * 1024**3, 7200)
        self.add_download(1, 'SSIS-839', r'B:\云下载\SSIS-839', 1)
        base = r'B:\云下载\第一會所新片@SIS001@DDH-454' + '\\'
        self.add(10, '115', base + 'DDH-454.mp4', 'video', 3271300936, 4221.9)
        self.add(11, '115', base + '[资源推荐]!下载地址.txt', 'other', 154647)
        self.add(12, '115', base + '推特新晋约啪大神原创约炮自拍.jpg', 'image', 1410048)
        self.add(13, '115', base + 'cap_e_3_ddh-454.jpg', 'image', 275947)
        # 目录名带番号却找不到同番号正片的不算下载包;下载根以外的目录也不算。
        self.add(20, '115', r'B:\云下载\ABC-123 合集\片段.mp4', 'video', 3 * 1024**3, 7200)
        self.add(21, '115', r'B:\云下载\ABC-123 合集\说明.txt', 'other', 400)
        self.add(30, '115', r'B:\日本\DDH-455\DDH-455.mp4', 'video', 3 * 1024**3, 4000)
        self.add(31, '115', r'B:\日本\DDH-455\推特新晋约啪大神原创约炮自拍.jpg', 'image', 1410048)
        items = {item['id']: item for item in q_ads(self.contract, limit=200)['items']}
        self.assertEqual(set(items), {11, 12})
        self.assertIn('DDH-454 下载包里的附带文件', items[12]['why'])
        checked = junk.isolate(self.contract.database, asset_ids=[10, 11, 12, 13, 20, 21, 30, 31])
        self.assertEqual(sorted(checked['ids']), [11, 12])

    def test_download_attachment_rules_need_a_bound_main_and_a_long_enough_release(self):
        self.add(1, '115', r'B:\云下载\IPX-867\IPX-867.mp4', 'video', 300 * 1024**2, 1200)
        self.add_download(1, 'IPX-867', r'B:\云下载\IPX-867', 1)
        self.add(2, '115', r'B:\云下载\IPX-867\片头.mp4', 'video', 20 * 1024**2, 90)
        self.add(3, '115', r'B:\云下载\SONE-136\好站全在这.txt', 'other', 470)
        with self.contract.database.write_transaction() as connection:
            connection.execute(
                "INSERT INTO download_task(id,provider,source_uri,target,code,state,ledger_path,updated_at) "
                "VALUES(2,'pikpak','magnet:?xt=urn:btih:unbound','115-2','SONE-136','ingested',?,'now')",
                (r'B:\云下载\SONE-136',))
        self.assertEqual(junk.isolate(self.contract.database, asset_ids=[1, 2, 3], protected_ids=[1])['ids'], [])

    def test_release_names_match_compact_padded_and_store_prefixed_spellings(self):
        for stem, code in (('hjd2048.com-0907mide580-h264', 'MIDE-580'), ('tkmj001jp', 'TKMJ-001'),
                           ('hhd800.com@FC2-PPV-2753986_2', 'FC2-PPV-2753986'), ('STZY-019', '107STZY-019')):
            with self.subTest(stem=stem):
                self.assertTrue(junk.names_release(stem, code))
        for stem, code in (('offkab@sukebei', 'FC2-PPV-4330669'), ('kpkp3.com_259LUXU-1555', 'SGKX-016'),
                           ('MIDE5800', 'MIDE-580')):
            with self.subTest(stem=stem):
                self.assertFalse(junk.names_release(stem, code))

    def test_identical_small_copies_across_release_folders_are_junk_evidence(self):
        for offset, folder in enumerate(('MIDA-117-UC', 'MIDA-154-UC', 'MIDA-234-UC')):
            self.add(20 + offset, '115', rf'B:\云下载\{folder}\情報.mp4', 'video', 11057812, 157.28)
            self.add(30 + offset, '115', rf'B:\云下载\{folder}\说明.txt', 'other', 136)
            self.add(40 + offset, '115', rf'B:\云下载\{folder}\ABC-123.jpg', 'image', 52000)
        self.add(50, '115', r'B:\云下载\MIDA-117-UC\片段.mp4', 'video', 2001226, 11)
        self.add(51, '115', r'B:\云下载\MIDA-154-UC\片段.mp4', 'video', 2001226, 11)
        self.add(52, '115', r'B:\云下载\MIDA-234-UC\片段.mp4', 'video', 2001226, 3600)
        # 创作者目录里每期都带的同一段片头不算:目录名不是发行包。
        for offset, folder in enumerate(('第一期', '第二期', '第三期')):
            self.add(70 + offset, '115', rf'B:\创作者\{folder}\片头.mp4', 'video', 3000000, 8)
        items = {item['id']: item for item in q_ads(self.contract, limit=200)['items']}
        self.assertEqual(set(items), {20, 21, 22, 30, 31, 32})
        self.assertIn('同名同体积的副本散布在 3 个目录', items[20]['why'])
        self.assertGreaterEqual(items[20]['score'], web_batch.AUTO_TRASH_SCORE)
        self.assertLess(items[30]['score'], web_batch.AUTO_TRASH_SCORE)

    def test_letter_spaced_promo_names_are_read_as_one_word(self):
        self.assertEqual(junk.despaced('x u u 6 2 . c o m'), 'xuu62.com')
        self.assertEqual(junk.despaced('最 新 位 址 獲 取'), '最新位址獲取')
        self.assertEqual(junk.despaced('No.017 - 서서 펠라_뒤치기'), 'No.017 - 서서 펠라_뒤치기')
        self.add(60, '115', r'B:\资源\x u u 9 2 .c o m.mp4', 'video', 23141112, 88.7)
        self.add(61, '115', r'B:\资源\最 新 位 址 獲 取:489155.com 收藏不迷路.txt', 'other', 136)
        self.add(62, '115', r'B:\资源\manko.fun.mp4', 'video', 11057812, 157.28)
        items = {item['id']: item for item in q_ads(self.contract, limit=200)['items']}
        self.assertEqual(set(items), {60, 61, 62})
        self.assertTrue(all(item['score'] >= web_batch.AUTO_TRASH_SCORE for item in items.values()))


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