Ctrl K
ComunidadGuías…

tests/test_follow_discovery.py

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

Archivo original ↗Preguntar a KiBot
"""从裸 id 或名字反查来源。全部注入 transport,测试不联网。"""
import json
import os
import tempfile
import time
import unittest
import urllib.parse
from pathlib import Path

from peach import follow_discovery
from peach.follow import FollowSourceError
from peach.follow_discovery import (
    CREATOR_INDEX_TTL_SECONDS, DEFAULT_PROVIDERS, MAX_TERM_LENGTH, CreatorIndex,
    archive_suggestions, discover, discovery_plan, forum_queries, identity_key,
    rule34video_slugs, search_variants, spelling_variants, suggest_term, tag_suggestions,
)
from peach.follow_secrets import Credential
from peach.http import HttpResponse
from support.backoff import no_real_backoff


CREATORS = json.dumps([
    {"id": "30917150", "name": "LazyProcrastinator", "service": "fanbox"},
    {"id": "109730638", "name": "EcchiWaffle", "service": "patreon"},
    {"id": "25775753", "name": "LazyMazer", "service": "patreon"},
    {"id": "suzutaro3d", "name": "鈴太郎3D", "service": "patreon"},
]).encode()

F95_HIT = json.dumps({"status": "ok", "msg": {"data": [
    {"thread_id": 50685, "title": "Lazy Procrastinator Collection",
     "version": "2026-06-28"}]}}).encode()
F95_MISS = json.dumps({"status": "ok", "msg": {"data": []}}).encode()
F95_SEARCH_FORM = b"""<html><body><input type="hidden" name="_xfToken" value="1,a" /></body></html>"""
F95_SEARCH_RESULTS = b"""<html><body><h3 class="contentRow-title">
<a href="/threads/ria-collection-2026-08-03-ria_neearts.146348/"><span class="label">Collection</span>Ria Collection [2026-08-03] [<em class="textHighlight">Ria_neearts</em>]</a>
</h3></body></html>"""
F95_SEARCH_NOTHING = b"""<html><body><div class="blockMessage">No results found.</div></body></html>"""
SIMPCITY_SEARCH_FORM = b"""<html><body><input type="hidden" name="_xfToken" value="1,s" /></body></html>"""
# 2026-09-08 实测 `solazola`:资源线程和讨论帖各一条,标题前的版块标签是分辨两者的依据。
SIMPCITY_SEARCH_RESULTS = b"""<html><body>
<h3 class="contentRow-title"><a href="/threads/solazola-baby_sue.17401/"><span class="label">OnlyFans</span><em class="textHighlight">Solazola</em> / baby_sue</a></h3>
<h3 class="contentRow-title"><a href="/threads/solazola-discussion.392510/"><span class="label">Simp Chat</span><em class="textHighlight">solazola</em> discussion</a></h3>
</body></html>"""
# 2026-09-01 实测:站上的写法是 `ria-neearts`,补全按字面前缀匹配,所以
# `Ria_neearts` 查出来是空的,`Ria-neearts` 才命中。
R34_AUTOCOMPLETE_HIT = json.dumps(
    [{"label": "ria-neearts (248)", "value": "ria-neearts"},
     {"label": "riahri (156)", "value": "riahri"}]).encode()
R34_CEEEEEKC = json.dumps([{"label": "ceeeeekc (40)", "value": "ceeeeekc"}]).encode()
# 首楼名片:一个与检索词不同的手柄、一个同名手柄、一个 pixiv 数字 id。
F95_PROFILE_PAGE = b"""<html><body><article class="message"><div class="message-userContent">
<a href="https://twitter.com/Ceeeeekc">X</a><a href="https://www.patreon.com/cekc">Patreon</a>
<a href="https://www.pixiv.net/users/123456">pixiv</a></div></article></body></html>"""
FANBOX_PROFILE = ("<html><meta name='metadata' content='"
                  + json.dumps({"urlContext": {"host": {"creatorId": "lazyprocrast"}}})
                    .replace("'", "&#39;")
                  + "'></html>").encode()
FANBOX_CREATOR = json.dumps({"body": {"user": {
    "userId": "30917150", "name": "LazyProcrastinator",
    "iconUrl": "https://pixiv.pximg.net/icon.jpeg",
}}}).encode()


def _router(routes, calls=None):
    """按 URL 片段派发的假 transport。没有匹配就回 404。"""
    def call(request, timeout, max_bytes):
        if calls is not None:
            calls.append(request.url)
        for fragment, response in routes.items():
            if fragment in request.url:
                return response
        return HttpResponse(404, {}, b"not found")
    return call


class _DiscoveryCase(unittest.TestCase):
    def setUp(self):
        self.temporary = tempfile.TemporaryDirectory()
        self.addCleanup(self.temporary.cleanup)
        self.root = Path(self.temporary.name)
        self.secrets = self.root / "secrets"
        self.state = self.root / "state"

    def _write_credential(self, provider, values):
        path = self.secrets / "follow" / f"{provider}.json"
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(values), encoding="utf-8")

    def _discover(self, term, routes, providers=None, calls=None):
        return discover(term, secrets_root=self.secrets, state_root=self.state,
                        transport=_router(routes, calls), providers=providers)


