"""从裸 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"""""" F95_SEARCH_RESULTS = b"""

CollectionRia Collection [2026-08-03] [Ria_neearts]

""" F95_SEARCH_NOTHING = b"""
No results found.
""" SIMPCITY_SEARCH_FORM = b"""""" # 2026-09-08 实测 `solazola`:资源线程和讨论帖各一条,标题前的版块标签是分辨两者的依据。 SIMPCITY_SEARCH_RESULTS = b"""

OnlyFansSolazola / baby_sue

Simp Chatsolazola discussion

""" # 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"""
XPatreon pixiv
""" FANBOX_PROFILE = ("").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""), "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""), }, 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"")} 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'')} 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, "