tests/test_follow_providers.py
Rama master · réplica de lectura del repositorio original.
import inspect
import unittest
from pathlib import Path
from peach import follow_discovery, follow_providers, web_follow
from peach.follow_sources import CONNECTORS, ParsedSource, _BaseConnector
from peach.follow_stream import _PROVIDER_HOSTS
from peach.follow_variants import PROVIDER_PRIORITY
from peach.follow_secrets import (
CREDENTIAL_GUIDE,
SYNCABLE_FIELDS,
credential_store_for,
)
from peach.follow_store import _OFFICIAL_IDENTITY_PROVIDERS
from peach.web_follow import PROVIDER_LABELS, _BACKFILL_PROVIDERS
class ProviderRegistryTests(unittest.TestCase):
def test_every_connector_is_registered_and_every_source_has_a_connector(self):
"""新增站点漏登记不会自己报错,只会在某个页面上少一行——所以这里挡住。
有 `source_url` 的 spec 就是追更来源,必须有连接器;反过来每个连接器也必须
有 spec,否则显示名、优先级、主机白名单都会静默走默认值。"""
registered = {key for key, spec in follow_providers.PROVIDERS.items() if spec.source_url}
self.assertEqual(
registered, set(CONNECTORS),
"追更来源与连接器必须一一对应;新增站点要同时写连接器和 follow_providers 登记",
)
def test_gofile_is_a_media_source_not_a_follow_source(self):
"""Gofile 只作为媒体来源出现在界面上,没有连接器也不该有作品页 URL。"""
self.assertIn("gofile", PROVIDER_LABELS)
self.assertNotIn("gofile", CONNECTORS)
self.assertIsNone(follow_providers.PROVIDERS["gofile"].source_url)
def test_semantics_is_declared_only_in_the_registry(self):
"""连接器类与解析结果都从登记表读语义,没有第二份可以改漏的清单。
改了登记表却忘了连接器时,分组语义会和优先级排序对不上,而且不会抛错;
所以连接器不得自己声明 `semantics`,解析结果也只能派生。"""
self.assertIsInstance(vars(_BaseConnector)["semantics"], property)
for key, factory in CONNECTORS.items():
self.assertNotIn("semantics", vars(factory), f"{key} 不得自己声明语义")
self.assertNotIn("semantics", ParsedSource.__dataclass_fields__)
self.assertEqual(
ParsedSource("f95zone", "1", "https://f95zone.to/threads/1/", "x").semantics,
"release")
def test_a_source_url_without_ref_is_refused_at_declaration_time(self):
with self.assertRaises(ValueError):
follow_providers.ProviderSpec(key="x", label="X", source_url="https://x/",
url_hosts=("x",))
def test_priority_is_unique_so_primary_choice_is_deterministic(self):
values = list(PROVIDER_PRIORITY.values())
self.assertEqual(len(values), len(set(values)), "优先级重复会让选主条目结果不确定")
def test_official_identity_preserves_each_sources_media_boundary(self):
"""官方作者身份与媒体传输独立;Iwara 的签名媒体由本机代理。"""
self.assertIn("iwara", _OFFICIAL_IDENTITY_PROVIDERS)
self.assertEqual(_PROVIDER_HOSTS["iwara"], ("iwara.tv",))
for key in _OFFICIAL_IDENTITY_PROVIDERS - {"iwara"}:
self.assertEqual(follow_providers.PROVIDERS[key].hosts, ())
self.assertNotIn(key, _PROVIDER_HOSTS)
def test_backfill_providers_are_sites_with_real_paging(self):
"""「抓更早一页」只给历史页走得完的来源:归档站按偏移翻页,fanbox 走
站点自己的游标清单(`post.paginateCreator`)。官方身份与可回填是两回事:
fanbox 两者都是,subscribestar、patreon 没有公开分页就不进这一档。"""
self.assertTrue(_BACKFILL_PROVIDERS <= set(CONNECTORS))
self.assertIn("fanbox", _BACKFILL_PROVIDERS)
self.assertTrue(_BACKFILL_PROVIDERS.isdisjoint({"subscribestar", "patreon"}))
def test_registry_carries_no_credential_policy(self):
"""凭据同步策略必须留在 CREDENTIAL_GUIDE 里逐字段声明,不进这张通用表。
收进来会让它更容易被顺手改错:今天 `api_key` 能同步、`cookie` 不能,
明天新增 `session_token` 就会落到错误的一侧。新增来源时作者必须单独表态,
这个摩擦是故意留的。"""
spec_fields = set(vars(follow_providers.PROVIDERS["fanbox"]))
for banned in ("syncable", "credential", "cookie", "token", "secret"):
self.assertNotIn(
banned, spec_fields, f"ProviderSpec 不得携带凭据相关字段:{banned}",
)
self.assertEqual(
SYNCABLE_FIELDS,
{provider: tuple(guide.get("syncable", ())) for provider, guide in CREDENTIAL_GUIDE.items()},
"SYNCABLE_FIELDS 只能从 CREDENTIAL_GUIDE 派生",
)
def test_every_layer_builds_its_credential_store_through_one_factory(self):
"""Web 与发现必须拿到同一套共享根与可同步字段声明。
分头 `CredentialStore(...)` 时只有 Web 那份带上了共享回填,同一份凭据
在网页里在、在发现那层「未配置」。这里挡住往回退化。
"""
sources = {
name: (Path(inspect.getsourcefile(module)).read_text(encoding="utf-8"))
for name, module in (("web_follow", web_follow),
("follow_discovery", follow_discovery))
}
for name, text in sources.items():
with self.subTest(module=name):
self.assertNotIn("CredentialStore(", text.replace(
"-> CredentialStore", ""),
f"{name} 必须走 credential_store_for,不要自己构造")
self.assertIn("credential_store_for(", text)
def test_the_factory_carries_the_syncable_declaration(self):
store = credential_store_for(Path("secrets"), shared_root=Path("shared"))
self.assertEqual(store.syncable_fields, SYNCABLE_FIELDS)
self.assertEqual(store.syncable("rule34xxx"), ("user_id", "api_key"))
self.assertEqual(store.syncable("f95zone"), ())
self.assertEqual(store.shared_root, Path("shared") / "secrets" / "follow")
def test_semantics_rejects_unknown_values(self):
with self.assertRaises(ValueError):
follow_providers.ProviderSpec(key="x", label="X", semantics="whatever")
class UrlHostTests(unittest.TestCase):
"""粘一条链接时「这个主机属于哪个站」的登记与查表。"""
def test_every_follow_source_declares_at_least_one_url_host(self):
"""没有 url_hosts 的追更来源永远无法从链接登记,而且不会报错。"""
for key, spec in follow_providers.PROVIDERS.items():
if spec.source_url:
self.assertTrue(spec.url_hosts, f"{key} 缺 url_hosts")
def test_a_source_url_without_url_hosts_is_refused_at_declaration_time(self):
with self.assertRaises(ValueError):
follow_providers.ProviderSpec(key="x", label="X",
source_url="https://x/{ref}")
def test_no_host_is_claimed_by_two_sources(self):
declared = [host for spec in follow_providers.PROVIDERS.values()
for host in spec.url_hosts]
self.assertEqual(len(declared), len(set(declared)),
"同一个主机登记两次,解析结果取决于字典顺序")
def test_url_hosts_are_not_the_media_proxy_allowlist(self):
"""两张表名字像、含义不同:paheal 的站点主机与媒体主机根本不一样。
「复用 hosts 就行」不成立:`hosts` 是媒体代理白名单,放宽它等于放宽能被
代理取回的地址;`url_hosts` 只决定一条链接归谁解析。
"""
paheal = follow_providers.PROVIDERS["rule34paheal"]
self.assertEqual(paheal.url_hosts, ("rule34.paheal.net",))
self.assertIn("paheal-cdn.net", paheal.hosts)
self.assertNotIn("rule34.paheal.net", paheal.hosts)
def test_rule34video_media_is_allowed_to_land_on_its_cdn(self):
"""正片不在站内:`/get_file/…` 会 302 到 `*.boomio-cdn.com`。
2026-09-04 实测四条候选分别落在 eu-cdn05/06/08/11-prem,最终一跳才回
206 `video/mp4`。少了这个后缀,媒体代理在跳出白名单那一步拒收,整站的
rule34video 视频一条都放不出来。站点主机仍然要留着——签名地址是从详情页
读出来的,那一步按 `hosts` 校验。
"""
video = follow_providers.PROVIDERS["rule34video"]
self.assertEqual(video.url_hosts, ("rule34video.com",))
self.assertEqual(video.hosts, ("rule34video.com", "boomio-cdn.com"))
def test_subdomains_and_www_resolve_to_the_registered_source(self):
for host, expected in (
("fanbox.cc", "fanbox"),
("www.fanbox.cc", "fanbox"),
("ffxivinitiala.fanbox.cc", "fanbox"),
("api.rule34.xxx", "rule34xxx"),
("kemono.cr", "kemono"),
("coomer.st", "coomer"),
("SubscribeStar.adult", "subscribestar"),
):
self.assertEqual(follow_providers.provider_for_host(host), expected, host)
def test_the_longest_registered_suffix_wins(self):
"""`rule34.paheal.net` 比将来可能出现的 `paheal.net` 更具体,必须赢。"""
self.assertEqual(follow_providers.provider_for_host("rule34.paheal.net"),
"rule34paheal")
self.assertEqual(
follow_providers.provider_for_host("cdn.rule34.paheal.net"),
"rule34paheal")
def test_an_unregistered_host_is_empty_not_a_guess(self):
for host in ("nyaa.si", "", "notfanbox.cc", "fanbox.cc.evil.example"):
self.assertEqual(follow_providers.provider_for_host(host), "", host)
class ReleaseKeyPerPostTests(unittest.TestCase):
def test_the_rule_is_declared_in_the_registry_not_named_in_the_data_layer(self):
"""论坛线程每层各自成组,这是来源语义,不是 `follow_store` 里的站点点名。"""
self.assertEqual(follow_providers.release_key_per_post(),
frozenset({"f95zone", "simpcity"}))
def test_it_only_applies_to_release_semantics(self):
"""每条各自成组只对「同一作品的历次发布」有意义;work 语义靠标题合并。"""
for key in follow_providers.release_key_per_post():
self.assertEqual(follow_providers.PROVIDERS[key].semantics, "release")
class PublicMediaHostsTests(unittest.TestCase):
def test_public_image_hosts_are_declared_in_the_registry(self):
"""论坛帖子里的图挂在几十家图站上,放行任意公网主机是来源语义,登记在这张表上。"""
self.assertEqual(follow_providers.public_media_hosts(), frozenset({"simpcity"}))
def test_public_mode_replaces_the_whitelist_instead_of_sitting_beside_it(self):
"""两个都写会让读者以为白名单还在起作用。"""
for key in follow_providers.public_media_hosts():
self.assertEqual(follow_providers.PROVIDERS[key].hosts, ())
self.assertNotIn(key, _PROVIDER_HOSTS)
with self.assertRaises(ValueError):
follow_providers.ProviderSpec("x", "X", hosts=("x.example",), public_media_hosts=True)
class ExcludedItemTests(unittest.TestCase):
def test_hidden_items_are_declared_in_the_registry_not_in_the_web_layer(self):
"""用户点名要隐藏的既有条目登记在这张表上。
它不是 Web 层的一个裸常量:「这个站有哪些条目要藏」是站点数据,不该跟
标签清理、缩略图这些展示逻辑混在同一个文件里。
"""
excluded = follow_providers.excluded_external_ids()
self.assertEqual(excluded, {"rule34video": frozenset({"4533145"})})
if __name__ == "__main__":
unittest.main()