class SearchVariantTests(unittest.TestCase):
    def test_camel_case_is_split_because_full_text_search_matches_words(self):
        # f95 搜 `lazyprocrastinator` 搜不到,搜 `lazy procrastinator` 能。
        self.assertEqual(search_variants("LazyProcrastinator"),
                         ("LazyProcrastinator", "Lazy Procrastinator"))

    def test_an_all_lowercase_run_has_no_reliable_split(self):
        # 全小写连写没有切分信号,就只用原词,不去猜词典。
        self.assertEqual(search_variants("lazyprocrastinator"), ("lazyprocrastinator",))

    def test_an_underscored_name_is_also_tried_as_words(self):
        self.assertEqual(search_variants("initial_a"), ("initial_a", "initial a"))

    def test_a_handle_is_also_tried_with_the_other_separators(self):
        # 手柄写作 `Ria_neearts`,rule34.xxx 上的标签是 `ria-neearts`。
        self.assertEqual(spelling_variants("Ria_neearts"),
                         ("Ria_neearts", "Ria-neearts", "Rianeearts", "Ria neearts"))

    def test_the_forum_search_ends_with_a_prefix_wildcard(self):
        # 2026-09-12 实测:`strauz` 站内命中 0 条,`strauz*` 与全名一样命中 3 条。
        self.assertEqual(forum_queries("strauz"), ("strauz", "strauz*"))

    def test_a_short_term_is_not_widened(self):
        # 三个字母加通配等于把半个站搜回来,命中一屏也认不出是哪个作者。
        self.assertEqual(forum_queries("ria"), ("ria",))

    def test_a_term_that_already_has_a_wildcard_is_left_alone(self):
        self.assertEqual(forum_queries("strauz*"), ("strauz*",))


class DiscoveryPlanTests(unittest.TestCase):
    def test_a_handle_includes_the_name_searching_sources(self):
        tasks = discovery_plan("suzutaro3d")
        self.assertEqual(tasks, DEFAULT_PROVIDERS)

    def test_f95zone_is_asked_first_so_its_profile_handles_reach_the_rest(self):
        self.assertEqual(discovery_plan("cekc")[0], "f95zone")
        self.assertEqual(discovery_plan("Lazy", providers=("kemono", "f95zone")),
                         ("f95zone", "kemono"))

    def test_a_numeric_word_skips_the_name_searching_sources(self):
        tasks = discovery_plan("30917150")
        for skipped in ("rule34video", "rule34xxx", "simpcity"):
            self.assertNotIn(skipped, tasks)

    def test_a_restricted_provider_paid_respects_the_selection(self):
        self.assertEqual(discovery_plan("Lazy", providers=("kemono",)),
                         ("kemono",))

    def test_a_handle_without_separators_has_only_itself(self):
        self.assertEqual(spelling_variants("lazyprocrastinator"), ("lazyprocrastinator",))

    def test_the_identity_key_ignores_separators_and_case(self):
        self.assertEqual(identity_key("Ria_neearts"), identity_key("ria-neearts"))
        self.assertNotEqual(identity_key("Ria_neearts"), identity_key("riahri"))

    def test_the_identity_key_keeps_names_written_in_cjk(self):
        # 中日文名不能被抹成空串:空键会让 rule34.xxx 那一路直接认定无从比对。
        self.assertEqual(identity_key("鈴太郎_3D"), "鈴太郎3d")
        self.assertEqual(identity_key("鈴太郎_3D"), identity_key("鈴太郎-3d"))
        self.assertEqual(identity_key("うるしばら"), "うるしばら")

    def test_a_spaced_term_is_left_alone(self):
        self.assertEqual(search_variants("Lazy Procrastinator"), ("Lazy Procrastinator",))


class Rule34VideoSlugTests(unittest.TestCase):
    def test_word_breaks_become_hyphens_before_they_are_dropped(self):
        # KVS 的 slug 是 `bulging-senpai`;连写的 `bulgingsenpai` 回 404。
        self.assertEqual(rule34video_slugs("Bulging Senpai"), ("bulging-senpai", "bulgingsenpai"))

    def test_a_joined_name_tries_itself_first_then_its_camel_split(self):
        self.assertEqual(rule34video_slugs("BulgingSenpai"), ("bulgingsenpai", "bulging-senpai"))
        self.assertEqual(rule34video_slugs("lazyprocrastinator"), ("lazyprocrastinator",))

    def test_a_name_without_latin_letters_has_no_slug(self):
        self.assertEqual(rule34video_slugs("鈴太郎"), ())


class CreatorIndexTests(_DiscoveryCase):
    def _index(self, calls=None):
        return CreatorIndex(self.state, transport=_router(
            {"/api/v1/creators": HttpResponse(200, {}, CREATORS)}, calls))

    def test_the_index_is_downloaded_once_and_then_cached(self):
        calls = []
        index = self._index(calls)
        self.assertEqual(len(index.load("kemono")), 4)
        self.assertEqual(len(index.load("kemono")), 4)
        self.assertEqual(len(calls), 1, "第二次不该再下载")

    def test_a_stale_cache_is_refreshed(self):
        calls = []
        index = self._index(calls)
        index.load("kemono")
        # `now` 是墙钟时间,不是相对量:要比文件 mtime 晚过一个 TTL 才算过期。
        index.load("kemono", now=time.time() + CREATOR_INDEX_TTL_SECONDS * 2)
        self.assertEqual(len(calls), 2)

    def test_a_broken_payload_is_an_error_not_an_empty_index(self):
        index = CreatorIndex(self.state, transport=_router(
            {"/api/v1/creators": HttpResponse(200, {}, b'{"nope": 1}')}))
        with self.assertRaises(FollowSourceError):
            index.load("kemono")


