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