class DiscoverTests(_DiscoveryCase):
    ROUTES = {
        "/api/v1/creators": HttpResponse(200, {}, CREATORS),
        "/fanbox/creator/30917150": HttpResponse(200, {}, FANBOX_PROFILE),
        "creator.get?creatorId=lazyprocrast": HttpResponse(200, {}, FANBOX_CREATOR),
        "rule34video.com/models/lazyprocrastinator/": HttpResponse(200, {}, b"<html/>"),
        "cat=animations": HttpResponse(200, {}, F95_HIT),
        "latest_data.php": HttpResponse(200, {}, F95_MISS),
    }

    def test_a_name_is_found_across_every_source_that_has_it(self):
        found = self._discover("LazyProcrastinator", self.ROUTES,
                               providers=("kemono", "rule34video", "f95zone"))
        by_provider = {c.provider: c for c in found.candidates}
        self.assertEqual(by_provider["kemono"].ref, "fanbox/30917150")
        self.assertEqual(by_provider["rule34video"].ref, "lazyprocrastinator")
        self.assertEqual(by_provider["f95zone"].ref, "50685")

    def test_a_spaced_name_finds_the_hyphenated_rule34video_page(self):
        calls = []
        found = self._discover("Bulging Senpai", {
            "rule34video.com/models/bulging-senpai/": HttpResponse(200, {}, b"<html/>"),
        }, providers=("rule34video",), calls=calls)
        self.assertEqual([(c.ref, c.url) for c in found.candidates],
                         [("bulging-senpai", "https://rule34video.com/models/bulging-senpai/")])
        # 第一个写法就命中,后面的不再探。
        self.assertEqual(calls, ["https://rule34video.com/models/bulging-senpai/"])

    def test_each_source_reports_itself_before_it_runs(self):
        notes = []
        found = discover("LazyProcrastinator", secrets_root=self.secrets,
                         state_root=self.state, transport=_router(self.ROUTES),
                         providers=("kemono", "fanbox"),
                         on_progress=lambda *args: notes.append(args))
        self.assertEqual(notes, [("kemono", 0, 2), ("fanbox", 1, 2)])
        self.assertTrue(found.candidates)

    def test_every_candidate_says_why_it_matched(self):
        found = self._discover("LazyProcrastinator", self.ROUTES,
                               providers=("kemono", "rule34video", "f95zone"))
        self.assertTrue(all(c.evidence for c in found.candidates))
        kemono = next(c for c in found.candidates if c.provider == "kemono")
        self.assertEqual(kemono.evidence, "创作者名精确匹配")

    def test_archive_identity_resolves_to_the_official_fanbox_page(self):
        found = self._discover("LazyProcrastinator", self.ROUTES,
                               providers=("kemono", "fanbox"))
        official = next(c for c in found.candidates if c.provider == "fanbox")
        self.assertEqual(official.ref, "lazyprocrast")
        self.assertEqual(official.url, "https://lazyprocrast.fanbox.cc/")
        self.assertEqual(official.label, "LazyProcrastinator")
        self.assertIn("官方资料", official.evidence)

    def test_a_numeric_term_matches_the_site_id_and_skips_name_only_sources(self):
        calls = []
        found = self._discover("30917150", self.ROUTES, calls=calls)
        self.assertEqual([c.ref for c in found.candidates if c.provider == "kemono"],
                         ["fanbox/30917150"])
        # 纯数字不去 rule34video / rule34.xxx 碰运气——那两个都是按名字/标签查的。
        self.assertFalse(any("/models/" in url for url in calls))

    def test_a_handle_may_be_the_creator_id_itself(self):
        # patreon 归档的 id 就是创作者手柄:`suzutaro3d` 按 id 命中,
        # 不需要它的日文名也写进索引。
        found = self._discover("suzutaro3d", self.ROUTES, providers=("kemono",))
        self.assertEqual([c.ref for c in found.candidates],
                         ["patreon/suzutaro3d"])
        self.assertEqual(found.candidates[0].evidence, "站内 id 精确匹配")

    def test_a_handle_id_matches_whatever_case_it_was_copied_in(self):
        # 手柄的大小写只是显示形态:从主页标题复制来的 `SuzuTaro3D` 和索引里的
        # `suzutaro3d` 是同一个创作者。按名字比对已经这么判,按 id 也要一样。
        found = self._discover("SuzuTaro3D", self.ROUTES, providers=("kemono",))
        self.assertEqual([c.ref for c in found.candidates],
                         ["patreon/suzutaro3d"])
        self.assertEqual(found.candidates[0].evidence, "站内 id 精确匹配")

    def test_a_numeric_term_probes_the_thread_id(self):
        routes = {**self.ROUTES, "f95zone.to/threads/50685/": HttpResponse(200, {}, b"<html/>")}
        found = self._discover("50685", routes, providers=("f95zone",))
        self.assertEqual([c.ref for c in found.candidates], ["50685"])
        self.assertEqual(found.candidates[0].evidence, "线程存在")

    def test_a_missing_thread_yields_nothing_rather_than_a_guessed_link(self):
        found = self._discover("99999999", self.ROUTES, providers=("f95zone",))
        self.assertEqual(found.candidates, ())

    def test_an_f95_miss_offers_google_without_guessing_a_thread(self):
        found = self._discover(
            "initial_a", {"latest_data.php": HttpResponse(200, {}, F95_MISS)},
            providers=("f95zone",))
        self.assertEqual(found.candidates, ())
        self.assertEqual(len(found.external_searches), 1)
        search = found.external_searches[0]
        self.assertEqual(search.provider, "f95zone")
        self.assertEqual(search.query, "initial_a f95zone")
        self.assertEqual(search.url,
                         "https://www.google.com/search?q=initial_a+f95zone")
        self.assertIn("真实线程链接", search.evidence)

    def test_an_f95_hit_does_not_add_a_redundant_google_link(self):
        found = self._discover("LazyProcrastinator", self.ROUTES,
                               providers=("f95zone",))
        self.assertTrue(found.candidates)
        self.assertEqual(found.external_searches, ())

    def test_a_partial_name_match_is_reported_as_partial(self):
        found = self._discover("Lazy", self.ROUTES, providers=("kemono",))
        evidence = {c.label: c.evidence for c in found.candidates}
        self.assertIn("LazyProcrastinator · fanbox", evidence)
        self.assertEqual(evidence["LazyProcrastinator · fanbox"], "创作者名以该词开头")

    def test_a_name_that_only_contains_the_term_says_so(self):
        found = self._discover("Mazer", self.ROUTES, providers=("kemono",))
        self.assertEqual([(c.label, c.evidence) for c in found.candidates],
                         [("LazyMazer · patreon", "创作者名包含该词")])

    def test_one_source_failing_does_not_lose_the_others(self):
        routes = {**self.ROUTES}
        del routes["/api/v1/creators"]
        found = self._discover("LazyProcrastinator", routes,
                               providers=("kemono", "rule34video"))
        self.assertEqual([c.provider for c in found.candidates], ["rule34video"])
        self.assertIn("kemono", found.failures)

    def test_a_handle_finds_the_tag_spelled_differently_on_the_site(self):
        # 站上是 `ria-neearts`,手边的手柄是 `Ria_neearts`。按手柄逐字查是零命中,
        # 补全把两种写法对上,登记的是站上的那个写法。
        routes = {"autocomplete.php?q=Ria_neearts": HttpResponse(200, {}, b"[]"),
                  "autocomplete.php?q=Ria-neearts": HttpResponse(200, {}, R34_AUTOCOMPLETE_HIT)}
        found = self._discover("Ria_neearts", routes, providers=("rule34xxx",))
        self.assertEqual([c.ref for c in found.candidates], ["ria-neearts"])
        self.assertIn("248", found.candidates[0].evidence)
        self.assertIn("tags=ria-neearts", found.candidates[0].url)

    def test_the_tag_search_needs_no_credentials_of_its_own(self):
        routes = {"autocomplete.php": HttpResponse(200, {}, R34_AUTOCOMPLETE_HIT)}
        found = self._discover("ria-neearts", routes, providers=("rule34xxx",))
        self.assertEqual([c.ref for c in found.candidates], ["ria-neearts"])
        self.assertEqual(found.failures, {})

    def test_an_exact_hit_leads_its_prefix_neighbors(self):
        # 精确写法排最前;下拉里同时给过的相似标签照列在后,由人分辨是不是同一个人。
        routes = {"autocomplete.php": HttpResponse(200, {}, b"["
                  b"{\"label\": \"ria-neearts (248)\", \"value\": \"ria-neearts\"},"
                  b"{\"label\": \"riahri (156)\", \"value\": \"riahri\"},"
                  b"{\"label\": \"ria (12)\", \"value\": \"ria\"}]")}
        found = self._discover("ria", routes, providers=("rule34xxx",))
        self.assertEqual([c.ref for c in found.candidates], ["ria", "ria-neearts", "riahri"])
        self.assertEqual(found.candidates[0].evidence, "站内标签 ria 下有 12 件作品")
        self.assertEqual(found.candidates[1].evidence,
                         "站内标签以该词开头:ria-neearts 下有 248 件作品")

    def test_a_prefix_hit_is_a_candidate_when_no_spelling_matched_exactly(self):
        # 敲名字的开头、站上的标签比它长时,精确判据下这条也找不到。前缀命中的
        # 写法仍是站上真实存在的标签,照列出来、证据写明以该词开头,由人分辨。
        # 没有凭据就不去 dapi 确认精确写法,只问补全。
        routes = {"autocomplete.php": HttpResponse(200, {}, R34_AUTOCOMPLETE_HIT)}
        calls = []
        found = self._discover("ria", routes, providers=("rule34xxx",), calls=calls)
        self.assertFalse(any("index.php" in url for url in calls))
        self.assertEqual([c.ref for c in found.candidates], ["ria-neearts", "riahri"])
        self.assertEqual(found.candidates[0].evidence,
                         "站内标签以该词开头:ria-neearts 下有 248 件作品")
        self.assertEqual(found.candidates[1].evidence,
                         "站内标签以该词开头:riahri 下有 156 件作品")
        self.assertIn("tags=ria-neearts", found.candidates[0].url)

    def test_a_prefix_hit_without_a_count_still_states_itself(self):
        routes = {"autocomplete.php": HttpResponse(200, {}, b"["
                  b"{\"label\": \"forceball_fx\", \"value\": \"forceball_fx\"}]")}
        found = self._discover("forceball", routes, providers=("rule34xxx",))
        self.assertEqual([c.ref for c in found.candidates], ["forceball_fx"])
        self.assertEqual(found.candidates[0].evidence,
                         "站内存在以该词开头的标签 forceball_fx")

    def test_prefix_neighbors_do_not_shadow_the_variant_that_matched_exactly(self):
        # 补全按字面前缀返回:`Ria_neearts` 查不出 `ria-neearts`,要换分隔符写法
        # 再问一次。前一种写法带回来的相似标签不能盖住后一种写法的精确命中。
        routes = {"autocomplete.php?q=Ria_neearts": HttpResponse(200, {}, R34_AUTOCOMPLETE_HIT),
                  "autocomplete.php?q=Ria-neearts": HttpResponse(200, {}, R34_AUTOCOMPLETE_HIT)}
        found = self._discover("Ria_neearts", routes, providers=("rule34xxx",))
        self.assertEqual([c.ref for c in found.candidates], ["ria-neearts"])

    def _typed_r34(self):
        self._write_credential("rule34xxx", {"user_id": "1", "api_key": "k"})
        # 分类缓存是模块级的,留到下一个用例里就会让它凭空少打几次请求。
        follow_discovery._TAG_TYPES.clear()
        self.addCleanup(follow_discovery._TAG_TYPES.clear)

    def test_the_exact_tag_pushed_out_of_the_autocomplete_still_leads(self):
        # 补全一次只回十条,热门前缀会把完整写法挤出去。有凭据时按原样问一次 dapi,
        # 标签下有帖子就是精确命中,排在前缀命中之前;前缀命中照列。
        self._typed_r34()
        post = json.dumps([{"id": 1, "image": "a.jpg", "tags": "ria",
                            "file_url": "https://api-cdn.rule34.xxx/images/1/a.jpg"}])
        routes = {"autocomplete.php": HttpResponse(200, {}, R34_AUTOCOMPLETE_HIT),
                  "s=post": HttpResponse(200, {}, post.encode()),
                  "s=tag": HttpResponse(200, {}, b'<tags type="array"></tags>')}
        found = self._discover("ria", routes, providers=("rule34xxx",))
        self.assertEqual([c.ref for c in found.candidates], ["ria", "ria-neearts", "riahri"])
        self.assertEqual(found.candidates[0].evidence, "站内标签 ria 下有作品")
        self.assertEqual(found.failures, {})

    def test_a_rejected_credential_keeps_the_prefix_hits(self):
        # 确认精确写法那一问只是锦上添花;凭据被拒时补全给的前缀命中照列。
        self._typed_r34()
        rejected = HttpResponse(200, {}, b'"Missing authentication. Go to api.rule34.xxx"')
        routes = {"autocomplete.php": HttpResponse(200, {}, R34_AUTOCOMPLETE_HIT),
                  "s=post": rejected, "s=tag": rejected}
        found = self._discover("ria", routes, providers=("rule34xxx",))
        self.assertEqual([c.ref for c in found.candidates], ["ria-neearts", "riahri"])
        self.assertEqual(found.failures, {})

    def test_an_f95_collection_thread_is_found_by_the_forum_search(self):
        # `latest_data.php` 只索引 Latest Updates,艺术家的 Collection 帖不在里面。
        self._write_credential("f95zone", {"cookie": "xf_user=1"})
        routes = {"latest_data.php": HttpResponse(200, {}, F95_MISS),
                  "/search/search": HttpResponse(200, {}, F95_SEARCH_RESULTS),
                  "f95zone.to/search/": HttpResponse(200, {}, F95_SEARCH_FORM)}
        found = self._discover("Ria_neearts", routes, providers=("f95zone",))
        self.assertEqual([c.ref for c in found.candidates], ["146348"])
        self.assertEqual(found.candidates[0].label,
                         "Ria Collection [2026-08-03] [Ria_neearts]")
        self.assertEqual(found.external_searches, ())

    def test_half_a_name_is_retried_as_a_prefix(self):
        """站内搜索按整词匹配,半个名字要靠尾部通配才找得到。

        2026-09-12 实测 `strauz` 命中 0 条,`strauz*` 命中三条,其中就有
        `Strauzek Collection [2026-09-04] [Mr_Strauz]`。
        """
        self._write_credential("f95zone", {"cookie": "xf_user=1"})
        queries = []

        def call(request, timeout, max_bytes):
            if "latest_data.php" in request.url:
                return HttpResponse(200, {}, F95_MISS)
            if "/search/search" not in request.url:
                return HttpResponse(200, {}, F95_SEARCH_FORM)
            body = urllib.parse.parse_qs((request.body or b"").decode("utf-8"))
            query = body.get("keywords", [""])[0]
            queries.append(query)
            return HttpResponse(200, {}, F95_SEARCH_RESULTS if query.endswith("*")
                                else F95_SEARCH_NOTHING)

        found = discover("strauz", secrets_root=self.secrets, state_root=self.state,
                         transport=call, providers=("f95zone",))
        self.assertEqual(queries, ["strauz", "strauz*"])
        self.assertEqual([c.ref for c in found.candidates], ["146348"])
        self.assertIn("按标题开头命中「strauz*」", found.candidates[0].evidence)

    def test_an_exact_hit_never_reaches_the_wildcard_round(self):
        self._write_credential("f95zone", {"cookie": "xf_user=1"})
        queries = []

        def call(request, timeout, max_bytes):
            if "latest_data.php" in request.url:
                return HttpResponse(200, {}, F95_MISS)
            if "/search/search" not in request.url:
                return HttpResponse(200, {}, F95_SEARCH_FORM)
            queries.append(urllib.parse.parse_qs(
                (request.body or b"").decode("utf-8")).get("keywords", [""])[0])
            return HttpResponse(200, {}, F95_SEARCH_RESULTS)

        discover("strauzek", secrets_root=self.secrets, state_root=self.state,
                 transport=call, providers=("f95zone",))
        self.assertEqual(queries, ["strauzek"])

    def test_f95_profile_handles_are_searched_on_the_other_sources(self):
        """搜 `cekc` 在 rule34.xxx 上碰不到 `Ceeeeekc`;F95 首楼名片上写着这个手柄。"""
        self._write_credential("f95zone", {"cookie": "xf_user=1"})
        routes = {"latest_data.php": HttpResponse(200, {}, F95_MISS),
                  "/search/search": HttpResponse(200, {}, F95_SEARCH_RESULTS),
                  "f95zone.to/search/": HttpResponse(200, {}, F95_SEARCH_FORM),
                  "f95zone.to/threads/146348/": HttpResponse(200, {}, F95_PROFILE_PAGE),
                  "autocomplete.php?q=cekc": HttpResponse(200, {}, b"[]"),
                  "autocomplete.php?q=Ceeeeekc": HttpResponse(200, {}, R34_CEEEEEKC)}
        found = self._discover("cekc", routes, providers=("rule34xxx", "f95zone"))
        self.assertEqual([(c.provider, c.ref) for c in found.candidates],
                         [("f95zone", "146348"), ("rule34xxx", "ceeeeekc")])
        f95, tag = found.candidates
        self.assertEqual(f95.aliases, ("Ceeeeekc",), "与检索词同名的 patreon 手柄和 pixiv 数字 id 不算别名")
        self.assertEqual(tag.aliases, ("Ceeeeekc",))
        self.assertTrue(tag.evidence.startswith("F95 首楼名片上的手柄 Ceeeeekc:"))

    def test_without_a_cookie_the_forum_search_is_skipped_not_guessed(self):
        calls = []
        found = self._discover("Ria_neearts", {"latest_data.php": HttpResponse(200, {}, F95_MISS)},
                               providers=("f95zone",), calls=calls)
        self.assertEqual(found.candidates, ())
        self.assertEqual(found.failures, {})
        self.assertEqual([search.provider for search in found.external_searches], ["f95zone"])
        self.assertFalse([url for url in calls if "/search/" in url])

    SIMPCITY_ROUTES = {
        "simpcity.cr/search/search": HttpResponse(200, {}, SIMPCITY_SEARCH_RESULTS),
        "simpcity.cr/search/": HttpResponse(200, {}, SIMPCITY_SEARCH_FORM),
    }

    def test_a_simpcity_thread_is_found_by_the_forum_search_with_its_forum_label(self):
        self._write_credential("simpcity", {"cookie": "yMziCv8BrCZz1o7_user=u"})
        found = self._discover("solazola", self.SIMPCITY_ROUTES, providers=("simpcity",))
        self.assertEqual([(c.provider, c.ref) for c in found.candidates],
                         [("simpcity", "17401"), ("simpcity", "392510")])
        self.assertEqual(found.candidates[0].url, "https://simpcity.cr/threads/17401/")
        self.assertEqual(found.candidates[0].label, "Solazola / baby_sue")
        self.assertEqual(found.candidates[0].evidence, "站内搜索按标题命中「solazola」,版块标签 OnlyFans")
        self.assertIn("Simp Chat", found.candidates[1].evidence)
        self.assertEqual(found.failures, {})

    def test_simpcity_is_asked_by_default_when_a_name_is_looked_up(self):
        self._write_credential("simpcity", {"cookie": "yMziCv8BrCZz1o7_user=u"})
        found = self._discover("solazola", {**self.ROUTES, **self.SIMPCITY_ROUTES})
        self.assertIn("simpcity", {c.provider for c in found.candidates})

    def test_without_a_simpcity_cookie_the_site_is_skipped_silently(self):
        # 来源筛选菜单里缺凭据的站已经挂着「打开凭据设置」,每查一个名字都再报一次只是噪音。
        calls = []
        found = self._discover("solazola", self.SIMPCITY_ROUTES, providers=("simpcity",), calls=calls)
        self.assertEqual(found.candidates, ())
        self.assertEqual(found.failures, {})
        self.assertEqual(calls, [])

    def test_a_bare_number_is_not_probed_on_simpcity(self):
        # 登录后任何数字的线程页都可能存在,「存在」不构成「就是他」的证据。
        self._write_credential("simpcity", {"cookie": "yMziCv8BrCZz1o7_user=u"})
        calls = []
        found = self._discover("17401", self.SIMPCITY_ROUTES, providers=("simpcity",), calls=calls)
        self.assertEqual(found.candidates, ())
        self.assertEqual(calls, [])

    def test_a_stale_simpcity_cookie_is_reported_as_a_failure_not_an_empty_result(self):
        self._write_credential("simpcity", {"cookie": "stale"})
        found = self._discover("solazola", {"simpcity.cr/search/": HttpResponse(403, {}, b"Forbidden")},
                               providers=("simpcity",))
        self.assertEqual(found.candidates, ())
        self.assertIn("cookie", found.failures["simpcity"])

    def test_junk_terms_are_refused_before_any_request(self):
        calls = []
        for term in ("", "   ", "../etc", "a" * 200, "<script>", "a?b", "a&b"):
            with self.assertRaises(FollowSourceError):
                self._discover(term, self.ROUTES, calls=calls)
        self.assertEqual(calls, [])

    def test_japanese_and_chinese_creator_names_are_accepted(self):
        # kemono 上大量创作者是日文或中文名,ASCII 白名单会把他们整批挡在门外。
        creators = json.dumps([
            {"id": "1", "name": "うるしばら", "service": "fanbox"},
            {"id": "2", "name": "冰鲜鱼子酱二代目", "service": "fanbox"},
        ]).encode()
        routes = {"/api/v1/creators": HttpResponse(200, {}, creators)}
        for term, expected in (("うるしばら", "1"), ("冰鲜鱼子酱二代目", "2")):
            found = self._discover(term, routes, providers=("kemono",))
            self.assertEqual([c.ref for c in found.candidates], [f"fanbox/{expected}"], term)

    def test_results_are_capped_per_source(self):
        many = json.dumps([{"id": str(i), "name": f"Lazy{i}", "service": "fanbox"}
                           for i in range(50)]).encode()
        found = self._discover("Lazy", {"/api/v1/creators": HttpResponse(200, {}, many)},
                               providers=("kemono",))
        self.assertEqual(len(found.candidates), 8)


class SuggestTermTests(unittest.TestCase):
    def test_one_letter_is_too_little_to_suggest_from(self):
        # 一个字母在十万条清单里命中几千个名字,排在最前的那几条和谁都没关系。
        self.assertEqual(suggest_term("l"), "")

    def test_a_pasted_link_is_not_a_name_to_guess_from(self):
        # 地址里必有 `/`,那时该走的是解析链接。
        self.assertEqual(suggest_term("https://kemono.cr/fanbox/user/123"), "")

    def test_a_name_comes_back_trimmed(self):
        self.assertEqual(suggest_term("  lewdgazer "), "lewdgazer")

    def test_a_term_longer_than_a_name_is_refused(self):
        self.assertEqual(suggest_term("l" * (MAX_TERM_LENGTH + 1)), "")


class ArchiveSuggestionTests(unittest.TestCase):
    """本机清单的前缀建议。清单是 `discover` 下过之后留在硬盘上的那一份。"""

    CREATORS = [
        {"id": "1", "name": "lewdgazer", "service": "fanbox"},
        {"id": "2", "name": "Lewdgatta", "service": "patreon"},
        {"id": "3", "name": "LewdGamesDev", "service": "fanbox"},
        {"id": "4", "name": "notlewdgazer", "service": "fanbox"},
    ]

    def setUp(self):
        self.temporary = tempfile.TemporaryDirectory()
        self.addCleanup(self.temporary.cleanup)
        self.state = Path(self.temporary.name) / "state"

    def _write(self, provider, rows):
        path = self.state / "follow" / f"creators-{provider}.json"
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(rows), encoding="utf-8")
        return path

    def test_half_a_name_finds_the_creators_that_start_with_it(self):
        """打 `lewdga` 要出 `lewdgazer`——这是建议存在的全部理由。

        前缀一样时短的排前面:多出来的字越少,越可能就是这个名字本身。
        """
        self._write("kemono", self.CREATORS)
        found = archive_suggestions("lewdga", state_root=self.state,
                                    providers=("kemono",))
        self.assertEqual([row.value for row in found],
                         ["Lewdgatta", "lewdgazer", "LewdGamesDev"])

    def test_the_middle_of_a_name_is_not_a_prefix(self):
        # 子串匹配要全扫十万条乘三个站,跟不上手速;`notlewdgazer` 因此不在结果里。
        self._write("kemono", self.CREATORS)
        self.assertEqual(archive_suggestions("gazer", state_root=self.state,
                                             providers=("kemono",)), ())

    def test_the_same_person_on_two_sites_is_one_suggestion(self):
        # 大小写只是拼写形态,留先问到的那个站的写法。
        self._write("kemono", [{"id": "1", "name": "lewdgazer", "service": "fanbox"}])
        self._write("pawchive", [{"id": "9", "name": "LewdGazer", "service": "fanbox"}])
        found = archive_suggestions("lewdga", state_root=self.state,
                                    providers=("kemono", "pawchive"))
        self.assertEqual([(row.value, row.matched) for row in found],
                         [("lewdgazer", "kemono")])

    def test_an_exact_name_comes_first(self):
        self._write("kemono", self.CREATORS)
        found = archive_suggestions("lewdgazer", state_root=self.state,
                                    providers=("kemono",))
        self.assertEqual([row.value for row in found], ["lewdgazer"])

    def test_a_missing_index_is_simply_no_suggestions(self):
        """清单没下过就没有这一组,**不为建议下载整站清单**。

        清单是几 MB 的东西,为一次敲键现下它会让输入框卡住十几秒。
        """
        self.assertEqual(archive_suggestions("lewdga", state_root=self.state), ())

    def test_a_refreshed_index_is_picked_up(self):
        # 清单一天刷一次;内存里那份表按文件 mtime 认新旧。
        path = self._write("kemono",
                           [{"id": "1", "name": "lewdgazer", "service": "fanbox"}])
        self.assertEqual(len(archive_suggestions("lewdga", state_root=self.state,
                                                 providers=("kemono",))), 1)
        self._write("kemono", self.CREATORS)
        later = time.time() + 10
        os.utime(path, (later, later))
        self.assertEqual(len(archive_suggestions("lewdga", state_root=self.state,
                                                 providers=("kemono",))), 3)


class TagSuggestionTests(unittest.TestCase):
    ROWS = json.dumps([{"label": "lewdgatta (380)", "value": "lewdgatta"},
                       {"label": "lewdgazer (237)", "value": "lewdgazer"}]).encode()
    #: dapi 的 tag 接口只回 XML,`json=1` 实测被忽略。1 是 artist、0 是 general。
    TYPES = {
        "lewdgatta": b'<?xml version="1.0"?><tags type="array">'
                     b'<tag type="1" count="380" name="lewdgatta" id="1"/></tags>',
        "lewdgazer": b'<?xml version="1.0"?><tags type="array">'
                     b'<tag type="0" count="237" name="lewdgazer" id="2"/></tags>',
    }

    def setUp(self):
        # 分类缓存是模块级的,留到下一个用例里就会让它凭空少打几次请求。
        follow_discovery._TAG_TYPES.clear()
        self.addCleanup(follow_discovery._TAG_TYPES.clear)
        self.credential = Credential("rule34xxx", {"user_id": "1", "api_key": "k"})

    def _transport(self, seen=None):
        def call(request, _timeout, _max_bytes):
            if seen is not None:
                seen.append(request.url)
            if "autocomplete.php" in request.url:
                return HttpResponse(200, {"content-type": "text/html"}, self.ROWS)
            name = urllib.parse.parse_qs(
                urllib.parse.urlsplit(request.url).query)["name"][0]
            return HttpResponse(200, {"content-type": "text/xml"}, self.TYPES[name])
        return call

    def test_the_site_taxonomy_decides_who_is_an_author(self):
        """谁是作者由站方的分类说了算,不由名字的样子说了算。

        实测 `lewdchuu_(artist)` 名字里带 `artist` 只是巧合,`lewdtuber` 是
        metadata,而 `lewd`(8548 帖)是普通标签——按词形猜会三条全错。
        """
        found = tag_suggestions("lewdga", transport=self._transport(),
                                credential=self.credential)
        self.assertEqual([(row.value, row.tag_type, row.matched) for row in found],
                         [("lewdgatta", "artist", "作者"),
                          ("lewdgazer", "general", "标签")])

    def test_without_a_credential_the_class_is_left_unknown(self):
        """分类接口要凭据,没有就只报名字——空的 `tag_type` 是「没问出来」。

        它和「问出来是普通标签」不是一回事,所以不能拿去分组。
        """
        seen = []
        found = tag_suggestions("lewdga", transport=self._transport(seen))
        self.assertEqual([(row.value, row.tag_type) for row in found],
                         [("lewdgatta", ""), ("lewdgazer", "")])
        self.assertEqual(seen, ["https://api.rule34.xxx/autocomplete.php?q=lewdga"])

    def test_a_class_asked_once_is_not_asked_again(self):
        """前缀越敲越长,后几下要问的名字前一下刚问过。

        站方额度是每 60 秒 60 次,一组八条就用掉八次;不记住的话光敲一个词就能
        把额度打满。
        """
        seen = []
        transport = self._transport(seen)
        tag_suggestions("lewdga", transport=transport, credential=self.credential)
        types = [url for url in seen if "s=tag" in url]
        seen.clear()
        tag_suggestions("lewdga", transport=transport, credential=self.credential)
        self.assertEqual(len(types), 2)
        self.assertEqual([url for url in seen if "s=tag" in url], [])

    def test_one_tag_that_will_not_answer_keeps_the_rest(self):
        # 一条问不出来只是它自己没有分类,别的条目照常分组。
        def call(request, _timeout, _max_bytes):
            if "autocomplete.php" in request.url:
                return HttpResponse(200, {}, self.ROWS)
            if "lewdgazer" in request.url:
                raise OSError("connection reset")
            return HttpResponse(200, {}, self.TYPES["lewdgatta"])

        with no_real_backoff():
            found = tag_suggestions("lewdga", transport=call, credential=self.credential)
        self.assertEqual([(row.value, row.tag_type) for row in found],
                         [("lewdgatta", "artist"), ("lewdgazer", "")])

    def test_the_public_autocomplete_answers_without_a_credential(self):
        """2026-09-12 实测 `lewdga` 回 `lewdgatta`(380)、`lewdgazer`(237)、`lewdgala`(3)。

        走的是 `api.rule34.xxx`:主站上那个同名地址被 Cloudflare 挡着,实测 403。
        """
        seen = []

        def call(request, _timeout, _max_bytes):
            seen.append(request.url)
            return HttpResponse(200, {"content-type": "text/html"}, self.ROWS)

        found = tag_suggestions("lewdga", transport=call)
        self.assertEqual([(row.value, row.count) for row in found],
                         [("lewdgatta", 380), ("lewdgazer", 237)])
        self.assertEqual(seen, ["https://api.rule34.xxx/autocomplete.php?q=lewdga"])

    def test_a_site_that_is_down_only_costs_its_own_group(self):
        """建议是锦上添花:站点挂了不该让本机那两组跟着消失,也不该让人等退避。

        连接器对 GET 失败的退避累计 15 秒,那是抓取任务的节奏;敲一下字问一次的路径
        站点一挂就该立刻回空。这里把真实的 sleep 换成一炸就红的桩,退避一旦回来
        就在这条上露出来。
        """
        def call(_request, _timeout, _max_bytes):
            raise OSError("connection reset")

        with no_real_backoff():
            self.assertEqual(tag_suggestions("lewdga", transport=call), ())

    def test_a_term_too_short_never_reaches_the_site(self):
        calls = []

        def call(request, _timeout, _max_bytes):
            calls.append(request.url)
            return HttpResponse(200, {}, self.ROWS)

        self.assertEqual(tag_suggestions("l", transport=call), ())
        self.assertEqual(calls, [])


class SearchKeepsWhatWasSuggestedTests(_DiscoveryCase):
    """添加框下拉里给过的名字,按回车查找之后必须还在结果里。"""

    def setUp(self):
        super().setUp()
        follow_discovery._TAG_TYPES.clear()
        self.addCleanup(follow_discovery._TAG_TYPES.clear)

    def test_archive_names_offered_for_a_prefix_survive_the_search(self):
        # 名字中间带这个词的九位排在清单前面;按清单原序截八条就轮不到下拉里那两位。
        rows = [{"id": str(n), "name": f"x{n}lewd", "service": "fanbox"} for n in range(9)]
        rows += [{"id": "90", "name": "lewdgazer", "service": "fanbox"},
                 {"id": "91", "name": "Lewdgatta", "service": "patreon"}]
        path = self.state / "follow" / "creators-kemono.json"
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(rows), encoding="utf-8")
        offered = [row.value for row in archive_suggestions(
            "lewd", state_root=self.state, providers=("kemono",))]
        found = self._discover("lewd", {}, providers=("kemono",))
        self.assertEqual(offered, ["Lewdgatta", "lewdgazer"])
        self.assertEqual([c.label.split(" · ")[0] for c in found.candidates[:2]], offered)
        self.assertEqual(found.candidates[0].evidence, "创作者名以该词开头")

    def test_site_tags_offered_for_a_prefix_survive_the_search(self):
        """`lewd` 本身是标签,下拉里的作者 `lewdrex` 和角色 `lewd_dorky` 也得在结果里。

        排法跟下拉一致:精确写法最前,其余作者在前、别的分类在后,证据写明站上的分类。
        """
        self._write_credential("rule34xxx", {"user_id": "1", "api_key": "k"})
        rows = json.dumps([{"label": "lewd (8548)", "value": "lewd"},
                           {"label": "lewd_dorky (401)", "value": "lewd_dorky"},
                           {"label": "lewdrex (57)", "value": "lewdrex"}]).encode()
        #: dapi 的分类代号:0 是 general、1 是 artist、4 是 character。
        types = {"lewd": 0, "lewd_dorky": 4, "lewdrex": 1}
        calls = []

        def call(request, _timeout, _max_bytes):
            calls.append(request.url)
            if "autocomplete.php" in request.url:
                return HttpResponse(200, {}, rows)
            name = urllib.parse.parse_qs(urllib.parse.urlsplit(request.url).query)["name"][0]
            return HttpResponse(200, {}, (f'<tags type="array"><tag type="{types[name]}"'
                                          f' count="1" name="{name}"/></tags>').encode())

        offered = tag_suggestions("lewd", transport=call, credential=Credential(
            "rule34xxx", {"user_id": "1", "api_key": "k"}))
        found = discover("lewd", secrets_root=self.secrets, state_root=self.state,
                         transport=call, providers=("rule34xxx",))
        self.assertEqual({row.value for row in offered}, {c.ref for c in found.candidates})
        self.assertEqual([c.ref for c in found.candidates], ["lewd", "lewdrex", "lewd_dorky"])
        self.assertEqual([c.evidence for c in found.candidates],
                         ["站内标签 lewd 下有 8548 件作品",
                          "站内标签以该词开头:lewdrex 下有 57 件作品,站上归为作者",
                          "站内标签以该词开头:lewd_dorky 下有 401 件作品,站上归为角色"])
        # 补全里已经有精确写法,就不花一次 dapi 请求去确认它。
        self.assertFalse(any("s=post" in url for url in calls))


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